feat(nuxt): add <NuxtRouteAnnouncer> and useRouteAnnouncer (#25741)

This commit is contained in:
Ivan Kalachikov 2024-04-17 23:58:13 +08:00 committed by GitHub
parent 75e43ac427
commit 4fea6da1ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 360 additions and 1 deletions

View File

@ -0,0 +1,56 @@
---
title: '<NuxtRouteAnnouncer>'
description: 'Add a hidden element with the page title for assistive technologies.'
navigation:
badge: New
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/components/nuxt-route-announcer.ts
size: xs
---
::important
This component will be available in Nuxt v3.12.
::
## Usage
Add `<NuxtRouteAnnouncer/>` in your [`app.vue`](/docs/guide/directory-structure/app) or [`layouts/`](/docs/guide/directory-structure/layouts) to enhance accessibility by informing assistive technologies about page's title changes. This ensures that navigational changes are announced to users relying on screen readers.
```vue [app.vue]
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
```
## Slots
You can pass custom HTML or components through the route announcer default slot.
```vue
<template>
<NuxtRouteAnnouncer>
<template #default="{ message }">
<p>{{ message }} was loaded.</p>
</template>
</NuxtRouteAnnouncer>
</template>
```
## Props
- `atomic`: Controls if screen readers announce only changes or the entire content. Set to true for full content readout on updates, false for changes only. (default `false`)
- `politeness`: Sets the urgency for screen reader announcements: `off` (disable the announcement), `polite` (waits for silence), or `assertive` (interrupts immediately). (default `polite`)
::callout
This component is optional. :br
To achieve full customization, you can implement your own one based on [its source code](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/components/nuxt-route-announcer.ts).
::
::callout
You can hook into the underlying announcer instance using [the `useRouteAnnouncer` composable](/docs/api/composables/use-route-announcer), which allows you to set a custom announcement message.
::

View File

@ -0,0 +1,60 @@
---
title: 'useRouteAnnouncer'
description: This composable observes the page title changes and updates the announcer message accordingly.
navigation:
badge: New
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/route-announcer.ts
size: xs
---
::important
This composable will be available in Nuxt v3.12.
::
## Description
A composable which observes the page title changes and updates the announcer message accordingly. Used by [`<NuxtRouteAnnouncer>`](/docs/api/components/nuxt-route-announcer) and controllable.
It hooks into Unhead's [`dom:rendered`](https://unhead.unjs.io/api/core/hooks#dom-hooks) to read the page's title and set it as the announcer message.
## Parameters
- `politeness`: Sets the urgency for screen reader announcements: `off` (disable the announcement), `polite` (waits for silence), or `assertive` (interrupts immediately). (default `polite`).
## Properties
### `message`
- **type**: `Ref<string>`
- **description**: The message to announce
### `politeness`
- **type**: `Ref<string>`
- **description**: Screen reader announcement urgency level `off`, `polite`, or `assertive`
## Methods
### `set(message, politeness = "polite")`
Sets the message to announce with its urgency level.
### `polite(message)`
Sets the message with `politeness = "polite"`
### `assertive(message)`
Sets the message with `politeness = "assertive"`
## Example
```ts
<script setup lang="ts">
const { message, politeness, set, polite, assertive } = useRouteAnnouncer({
politeness: 'assertive'
})
</script>
```

View File

@ -50,7 +50,7 @@ export function resolveIgnorePatterns (relativePath?: string): string[] {
if (relativePath) {
// Map ignore patterns based on if they start with * or !*
return nuxt._ignorePatterns.map(p => {
return nuxt._ignorePatterns.map((p) => {
const [_, negation = '', pattern] = p.match(NEGATION_RE) || []
if (pattern[0] === '*') {
return p

View File

@ -0,0 +1,48 @@
import { defineComponent, h } from 'vue'
import type { Politeness } from '#app/composables/route-announcer'
import { useRouteAnnouncer } from '#app/composables/route-announcer'
export default defineComponent({
name: 'NuxtRouteAnnouncer',
props: {
atomic: {
type: Boolean,
default: false,
},
politeness: {
type: String as () => Politeness,
default: 'polite',
},
},
setup (props, { slots, expose }) {
const { set, polite, assertive, message, politeness } = useRouteAnnouncer({ politeness: props.politeness })
expose({
set, polite, assertive, message, politeness,
})
return () => h('span', {
class: 'nuxt-route-announcer',
style: {
position: 'absolute',
},
}, h('span', {
'role': 'alert',
'aria-live': politeness.value,
'aria-atomic': props.atomic,
'style': {
'border': '0',
'clip': 'rect(0 0 0 0)',
'clip-path': 'inset(50%)',
'height': '1px',
'width': '1px',
'overflow': 'hidden',
'position': 'absolute',
'white-space': 'nowrap',
'word-wrap': 'normal',
'margin': '-1px',
'padding': '0',
},
}, slots.default ? slots.default({ message: message.value }) : message.value))
},
})

View File

@ -0,0 +1,89 @@
import type { Ref } from 'vue'
import { getCurrentScope, onScopeDispose, ref } from 'vue'
import { injectHead } from '@unhead/vue'
import { useNuxtApp } from '#app'
export type Politeness = 'assertive' | 'polite' | 'off'
export type NuxtRouteAnnouncerOpts = {
/** @default 'polite' */
politeness?: Politeness
}
export type RouteAnnouncer = {
message: Ref<string>
politeness: Ref<Politeness>
set: (message: string, politeness: Politeness) => void
polite: (message: string) => void
assertive: (message: string) => void
_cleanup: () => void
}
function createRouteAnnouncer (opts: NuxtRouteAnnouncerOpts = {}) {
const message = ref('')
const politeness = ref<Politeness>(opts.politeness || 'polite')
const activeHead = injectHead()
function set (messageValue: string = '', politenessSetting: Politeness = 'polite') {
message.value = messageValue
politeness.value = politenessSetting
}
function polite (message: string) {
return set(message, 'polite')
}
function assertive (message: string) {
return set(message, 'assertive')
}
function _updateMessageWithPageHeading () {
set(document?.title?.trim(), politeness.value)
}
function _cleanup () {
activeHead?.hooks?.removeHook('dom:rendered', _updateMessageWithPageHeading)
}
_updateMessageWithPageHeading()
activeHead?.hooks?.hook('dom:rendered', () => {
_updateMessageWithPageHeading()
})
return {
_cleanup,
message,
politeness,
set,
polite,
assertive,
}
}
/**
* composable to handle the route announcer
* @since 3.12.0
*/
export function useRouteAnnouncer (opts: Partial<NuxtRouteAnnouncerOpts> = {}): Omit<RouteAnnouncer, '_cleanup'> {
const nuxtApp = useNuxtApp()
// Initialise global route announcer if it doesn't exist already
const announcer = nuxtApp._routeAnnouncer = nuxtApp._routeAnnouncer || createRouteAnnouncer(opts)
if (opts.politeness !== announcer.politeness.value) {
announcer.politeness.value = opts.politeness || 'polite'
}
if (import.meta.client && getCurrentScope()) {
nuxtApp._routeAnnouncerDeps = nuxtApp._routeAnnouncerDeps || 0
nuxtApp._routeAnnouncerDeps++
onScopeDispose(() => {
nuxtApp._routeAnnouncerDeps!--
if (nuxtApp._routeAnnouncerDeps === 0) {
announcer._cleanup()
delete nuxtApp._routeAnnouncer
}
})
}
return announcer
}

View File

@ -17,6 +17,7 @@ import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '../app/composables/manifest'
import type { LoadingIndicator } from '../app/composables/loading-indicator'
import type { RouteAnnouncer } from '../app/composables/route-announcer'
import type { ViewTransition } from './plugins/view-transitions.client'
import type { NuxtAppLiterals } from '#app'
@ -150,6 +151,11 @@ interface _NuxtApp {
/** @internal */
_payloadRevivers: Record<string, (data: any) => any>
/** @internal */
_routeAnnouncer?: RouteAnnouncer
/** @internal */
_routeAnnouncerDeps?: number
// Nuxt injections
$config: RuntimeConfig

View File

@ -326,6 +326,14 @@ async function initNuxt (nuxt: Nuxt) {
filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator'),
})
// Add <NuxtRouteAnnouncer>
addComponent({
name: 'NuxtRouteAnnouncer',
priority: 10, // built-in that we do not expect the user to override
filePath: resolve(nuxt.options.appDir, 'components/nuxt-route-announcer'),
mode: 'client',
})
// Add <NuxtClientFallback>
if (nuxt.options.experimental.clientFallback) {
addComponent({

View File

@ -1045,6 +1045,21 @@ describe('composables', () => {
expect(pageErrors).toEqual([])
await page.close()
})
it('`useRouteAnnouncer` should change message on route change', async () => {
const { page } = await renderPage('/route-announcer')
expect(await page.getByRole('alert').textContent()).toContain('First Page')
await page.getByRole('link').click()
await page.getByText('Second page content').waitFor()
expect(await page.getByRole('alert').textContent()).toContain('Second Page')
await page.close()
})
it('`useRouteAnnouncer` should change message on dynamically changed title', async () => {
const { page } = await renderPage('/route-announcer')
await page.getByRole('button').click()
await page.waitForFunction(() => document.title.includes('Dynamically set title'))
expect(await page.getByRole('alert').textContent()).toContain('Dynamically set title')
await page.close()
})
})
describe('middlewares', () => {

View File

@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<slot />
</div>
</template>

View File

@ -0,0 +1,25 @@
<script setup>
useHead({
title: 'First Page',
})
definePageMeta({
layout: 'with-route-announcer',
})
</script>
<template>
<div>
<NuxtLink
role="link"
to="/route-announcer2"
>
Link
</NuxtLink>
<button
role="button"
@click="() => useHead({ title: 'Dynamically set title' })"
>
Button
</button>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
useHead({
title: 'Second Page',
})
definePageMeta({
layout: 'with-route-announcer',
})
</script>
<template>
<div>Second page content</div>
</template>

View File

@ -18,6 +18,7 @@ import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useId } from '#app/composables/id'
import { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator'
import { useRouteAnnouncer } from '#app/composables/route-announcer'
registerEndpoint('/api/test', defineEventHandler(event => ({
method: event.method,
@ -695,3 +696,36 @@ describe('callOnce', () => {
expect(fn).toHaveBeenCalledTimes(2)
})
})
describe('route announcer', () => {
it('should create a route announcer with default politeness', () => {
const announcer = useRouteAnnouncer()
expect(announcer.politeness.value).toBe('polite')
})
it('should create a route announcer with provided politeness', () => {
const announcer = useRouteAnnouncer({ politeness: 'assertive' })
expect(announcer.politeness.value).toBe('assertive')
})
it('should set message and politeness', () => {
const announcer = useRouteAnnouncer()
announcer.set('Test message with politeness', 'assertive')
expect(announcer.message.value).toBe('Test message with politeness')
expect(announcer.politeness.value).toBe('assertive')
})
it('should set message with polite politeness', () => {
const announcer = useRouteAnnouncer()
announcer.polite('Test message polite')
expect(announcer.message.value).toBe('Test message polite')
expect(announcer.politeness.value).toBe('polite')
})
it('should set message with assertive politeness', () => {
const announcer = useRouteAnnouncer()
announcer.assertive('Test message assertive')
expect(announcer.message.value).toBe('Test message assertive')
expect(announcer.politeness.value).toBe('assertive')
})
})