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 { QUICK_BRICK_CONTENT, QUICK_BRICK_NAVBAR, } from "@applicaster/quick-brick-core/const"; import { logger } from "./logger"; import { TreeNode } from "./TreeNode"; import { Tree } from "./Tree"; import { subscriber } from "../functionUtils"; import { getFocusableId, toFocusDirection } from "./utils"; import { findSelectedMenuId, isTabsScreenContentFocused, findSelectedTabId, contextWithoutScrolling, } from "./aux"; export { contextWithoutScrolling } from "./aux"; 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 ) { if (options?.direction) { FocusManager.instance.previousNavigationDirection = options.direction; } } registerFocusable({ touchableRef, parentFocusableRef, isFocusableCell, parentFocusableId, }: { touchableRef: FocusManager.TouchableReactRef; parentFocusableRef: FocusManager.TouchableReactRef; isFocusableCell: boolean; parentFocusableId: string; }) { const focusableId = getFocusableId(touchableRef); const focusableComponent = FocusManager.findFocusable(focusableId); if (!focusableComponent && touchableRef) { this.focusableComponents.push(touchableRef); this.tree.add( touchableRef, parentFocusableRef, isFocusableCell, parentFocusableId ); } 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, context?: FocusManager.FocusContext ) { 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); if (nextNextFocus) { 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 ?? {}, context); } } blurPrevious(options?: FocusManager.Android.CallbackOptions) { FocusManager.instance.prevFocused?.onBlur?.( FocusManager.instance.prevFocused, options ?? {} // Adding fallback to avoid potential regression caused by #7509 ); } onDisableFocusChange = (id) => { const isFocused = FocusManager.instance.isFocused(id); const isDescendantFocused = (() => { try { return FocusManager.instance.isAnyDescendantFocused(id); } catch (_e) { return false; } })(); if (isFocused || isDescendantFocused) { // 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 chance to be registered setTimeout(() => { FocusManager.instance.setFocus(nextFocus, { direction: "down", }); }, 0); } } }; setFocus( newFocus: FocusManager.TouchableReactRef | string, options?: FocusManager.Android.CallbackOptions, context?: FocusManager.FocusContext ) { // 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, context); } } } 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, {}); } // send reset event to some handler to reset their internal state, before real reset happens this.eventHandler?.invokeHandler?.(FOCUS_EVENTS.RESET, { focusedId: FocusManager.instance.focusedId, }); FocusManager.instance.setFocusLocal({ current: null }); } private extractDirectionFromOptions( options: Nullable ): Nullable { 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`); } } isFocusOnMenu(): boolean { return this.isFocusableChildOf( FocusManager.instance.focusedId, QUICK_BRICK_NAVBAR ); } isFocusOnContent(): boolean { return this.isFocusableChildOf( FocusManager.instance.focusedId, QUICK_BRICK_CONTENT ); } private landFocusToWithoutScrolling = (id) => { if (id) { // set focus on selected menu item const direction = undefined; const context: FocusManager.FocusContext = contextWithoutScrolling("back"); logger.log({ message: "landFocusToWithoutScrolling", data: { id } }); this.setFocus(id, direction, context); } }; // Move focus to appropriate top navigation tab with context focusOnSelectedTab(index: number): void { const selectedTabId = findSelectedTabId(this.tree, index); // Set focus with back button context to tabs-menu this.landFocusToWithoutScrolling(selectedTabId); } // Move focus to appropriate top navigation tab with context focusOnSelectedTopMenuItem(index: number, sectionKey: string): void { const selectedMenuItemId = findSelectedMenuId(this.tree, { index, sectionKey, }); // Set focus with back button context to top-menu this.landFocusToWithoutScrolling(selectedMenuItemId); } isTabsScreenContentFocused(): boolean { return isTabsScreenContentFocused( this.tree, FocusManager.instance.focusedId ); } } export const focusManager = FocusManager.getInstance();