UNPKG

device-navigation

Version:

Navigate HTML elements in two dimensions with non-pointer devices.

262 lines (261 loc) 8.32 kB
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, }; } }