a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560
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>
This comment was marked as outdated.
This comment was marked as outdated.
…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'.
| <div class={toast({ position: $position })} role="log"> | ||
| <div class={toast({ position: $position })}> | ||
| <LiveRegion messages={$announcements} data-testid="toast-live-region" /> | ||
| {#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'.
Add an optional `politeness` prop ('polite' | 'assertive' | 'both',
default 'both') so consumers that only ever emit one politeness level
(e.g. status chips, filter counts, the session-warning countdown) don't
render an unused live region. Zone selection must be declared at
instantiation because a live region must pre-exist empty before content
is inserted — it cannot be derived from current message content.
Toaster keeps the default 'both' (success=polite, error=assertive), so
its behavior and the existing integration contract are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…render" This reverts commit 9d60456.
Update — reworked onto a shared LiveRegion/announcer primitiveWhat changed since the original PRThe original approach (per-toast
Final architecture
Decisions
Out of scope (follow-up: "ambient SR announcements")Audited
Banners/modals/status-chips/counts excluded (persistent or in-place → element-level live regions). Remaining gateManual NVDA + VoiceOver verification (polite no-interrupt, error interrupts, both regions pre-exist) before leaving draft. Note: VoiceOver coalesces rapid polite updates (latest-wins) — expected SR behavior, not a regression. |
| clear: () => void; | ||
| } | ||
|
|
||
| const DEFAULT_TIMEOUT = 7000; |
There was a problem hiding this comment.
Am I understanding correctly that we are defaulting to a 7 second delay before starting the announcement? Is that normal? IMO it feels like a long time, if something is 'polite' does it need a manual delay at all?
There was a problem hiding this comment.
It's not a delay on the announcement itself, but rather how long the announcement text stays in the DOM. This gives the screen reader a chance to actually pick up the announcement, while also ensuring it gets automatically cleared out of the dom after a certain amount of time.
The default of 7000ms is just directly ripped from other popular implementations, e.g. react-aria
| const polite = $derived( | ||
| messages.filter((message) => message.politeness === 'polite'), | ||
| ); | ||
| const assertive = $derived( | ||
| messages.filter((message) => message.politeness === 'assertive'), | ||
| ); |
There was a problem hiding this comment.
Feel free to ignore this suggestion, but it seems like a good use case for the newish Object.groupBy.
| const polite = $derived( | |
| messages.filter((message) => message.politeness === 'polite'), | |
| ); | |
| const assertive = $derived( | |
| messages.filter((message) => message.politeness === 'assertive'), | |
| ); | |
| const { polite, assertive } = $derived( | |
| Object.groupBy(messages, (message) => message.politeness) | |
| ); |
There was a problem hiding this comment.
pnpm check fails when we try to implement this:
1782322397445 ERROR "src/lib/holocene/live-region.svelte" 19:12 "Property 'groupBy' does not exist on type 'ObjectConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2024' or later."
Looks like it's an ES2024 and beyond feature 😞
tegan-temporal
left a comment
There was a problem hiding this comment.
couple comments, no blockers
Addresses review feedback — replaces the hand-rolled { subscribe }
wrapper with svelte/store's readonly() helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-generated version bump from 2.51.1 to 2.52.0 Bump type: minor Changes included: - [`9c6fcd54`](9c6fcd5) Add direct SSO redirect and backend logout (#3590) - [`feab0d90`](feab0d9) a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics (#3560) - [`d7bbfb01`](d7bbfb0) a11y(2.5.8): toast — replace bare close button with IconButton (36×36 px) (#3553) - [`4efd6ab6`](4efd6ab) a11y(3.3.1): Holocene input primitives — add aria-invalid, aria-describedby, and uniform live-region error announcement (#3554) - [`0600b700`](0600b70) Use green only (#3599) - [`94107549`](9410754) Clarify activity pause timeout behavior (#3600) - [`9f88d5a8`](9f88d5a) Bump node version to 22.23.1 or newer to fix issue from previous security release (#3604) - [`ca4104ae`](ca4104a) Update reason copy and inputs (#3588) - [`51db73fa`](51db73f) Update batch operation reason input hint (#3605) - [`836d7c55`](836d7c5) a11y(2.4.3): route navigation — move focus to the main landmark on afterNavigate, not just scroll (#3538) - [`4ee95696`](4ee9569) Improve re-renders for large encoded event histories (#3592) - [`fa5e7aa1`](fa5e7aa) feature/schedules (#3603) - [`0d9467fd`](0d9467f) Default includeHeartbeatDetails and includeLastFailure to true for getActivityExecution and pollActivityExecution (#3521) - [`e5bc514e`](e5bc514) fix schedules: duplicate key (#3612) - [`f1eb489f`](f1eb489) Remove preview badge (#3581) - [`1e5173b4`](1e5173b) Add existing versions to worker create deployment version form (#3601) - [`a8552bae`](a8552ba) fix schedules -e.trim not a function (#3613) - [`0442f847`](0442f84) Fix width of ComputeBadge in VersionTableRow (#3615) - [`ddbeb54f`](ddbeb54) fix(cloud-nav): animate and stabilize side-nav collapse (#3606) - [`3cfc684b`](3cfc684) fix - schedules recent/upcoming runs - sort issue (#3616) - [`92b8875b`](92b8875) Move timestamp out of translate (#3617) - [`901258af`](901258a) Fix SAA and WF consistency (#3614) - [`99593d97`](99593d9) DT-4001 - standalone nexus operations (#3496) - [`f10d1bf1`](f10d1bf) Clamp codeblock to container width (#3621) - [`0a9d93ce`](0a9d93c) Remove unnecessary bottom-0 (#3623) - [`9dabb522`](9dabb52) chore(deps-dev): bump vite from 6.4.2 to 6.4.3 (#3610) - [`5c1854d3`](5c1854d) News Feed (#3596)
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