diff --git a/src/primitives/announcer/speech.ts b/src/primitives/announcer/speech.ts index ec3b82d..5d8f355 100644 --- a/src/primitives/announcer/speech.ts +++ b/src/primitives/announcer/speech.ts @@ -58,6 +58,12 @@ function addChildrenToAriaDiv(phrase: AriaLabel) { * @description This function is triggered finally when the speak series is finished and we are to speak the aria labels */ function focusElementForAria() { + // Nothing new to announce (e.g. a canceled series). Leave whatever the live + // region currently holds in place so an in-progress screen reader isn't cut off. + if (ariaLabelPhrases.length === 0) { + return; + } + const element = createAriaElement(); if (!element) { @@ -65,6 +71,12 @@ function focusElementForAria() { return; } + // Replace-on-next-write: drop the previous announcement's nodes, then inject + // the current label. The label stays in the assertive live region until the + // *next* announcement replaces it — rather than being torn down on a timer — + // so on-device TV screen readers have time to finish reading it. + cleanAriaLabelParent(); + for (const object of ariaLabelPhrases) { const span = document.createElement('span'); @@ -74,12 +86,7 @@ function focusElementForAria() { element.appendChild(span); } - // Cleanup - setTimeout(() => { - ariaLabelPhrases = []; - cleanAriaLabelParent(); - focusCanvas(); - }, 100); + ariaLabelPhrases = []; } /** @@ -94,14 +101,6 @@ function cleanAriaLabelParent(): void { } } -/** - * @description Focus the canvas element - */ -function focusCanvas(): void { - const canvas = document.getElementById('app')?.firstChild as HTMLElement; - canvas?.focus(); -} - /** * @description Create the aria element in the DOM if it doesn't exist * @private For xbox, we may need to create a different element each time we wanna use aria @@ -299,19 +298,14 @@ function speakSeries( if (root) { if (aria) { - const element = createAriaElement(); - - if (element) { - ariaLabelPhrases = []; - cleanAriaLabelParent(); - element.focus(); - focusCanvas(); - } - - return; + // Replace-on-next-write: don't tear down the live region here. The + // current label stays until the next announcement replaces it, so the + // screen reader can finish. Just drop any partially accumulated + // phrases from this canceled series. + ariaLabelPhrases = []; + } else { + synth.cancel(); // Cancel all ongoing speech } - - synth.cancel(); // Cancel all ongoing speech } nestedSeriesResults.forEach((nestedSeriesResult) => { nestedSeriesResult.cancel(); diff --git a/tests/announcer-aria.spec.ts b/tests/announcer-aria.spec.ts new file mode 100644 index 0000000..efc407a --- /dev/null +++ b/tests/announcer-aria.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import speak from '../src/primitives/announcer/speech.ts'; + +const ARIA_PARENT_ID = 'aria-parent'; + +function ariaParent(): HTMLElement | null { + return document.getElementById(ARIA_PARENT_ID); +} + +function ariaLabels(): string[] { + const parent = ariaParent(); + if (!parent) return []; + return Array.from(parent.querySelectorAll('span')).map( + (span) => span.getAttribute('aria-label') ?? '', + ); +} + +describe('Announcer aria mode (replace-on-next-write)', () => { + beforeEach(() => { + // Start each test from a clean DOM. The aria region is a module-level + // singleton appended to , so reset it explicitly. + ariaParent()?.remove(); + }); + + it('injects the label into the assertive live region', async () => { + await speak(['Hello', 'button'], true).series; + + const parent = ariaParent(); + expect(parent).toBeTruthy(); + expect(parent!.getAttribute('aria-live')).toBe('assertive'); + + const labels = ariaLabels(); + expect(labels.length).toBe(1); + expect(labels[0]).toContain('Hello'); + expect(labels[0]).toContain('button'); + }); + + it('leaves the label in place well past the old 100ms teardown window', async () => { + await speak(['Persisted label'], true).series; + expect(ariaLabels()[0]).toContain('Persisted label'); + + // The previous implementation deleted the nodes after 100ms, so an + // on-device screen reader never got to read them. Verify they survive. + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(ariaLabels().length).toBe(1); + expect(ariaLabels()[0]).toContain('Persisted label'); + }); + + it('replaces the previous label on the next announcement', async () => { + await speak(['First message'], true).series; + expect(ariaLabels()[0]).toContain('First message'); + + await speak(['Second message'], true).series; + + const labels = ariaLabels(); + expect(labels.length).toBe(1); + expect(labels[0]).toContain('Second message'); + expect(labels[0]).not.toContain('First message'); + }); + + it('does not wipe the live region when a follow-up series is canceled', async () => { + await speak(['Keep me'], true).series; + expect(ariaLabels()[0]).toContain('Keep me'); + + const next = speak(['Should not appear'], true); + next.cancel(); + await next.series; + + // The canceled series must not blank out the region; the prior label stays + // until a real announcement replaces it. + const labels = ariaLabels(); + expect(labels.length).toBe(1); + expect(labels[0]).toContain('Keep me'); + expect(labels[0]).not.toContain('Should not appear'); + }); +});