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