UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

348 lines (288 loc) • 9.71 kB
import * as R from "ramda"; import { BehaviorSubject } from "rxjs"; import { accessibilityManagerLogger as logger } from "./logger"; import { TTSManager } from "../platform"; import { BUTTON_ACCESSIBILITY_KEYS } from "./const"; import { toString } from "../../utils"; import { calculateReadingTime } from "./utils"; import { AccessibilityRole } from "react-native"; export class AccessibilityManager { private static _instance: AccessibilityManager | null = null; private headingTimeout: NodeJS.Timeout | null = null; private announcementDelayTimeout: NodeJS.Timeout | null = null; private WORDS_PER_MINUTE = 140; private MINIMUM_PAUSE = 500; private ANNOUNCEMENT_DELAY = 700; private state$ = new BehaviorSubject<AccessibilityState>({ screenReaderEnabled: false, reduceMotionEnabled: false, boldTextEnabled: false, }); private announcements$ = new BehaviorSubject<{ message: string; localizedMessage?: string | undefined; timestamp: number; } | null>(null); private ttsManager = TTSManager.getInstance(); private localizations: { [key: string]: string } = {}; private headingQueue: string[] = []; private currentFocusId: string | null = null; private headingFocusMap: Map<string, string> = new Map(); private pendingFocusId: string | null = null; private isInitialPlayerAnnouncementReady$ = new BehaviorSubject<boolean>( false ); private constructor() { this.ttsManager .getScreenReaderEnabledAsObservable() .subscribe((enabled) => { const state = this.state$.getValue(); if (state.screenReaderEnabled !== enabled) { this.state$.next({ ...state, screenReaderEnabled: enabled, }); } }); } public static getInstance(): AccessibilityManager { if (!AccessibilityManager._instance) { AccessibilityManager._instance = new AccessibilityManager(); } return AccessibilityManager._instance; } public get isInitialPlayerAnnouncementReady(): boolean { return this.isInitialPlayerAnnouncementReady$.getValue(); } public setInitialPlayerAnnouncementReady(): void { this.isInitialPlayerAnnouncementReady$.next(true); } public resetInitialPlayerAnnouncementReady(): void { this.isInitialPlayerAnnouncementReady$.next(false); } public getInitialAnnouncementReadyObservable() { return this.isInitialPlayerAnnouncementReady$.asObservable(); } public getTTSStateObservable() { return this.ttsManager.getStateAsObservable(); } /** * The method now accepts any object with localizations using a flattened structure * * i.e. { accessibility_close_label: "Close", accessibility_close_hint: "Press here to close" } * * No longer accepts: * * i.e. localizations: [{ en: { accessibility_close_label: "Close", accessibility_close_hint: "Press here to close" } }] */ public updateLocalizations(localizations: { [key: string]: string }) { if (!R.isEmpty(localizations)) { this.localizations = localizations; } } public getState(): AccessibilityState { return this.state$.getValue(); } public getStateAsObservable() { return this.state$.asObservable(); } /** * Adds a heading to the queue, headings will be read before the next text * Each heading will be read once and removed from the queue * Does nothing if screen reader is not enabled */ public addHeading(heading: string) { const state = this.state$.getValue(); if (!state.screenReaderEnabled) { return; } if (!this.pendingFocusId) { this.pendingFocusId = Date.now().toString(); } this.headingFocusMap.set(heading, this.pendingFocusId); this.headingQueue.push(heading); } /** * text you want to be read, if you want to use localized text pass keyOfLocalizedText instead * keyOfLocalizedText is the key to the localized text * * Implements a delay mechanism to reduce noise during rapid navigation. * Only the most recent announcement will be read after the delay period. * Does nothing if screen reader is not enabled */ public readText({ text, keyOfLocalizedText, }: { text: string; keyOfLocalizedText?: string; }) { const state = this.state$.getValue(); if (!state.screenReaderEnabled) { return; } let textToRead = text; if (keyOfLocalizedText) { if (!this.localizations) { logger.error( "Attempting to use localized key without initialized localizations" ); return; } const localizedMessage = this.getLocalizedMessage(keyOfLocalizedText); if (!localizedMessage) { logger.warn(`No localization found for key: ${keyOfLocalizedText}`); return; } textToRead = localizedMessage; } const focusId = this.pendingFocusId || Date.now().toString(); this.currentFocusId = focusId; this.pendingFocusId = null; this.clearAnnouncement(); this.announcementDelayTimeout = setTimeout(() => { this.executeAnnouncement(textToRead, keyOfLocalizedText, focusId); }, this.ANNOUNCEMENT_DELAY); } /** * Internal method to execute the actual announcement after the delay */ private executeAnnouncement( textToRead: string, keyOfLocalizedText?: string, focusId?: string ) { if (this.headingQueue.length > 0) { this.processHeadingQueue(textToRead, focusId); } else { this.ttsManager?.readText(textToRead); } this.announcements$.next({ message: textToRead, localizedMessage: keyOfLocalizedText ? textToRead : undefined, timestamp: Date.now(), }); } /** * Recursively processes all headings in the queue, reading them one by one */ private processHeadingQueue(textToRead: string, focusId?: string) { // If focus has changed, abort this announcement if (focusId && this.currentFocusId !== focusId) { return; } if (this.headingQueue.length === 0) { if (focusId && this.currentFocusId === focusId) { this.ttsManager?.readText(textToRead); } return; } const heading = this.headingQueue.shift()!; const headingFocusId = this.headingFocusMap.get(heading); if (headingFocusId && headingFocusId !== focusId) { // This heading belongs to a previous focus, skip it this.headingFocusMap.delete(heading); this.processHeadingQueue(textToRead, focusId); return; } this.ttsManager?.readText(heading); this.headingFocusMap.delete(heading); // Clean up after reading if (this.headingTimeout) { clearTimeout(this.headingTimeout); } const pauseTime = calculateReadingTime( heading, this.WORDS_PER_MINUTE, this.MINIMUM_PAUSE, this.ANNOUNCEMENT_DELAY ); this.headingTimeout = setTimeout(() => { this.processHeadingQueue(textToRead, focusId); }, pauseTime); } public getButtonAccessibilityProps(name: string): AccessibilityProps { const buttonName = toString(name); const buttonConfig = BUTTON_ACCESSIBILITY_KEYS[buttonName]; if (!buttonConfig) { return { accessible: true, accessibilityLabel: buttonName, accessibilityHint: `Press button to perform action on ${buttonName}`, "aria-label": buttonName, "aria-description": `Press button to perform action on ${buttonName}`, accessibilityRole: "button" as AccessibilityRole, "aria-role": "button", role: "button", tabindex: 0, }; } const labelKey = buttonConfig.label; const hintKey = buttonConfig.hint; const label = this.getLocalizedMessage(labelKey) || buttonName; const hint = this.getLocalizedMessage(hintKey) || `Press button to perform action on ${buttonName}`; return { accessible: true, accessibilityLabel: label, accessibilityHint: hint, "aria-label": label, "aria-description": hint, accessibilityRole: "button" as AccessibilityRole, "aria-role": "button", role: "button", tabindex: 0, }; } public getInputAccessibilityProps(inputName: string): AccessibilityProps { return { accessible: true, accessibilityLabel: inputName, accessibilityHint: `Enter text into ${inputName}`, "aria-label": inputName, "aria-description": `Enter text into ${inputName}`, accessibilityRole: "searchbox" as AccessibilityRole, "aria-role": "searchbox", role: "searchbox", tabindex: 0, }; } /** * Extracts accessibility props from component props and returns them as HTML attributes * @param props - Component props containing accessibility properties * @returns Object with accessibility HTML attributes */ public getWebAccessibilityProps(props: any): AccessibilityProps { const { "aria-label": ariaLabel, "aria-description": ariaDescription, "aria-role": ariaRole, role, tabindex, } = props; return { "aria-label": ariaLabel, "aria-description": ariaDescription, "aria-role": ariaRole, role: role || ariaRole, tabindex, }; } public getLocalizedMessage(key: string): string | void { if (!key) return; if (!this.localizations) { logger.error( "Attempting to use localized key without initialized localizations", { key } ); return; } return this.localizations[key]; } private clearAnnouncement() { if (this.announcementDelayTimeout) { clearTimeout(this.announcementDelayTimeout); this.announcementDelayTimeout = null; } } }