import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { usePaywallContext } from './PaywallContext'; import { buildSmartTextReplacements, interpolateSmartText } from '../utils/smartText'; import { collectPageAnnouncementText, type AnnouncementPage, type ButtonAnnouncementKind, } from '../utils/a11yAnnouncement'; import { createAnnouncementMachine } from '../utils/announcementMachine'; import { getVideoControlState, subscribeVideoControls } from '../utils/videoControls'; export interface AnnouncerValue { isVideoPlaying: boolean; liveRegionText: string; announceOnFocus(kind: ButtonAnnouncementKind, ownLabel: string): void; } const AnnouncementCtx = createContext(null); // Re-announcing identical text will not fire the live region again; append an // alternating zero-width space (U+200B) so each announce is a distinct text value. const ZERO_WIDTH_SPACE = "\u200B"; let toggle = false; function distinct(text: string): string { toggle = !toggle; return toggle ? text : `${text}${ZERO_WIDTH_SPACE}`; } /** * NAM-403: holds per-page announcement state and drives the assertive live region. * Pure decision logic lives in announcementMachine; this provider wires it to * React state, the video-controls subscription, and smart-text resolution. * Reset per page via the `page` dependency. */ export const AnnouncementProvider: React.FC<{ page: AnnouncementPage; children: React.ReactNode }> = ({ page, children, }) => { const ctx = usePaywallContext(); const [liveRegionText, setLiveRegionText] = useState(''); const [isVideoPlaying, setIsVideoPlaying] = useState(() => getVideoControlState().playing); const machineRef = useRef(createAnnouncementMachine()); const prevPlayingRef = useRef(getVideoControlState().playing); const buildComposite = useCallback( (ownLabel: string): string => { const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, undefined); const resolve = (v?: string) => { const out = interpolateSmartText(v ?? '', replacements); return out == null ? '' : String(out); }; const pageText = collectPageAnnouncementText(page, resolve); return pageText ? `${pageText}. ${ownLabel}` : ownLabel; }, [ctx.state, ctx.flow, page], ); const announceOnFocus = useCallback( (kind: ButtonAnnouncementKind, ownLabel: string) => { const text = machineRef.current.onFocus({ kind, ownLabel, videoPlaying: getVideoControlState().playing, buildComposite: () => buildComposite(ownLabel), }); if (text != null) setLiveRegionText(distinct(text)); }, [buildComposite], ); // Track video playback; flush any deferred announcement when it ends. useEffect( () => subscribeVideoControls((state) => { setIsVideoPlaying(state.playing); if (prevPlayingRef.current && !state.playing) { const text = machineRef.current.onVideoStopped(); if (text != null) setLiveRegionText(distinct(text)); } prevPlayingRef.current = state.playing; }), [], ); // Reset per page. useEffect(() => { machineRef.current.reset(); setLiveRegionText(''); }, [page]); const value = useMemo( () => ({ isVideoPlaying, liveRegionText, announceOnFocus }), [isVideoPlaying, liveRegionText, announceOnFocus], ); return {children}; }; export function useAnnouncer(): AnnouncerValue { const ctx = useContext(AnnouncementCtx); if (!ctx) throw new Error('useAnnouncer must be used within an AnnouncementProvider'); return ctx; }