UNPKG

device-navigation

Version:

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

239 lines (238 loc) 8.57 kB
import { assert } from '@augment-vir/assert'; import { getNestedChildrenTree } from '@augment-vir/web'; import { ListenTarget } from 'typed-event-target'; import { mapTree } from '../nav-tree/nav-tree.js'; import { findNavTreeNodeByNavEntry } from '../nav-tree/walk-nav-tree.js'; import { enterInto } from './enter-into.js'; import { exitOutOf } from './exit-out-of.js'; import { NavActivateEvent, NavEnterEvent, NavExitEvent, NavFocusEvent, NavigateEvent, NavPiblingEvent, } from './nav-controller-events.js'; import { findDefaultChild, NavAction, navigate, navigatePibling, } from './navigate.js'; /** * Allows navigation around the nav tree contained within the given `rootElement`. If there is no * nav tree, all operations simply do nothing. For a full example, see * {@link https://github.com/electrovir/device-navigation/blob/dev/src/test/elements/vir-test-app.element.ts | vir-test-app.element.ts}. * * @category Main * @example * * ```ts * import {NavController, NavDirection} from 'device-navigation'; * * const navController = new NavController(host); * * window.addEventListener('keydown', (event) => { * if (event.code === 'ArrowDown') { * navController.navigate({direction: NavDirection.Down, allowWrapper: false}); * } else if (event.code === 'ArrowUp') { * navController.navigate({direction: NavDirection.Up, allowWrapper: false}); * } * // etc. all other navigation directions * }); * ``` */ export class NavController extends ListenTarget { rootElement; options; constructor(rootElement, options = {}) { super(); this.rootElement = rootElement; this.options = options; } /** If `true`, the nav tree will rebuild on next operation. */ needsUpdate = false; /** If true, all nav is prevented. */ locked = false; navEntries = new Set(); currentNavEntry; cachedNavTree; /** Gets or builds the current nav tree. */ getNavTree() { if (this.needsUpdate || !this.cachedNavTree) { this.needsUpdate = false; return this.buildNavTree(); } else { return this.cachedNavTree; } } /** Focus the default element for the whole tree. */ focusDefaultElement() { findDefaultChild(this.getNavTree().children)?.node.element.focus(); } /** Add a new {@link NavEntry} to this controller. */ addNavEntry(navEntry) { this.navEntries.add(navEntry); if (this.options.alwaysRequireFocused && !this.currentNavEntry) { requestAnimationFrame(() => { this.focusDefaultElement(); }); } } /** Remove a {@link NavEntry} from this controller. */ removeNavEntry(navEntry) { this.navEntries.delete(navEntry); if (this.options.alwaysRequireFocused && !this.currentNavEntry) { requestAnimationFrame(() => { this.focusDefaultElement(); }); } } /** Sets the current nav entry with the given action. */ triggerNavEntry(navEntry, enabled, navAction) { if (this.locked) { return { success: false, direction: undefined, navAction, reason: 'NavController is locked.', }; } else if (!navEntry) { return { success: false, direction: undefined, navAction: navAction, reason: 'No nav entry to operate on.', }; } const position = findNavTreeNodeByNavEntry(this.getNavTree(), navEntry); if (enabled) { this.navEntries.forEach((nestedNavEntry) => { if (nestedNavEntry !== navEntry) { nestedNavEntry.clearNavValue(); } }); this.currentNavEntry = { entry: navEntry, navAction: navAction, position, }; } else if (this.currentNavEntry?.entry === navEntry && this.currentNavEntry.navAction === navAction && !this.options.alwaysRequireFocused) { this.currentNavEntry = undefined; } const result = { success: true, defaulted: false, direction: undefined, newElement: navEntry.element, wrapped: false, navAction, coords: position.nodeCoords, }; if (enabled) { if (navAction === NavAction.Activate) { this.dispatch(new NavActivateEvent({ detail: result })); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (navAction === NavAction.Focus) { this.dispatch(new NavFocusEvent({ detail: result })); } } return result; } /** Navigate around the nav tree. */ navigate({ direction, allowWrapping, }) { if (this.locked) { return { success: false, direction, navAction: NavAction.Navigate, reason: 'NavController is locked.', }; } const result = navigate(this.getNavTree(), this.currentNavEntry, direction, allowWrapping); this.dispatch(new NavigateEvent({ detail: result })); return result; } enterInto({ fallbackToActivate, } = {}) { if (this.locked) { return { success: false, direction: undefined, navAction: NavAction.Enter, reason: 'NavController is locked.', }; } const result = enterInto(this.getNavTree(), this.currentNavEntry); if (!result.success && fallbackToActivate) { return this.activate(); } else { this.dispatch(new NavEnterEvent({ detail: result })); return result; } } /** Activate the currently focused nav entry. */ activate() { if (this.locked) { return { success: false, direction: undefined, navAction: NavAction.Activate, reason: 'NavController is locked.', }; } if (!this.currentNavEntry?.entry) { return { success: false, direction: undefined, navAction: NavAction.Activate, reason: 'No focused NavEntry to activate.', }; } const result = this.currentNavEntry.entry.activate(true); assert.isDefined(result, 'Cannot activate a group.'); return result; } /** * Shift focus from the currently focused node to its parent. If there is no parent, or rather * if the parent is the tree root, this fails. */ exitOutOf() { if (this.locked) { return { success: false, direction: undefined, navAction: NavAction.Exit, reason: 'NavController is locked.', }; } if (this.currentNavEntry?.navAction === NavAction.Activate) { this.currentNavEntry.entry.focus(true); } const result = exitOutOf(this.getNavTree(), this.currentNavEntry); this.dispatch(new NavExitEvent({ detail: result })); return result; } /** Navigate to siblings of the parent of the currently focused element, if they exist. */ navigatePibling({ allowWrapping, direction, }) { if (this.locked) { return { success: false, direction, navAction: NavAction.Pibling, reason: 'NavController is locked.', }; } const navTree = this.getNavTree(); const rawResult = this.currentNavEntry ? navigatePibling(this.currentNavEntry, direction, allowWrapping) : navigate(navTree, undefined, direction, allowWrapping); const result = { ...rawResult, navAction: NavAction.Pibling, }; this.dispatch(new NavPiblingEvent({ detail: result })); return result; } /** Builds the latest tree, sets it internally, and returns it. */ buildNavTree() { const elementTree = getNestedChildrenTree(this.rootElement); const tree = mapTree(elementTree); this.cachedNavTree = tree; return tree; } }