mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-13 09:33:54 +00:00
feat(nuxt): add <NuxtRouteAnnouncer>
and useRouteAnnouncer
(#25741)
This commit is contained in:
parent
75e43ac427
commit
4fea6da1ae
56
docs/3.api/1.components/12.nuxt-route-announcer.md
Normal file
56
docs/3.api/1.components/12.nuxt-route-announcer.md
Normal 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.
|
||||||
|
::
|
60
docs/3.api/2.composables/use-route-announcer.md
Normal file
60
docs/3.api/2.composables/use-route-announcer.md
Normal 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>
|
||||||
|
```
|
@ -50,7 +50,7 @@ export function resolveIgnorePatterns (relativePath?: string): string[] {
|
|||||||
|
|
||||||
if (relativePath) {
|
if (relativePath) {
|
||||||
// Map ignore patterns based on if they start with * or !*
|
// 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) || []
|
const [_, negation = '', pattern] = p.match(NEGATION_RE) || []
|
||||||
if (pattern[0] === '*') {
|
if (pattern[0] === '*') {
|
||||||
return p
|
return p
|
||||||
|
48
packages/nuxt/src/app/components/nuxt-route-announcer.ts
Normal file
48
packages/nuxt/src/app/components/nuxt-route-announcer.ts
Normal 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))
|
||||||
|
},
|
||||||
|
})
|
89
packages/nuxt/src/app/composables/route-announcer.ts
Normal file
89
packages/nuxt/src/app/composables/route-announcer.ts
Normal 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
|
||||||
|
}
|
@ -17,6 +17,7 @@ import type { NuxtError } from '../app/composables/error'
|
|||||||
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
|
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
|
||||||
import type { NuxtAppManifestMeta } from '../app/composables/manifest'
|
import type { NuxtAppManifestMeta } from '../app/composables/manifest'
|
||||||
import type { LoadingIndicator } from '../app/composables/loading-indicator'
|
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 { ViewTransition } from './plugins/view-transitions.client'
|
||||||
|
|
||||||
import type { NuxtAppLiterals } from '#app'
|
import type { NuxtAppLiterals } from '#app'
|
||||||
@ -150,6 +151,11 @@ interface _NuxtApp {
|
|||||||
/** @internal */
|
/** @internal */
|
||||||
_payloadRevivers: Record<string, (data: any) => any>
|
_payloadRevivers: Record<string, (data: any) => any>
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_routeAnnouncer?: RouteAnnouncer
|
||||||
|
/** @internal */
|
||||||
|
_routeAnnouncerDeps?: number
|
||||||
|
|
||||||
// Nuxt injections
|
// Nuxt injections
|
||||||
$config: RuntimeConfig
|
$config: RuntimeConfig
|
||||||
|
|
||||||
|
@ -326,6 +326,14 @@ async function initNuxt (nuxt: Nuxt) {
|
|||||||
filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator'),
|
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>
|
// Add <NuxtClientFallback>
|
||||||
if (nuxt.options.experimental.clientFallback) {
|
if (nuxt.options.experimental.clientFallback) {
|
||||||
addComponent({
|
addComponent({
|
||||||
|
@ -1045,6 +1045,21 @@ describe('composables', () => {
|
|||||||
expect(pageErrors).toEqual([])
|
expect(pageErrors).toEqual([])
|
||||||
await page.close()
|
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', () => {
|
describe('middlewares', () => {
|
||||||
|
6
test/fixtures/basic/layouts/with-route-announcer.vue
vendored
Normal file
6
test/fixtures/basic/layouts/with-route-announcer.vue
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
25
test/fixtures/basic/pages/route-announcer.vue
vendored
Normal file
25
test/fixtures/basic/pages/route-announcer.vue
vendored
Normal 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>
|
12
test/fixtures/basic/pages/route-announcer2.vue
vendored
Normal file
12
test/fixtures/basic/pages/route-announcer2.vue
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script setup>
|
||||||
|
useHead({
|
||||||
|
title: 'Second Page',
|
||||||
|
})
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'with-route-announcer',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>Second page content</div>
|
||||||
|
</template>
|
@ -18,6 +18,7 @@ import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
|||||||
import { useId } from '#app/composables/id'
|
import { useId } from '#app/composables/id'
|
||||||
import { callOnce } from '#app/composables/once'
|
import { callOnce } from '#app/composables/once'
|
||||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||||
|
import { useRouteAnnouncer } from '#app/composables/route-announcer'
|
||||||
|
|
||||||
registerEndpoint('/api/test', defineEventHandler(event => ({
|
registerEndpoint('/api/test', defineEventHandler(event => ({
|
||||||
method: event.method,
|
method: event.method,
|
||||||
@ -695,3 +696,36 @@ describe('callOnce', () => {
|
|||||||
expect(fn).toHaveBeenCalledTimes(2)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user