Skip to content
Source
Sign inSign up

announce.ts

56 lines2.2 KB

1// Screen readers skip a live region that appears already filled, and moving focus cuts off
2// whatever they were saying. So the regions exist before the first message lands, and
3// messages wait until focus has stayed put for a moment, then are spoken together.
4const FOCUS_SETTLE_MS = 250;
5​
6let regions: readonly [HTMLElement, HTMLElement] | null = null;
7let pending: { message: string; source: string | undefined }[] = [];
8let flushTimer: ReturnType<typeof setTimeout> | undefined;
9​
10function scheduleFlush() {
11 clearTimeout(flushTimer);
12 flushTimer = setTimeout(flush, FOCUS_SETTLE_MS);
14​
15function flush() {
16 if (!regions || pending.length === 0) return;
17 const [first, second] = regions;
18 // Alternating between two regions lets the same message be spoken twice in a row.
19 const target = first.textContent ? second : first;
20 first.textContent = "";
21 second.textContent = "";
22 target.textContent = pending.map((entry) => entry.message).join(". ");
23 pending = [];
25​
26function createRegion() {
27 const region = document.createElement("div");
28 region.className = "sr-only";
29 region.setAttribute("aria-live", "polite");
30 region.setAttribute("aria-atomic", "true");
31 document.body.append(region);
32 return region;
34​
35/**
36 * Tells screen reader users what an action did when focus alone doesn't show it. A message
37 * replaces one still waiting from the same `source`, so a superseded status isn't read out.
38 */
39export function announce(message: string, source?: string) {
40 // Stores announce without a mounted owner, so they also run where no DOM exists (unit tests).
41 if (typeof document === "undefined") return;
42 if (!regions) {
43 regions = [createRegion(), createRegion()];
44 document.addEventListener("focusin", () => {
45 if (pending.length > 0) scheduleFlush();
46 });
47 }
48 if (source !== undefined) pending = pending.filter((entry) => entry.source !== source);
49 if (pending.at(-1)?.message !== message) pending.push({ message, source });
50 scheduleFlush();
52​
53/** Drops a message from `source` that hasn't been spoken yet, once it no longer applies. */
54export function cancelAnnouncement(source: string) {
55 pending = pending.filter((entry) => entry.source !== source);