/** * Screen-reader announcer for transient admin messages (toasts). * * Two regions are created once and left in the document: assistive tech only * reports mutations that happen *inside* a live region it has already seen, so a * region that is mounted together with its text — or that is revealed from * `aria-hidden`/`display:none` in the same commit as its text — is routinely * missed. Keeping both regions permanently mounted and empty removes that class * of failure. * * Politeness is fixed per region rather than swapped on one node, because * `role`/`aria-live` are read when the live region is registered; mutating them * on an existing node re-creates the accessible object and drops the update that * arrived with it. * * This mirrors `@wordpress/a11y`'s `speak()`. That package is not used directly: * these bundles externalize only `@wordpress/i18n` to `wp.i18n`, so consuming * `wp.a11y` would require enqueuing the `wp-a11y` script from PHP. */ export type AnnouncerPoliteness = "polite" | "assertive"; export const ANNOUNCER_ROOT_ID = "b3-wvs-a11y-announcer"; const REGION_STYLE = [ "position:absolute", "width:1px", "height:1px", "margin:-1px", "padding:0", "overflow:hidden", "clip:rect(0 0 0 0)", "clip-path:inset(50%)", "white-space:nowrap", "border:0", ].join(";"); function createRegion( doc: Document, politeness: AnnouncerPoliteness, ): HTMLElement { const region = doc.createElement("div"); region.id = `${ANNOUNCER_ROOT_ID}-${politeness}`; region.setAttribute("role", politeness === "assertive" ? "alert" : "status"); region.setAttribute("aria-live", politeness); region.setAttribute("aria-atomic", "true"); region.setAttribute("aria-relevant", "additions text"); region.style.cssText = REGION_STYLE; return region; } /** * Creates the announcer regions if they are absent. Idempotent. Call once on * mount so the regions are registered before the first message is written. */ export function ensureAnnouncerRegions( doc: Document = document, ): HTMLElement | null { if (!doc?.body) { return null; } const existing = doc.getElementById(ANNOUNCER_ROOT_ID); if (existing) { return existing; } const root = doc.createElement("div"); root.id = ANNOUNCER_ROOT_ID; root.appendChild(createRegion(doc, "polite")); root.appendChild(createRegion(doc, "assertive")); doc.body.appendChild(root); return root; } /** * Announces `message` in the region matching `politeness`. * * Both regions are cleared first so a stale message is never re-read, and a * message identical to the one announced immediately before gets a trailing * non-breaking space. Without it the assigned text would equal the text already * in the region, no mutation would be recorded, and nothing would be announced — * which is what makes a repeated "Settings saved." silent. */ export function announce( message: string, politeness: AnnouncerPoliteness = "polite", doc: Document = document, ): void { const root = ensureAnnouncerRegions(doc); if (!root) { return; } const polite = doc.getElementById(`${ANNOUNCER_ROOT_ID}-polite`); const assertive = doc.getElementById(`${ANNOUNCER_ROOT_ID}-assertive`); if (!polite || !assertive) { return; } polite.textContent = ""; assertive.textContent = ""; const text = message.trim(); if (!text) { delete root.dataset.lastSpoken; return; } const spoken = root.dataset.lastSpoken === text ? `${text}\u00a0` : text; root.dataset.lastSpoken = spoken; (politeness === "assertive" ? assertive : polite).textContent = spoken; }