{"version":3,"file":"use-announce.cjs","names":[],"sources":["../../src/hooks/use-announce.ts"],"sourcesContent":["import { useCallback, useEffect } from \"react\";\n\n/**\n * How urgently a screen reader should interrupt.\n *\n * `\"polite\"` waits for a pause in what is being read. `\"assertive\"` cuts in\n * immediately, which is right for an error the user must act on and wrong for\n * everything else — an assertive announcement can truncate the sentence the user\n * was in the middle of.\n */\nexport type AnnouncePoliteness = \"polite\" | \"assertive\";\n\n/** How long an announcement stays in the DOM before it is cleaned up. */\nconst CLEAR_AFTER_MS = 7_000;\n\n/**\n * Inline styles that hide an element visually while leaving it readable.\n *\n * Inline and not a CSS module on purpose: these nodes are created imperatively by\n * a hook, and a hook that only works when the app remembered to import\n * `tempest-react-sdk/styles.css` would drop two visible, unstyled paragraphs into\n * the page — a visual bug caused by an accessibility feature.\n */\nconst HIDDEN_STYLE: Partial<CSSStyleDeclaration> = {\n    position: \"absolute\",\n    width: \"1px\",\n    height: \"1px\",\n    margin: \"-1px\",\n    padding: \"0\",\n    border: \"0\",\n    overflow: \"hidden\",\n    clip: \"rect(0 0 0 0)\",\n    clipPath: \"inset(50%)\",\n    whiteSpace: \"nowrap\",\n};\n\ninterface Regions {\n    polite: HTMLElement;\n    assertive: HTMLElement;\n}\n\nlet regions: Regions | null = null;\nconst timers = new Set<ReturnType<typeof setTimeout>>();\n\n/**\n * Build one live region.\n *\n * The polite region carries `role=\"status\"`; the assertive one carries only\n * `aria-live=\"assertive\"`. `role=\"alert\"` is deliberately **not** used: these\n * regions live in the document for the page's whole life, and an empty element\n * claiming to be an alert is both a lie about its content and a trap for every\n * `getByRole(\"alert\")` in a consuming app's test suite, which would suddenly match\n * two nodes. A bare `aria-live` region is announced by every screen reader that\n * supports live regions at all.\n *\n * @param politeness - Which region to build.\n * @returns The region, already appended to `<body>`.\n */\nfunction createRegion(politeness: AnnouncePoliteness): HTMLElement {\n    const node = document.createElement(\"div\");\n    if (politeness === \"polite\") node.setAttribute(\"role\", \"status\");\n    node.setAttribute(\"aria-live\", politeness);\n    node.setAttribute(\"aria-atomic\", \"true\");\n    node.setAttribute(\"data-tempest-announcer\", politeness);\n    Object.assign(node.style, HIDDEN_STYLE);\n    document.body.appendChild(node);\n    return node;\n}\n\n/**\n * Get the two shared live regions, creating them on first use.\n *\n * **Two regions, not one with a switchable `aria-live`.** Politeness is a property\n * of the region, read when the assistive technology first registers it — flipping\n * the attribute later is honoured by some screen readers, ignored by others, and\n * in the worst case drops the announcement entirely. Two regions that never change\n * are the only version that behaves the same everywhere.\n *\n * **Shared, not one per component.** Every live region on the page is polled;\n * several of them mutating at once is how announcements get dropped or doubled.\n * One pair per document keeps the order deterministic.\n *\n * A pair is replaced as a **pair**, discarding the old one: if only one region was\n * torn out of the DOM (a router that replaced `<body>`, a micro-frontend unmount),\n * keeping its still-attached sibling would leave two regions of that politeness on\n * the page — the exact duplication the shared pair exists to prevent.\n *\n * @returns The polite and assertive regions, or `null` outside a document.\n */\nfunction ensureRegions(): Regions | null {\n    if (typeof document === \"undefined\") return null;\n    if (regions && regions.polite.isConnected && regions.assertive.isConnected) return regions;\n    regions?.polite.remove();\n    regions?.assertive.remove();\n    regions = { polite: createRegion(\"polite\"), assertive: createRegion(\"assertive\") };\n    return regions;\n}\n\n/**\n * Announce a message to screen readers, from anywhere — a hook, an event handler,\n * a plain function outside React.\n *\n * ## Why the same string announces twice\n *\n * Screen readers announce a live region when its **content changes**. Writing the\n * same text again is not a change, so \"Item removido\" twice in a row is read once —\n * the classic reason these announcers are quietly broken. Instead of mutating text,\n * every call replaces the region's child with a **new element**. The DOM mutation is\n * real even when the string is identical, so the second announcement happens, and\n * the reader hears the exact message with no padding characters bolted on.\n *\n * @param message - Text to read out. Empty strings are ignored.\n * @param politeness - `\"polite\"` (default) or `\"assertive\"`.\n *\n * @example\n * announce(`${count} pedidos encontrados`);\n * announce(\"Falha ao salvar\", \"assertive\");\n */\nexport function announce(message: string, politeness: AnnouncePoliteness = \"polite\"): void {\n    if (!message) return;\n    const pair = ensureRegions();\n    if (!pair) return;\n\n    const region = politeness === \"assertive\" ? pair.assertive : pair.polite;\n    region.replaceChildren();\n    const item = document.createElement(\"div\");\n    item.textContent = message;\n    region.appendChild(item);\n\n    const timer = setTimeout(() => {\n        timers.delete(timer);\n        if (item.isConnected) item.remove();\n    }, CLEAR_AFTER_MS);\n    timers.add(timer);\n}\n\n/**\n * Remove the shared regions and cancel pending cleanups.\n *\n * For test teardown and for a micro-frontend being unmounted from a page it does\n * not own. Regular apps never need it — two empty hidden `div`s cost nothing, and\n * tearing them down while another component still announces would lose messages.\n */\nexport function clearAnnouncer(): void {\n    for (const timer of timers) clearTimeout(timer);\n    timers.clear();\n    regions?.polite.remove();\n    regions?.assertive.remove();\n    regions = null;\n}\n\n/**\n * Announce transient messages to screen readers through one shared live region\n * pair.\n *\n * Reach for this when something happened that a sighted user can see and a screen\n * reader user cannot: a filter narrowed a list, a row saved, a copy succeeded, an\n * upload failed. It is **not** for content that is already on screen inside a\n * region with a role — a status pill, a toast that renders as `role=\"status\"`,\n * a form error tied to its input — announcing those again reads them twice.\n *\n * !!! warning \"Never wrap streaming text in a live region\"\n *     A live region over text that grows token by token makes the reader start the\n *     whole answer again on every token. Announce the **edges** instead — \"gerando\n *     resposta\" and \"resposta concluída\" — and leave the transcript in a plain\n *     `role=\"log\"` the user reads at their own pace. `AIChat` does exactly that.\n *\n * Mounting the hook creates the regions even before the first message. That is not\n * tidiness: a live region inserted into the DOM in the same frame as its first\n * content routinely loses that announcement, because the assistive technology has\n * to have registered the region before it can notice a change inside it.\n *\n * @returns A stable `announce(message, politeness?)` function.\n *\n * @example\n * const announce = useAnnounce();\n *\n * function onFilter(rows: Row[]) {\n *     announce(`${rows.length} resultados`);\n * }\n */\nexport function useAnnounce(): (message: string, politeness?: AnnouncePoliteness) => void {\n    useEffect(() => {\n        ensureRegions();\n    }, []);\n\n    return useCallback(\n        (message: string, politeness: AnnouncePoliteness = \"polite\") =>\n            announce(message, politeness),\n        [],\n    );\n}\n"],"mappings":"uBAaA,IAAM,EAAiB,IAUjB,EAA6C,CAC/C,SAAU,WACV,MAAO,MACP,OAAQ,MACR,OAAQ,OACR,QAAS,IACT,OAAQ,IACR,SAAU,SACV,KAAM,gBACN,SAAU,aACV,WAAY,QAChB,EAOI,EAA0B,KACxB,EAAS,IAAI,IAgBnB,SAAS,EAAa,EAA6C,CAC/D,IAAM,EAAO,SAAS,cAAc,KAAK,EAOzC,OANI,IAAe,UAAU,EAAK,aAAa,OAAQ,QAAQ,EAC/D,EAAK,aAAa,YAAa,CAAU,EACzC,EAAK,aAAa,cAAe,MAAM,EACvC,EAAK,aAAa,yBAA0B,CAAU,EACtD,OAAO,OAAO,EAAK,MAAO,CAAY,EACtC,SAAS,KAAK,YAAY,CAAI,EACvB,CACX,CAsBA,SAAS,GAAgC,CAMrC,OALI,OAAO,SAAa,IAAoB,KACxC,GAAW,EAAQ,OAAO,aAAe,EAAQ,UAAU,YAAoB,GACnF,GAAS,OAAO,OAAO,EACvB,GAAS,UAAU,OAAO,EAC1B,EAAU,CAAE,OAAQ,EAAa,QAAQ,EAAG,UAAW,EAAa,WAAW,CAAE,EAC1E,EACX,CAsBA,SAAgB,EAAS,EAAiB,EAAiC,SAAgB,CACvF,GAAI,CAAC,EAAS,OACd,IAAM,EAAO,EAAc,EAC3B,GAAI,CAAC,EAAM,OAEX,IAAM,EAAS,IAAe,YAAc,EAAK,UAAY,EAAK,OAClE,EAAO,gBAAgB,EACvB,IAAM,EAAO,SAAS,cAAc,KAAK,EACzC,EAAK,YAAc,EACnB,EAAO,YAAY,CAAI,EAEvB,IAAM,EAAQ,eAAiB,CAC3B,EAAO,OAAO,CAAK,EACf,EAAK,aAAa,EAAK,OAAO,CACtC,EAAG,CAAc,EACjB,EAAO,IAAI,CAAK,CACpB,CASA,SAAgB,GAAuB,CACnC,IAAK,IAAM,KAAS,EAAQ,aAAa,CAAK,EAC9C,EAAO,MAAM,EACb,GAAS,OAAO,OAAO,EACvB,GAAS,UAAU,OAAO,EAC1B,EAAU,IACd,CAgCA,SAAgB,GAA0E,CAKtF,OAJA,EAAA,EAAA,UAAA,KAAgB,CACZ,EAAc,CAClB,EAAG,CAAC,CAAC,GAEL,EAAO,EAAA,YAAA,EACF,EAAiB,EAAiC,WAC/C,EAAS,EAAS,CAAU,EAChC,CAAC,CACL,CACJ"}