/** * NAM-403: the pure decision logic behind full-page screenreader announcements. * RN-free and side-effect-free so it runs under the jest node environment. The * React AnnouncementProvider owns state (live-region text, video subscription) * and delegates every decision here. * * Contract (see TV Full-Page Announcement): * - First focus of a primary button announces the composite (page text + label). * - Subsequent focuses and auxiliary buttons speak only their own label, which * the OS screenreader reads naturally — so this machine returns null for them. * - While a video plays, the announcement is deferred and flushed on video end. */ import type { ButtonAnnouncementKind } from './a11yAnnouncement'; export interface AnnouncementFocusInput { kind: ButtonAnnouncementKind; ownLabel: string; videoPlaying: boolean; buildComposite: () => string; } export interface AnnouncementMachine { /** Returns text to speak now via the live region, or null (natural read / deferred). */ onFocus(input: AnnouncementFocusInput): string | null; /** Flush a deferred announcement when video playback stops. */ onVideoStopped(): string | null; /** Reset per-page state (call on page change). */ reset(): void; } export function createAnnouncementMachine(): AnnouncementMachine { let pageAnnounced = false; let deferred: string | null = null; return { onFocus({ kind, ownLabel, videoPlaying, buildComposite }) { if (videoPlaying) { // Defer: the button is hidden from a11y during playback, so nothing is // spoken now; the flush on video end says the composite (first primary // focus) or the focused button's own label. deferred = kind === 'primary' && !pageAnnounced ? buildComposite() : ownLabel; if (kind === 'primary') pageAnnounced = true; return null; } if (kind === 'auxiliary') return null; // natural focus read handles it if (pageAnnounced) return null; // subsequent focus -> natural read pageAnnounced = true; return buildComposite(); }, onVideoStopped() { if (deferred == null) return null; const text = deferred; deferred = null; return text; }, reset() { pageAnnounced = false; deferred = null; }, }; }