feat: add app-wide live regions for screen reader announcements

This commit is contained in:
Linus Rath
2026-10-29 17:53:44 +01:00
parent 420bf8f9a4
commit 36d13b76bd
5 changed files with 263 additions and 3 deletions
-3
View File
@@ -5600,9 +5600,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/>
)}
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{pendingMailtoAccountChoice && (
<ProtocolAccountPicker
@@ -2,6 +2,7 @@
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
import { ToastContainer } from '@/components/ui/toast';
import { LiveRegions } from '@/components/ui/live-regions';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
@@ -14,6 +15,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode
<>
{children}
<ToastContainer toasts={toasts} onClose={removeToast} />
<LiveRegions />
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { ensureLiveRegions } from "@/lib/announce";
/**
* Puts the app-wide screen reader regions (lib/announce) into the page as
* soon as an app starts, so they are there before the first announcement.
* Renders nothing itself: the regions live at the end of <body>.
*/
export function LiveRegions() {
useEffect(() => {
ensureLiveRegions();
}, []);
return null;
}
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { announce, cancelAnnouncement, ensureLiveRegions, LIVE_REGION_IDS, resetAnnouncerForTests } from '../announce';
const polite = () => document.getElementById(LIVE_REGION_IDS.polite);
const assertive = () => document.getElementById(LIVE_REGION_IDS.assertive);
describe('announce', () => {
beforeEach(() => {
vi.useFakeTimers();
resetAnnouncerForTests();
document.body.innerHTML = '';
});
afterEach(() => {
vi.useRealTimers();
});
it('creates one polite and one assertive region, once', () => {
ensureLiveRegions();
ensureLiveRegions();
expect(document.querySelectorAll(`#${LIVE_REGION_IDS.polite}`)).toHaveLength(1);
expect(polite()).toHaveAttribute('aria-live', 'polite');
expect(polite()).toHaveAttribute('role', 'status');
expect(assertive()).toHaveAttribute('aria-live', 'assertive');
expect(assertive()).toHaveAttribute('role', 'alert');
});
it('writes a message to the polite region after clearing it', () => {
announce('Moved to Archive');
expect(polite()?.textContent).toBe('');
vi.advanceTimersByTime(100);
expect(polite()?.textContent).toBe('Moved to Archive');
expect(assertive()?.textContent).toBe('');
});
it('sends errors to the assertive region', () => {
announce('Could not delete', { assertive: true });
vi.advanceTimersByTime(100);
expect(assertive()?.textContent).toBe('Could not delete');
expect(polite()?.textContent).toBe('');
});
it('does not repeat the same message within a short time, but does later', () => {
announce('Draft saved');
vi.advanceTimersByTime(100);
polite()!.textContent = '';
announce('Draft saved');
vi.advanceTimersByTime(100);
expect(polite()?.textContent).toBe('');
vi.advanceTimersByTime(3000);
announce('Draft saved');
vi.advanceTimersByTime(100);
expect(polite()?.textContent).toBe('Draft saved');
});
it('reads messages that arrive together as one', () => {
announce('Email archived');
announce('3 new messages');
vi.advanceTimersByTime(100);
expect(polite()?.textContent).toBe('Email archived 3 new messages');
});
it('reads only the last debounced message of a burst', () => {
announce('Inbox, 10 messages', { key: 'list', debounceMs: 500 });
vi.advanceTimersByTime(200);
announce('Inbox, 12 messages', { key: 'list', debounceMs: 500 });
vi.advanceTimersByTime(700);
expect(polite()?.textContent).toBe('Inbox, 12 messages');
});
it('drops a cancelled debounced message', () => {
announce('12 results', { key: 'search', debounceMs: 500 });
cancelAnnouncement('search');
vi.advanceTimersByTime(1000);
expect(polite()?.textContent ?? '').toBe('');
});
it('clears old text after a while', () => {
announce('Email deleted');
vi.advanceTimersByTime(100);
expect(polite()?.textContent).toBe('Email deleted');
vi.advanceTimersByTime(8000);
expect(polite()?.textContent).toBe('');
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Screen reader announcements: short status messages ("Moved to Archive",
* "3 new messages") read out without moving the focus.
*
* Two live regions are shared by the whole app, one polite and one
* assertive. They sit at the end of <body>, visually hidden, and are created
* on first use or by <LiveRegions /> when an app starts, so a region already
* exists when the first message arrives (some screen readers ignore a region
* that appears together with its text).
*
* Errors go to the assertive region, everything else to the polite one. The
* helper keeps the chatter down: the same message is not repeated within a
* couple of seconds, messages that arrive together are read as one, and a
* caller can debounce a message under a key so only the last one of a burst
* is read (a folder that loads in two steps, a count that changes while
* typing).
*/
export type Politeness = 'polite' | 'assertive';
export const LIVE_REGION_IDS: Record<Politeness, string> = {
polite: 'a11y-live-polite',
assertive: 'a11y-live-assertive',
};
/** The same text again within this time is not read a second time. */
const REPEAT_WINDOW_MS = 2000;
/**
* The region is emptied first and filled a moment later: screen readers read
* a change, and an unchanged text written again is no change.
*/
const WRITE_DELAY_MS = 60;
/** Old text is cleared, so browsing the page later doesn't land on it. */
const CLEAR_AFTER_MS = 7000;
export interface AnnounceOptions {
/** Interrupts the screen reader. For errors only. */
assertive?: boolean;
/**
* Waits this long and reads only the last message given under `key` in
* that time.
*/
debounceMs?: number;
/** Groups messages for `debounceMs`; also the repeat check's key. Defaults to the message. */
key?: string;
}
interface RegionState {
queue: string[];
writeTimer: ReturnType<typeof setTimeout> | null;
clearTimer: ReturnType<typeof setTimeout> | null;
}
const regions: Record<Politeness, RegionState> = {
polite: { queue: [], writeTimer: null, clearTimer: null },
assertive: { queue: [], writeTimer: null, clearTimer: null },
};
const lastSaid = new Map<string, number>();
const debounced = new Map<string, ReturnType<typeof setTimeout>>();
// Visually hidden, but read by screen readers (the usual "sr-only" rules,
// inline so the regions don't depend on the app's stylesheet).
const HIDDEN_STYLE =
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
/** Creates the two regions when they aren't in the page yet. */
export function ensureLiveRegions(doc: Document = document): void {
if (!doc.body) return;
for (const politeness of ['polite', 'assertive'] as const) {
if (doc.getElementById(LIVE_REGION_IDS[politeness])) continue;
const el = doc.createElement('div');
el.id = LIVE_REGION_IDS[politeness];
el.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status');
el.setAttribute('aria-live', politeness);
el.setAttribute('aria-atomic', 'true');
el.setAttribute('style', HIDDEN_STYLE);
doc.body.appendChild(el);
}
}
function regionElement(politeness: Politeness): HTMLElement | null {
ensureLiveRegions();
return document.getElementById(LIVE_REGION_IDS[politeness]);
}
function flush(politeness: Politeness): void {
const state = regions[politeness];
state.writeTimer = null;
const text = state.queue.join(' ');
state.queue = [];
const el = regionElement(politeness);
if (!el || !text) return;
el.textContent = text;
if (state.clearTimer) clearTimeout(state.clearTimer);
state.clearTimer = setTimeout(() => {
state.clearTimer = null;
if (el.textContent === text) el.textContent = '';
}, CLEAR_AFTER_MS);
}
function say(message: string, politeness: Politeness, key: string): void {
const now = Date.now();
const last = lastSaid.get(`${politeness}\u0000${key}\u0000${message}`);
if (last !== undefined && now - last < REPEAT_WINDOW_MS) return;
lastSaid.set(`${politeness}\u0000${key}\u0000${message}`, now);
if (lastSaid.size > 50) {
for (const [k, at] of lastSaid) if (now - at >= REPEAT_WINDOW_MS) lastSaid.delete(k);
}
const state = regions[politeness];
if (!state.queue.includes(message)) state.queue.push(message);
if (state.writeTimer) return;
const el = regionElement(politeness);
if (el) el.textContent = '';
state.writeTimer = setTimeout(() => flush(politeness), WRITE_DELAY_MS);
}
/**
* Reads `message` out to screen reader users. Use plain, short text that
* makes sense on its own; never message content (subjects, senders).
*/
export function announce(message: string, options: AnnounceOptions = {}): void {
if (typeof document === 'undefined') return;
const text = message.replace(/\s+/g, ' ').trim();
if (!text) return;
const politeness: Politeness = options.assertive ? 'assertive' : 'polite';
const key = options.key ?? text;
if (options.debounceMs && options.debounceMs > 0) {
const timerKey = `${politeness}\u0000${key}`;
const pending = debounced.get(timerKey);
if (pending) clearTimeout(pending);
debounced.set(timerKey, setTimeout(() => {
debounced.delete(timerKey);
say(text, politeness, key);
}, options.debounceMs));
return;
}
say(text, politeness, key);
}
/** Drops a debounced message that hasn't been read yet. */
export function cancelAnnouncement(key: string, assertive = false): void {
const timerKey = `${assertive ? 'assertive' : 'polite'}\u0000${key}`;
const pending = debounced.get(timerKey);
if (pending) clearTimeout(pending);
debounced.delete(timerKey);
}
/** Tests only: forget what was said and drop pending writes. */
export function resetAnnouncerForTests(): void {
for (const state of Object.values(regions)) {
if (state.writeTimer) clearTimeout(state.writeTimer);
if (state.clearTimer) clearTimeout(state.clearTimer);
state.queue = [];
state.writeTimer = null;
state.clearTimer = null;
}
for (const timer of debounced.values()) clearTimeout(timer);
debounced.clear();
lastSaid.clear();
}