diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 48ca4cada..3e2ce34a0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -5600,9 +5600,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { /> )} - {/* Screen reader live region for dynamic status announcements */} -
- {pendingMailtoAccountChoice && ( {children} + ); } diff --git a/components/ui/live-regions.tsx b/components/ui/live-regions.tsx new file mode 100644 index 000000000..a5e11e8c3 --- /dev/null +++ b/components/ui/live-regions.tsx @@ -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 . + */ +export function LiveRegions() { + useEffect(() => { + ensureLiveRegions(); + }, []); + return null; +} diff --git a/lib/__tests__/announce.test.ts b/lib/__tests__/announce.test.ts new file mode 100644 index 000000000..9a10066fb --- /dev/null +++ b/lib/__tests__/announce.test.ts @@ -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(''); + }); +}); diff --git a/lib/announce.ts b/lib/announce.ts new file mode 100644 index 000000000..66cdba704 --- /dev/null +++ b/lib/announce.ts @@ -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 , visually hidden, and are created + * on first use or by 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 = { + 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 | null; + clearTimer: ReturnType | null; +} + +const regions: Record = { + polite: { queue: [], writeTimer: null, clearTimer: null }, + assertive: { queue: [], writeTimer: null, clearTimer: null }, +}; +const lastSaid = new Map(); +const debounced = new Map>(); + +// 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(); +}