/** * NAM-403: full-page screenreader announcement helpers for the Vega/Kepler * (and shared Expo) renderer. RN-free so the jest node environment can unit-test * it (mirrors imageAccessibility.ts). See the TV Full-Page Announcement contract. */ export type TextResolver = (value: string | undefined) => string; export interface AnnouncementPage { header?: unknown; contentContainer?: unknown; footer?: unknown; } // Reserved action functions whose buttons never trigger a full-page announcement // and always speak only their own label. Source of truth: utils/actionHandler.ts // (duplicated here to keep this module RN-free and node-testable). const AUXILIARY_ACTIONS = new Set([ 'namiRestorePurchases', 'namiSignIn', 'namiClosePaywall', 'namiNavigateToScreen', ]); const TEXT_TYPES = new Set(['text', 'text-list']); // Subtrees never descended into during the page-text walk. Button labels are // announced on their own focus; media/decoration carry no announce-worthy text. const SKIP_SUBTREE_TYPES = new Set(['button', 'image', 'videoUrl', 'spacer', 'symbol']); function childrenOf(node: any): any[] { if (Array.isArray(node)) return node; if (node && Array.isArray(node.components)) return node.components; return []; } function collectNode(node: any, resolve: TextResolver, out: string[]): void { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const child of node) collectNode(child, resolve, out); return; } if (node.hidden === true) return; const type: string | undefined = node.component; if (type && TEXT_TYPES.has(type)) { if (type === 'text-list' && Array.isArray(node.texts)) { for (const item of node.texts) { const resolved = resolve(typeof item === 'string' ? item : String(item ?? '')).trim(); if (resolved) out.push(resolved); } } else { const raw = node.text ?? node.title; const resolved = resolve(typeof raw === 'string' ? raw : raw == null ? '' : String(raw)).trim(); if (resolved) out.push(resolved); } return; // text nodes have no announce-worthy descendants } if (type && SKIP_SUBTREE_TYPES.has(type)) return; for (const child of childrenOf(node)) collectNode(child, resolve, out); } /** * Walk a page (header -> contentContainer -> footer) in source order and join the * resolved text of every `text` / `text-list` node. Skips images, media, spacers, * symbols, button subtrees, and any node with `hidden: true` (and its children). */ export function collectPageAnnouncementText(page: AnnouncementPage, resolve: TextResolver): string { const out: string[] = []; collectNode((page as any)?.header, resolve, out); collectNode((page as any)?.contentContainer, resolve, out); collectNode((page as any)?.footer, resolve, out); return out.join('. '); } export type ButtonAnnouncementKind = 'primary' | 'auxiliary'; /** * Auxiliary buttons (restore / sign in / skip-close / navigate) only ever speak * their own label; primary buttons (the default CTA and product buttons) trigger * the full-page announcement on first focus. */ export function classifyButtonAnnouncement( component: { onTap?: { function?: string } } | null | undefined, ): ButtonAnnouncementKind { const fn = component?.onTap?.function; return fn && AUXILIARY_ACTIONS.has(fn) ? 'auxiliary' : 'primary'; }