mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 07:05:11 +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) {
|
||||
// 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
|
||||
|
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 { 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
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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', () => {
|
||||
|
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 { 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')
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user