@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
348 lines (288 loc) • 9.71 kB
text/typescript
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;
}
}
}