a11y: app toast notifications become a live region for screen readers

showAppToast spawns / reuses a single floating toast at the bottom-
right of the window for transient status (e.g. "1 new VOD auto-queued",
"Cannot start recording", etc). The toast had no a11y semantics —
screen readers never announced it, so the entire transient-feedback
channel was silent for AT users.

Promoted the toast container to a live region:
- role="status" for info toasts + aria-live="polite" so the reader
  waits for a natural break in current speech before announcing
- role="alert" for warn toasts + aria-live="assertive" so the reader
  interrupts whatever it was saying (matches the visual amber-left-
  border meaning — warn IS urgent)
- aria-atomic="true" so the reader announces the whole message at
  once instead of attempting to diff against the previous toast

Critical detail: aria-live attributes have to be in place BEFORE the
text changes for AT to register the change as a live-region update.
The current implementation now sets role / aria-live first and only
then writes the new textContent.

WCAG 4.1.3 — Status Messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 04:40:32 +02:00
parent 7be9453762
commit 5d5e58ae09

View File

@ -689,14 +689,28 @@ function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
toast = document.createElement('div'); toast = document.createElement('div');
toast.id = 'appToast'; toast.id = 'appToast';
toast.className = 'app-toast'; toast.className = 'app-toast';
// Live region — screen readers announce the toast text whenever
// it changes. Warn toasts go through aria-live="assertive" so the
// reader interrupts whatever it was speaking; info toasts use
// "polite" so they wait for a natural break in current speech.
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
document.body.appendChild(toast); document.body.appendChild(toast);
} }
toast.textContent = message;
toast.classList.remove('warn', 'show'); toast.classList.remove('warn', 'show');
if (type === 'warn') { if (type === 'warn') {
toast.classList.add('warn'); toast.classList.add('warn');
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
} else {
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
} }
// Setting textContent AFTER the aria-live attribute is in place
// ensures the change is captured as a live-region update by AT.
toast.textContent = message;
requestAnimationFrame(() => { requestAnimationFrame(() => {
toast?.classList.add('show'); toast?.classList.add('show');