preact-spatial-navigation
Version:
A powerful Preact library for TV-style spatial navigation with LRUD algorithm, virtualized lists/grids, and smart TV support
218 lines (189 loc) • 7.27 kB
text/typescript
import type { Direction } from '@bam.tech/lrud';
import { Lrud } from '@bam.tech/lrud';
import { isError } from './helpers/isError';
export type OnDirectionHandledWithoutMovement = (direction: Direction) => void;
type OnDirectionHandledWithoutMovementRef = { current: OnDirectionHandledWithoutMovement };
type SpatialNavigatorParams = {
onDirectionHandledWithoutMovementRef: OnDirectionHandledWithoutMovementRef;
};
export default class SpatialNavigator {
private lrud: Lrud;
private onDirectionHandledWithoutMovementRef: OnDirectionHandledWithoutMovementRef;
constructor({
onDirectionHandledWithoutMovementRef = { current: () => undefined },
}: SpatialNavigatorParams) {
this.lrud = new Lrud();
this.onDirectionHandledWithoutMovementRef = onDirectionHandledWithoutMovementRef;
}
private registerMap: { [key: string]: Array<Parameters<Lrud['registerNode']>> } = {};
public registerNode(...params: Parameters<Lrud['registerNode']>) {
try {
const parent = params[1] && params[1].parent;
const id = params[0];
// If no parent is given, we are talking about a root node. We want to register it.
// If a parent is given, we need the node to exist. Otherwise, we'll pass and queue the node for later registration.
if (parent === undefined || this.lrud.getNode(parent)) {
this.lrud.registerNode(...params);
// After we successfully register a node, we need to check whether it needs to grab the focus or not.
this.handleQueuedFocus();
// OK, we successfully registered an element.
// Now, we check if some other elements were depending on us to be registered.
// ...and we do it recursively.
const potentialNodesToRegister = this.registerMap[id];
if (!potentialNodesToRegister || potentialNodesToRegister.length === 0) return;
potentialNodesToRegister.forEach((node) => {
this.registerNode(...node);
});
delete this.registerMap[id];
} else {
// If the parent is not registered yet, we queue the node for later registration.
if (!this.registerMap[parent]) {
this.registerMap[parent] = [];
}
this.registerMap[parent].push(params);
}
} catch (e) {
console.error(e);
}
}
public unregisterNode(...params: Parameters<Lrud['unregisterNode']>) {
this.lrud.unregisterNode(...params);
}
public async handleKeyDown(direction: Direction | null) {
if (!direction) {
return;
}
if (!this.hasRootNode) {
console.warn('❌ SpatialNavigator: No root node');
return;
}
if (!this.lrud.getRootNode()) {
console.warn('❌ SpatialNavigator: LRUD root node not found');
return;
}
// Handle Enter/Select separately
if (direction === 'enter') {
const currentNode = this.lrud.getCurrentFocusNode();
if (currentNode && currentNode.onSelect) {
currentNode.onSelect(currentNode);
}
return;
}
// Handle long enter
if (direction === 'long_enter') {
const currentNode = this.lrud.getCurrentFocusNode();
if (currentNode && currentNode.onLongSelect) {
currentNode.onLongSelect(currentNode);
}
return;
}
// Handle directional navigation
if (direction) {
const nodeBeforeMovement = this.lrud.getCurrentFocusNode();
this.lrud.handleKeyEvent({ direction }, { forceFocus: true });
const nodeAfterMovement = this.lrud.getCurrentFocusNode();
if (nodeBeforeMovement === nodeAfterMovement) {
this.onDirectionHandledWithoutMovementRef.current(direction);
}
}
}
public hasOneNodeFocused() {
return this.lrud.getCurrentFocusNode() !== undefined;
}
/**
* Sometimes we need to focus an element, but it is not registered yet.
* That's where we put this waiting element.
*/
private focusQueue: string | null = null;
/**
* In the case of virtualized lists, we have some race condition issues when trying
* to imperatively assign focus.
* Indeed, we need the list to scroll to the element and then focus it. But the element
* needs to exist to be focused, so we need first to scroll then wait for the element to render
* then focus it.
*/
private virtualNodeFocusQueue: string | null = null;
/**
* To handle the default focus, we want to queue the element to be focused.
* We queue it because it might not be registered yet when it asks for focus.
*
* We queue it only if there is no currently focused element already (or currently queued),
* because multiple elements might try to take the focus (DefaultFocus is a context, so all its children
* will try to grab it). We only want the first of these element to grab it.
*/
public handleOrQueueDefaultFocus = (id: string) => {
if (this.getCurrentFocusNode()) return;
if (this.focusQueue) return;
if (this.lrud.getNode(id)) {
this.lrud.assignFocus(id);
return;
}
this.focusQueue = id;
};
/**
* Sometimes we want to queue focus an element, even if one is already focused.
* That happens with an imperative focus for example. I can force a focus to an element,
* even though another one is already focused.
*
* Still, I want to queue it, because the element might not be registered yet (example: in the case of virtualized lists)
*/
public grabFocusDeferred = (id: string) => {
try {
if (this.lrud.getNode(id)) {
this.lrud.assignFocus(id);
return;
}
} catch (error) {
// If the element exists but is not focusable, it is very likely that it will
// have a focusable child soon. This is the case for imperative focus on virtualized lists.
if (isError(error) && error.message === 'trying to assign focus to a non focusable node') {
this.virtualNodeFocusQueue = id;
}
}
};
/**
* This will focus the currently queued element if it exists.
* Otherwise, it will do nothing.
*
* This function will eventually be called with the proper element
* when the element is finally registered.
*/
private handleQueuedFocus = () => {
// Handle focus queue
if (this.focusQueue && this.lrud.getNode(this.focusQueue)) {
try {
this.lrud.assignFocus(this.focusQueue);
this.focusQueue = null;
} catch (e) {
// pass
}
}
// Handle virtual nodes (for virtualized lists) focus queue
if (this.virtualNodeFocusQueue) {
const virtualNode = this.lrud.getNode(this.virtualNodeFocusQueue);
if (virtualNode && virtualNode.children && virtualNode.children.length !== 0) {
try {
this.lrud.assignFocus(this.virtualNodeFocusQueue);
this.virtualNodeFocusQueue = null;
} catch (e) {
// pass
}
}
}
};
public grabFocus = (id: string) => {
return this.lrud.assignFocus(id);
};
public getCurrentFocusNode = () => {
return this.lrud.currentFocusNode;
};
private get hasRootNode(): boolean {
try {
this.lrud.getRootNode();
return true;
} catch (e) {
console.warn('[Preact Spatial Navigation] No registered node on this page.');
return false;
}
}
}