feat(nuxt): add useAnnouncer composable and <NuxtAnnouncer> component#34318
feat(nuxt): add useAnnouncer composable and <NuxtAnnouncer> component#34318
useAnnouncer composable and <NuxtAnnouncer> component#34318Conversation
|
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a screen‑reader announcer system: a new useAnnouncer composable and NuxtAnnouncer component, plus types (AnnouncerPoliteness, NuxtAnnouncer, NuxtAnnouncerOpts) and re‑exports. Registers the component, extends internal _NuxtApp with _announcer and _announcerDeps, and updates imports/presets. Documentation pages and examples were added; tests for polite/assertive announcements and lifecycle cleanup were included. Route announcer behaviour/signatures were adjusted (set politeness optional, useRouteAnnouncer signature changed) and live region role is now derived from politeness. No public exports were removed. 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
@nuxt/kit
@nuxt/nitro-server
nuxt
@nuxt/rspack-builder
@nuxt/schema
@nuxt/vite-builder
@nuxt/webpack-builder
commit: |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 17-20: The code passes props.politeness as a plain value to
useAnnouncer, so changes to the parent prop won't propagate; update setup to
pass a reactive reference instead (e.g., create a ref with toRef(props,
"politeness") or watch props.politeness and call set/update on the useAnnouncer
API) and ensure you import toRef (or watch) from 'vue' and use the reactive ref
when calling useAnnouncer or when updating its politeness option; target the
useAnnouncer call and the props.politeness usage to keep the composable in sync.
- Around line 35-37: The span currently hardcodes role="alert" while binding
aria-live to politeness.value, causing a conflict; change the role to be
computed from politeness.value (e.g. if politeness.value === 'assertive' use
'alert', otherwise use 'status' or omit role) and replace the literal 'role':
'alert' in the h('span', ...) props with that computed value (or remove role
when politeness is absent) so the role and aria-live are consistent; locate the
h('span', { 'role': 'alert', 'aria-live': politeness.value, ... }) occurrence in
nuxt-announcer.ts and update it to use the computedRole derived from
politeness.value.
In `@packages/nuxt/src/app/composables/announcer.ts`:
- Around line 25-28: The set function using shallowRef for message will not
re-trigger reactivity when the same primitive string is set twice; update the
set implementation (function set) to clear message.value briefly before
assigning the new message (e.g., set message.value = '' then await nextTick() or
use a zero-delay setTimeout) and then assign message.value = messageValue and
politeness.value = politenessSetting so identical consecutive messages are
re-announced by the aria-live region.
In `@test/nuxt/composables.test.ts`:
- Around line 922-926: The test description claims it verifies the default
politeness but calls useAnnouncer with an explicit { politeness: 'polite' };
change the test to call useAnnouncer() with no arguments so it exercises the
default behavior (replace useAnnouncer({ politeness: 'polite' }) with
useAnnouncer()), then assert announcer.politeness.value === 'polite' to validate
the default; keep the test name and expectations otherwise unchanged.
🧹 Nitpick comments (1)
packages/nuxt/src/app/composables/announcer.ts (1)
66-70: Last-caller-wins semantics for the defaultpolitenessoption.When multiple components call
useAnnouncer({ politeness: 'assertive' })anduseAnnouncer({ politeness: 'polite' }), the second call silently overwrites the first's default. This is consistent with the singleton design, but it may surprise consumers.Consider either documenting this behaviour explicitly or only allowing the initial caller to set the default (i.e. skip the override for subsequent calls).
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
packages/nuxt/src/app/components/nuxt-announcer.ts (1)
17-20: Props are not reactive when passed touseAnnouncer.
props.politenessis read once as a plain value at setup time. If the parent dynamically changes thepolitenessprop, the composable's internal state won't update. Consider watching the prop:♻️ Suggested fix
+import { computed, defineComponent, h, watch } from 'vue' -import { computed, defineComponent, h } from 'vue' import type { AnnouncerPoliteness } from '../composables/announcer' import { useAnnouncer } from '../composables/announcer' ... setup (props, { slots, expose }) { const { set, polite, assertive, message, politeness } = useAnnouncer({ politeness: props.politeness, }) + + watch(() => props.politeness, (val) => { + politeness.value = val + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/nuxt/src/app/components/nuxt-announcer.ts` around lines 17 - 20, The setup currently passes props.politeness as a plain value into useAnnouncer, so downstream state in useAnnouncer won’t update when the parent changes politeness; update setup to pass a reactive reference (e.g., toRef(props, 'politeness') or a watched wrapper) into useAnnouncer or add a watch on props.politeness that calls the composable’s setter (setPoliteness / set or similar) so the composable (useAnnouncer) receives updates; locate the setup function and the useAnnouncer call to implement the reactive prop passing or watch.
🧹 Nitpick comments (2)
test/nuxt/composables.test.ts (1)
957-959: Unnecessaryas anycast on_announcerDeps.Both
_announcerand_announcerDepsare declared as optional properties on the_NuxtAppinterface (seenuxt.tslines 194–197), sodeleteshould work on both without casting. Theas anyon line 959 is inconsistent with line 958.♻️ Suggested fix
// Clean up any pre-existing announcer state delete nuxtApp._announcer - delete (nuxtApp as any)._announcerDeps + delete nuxtApp._announcerDeps🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/nuxt/composables.test.ts` around lines 957 - 959, The delete on nuxtApp._announcerDeps uses an unnecessary and inconsistent cast; remove the `as any` and delete the property directly like the `_announcer` cleanup. Locate the cleanup block around nuxtApp and replace `delete (nuxtApp as any)._announcerDeps` with a plain `delete nuxtApp._announcerDeps`, relying on the optional `_announcerDeps` declaration on the _NuxtApp interface.packages/nuxt/src/app/components/nuxt-announcer.ts (1)
32-54: Outer wrapper<span>may needaria-hiddenor be semantically inert.The outer
<span class="nuxt-announcer">with onlyposition: absoluteis not visually hidden itself — only the inner span carries the sr-only styles. While this doesn't break anything, the outer span could be simplified. More importantly, if you ever need multiple children inside it, the current single-child render structure works but is fragile.This is a minor observation — the current structure is functional.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/nuxt/src/app/components/nuxt-announcer.ts` around lines 32 - 54, The outer wrapper span created in the render return (the element with class "nuxt-announcer") should be made semantically inert to avoid exposing it to assistive tech; update that element's props to include either 'aria-hidden': 'true' or role: 'presentation' (while keeping the inner sr-only span with aria-live/aria-atomic intact), and ensure the render still uses slots.default ? slots.default({ message: message.value }) : message.value for the inner content so the announcer remains functional.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 37-39: The component currently sets role.value alongside
politeness.value, which makes role('status') conflict when politeness is 'off';
change the computed role (symbol: role or its getter in nuxt-announcer.ts, and
the AnnouncerPoliteness type usage) to explicitly handle the 'off' case by
returning undefined/null (or mapping to a neutral value like 'log') instead of
'status', and update the render call (the h('span', { 'role': role.value,
'aria-live': politeness.value })) to only add the role attribute when role is
defined so that aria-live="off" is not paired with role="status".
In `@packages/nuxt/src/app/components/nuxt-route-announcer.ts`:
- Line 20: The computed role mapping for nuxt-route-announcer.ts incorrectly
maps politeness.value === 'off' to 'status', conflicting with aria-live="off";
update the computed role (the role constant defined via computed(() =>
politeness.value === 'assertive' ? 'alert' : 'status')) to explicitly handle
'off' by returning undefined or an empty string when politeness.value === 'off',
mirroring the change made in nuxt-announcer.ts so aria-live and role stay
consistent (use politeness.value checks in the computed to return 'alert' for
'assertive', 'status' for 'polite', and undefined/'' for 'off').
---
Duplicate comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 17-20: The setup currently passes props.politeness as a plain
value into useAnnouncer, so downstream state in useAnnouncer won’t update when
the parent changes politeness; update setup to pass a reactive reference (e.g.,
toRef(props, 'politeness') or a watched wrapper) into useAnnouncer or add a
watch on props.politeness that calls the composable’s setter (setPoliteness /
set or similar) so the composable (useAnnouncer) receives updates; locate the
setup function and the useAnnouncer call to implement the reactive prop passing
or watch.
---
Nitpick comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 32-54: The outer wrapper span created in the render return (the
element with class "nuxt-announcer") should be made semantically inert to avoid
exposing it to assistive tech; update that element's props to include either
'aria-hidden': 'true' or role: 'presentation' (while keeping the inner sr-only
span with aria-live/aria-atomic intact), and ensure the render still uses
slots.default ? slots.default({ message: message.value }) : message.value for
the inner content so the announcer remains functional.
In `@test/nuxt/composables.test.ts`:
- Around line 957-959: The delete on nuxtApp._announcerDeps uses an unnecessary
and inconsistent cast; remove the `as any` and delete the property directly like
the `_announcer` cleanup. Locate the cleanup block around nuxtApp and replace
`delete (nuxtApp as any)._announcerDeps` with a plain `delete
nuxtApp._announcerDeps`, relying on the optional `_announcerDeps` declaration on
the _NuxtApp interface.
ℹ️ Review info
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
packages/nuxt/src/app/components/nuxt-announcer.tspackages/nuxt/src/app/components/nuxt-route-announcer.tspackages/nuxt/src/app/nuxt.tstest/basic.test.tstest/nuxt/composables.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- test/basic.test.ts
This reverts commit c958708.
🔗 Linked issue
Relates to #23255 (Accessibility Roadmap)
Follows the pattern established in #25741 (
useRouteAnnouncer)📚 Description
This PR adds a general-purpose announcer for screen readers, complementing the existing route announcer.
Why is this useful?
While
useRouteAnnouncerhandles page navigation announcements automatically, many apps need to announce dynamic in-page content changes that aren't tied to navigation:Common use cases:
To be fair, this composable shouldn't be overused. Most cases can be solved by just focusing the new content and maybe adding an
aria-labelfor extra context. For example, in a checkout flow you could useuseAnnouncerto announce success. But if you have a dedicated success page, just focusing the heading is already sufficient.I'm actually using this in my own Nuxt app for a complex calendar. There's just no good spot to put an
aria-liveon, and focusing stuff on every change would be annoying. So the announcer works well here for date changes, event updates etc.(Not sure if we should clarify this in the docs though)
What's included
useAnnouncer()<NuxtAnnouncer>Usage
Why separate singletons for
useAnnounceranduseRouteAnnouncer?Both composables maintain their own state (
nuxtApp._announcervsnuxtApp._routeAnnouncer). Yes, this means some code duplication, but it makes sure the route announcement is never overwritten by the announcer.Open to discussion here (happy to refactor). But for now, this approach keeps things predictable.