UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

401 lines (318 loc) • 10.8 kB
import { path } from "ramda"; import { isString } from "@applicaster/zapp-react-native-utils/stringUtils"; import * as FOCUS_EVENTS from "@applicaster/zapp-react-native-utils/appUtils/focusManager/events"; import { logger } from "./logger"; import { TreeNode } from "./TreeNode"; import { Tree } from "./Tree"; import { subscriber } from "../functionUtils"; import { getFocusableId, toFocusDirection } from "./utils"; export { toFocusDirection, isHorizontalDirection, isVerticalDirection, } from "./utils"; class FocusManager { private static instance: FocusManager; private _focusedId: string | null = null; private _prevFocusedId: string | null = null; public previousNavigationDirection: FocusManager.Android.NavDir = null; /** * @deprecated */ public focusableComponents: FocusManager.TouchableReactRef[] = []; private eventHandler = subscriber(); private tree = new Tree(); on(event: string, callback: (...args: any[]) => void) { this.eventHandler?.on?.(event, callback); } invokeHandler(event: string, ...args: any[]) { this.eventHandler?.invokeHandler?.(event, ...args); } removeHandler(event: string, callback: (...args: any[]) => void) { this.eventHandler?.removeHandler?.(event, callback); } get focused() { const focusedRef = this.focusedId ? FocusManager.findFocusable(this.focusedId) : { current: null }; return focusedRef?.current; } get prevFocused() { const focusedRef = this.prevFocusedId ? FocusManager.findFocusable(this.prevFocusedId) : { current: null }; return focusedRef?.current; } get focusedId() { return FocusManager.instance._focusedId; } get prevFocusedId() { return FocusManager.instance._prevFocusedId; } public static getInstance(): FocusManager { if (!FocusManager.instance) { FocusManager.instance = new FocusManager(); } return FocusManager.instance; } public static findFocusable(id) { return FocusManager.instance.focusableComponents.find( (component) => getFocusableId(component) === id ); } private static getNextFocusable( direction: FocusManager.Android.FocusNavigationDirections, focusable?: FocusManager.TouchableReactRef ) { const props = focusable ? focusable.current?.props : FocusManager.instance.focused?.props; const focusDirection = toFocusDirection(direction); const nextFocusable = props?.[focusDirection]; if (!nextFocusable) { return null; } if (isString(nextFocusable)) { return FocusManager.findFocusable(nextFocusable); } return nextFocusable; } private static isFocusable(component) { if (!component) { return { isFocusable: false, error: "ID or reference to your component is missing", }; } if (isString(component)) { // check if component is registered const _component = FocusManager.findFocusable(component); if (!_component) { return { isFocusable: false, error: `Focusable component with id ${component} is not registered`, }; } else { return { isFocusable: true }; } } if (!component.current) { return { isFocusable: false, error: "Reference to your component needs to include 'current' property", }; } return { isFocusable: true }; } updateFocusedSilently(nextFocus: FocusManager.TouchableReactRef) { const nextFocusId = getFocusableId(nextFocus); // Check that nextFocus is a valid focusable const isFocusable = FocusManager.isFocusable(nextFocus); if (isFocusable && nextFocusId) { FocusManager.instance._focusedId = nextFocusId; } else { if (!isFocusable) { // this will include cases when nextFocus is null, a string or doesn't have a 'current' property logger.warning( "Attempted to focus a non-focusable component, focused element wasn't changed", { attemptedId: nextFocusId, } ); } if (!nextFocusId) { logger.warning( "Attempted to focus a component without a valid ID, focused element wasn't changed", { attemptedId: nextFocusId, } ); } } } private setFocusLocal(nextFocus: FocusManager.TouchableReactRef) { FocusManager.instance._prevFocusedId = FocusManager.instance._focusedId; FocusManager.instance._focusedId = nextFocus?.current?.props?.id ?? null; } private setPreviousNavigationDirection( options: Nullable<FocusManager.Android.CallbackOptions> ) { if (options?.direction) { FocusManager.instance.previousNavigationDirection = options.direction; } } registerFocusable( component: FocusManager.TouchableReactRef, parentFocusable: FocusManager.TouchableReactRef, isFocusableCell: boolean ) { const focusableId = getFocusableId(component); const focusableComponent = FocusManager.findFocusable(focusableId); if (!focusableComponent && component) { this.focusableComponents.push(component); this.tree.add(component, parentFocusable, isFocusableCell); } else { logger.warning("Focusable component already registered", { id: focusableId, }); } return () => this.unregisterFocusable(focusableId); } unregisterFocusable(focusableId: string) { const node = this.tree.find(focusableId); this.focusableComponents = this.focusableComponents.filter( (c) => c !== node?.component ); this.tree.remove(focusableId); } private setNextFocus( nextFocus: FocusManager.TouchableReactRef, options?: FocusManager.Android.CallbackOptions ) { if (nextFocus?.current?.props?.blockFocus) { return; } if (nextFocus?.current?.props?.disableFocus) { const direction = FocusManager.instance.extractDirectionFromOptions( options ?? null ); if (!direction) { // failed to extract direction - ignore this focus attempt return; } const nextNextFocus = FocusManager.getNextFocusable(direction, nextFocus); FocusManager.instance.setFocus(nextNextFocus, options); } else { FocusManager.instance.setFocusLocal(nextFocus); FocusManager.instance.blurPrevious(options); this.eventHandler?.invokeHandler?.(FOCUS_EVENTS.FOCUS, nextFocus); FocusManager.instance.setPreviousNavigationDirection(options ?? null); nextFocus?.current?.onFocus?.(nextFocus.current, options ?? {}); } } blurPrevious(options?: FocusManager.Android.CallbackOptions) { if (options) { FocusManager.instance.prevFocused?.onBlur?.( FocusManager.instance.prevFocused, options ); } } onDisableFocusChange = (id) => { if (FocusManager.instance.isFocused(id)) { // Move focus to next one const nextFocus = FocusManager.instance.focused?.props ?.nextFocusDown as string; if (nextFocus) { // HACK: hack to fix the hack below // HACK: putting call to the end of the event loop so the next component has a chane to be registered setTimeout(() => { FocusManager.instance.setFocus(nextFocus, { direction: "down", }); }, 0); } } }; setFocus( newFocus: FocusManager.TouchableReactRef | string, options?: FocusManager.Android.CallbackOptions ) { // Checks if element is focusable const { isFocusable, error } = FocusManager.isFocusable(newFocus); if (error) { logger.error({ message: error }); return; } if (isFocusable) { let newFocusRef: FocusManager.TouchableReactRef | null = null; if (isString(newFocus)) { const newFocusable = FocusManager.findFocusable(newFocus); if (newFocusable) { newFocusRef = newFocusable; } } else { newFocusRef = newFocus; } if (newFocusRef) { FocusManager.instance.setNextFocus(newFocusRef, options); } } } getFocusedNode() { return this.tree.find(FocusManager.instance.focusedId); } getNextFocusable(direction) { return FocusManager.getNextFocusable(direction); } getNextFocusableForParent(direction) { const focusDirection = toFocusDirection(direction); const parentNode = this.tree.findParent(FocusManager.instance.focusedId); return parentNode?.component?.current?.props[focusDirection]; } getSecondChildId(id) { const node = this.tree.find(id); return path(["children", 1, "id"], node); } isFocused(id: string | number) { return FocusManager.instance.focusedId === id; } resetFocus() { if (FocusManager.instance.focused?.onBlur) { FocusManager.instance.focused.onBlur(FocusManager.instance.focused, {}); } FocusManager.instance.setFocusLocal({ current: null }); } private extractDirectionFromOptions( options: Nullable<FocusManager.Android.CallbackOptions> ): Nullable<FocusManager.Android.FocusNavigationDirections> { if (options?.direction) { return options.direction; } if (options?.initialFocusDirection) { return options.initialFocusDirection; } if (FocusManager.instance.previousNavigationDirection) { return FocusManager.instance.previousNavigationDirection; } logger.warning("Failed to extract focusDirection"); return null; } public isFocusableChildOf( focusable: FocusManager.TouchableReactRef | string, referenceFocusable: FocusManager.TouchableReactRef | string, options: { direct: boolean } = { direct: false } ): boolean { const focusableNode = this.tree.findNode(focusable); const referenceNode = this.tree.findNode(referenceFocusable); if (!referenceNode || !focusableNode) return false; if (options.direct) { return referenceNode.children.some(({ id }) => { const focusableId = isString(focusable) ? focusable : getFocusableId(focusable); return id === focusableId; }); } else { return !!referenceNode.findNode(focusable); } } private hasFocus = (node) => { if (node.children?.length > 0) { return node.children.some((item) => this.hasFocus(item)); } else if (this.isFocused(node?.component?.current?.props.id)) { return true; } return false; }; isAnyDescendantFocused(id: string) { const node: TreeNode | null = this.tree.find(id); if (node) { return this.hasFocus(node); } else { // return false; throw new Error(`Group with id ${id} not found`); } } } export const focusManager = FocusManager.getInstance();