a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560
a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560rosanusi wants to merge 8 commits into
Conversation
…ert" - Remove role="log" from the Toaster container; live-region semantics move entirely to individual Toast instances - Add getRole() to Toast: error variant → role="alert" (assertive); all others → role="status" (polite) - Add aria-atomic="true" so the full toast content re-announces on each insertion, even when consecutive toasts share similar markup Screen readers can now distinguish urgency between error and informational toasts (WCAG 2.2 SC 4.1.3 Status Messages). A11y-Audit-Ref: 4.1.3-toaster-role-status-alert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Implement framework-agnostic Svelte store factory that manages announcement messages with auto-removal timeout, supporting polite and assertive politeness levels for WCAG 4.1.3 live-region announcements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| <div class={toast({ position: $position })} role="log"> | ||
| <div class={toast({ position: $position })}> | ||
| <LiveRegion messages={liveMessages} /> | ||
| {#each $toasts as { message, variant, id, link } (id)} |
There was a problem hiding this comment.
⚠️ Type 'ToastVariant | undefined' is not assignable to type 'ToastVariant'.⚠️ Type 'string | undefined' is not assignable to type 'string'.
Reworked: per-toast roles → placeable
|
| File | Change |
|---|---|
src/lib/stores/announcer.ts (new) |
createAnnouncer() — buffer of {id, message, politeness}; announce() appends a uniquely-keyed node (not clear-and-reset); each removed after 7s. |
src/lib/holocene/live-region.svelte (new) |
Two always-rendered sr-only regions — aria-live="polite" + aria-live="assertive", both aria-relevant="additions"; messages routed by politeness, keyed by id. |
src/lib/stores/toaster.ts |
push() calls announce(message, variant === 'error' ? 'assertive' : 'polite') — one announcement per toast, from the store (not the rendered node). Exposes announcements; clear() also clears the announcer. |
src/lib/holocene/toaster.svelte |
Mounts <LiveRegion> (optional self-subscribing announcements prop defaulting to the singleton store); container keeps no role. |
src/lib/holocene/toast.svelte |
Reverted to purely visual — per-toast role/aria-atomic/getRole removed. |
Why this shape (research-backed)
- Regions pre-exist empty → reliable announcement (MDN Live Regions; WCAG ARIA22).
aria-liveon the wrapper (notrole) avoids iOS-VoiceOver double-speak;aria-relevant="additions"keeps the 7s removals silent.- Unique-keyed append → identical consecutive messages re-announce (fixes the stale-announcement failure mode).
- A placeable component (vs a body-level singleton) is deliberate: a body-level region is inerted by
aria-modalunder VoiceOver+Safari (W3C ARIA Update drawer types and x color props #1854), so it can't serve modal-scoped messages. The component can be mounted inside a dialog where needed.
Notes / follow-ups
- Manual AT is a hard gate before this leaves draft. The repo has no component-render harness, so the load-bearing property — that the regions actually announce — is only confirmable on real AT. Please verify on NVDA + VoiceOver: polite toast does not interrupt; error toast interrupts; and the two
.sr-only[aria-live]regions exist before any toast fires. (The test-plan boxes above are still unchecked.) - cloud-ui cascade:
announcementsis an optional prop defaulting to the singleton, so cloud-ui's two<Toaster>mounts compile and work unchanged after the pack bump — no cloud-ui edits required. - Future adopters of
LiveRegion(not in this PR): the session-expiry modal (a11y(2.2.1): ui-main — warn the user before session expiry and offer an extend affordance #3531) must mount its own<LiveRegion>inside the dialog (thearia-modalinert-background reason above); status chips/counts (a11y(4.1.3): Add live-region wrappers for workflow status, event count, filter counts, and Banner #3561) are DOM-resident and announce in place. This PR scopes to toasts only. - Unit tests:
createAnnouncer(politeness mapping, 7s timeout removal, distinct ids) + toaster store wiring. Full suite green (2050 passed).
…on contract Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| toastWithDefaults.variant === 'error' ? 'assertive' : 'polite', | ||
| toastWithDefaults.duration, | ||
| ); | ||
| const timeoutId = setTimeout(() => { |
There was a problem hiding this comment.
⚠️ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Summary
Fixes WCAG 2.2 SC 4.1.3 (Status Messages) for the toast notification surface by introducing a small, placeable live-region primitive and decoupling the screen-reader announcement from the visual toast.
The earlier approach in this PR put
role="status"/role="alert"+aria-atomicon each per-toast<div>. That inverts the one rule a live region must obey: the region has to exist in the DOM, empty, before its content is inserted. A node that is simultaneously the region and its content (inserted in a single DOM mutation) does not reliably announce — and the polite case (status: success/info/warning/primary, the majority of toasts) is exactly the unreliable one. So the original change likely regressed the SC it set out to fix. This rework replaces it.Approach
A persistent sr-only live region, fed by the toaster store — one announcement per toast, independent of the animated visual node:
src/lib/stores/announcer.ts(new)createAnnouncer()— a buffer of{ id, message, politeness }.announce()appends a uniquely-keyed node (append, not clear-and-reset); each is removed aftermax(toast duration, 7s).clear()empties the buffer and cancels pending timers.src/lib/holocene/live-region.svelte(new)aria-live="polite"andaria-live="assertive", botharia-relevant="additions"; messages routed by politeness, keyed byid. Optionaldata-testidapplied to both regions (-polite/-assertive).src/lib/stores/toaster.tspush()callsannounce(message, variant === 'error' ? 'assertive' : 'polite', duration); exposesannouncements;clear()also clears the announcer.src/lib/holocene/toaster.svelte<LiveRegion>(optional self-subscribingannouncementsprop, defaults to the singleton store); container keeps norole.src/lib/holocene/toast.svelterole/aria-atomicremoved.Why this shape
aria-liveon the wrapper (notrole) avoids iOS-VoiceOver double-speak;aria-relevant="additions"keeps the timed removals silent.aria-modalunder VoiceOver+Safari (W3C ARIA Update drawer types and x color props #1854). Modal-scoped messages (future, e.g. a11y(2.2.1): ui-main — warn the user before session expiry and offer an extend affordance #3531) must mount their own<LiveRegion>inside the dialog; DOM-resident status (e.g. a11y(4.1.3): Add live-region wrappers for workflow status, event count, filter counts, and Banner #3561) announces in place. This PR scopes to toasts only.Test plan
.sr-only[aria-live="polite"]/[aria-live="assertive"]regions exist before any toast fires; the visible container<div>and each toast<div>carry norole.Automated: unit tests for
createAnnouncer(politeness mapping, lifetime/timeout, distinct ids, clear) and toaster store wiring; an integration assertion that the live regions pre-exist and a toast message routes to the polite region.Downstream (cloud-ui)
announcementsis an optional prop defaulting to the singleton, so cloud-ui's two<Toaster>mounts compile and work unchanged after the@temporalio/uipack bump — no cloud-ui edits required.A11y-Audit-Ref: 4.1.3-toaster-role-status-alert
🤖 Generated with Claude Code