device-navigation
Version:
Navigate HTML elements in two dimensions with non-pointer devices.
262 lines (261 loc) • 8.32 kB
JavaScript
import { assert, assertWrap } from '@augment-vir/assert';
import { log, wrapNumber } from '@augment-vir/common';
import { greaterThan, lessThan } from '../util/comparisons.js';
import { focusElement } from '../util/focus.js';
/**
* All the possible nav directions.
*
* @category Internal
*/
export var NavDirection;
(function (NavDirection) {
NavDirection["Up"] = "up";
NavDirection["Down"] = "down";
NavDirection["Left"] = "left";
NavDirection["Right"] = "right";
})(NavDirection || (NavDirection = {}));
/**
* All possible nav actions.
*
* @category Internal
*/
export var NavAction;
(function (NavAction) {
NavAction["Enter"] = "enter";
NavAction["Exit"] = "exit";
NavAction["Activate"] = "activate";
NavAction["Focus"] = "focus";
NavAction["Navigate"] = "navigate";
NavAction["Pibling"] = "pibling";
})(NavAction || (NavAction = {}));
/**
* Finds the default node to select within the given node.
*
* @category Internal
*/
export function findDefaultChild(children) {
const coords = {
x: -1,
y: -1,
};
let node;
while (coords.y < children.length - 1 && !node) {
coords.y++;
const row = children[coords.y];
while (row && coords.x < row.length - 1 && !node) {
coords.x++;
const cell = row[coords.x];
if (cell) {
if (cell.navEntry.navParams.group) {
const results = findDefaultChild(cell.children);
if (results) {
node = results.node;
}
}
else if (!cell.navEntry.navParams.disabled) {
node = cell;
}
}
}
}
if (node) {
return {
node,
coords,
};
}
else {
return undefined;
}
}
/**
* Navigate around the nav tree.
*
* @category Internal
*/
export function navigate(navTree, currentlyFocused,
/**
* The direction to navigate within the tree. Note that 1 dimensional navigation treads up and
* left as the same, down and right as the same.
*/
direction,
/** Set to true to allow navigation to wrap. */
allowWrapping) {
/** If there is no currently focused nav node, try to focus the first node in the tree. */
if (!currentlyFocused) {
const defaulted = findDefaultChild(navTree.children);
if (defaulted) {
focusElement(defaulted.node.element);
return {
success: true,
wrapped: false,
defaulted: true,
newElement: defaulted.node.element,
coords: defaulted.coords,
direction,
navAction: NavAction.Navigate,
};
}
else {
/** Nothing we can do, we found no nav nodes to focus. */
return {
success: false,
reason: 'no default element to focus',
direction,
navAction: NavAction.Navigate,
};
}
}
const { nextNode, requiresWrapping, coords } = calculateNextNode(currentlyFocused.position, direction);
const isWrappingValid = allowWrapping ? true : !requiresWrapping;
if (nextNode && isWrappingValid) {
focusElement(nextNode.element);
return {
success: true,
defaulted: false,
newElement: nextNode.element,
wrapped: requiresWrapping,
direction,
navAction: NavAction.Navigate,
coords,
};
}
else if (!nextNode) {
return {
success: false,
reason: 'failed to find node to focus',
direction,
navAction: NavAction.Navigate,
};
/* node:coverage ignore next 7 */
}
else if (isWrappingValid) {
return {
success: false,
reason: 'no conditions matched',
direction,
navAction: NavAction.Navigate,
};
}
else {
return {
success: false,
reason: 'wrapping blocked',
direction,
navAction: NavAction.Navigate,
};
/**
* The below else is an edge cause that cannot be triggered, given the above logic. However,
* it must exist for type guarding purposes.
*/
}
}
function calculateNextNode(treePosition, direction) {
let isEnabled = false;
let output;
let step = 1;
const startTime = Date.now();
while (!isEnabled || !output) {
output = innerCalculateNextNode(treePosition, direction, step);
isEnabled = !output.nextNode?.navEntry.navParams.disabled;
step++;
if (Date.now() - startTime > 1000) {
log.warning('Failed to find next non-disabled node.');
return output;
}
}
return output;
}
function innerCalculateNextNode(treePosition, direction,
/** Number of steps to take. Usually this should be just one. */
step) {
const parentNode = treePosition.ancestorChain[treePosition.ancestorChain.length - 1]?.node;
assert.isDefined(parentNode, 'missing parent');
const currentRow = assertWrap.isDefined(parentNode.children[treePosition.nodeCoords.y]);
const isVertical = parentNode.children.length > 1 &&
(direction === NavDirection.Down || direction === NavDirection.Up);
const increment = direction === NavDirection.Down || direction === NavDirection.Right ? step : -1 * step;
const wrapComparison = increment < 0 ? greaterThan : lessThan;
const nextY = isVertical
? wrapNumber(treePosition.nodeCoords.y + increment, {
min: 0,
max: parentNode.children.length - 1,
takeOverflow: true,
})
: treePosition.nodeCoords.y;
const nextRow = assertWrap.isDefined(parentNode.children[nextY]);
const nextX = isVertical
? treePosition.nodeCoords.x >= nextRow.length
? /**
* Handles the case where the next row has fewer elements than the currently focused element's x
* index.
*/
nextRow.length - 1
: treePosition.nodeCoords.x
: wrapNumber(treePosition.nodeCoords.x + increment, {
min: 0,
max: currentRow.length - 1,
takeOverflow: true,
});
const nextNode = parentNode.children[nextY]?.[nextX];
const requiresWrapping = isVertical
? wrapComparison(nextY, treePosition.nodeCoords.y)
: wrapComparison(nextX, treePosition.nodeCoords.x);
return {
nextNode,
requiresWrapping,
coords: {
x: nextX,
y: nextY,
},
};
}
/**
* Navigate only to piblings (siblings of parent).
*
* @category Internal
*/
export function navigatePibling(currentlyFocused, direction, allowWrapping) {
const parent = currentlyFocused.position.ancestorChain[currentlyFocused.position.ancestorChain.length - 1];
if (!parent) {
return {
success: false,
reason: 'no parent to find a pibling from',
direction,
navAction: NavAction.Pibling,
};
}
const { nextNode, requiresWrapping, coords } = calculateNextNode(parent, direction);
const nodeToFocus = nextNode?.navEntry.navParams.group
? findDefaultChild(nextNode.children)
: { node: nextNode, coords };
const isWrappingValid = allowWrapping ? true : !requiresWrapping;
if (!nodeToFocus || !nodeToFocus.node) {
return {
success: false,
reason: 'no node to navigate to',
direction,
navAction: NavAction.Pibling,
};
}
else if (isWrappingValid) {
focusElement(nodeToFocus.node.element);
return {
success: true,
defaulted: false,
newElement: nodeToFocus.node.element,
wrapped: requiresWrapping,
coords: nodeToFocus.coords,
direction,
navAction: NavAction.Pibling,
};
}
else {
return {
success: false,
reason: 'wrapping blocked',
direction,
navAction: NavAction.Pibling,
};
}
}