Skip to content

a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560

Open
rosanusi wants to merge 8 commits into
mainfrom
wcag/4.1.3-toaster-role-status-alert
Open

a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560
rosanusi wants to merge 8 commits into
mainfrom
wcag/4.1.3-toaster-role-status-alert

Conversation

@rosanusi

@rosanusi rosanusi commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

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-atomic on 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:

File Change
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 after max(toast duration, 7s). clear() empties the buffer and cancels pending timers.
src/lib/holocene/live-region.svelte (new) Two always-rendered sr-only regions — aria-live="polite" and aria-live="assertive", both aria-relevant="additions"; messages routed by politeness, keyed by id. Optional data-testid applied to both regions (-polite/-assertive).
src/lib/stores/toaster.ts push() calls announce(message, variant === 'error' ? 'assertive' : 'polite', duration); exposes announcements; clear() also clears the announcer.
src/lib/holocene/toaster.svelte Mounts <LiveRegion> (optional self-subscribing announcements prop, defaults to the singleton store); container keeps no role.
src/lib/holocene/toast.svelte Reverted to purely visual — per-toast role/aria-atomic removed.

Why this shape

Test plan

  • Screen-reader smoke test (VoiceOver + NVDA) — success/info/warning/primary toast announces politely (no interrupt); error toast announces assertively (interrupts). (Note: under a rapid burst, screen readers coalesce same-region polite updates to latest-wins; this matches react-aria/Radix and is expected.)
  • DOM: two .sr-only[aria-live="polite"] / [aria-live="assertive"] regions exist before any toast fires; the visible container <div> and each toast <div> carry no role.
  • Workflow client-action confirmations (Terminate / Cancel / Reset / Signal) fire a toast that renders and announces.
  • axe-core: no new violations on a page with a live toast.

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)

announcements is an optional prop defaulting to the singleton, so cloud-ui's two <Toaster> mounts compile and work unchanged after the @temporalio/ui pack bump — no cloud-ui edits required.

A11y-Audit-Ref: 4.1.3-toaster-role-status-alert

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment Jun 20, 2026 12:41am

Request Review

@github-actions github-actions Bot added a11y Accessibility audit PR a11y:bucket-3 Bucket 3: engineer required a11y:sc-4.1.3 labels Jun 12, 2026
@temporal-cicd

temporal-cicd Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

📊 Strict Mode: 3 errors in 2 files (0.3% of 897 total)

src/lib/stores/toaster.ts (1)
  • L38:8: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/lib/holocene/toaster.svelte (2)
  • L54:40: Type 'ToastVariant | undefined' is not assignable to type 'ToastVariant'.
  • L54:50: Type 'string | undefined' is not assignable to type 'string'.

Generated by 🚫 dangerJS against 4276cb5

@rosanusi rosanusi marked this pull request as ready for review June 12, 2026 16:30
@rosanusi rosanusi requested a review from a team as a code owner June 12, 2026 16:30
ardiewen and others added 5 commits June 19, 2026 16:18
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)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ⚠️ Type 'ToastVariant | undefined' is not assignable to type 'ToastVariant'.
  • ⚠️ Type 'string | undefined' is not assignable to type 'string'.

@ardiewen

Copy link
Copy Markdown
Contributor

Reworked: per-toast roles → placeable LiveRegion + createAnnouncer()

Pushed a rework of this PR's approach. The original commit put role="status"/role="alert" + aria-atomic on each per-toast <div>. That inverts the one structurally-correct property of a live region: the region must already exist in the DOM, empty, before its content is inserted. A node that is both the region and its content (inserted in one mutation) does not reliably announce — and the polite case (status, i.e. success/info/warning/primary — the majority of toasts) is exactly the unreliable one. role="alert" is a documented special case that usually announces on insert, so error toasts mostly survived, but the common path likely regressed the SC this PR claims to fix.

New approach

A small, placeable live-region primitive — the announcement is decoupled from the visual toast:

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-live on the wrapper (not role) 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-modal under 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: announcements is 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 (the aria-modal inert-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).

ardiewen and others added 2 commits June 19, 2026 20:35
…on contract

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread src/lib/stores/toaster.ts
toastWithDefaults.variant === 'error' ? 'assertive' : 'polite',
toastWithDefaults.duration,
);
const timeoutId = setTimeout(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ⚠️ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y:bucket-3 Bucket 3: engineer required a11y:sc-4.1.3 a11y Accessibility audit PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants