UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

568 lines (481 loc) 16.6 kB
import * as R from "ramda"; import { isNilOrEmpty, isNotNil, } from "@applicaster/zapp-react-native-utils/reactUtils/helpers"; import { Tree } from "./treeDataStructure/Tree"; import { findFocusableNode, findPriorityItem, haveSameParentBeforeRoot, } from "./treeDataStructure/Utils"; import { subscriber } from "../../functionUtils"; import { coreLogger } from "../../logger"; import { ACTION } from "./utils/enums"; const logger = coreLogger.addSubsystem("focusManager"); const isFocusEnabled = (focusableItem): boolean => { const isFocusable = R.path(["props", "isFocusable"], focusableItem); if (isFocusable === false) { return false; } return true; }; /** * The focusManager is meant to keep references of focusables elements in the UI, and provide * ways to assign the focus to one of the focusables. * Focusables belong in groups (possibly nested), to allow for logical navigation between * the Focusables. * * Focusables and FocusableGroups are created in the UI by using * @applicaster/zapp-react-native-ui-components/Components/Focusable and * @applicaster/zapp-react-native-ui-components/Components/FocusableGroup * See the components documentation for more information regarding available features. * * when these components are mounted, they automatically register to the focus manager, * which is then responsible for providing a deterministic and reliable way to move the * focus around, and activate the underlying feature. * * @returns {Object} focusManager * @returns {Function} `focusManager.register` registers a Focusable (item or group) * @returns {Function} `focusManager.unregister` unregisters a Focusable (item or group) * @returns {Function} `focusManager.moveFocus` moves the focus in a given direction * @returns {Function} `focusManager.getCurrentFocus` returns the current focus * @returns {Function} `focusManager.setFocus` sets the focus to a specific Focusable item * @returns {Function} `focusManager.press` calls the `press` method on the current focus */ export const focusManager = (function () { // focusable node which currently holds the focus let currentFocusNode = null; let lastFocusedId = null; let focusDisabled = false; const eventHandler = subscriber(); function enableFocus() { focusDisabled = false; } function disableFocus() { focusDisabled = true; } function isFocusDisabled() { return focusDisabled; } /** * returns the current focusable component * @private * @returns {React.Component} the current focus */ function getCurrentFocus() { return currentFocusNode?.component || null; } /** * calls the `blur` method on the current focus component * @private * @param {Object} direction of the navigation which led to this action */ function blur(direction?: FocusManager.Web.Direction) { const currentFocusable = getCurrentFocus(); if ( currentFocusable && !currentFocusable.isGroup && currentFocusable.isMounted() ) { currentFocusable.blur(direction); currentFocusable.hasLostFocus(getCurrentFocus(), direction); } } /** * calls the `focus` method on the current focus component * @private * @param {Object} direction of the navigation which led to this action */ function focus(direction) { const currentFocusable = getCurrentFocus(); if ( currentFocusable && !currentFocusable.isGroup && currentFocusable.isMounted() ) { currentFocusable.setFocus(direction); } } // eslint-disable-next-line @typescript-eslint/no-use-before-define const focusableTree = new Tree(treeLoaded); function setLastFocusOnParentNode(node) { if (node && node.parent) { node.parent.lastFocusedItem = node.id; setLastFocusOnParentNode(node.parent); } } /** * returns a group from a group Id. * @private * @param {String} groupId id of the group * @returns {React.Component} matching group */ function getGroupById(groupId) { const group = focusableTree.findInTree(groupId); return group ? group.component : null; } function getGroupRootById(groupId) { const group = focusableTree.findInTree(groupId); return group || null; } // recursively check if group.children and children's children have component.state.focus as true const hasFocus = (node, id) => { if (node.children?.length > 0) { if (id) { return node.children .filter((item) => item.id.includes(id)) .some((item) => hasFocus(item, id)); } return node.children.some((item) => hasFocus(item, id)); } else if (node.component.state.focused) { return true; } return false; }; function isGroupItemFocused(groupId, id) { const group = getGroupRootById(groupId); if (!(group || group?.component)) { throw new Error(`Group not found: ${groupId}, id: ${id}`); } else { return hasFocus(group, id); } } /** * Computes the difference in parent groups between two nodes. * It returns the IDs of parents that are present in the current node but not in the previous node, * as well as the IDs of parents that are present in the previous node but not in the current node. * * @param {string} currentNodeID - The ID of the current node. * @param {string} previousNodeID - The ID of the previous node. * @returns {Object} An object containing the differences in parent IDs: * - {Array<string>} currentNodeParentsIDs: Parent IDs present in the current node but not in the previous node. * - {Array<string>} previousNodeParentsIDs: Parent IDs present in the previous node but not in the current node. */ const getParentDiffs = (currentNodeID, previousNodeID) => { const currentNodeParentsIDs = focusableTree.getParentGroupIds(currentNodeID); const previousNodeParentsIDs = focusableTree.getParentGroupIds(previousNodeID); return { currentNodeParentsIDs: R.difference( currentNodeParentsIDs, previousNodeParentsIDs ), previousNodeParentsIDs: R.difference( previousNodeParentsIDs, currentNodeParentsIDs ), }; }; /** * Updates the focus state of nodes identified by their IDs. * For each ID, if `setFocus` is true, the node will receive focus; * otherwise, it will lose focus. * * @param {Array<string>} ids - An array of node IDs to update. * @param {boolean} setFocus - A flag indicating whether to set focus (true) or blur (false) on the nodes. */ const updateNodeFocus = (ids, action) => { if (!ids || ids.length === 0) { return; // Nothing to do } const startNode = focusableTree.findInTree(ids[0]); if (!startNode) { return; // Start node not found } const endNodeId = ids[ids.length - 1]; // Get the ID of the end node let currentNode = startNode; // Function to apply the action (focus or blur) const applyAction = (node) => { if (node && node.component) { if (action === "focus") { node.component.setFocus(); } else if (action === "blur") { node.component.setBlur(); } } }; // Apply action to the starting node applyAction(currentNode); // Traverse upwards while (currentNode && currentNode.id !== endNodeId) { currentNode = currentNode.parent; if (currentNode) { // check if current node exists, as parent might be root applyAction(currentNode); } } }; /** * Sets the current focus to the provided id. If there is an existing focus, it will * blur it first, so all handlers are called properly * @private * @param {String} id of the focusable to set as currentFocus * @param {Object} direction of the navigation, which led to this focus change * to another group or not. defaults to false */ function setFocus(id: string, direction?: FocusManager.Web.Direction) { if (focusDisabled) return false; // due to optimisiation it's recommanded to set currentFocusNode before setFocus // is called, so there will be no delays on tree search. // If currentFocusNode is null or undefined we still want it to findInTree if (currentFocusNode?.id !== id) { const { currentNodeParentsIDs, previousNodeParentsIDs } = getParentDiffs( id, currentFocusNode?.id ); // Set focus on current node parents and blur on previous node parents updateNodeFocus(currentNodeParentsIDs, ACTION.FOCUS); updateNodeFocus(previousNodeParentsIDs, ACTION.BLUR); currentFocusNode = focusableTree.findInTree(id); } setLastFocusOnParentNode(currentFocusNode); focus(direction); } /** * sets the initial focus when the screen loads, or when focus is lost */ function setInitialFocus(lastAddedParentNode?: any) { const preferredFocus = findPriorityItem( lastAddedParentNode?.children || focusableTree.root.children ); logger.log({ message: "setInitialFocus", data: { preferredFocus } }); if (isNilOrEmpty(preferredFocus)) { return { success: false }; } const focusCandidate = preferredFocus[0]; const focusableItem = focusCandidate.component; const currentFocusedId = R.path(["props", "id"], getCurrentFocus()); const nextFocusedId = R.path(["props", "id"], focusableItem); if ( isNotNil(currentFocusedId) && isNotNil(nextFocusedId) && currentFocusedId === nextFocusedId ) { // we are going to move focus to the same node(already focused) -> nothing to do return { success: true }; } if (focusableItem?.isGroup) { // focusCandidate is a FocusableGroup, we are not able to land focus on group(only on Focusable) return { success: false }; } if ( focusableItem && !focusableItem.isGroup && isFocusEnabled(focusableItem) ) { // TODO: Move this to setFocus method blur(); logger.log({ message: "setInitialFocus – currentFocusNode", data: { focusableItem, }, }); focusableItem && setFocus(focusCandidate.id, null); return { success: true }; } return { success: false }; } function treeLoaded(val, node) { logger.log({ message: "treeLoaded", data: { val, node, currentFocusNode, }, }); } /** * calls the `press` method on the current focus component * @public */ function press() { const currentFocusable = getCurrentFocus(); if (currentFocusable) { currentFocusable.press(null); } } /** * calls the `longPress` method on the current focus component * @public */ function longPress() { const currentFocusable = getCurrentFocus(); if (currentFocusable) { currentFocusable.longPress(null); } } /** * calls the `pressOut` method on the current focus component * @public */ function pressOut() { const currentFocusable = getCurrentFocus(); if (currentFocusable) { currentFocusable.pressOut(null); } } /** * registers a group in the focus manager. This method is called automatically * when a FocusableGroup mounts. * If there is no current focus * we will try to assign one, taking into consideration wether the group * has a preferred focus. If not, it will pick the first focusable which is in the group * NB: focusable Items are registered to the focus manager before the groups * @private * @param {string} id of the focusable group * @param {React.Component} component the actual FocusableGroup * @returns {React.Component} the registered component */ function registerGroup(id, component) { focusableTree.addNode({ id, component }); return component; } /** * registers a Focusable in the focus manager. This happens before * groups are registered, and is called automatically when the * Focusable component mounts * @private * @param {String} id of the Focusable * @param {React.Component} component the actual Focusable component * @returns {React.Component} the registered component */ function registerItem(id, component) { focusableTree.addNode({ id, component }); if (!currentFocusNode && id === lastFocusedId && !id.includes("menu")) { setFocus(focusableTree.findInTree(id)); } return component; } /** * unregisters a group. This is called automatically when the underlying component * unmounts * @private * @param {String} id of the group to unregister */ function unregisterGroup(id) { focusableTree.findAndRemoveNode(id); } /** * unregisters an item. This is called automatically when the underlying component * unmounts. * If the component is the current focus, it will set the current focus to null * @private * @param {String} id of the Focusable to unregister */ function unregisterItem(id) { focusableTree.findAndRemoveNode(id); if (currentFocusNode && currentFocusNode.id === id) { currentFocusNode = null; lastFocusedId = id; } } /** * Registers a Focusable or a FocusableGroup. This is the function actually called * when a Focusable or FocusableGroup is being mounted, and figures out if it needs * to register a group or a focusable * @public * @param {Object} options * @param {String} options.id id of the Focusable to register * @param {React.Component} options.component underlying React Component * @returns {React.Component} the registered component */ function register({ id, component }) { const { isGroup = false } = component; return isGroup ? registerGroup(id, component) : registerItem(id, component); } /** * Unregisters a Focusable or a Focusable group. This is the function actually called when a * Focusable of FocusableGroup is unmounting. * @public * @param {String} id if the Focusable to unregister * @param {Object} options * @param {Boolean} options.group tells wether or not the focusable to * unregister is a group. defaults to false. */ function unregister(id, { group = false } = {}) { group ? unregisterGroup(id) : unregisterItem(id); } function isCurrentFocusOnTheTopScreen() { const routes = R.pathOr([], ["root", "children"], focusableTree); if (currentFocusNode && routes.length <= 1) { // there is only one or zero routes return true; } return haveSameParentBeforeRoot(currentFocusNode, R.last(routes)); } function recoverFocus() { if (!isCurrentFocusOnTheTopScreen()) { // We've failed to set focused node on the new screen => run focus recovery setInitialFocus(); } } /** * moves the focus in a provided direction * @public * @param {Object} direction of the scroll */ function moveFocus(direction) { if (!isCurrentFocusOnTheTopScreen()) { // We are opening new screen. We need to ignore movement of focus. return false; } if (focusDisabled) return false; if (currentFocusNode) { currentFocusNode.component.willLoseFocus(currentFocusNode, direction); const nextFocusable = findFocusableNode(direction, currentFocusNode); if (nextFocusable?.component?.isMounted?.()) { const nextRect = nextFocusable.component.getRect(); if (nextRect && nextRect.width > 0) { // TODO: Move this to setFocus method blur(direction); setFocus(nextFocusable.id, direction); } } else { currentFocusNode.component.failedLostFocus(currentFocusNode, direction); } } } /** * returns the current focusable node * @private * @returns {Object} the current focus node object */ function getCurrentFocusNode(id) { // return currentFocusNode or null; return focusableTree.findInTree(id) || null; } const { on, invokeHandler, removeHandler } = eventHandler; /** * this is the list of the functions available externally * when importing the focus manager */ return { register, unregister, moveFocus, getCurrentFocus, getGroupById, setFocus, press, blur, on, invokeHandler, removeHandler, longPress, pressOut, enableFocus, setInitialFocus, disableFocus, isFocusDisabled, getCurrentFocusNode, focusableTree, getGroupRootById, isGroupItemFocused, recoverFocus, isCurrentFocusOnTheTopScreen, }; })();