mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: add app-wide live regions for screen reader announcements
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user