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