Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 20 additions & 26 deletions src/primitives/announcer/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,25 @@ 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) {
console.error(`ARIA div not found: ${ARIA_PARENT_ID}`);
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');

Expand All @@ -74,12 +86,7 @@ function focusElementForAria() {
element.appendChild(span);
}

// Cleanup
setTimeout(() => {
ariaLabelPhrases = [];
cleanAriaLabelParent();
focusCanvas();
}, 100);
ariaLabelPhrases = [];
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
77 changes: 77 additions & 0 deletions tests/announcer-aria.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <body>, 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');
});
});
Loading