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();
+}