device-navigation
Version:
Navigate HTML elements in two dimensions with non-pointer devices.
239 lines (238 loc) • 8.57 kB
JavaScript
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;
}
}