device-navigation
Version:
Navigate HTML elements in two dimensions with non-pointer devices.
241 lines (240 loc) • 8.14 kB
JavaScript
import { assert, assertWrap } from '@augment-vir/assert';
import { makeWritable } from '@augment-vir/common';
import { isElementFocused } from '@augment-vir/web';
import { css, unsafeCSS } from 'element-vir';
import { NavAction } from '../nav-controller/navigate.js';
/**
* Values for the nav attribute that `nav` applies to elements.
*
* @category Internal
*/
export var NavValue;
(function (NavValue) {
NavValue["Disabled"] = "disabled";
NavValue["Group"] = "group";
NavValue["Focused"] = "focused";
NavValue["Active"] = "active";
})(NavValue || (NavValue = {}));
/**
* The attribute which the `nav` directive applies to elements with a value of {@link NavValue}.
*
* @category Internal
*/
export const navAttribute = {
/** Name of the attribute. */
name: 'data-nav',
/**
* Use this to generate a query selector string for the attribute, to be within with JavaScript
* queries.
*/
js(
/**
* Omit this or set to `undefined` or `''` to generate a selector for all elements with the
* nav attribute.
*/
value) {
if (value) {
return `[${navAttribute.name}*="${value}"]`;
}
else {
return `[${navAttribute.name}]`;
}
},
/** Use this to generate a selector for the attribute in CSS. */
css({ baseSelector = '', navValue, } = {}) {
return css `
${unsafeCSS(baseSelector)}${unsafeCSS(navAttribute.js(navValue))}
`;
},
};
/**
* The property with which {@link NavEntry} instances are attached to elements.
*
* @category Internal
*/
export const navEntryPropertyKey = 'navEntry';
/**
* Checks if the given element has a {@link NavEntry} instance attached to it via
* {@link navEntryPropertyKey}
*
* @category Internal
*/
export function hasNavEntry(element) {
return navEntryPropertyKey in element;
}
/**
* Extracts a {@link NavEntry} instance attached to the given element, if it exists, by checking the
* {@link navEntryPropertyKey} property.
*
* @category Internal
*/
export function extractNavEntry(element) {
if (hasNavEntry(element)) {
const navEntry = element[navEntryPropertyKey];
return assertWrap.instanceOf(navEntry, NavEntry, 'Invalid nav entry');
}
else {
return undefined;
}
}
function createEventListener(navEntry) {
return (event) => {
if (navEntry.navParams.group ||
navEntry.navParams.disabled ||
navEntry.navController.locked) {
return;
}
else if ((event.type === 'mousedown' && !navEntry.navController.options.activateOnMouseUp) ||
(event.type === 'mouseup' && navEntry.navController.options.activateOnMouseUp)) {
if (event.target === navEntry.element) {
navEntry.activate(true);
}
}
else if (event.type === 'mouseup' || event.type === 'focus') {
if (event.target === navEntry.element) {
navEntry.focus(true);
}
}
else if (event.type === 'mousemove') {
if (event.target === navEntry.element && navEntry.navValue !== NavValue.Active) {
navEntry.focus(true);
}
}
else if (event.type === 'blur' || event.type === 'mouseleave') {
// eslint-disable-next-line unicorn/no-lonely-if
if (event.target === navEntry.element) {
navEntry.activate(false);
navEntry.focus(false);
}
}
};
}
/**
* A class that is attached to all navigable elements. It is attached to the
* {@link navEntryPropertyKey} property.
*
* @category Internal
*/
export class NavEntry {
element;
navParams;
navTreeNode;
navValue;
/** Use a singular event listener for all events so it can be removed. */
eventListener = createEventListener(this);
constructor(element, navController, navParams) {
this.element = element;
this.navParams = navParams;
this.attachListeners();
this.navController = navController;
}
/** Set the {@link NavController} and add this instance to it. */
set navController(navController) {
if (this._navController !== navController) {
this._navController?.removeNavEntry(this);
this._navController = navController;
navController.addNavEntry(this);
}
}
/** Set the current {@link NavController}. */
get navController() {
assert.isDefined(this._navController, 'this.navController has not been set in NavEntry constructor yet.');
return this._navController;
}
/** Clear all nav values from the element, just leave the plain attribute (without a value). */
clearNavValue() {
if (this.navParams.group || this.navController.locked) {
return;
}
makeWritable(this).navValue = undefined;
this.element.setAttribute(navAttribute.name, '');
if (isElementFocused(this.element)) {
this.element.blur();
}
}
/** Focus or blur the element. */
focus(
/**
* - `true` to focus
* - `false` to unfocus (or "blur")
*/
enabled, skipListener) {
const previousNavValue = this.navValue;
const alreadySet = enabled === (previousNavValue === NavValue.Focused);
if (this.navParams.group ||
this.navController.locked ||
alreadySet ||
(!enabled && this.navController.options.alwaysRequireFocused)) {
return;
}
if (enabled) {
this.setNavValue(NavValue.Focused);
if (!isElementFocused(this.element)) {
this.element.focus();
}
}
else {
this.removeNavValue(NavValue.Focused);
if (isElementFocused(this.element)) {
this.element.blur();
}
}
if (!skipListener) {
void this.navParams.listeners?.focus?.({
element: this.element,
navEntry: this,
enabled,
previousNavValue,
});
}
return this.navController.triggerNavEntry(this, enabled, NavAction.Focus);
}
/** Activate or deactivate the element. */
activate(
/**
* - `true` to activate
* - `false` to deactivate
*/
enabled) {
const previousNavValue = this.navValue;
const alreadySet = enabled === (previousNavValue === NavValue.Active);
if (this.navParams.group || this.navController.locked || alreadySet) {
return;
}
this.focus(enabled, true);
if (enabled) {
this.setNavValue(NavValue.Active);
}
else {
this.setNavValue(NavValue.Focused);
}
void this.navParams.listeners?.activate?.({
element: this.element,
navEntry: this,
enabled,
previousNavValue,
});
return this.navController.triggerNavEntry(this, enabled, NavAction.Activate);
}
/** Set the given {@link NavValue} on the element. */
setNavValue(navValue) {
makeWritable(this).navValue = navValue;
this.element.setAttribute(navAttribute.name, navValue);
}
/** Remove the given {@link NavValue}, if it exists, from the element. */
removeNavValue(navValue) {
if (this.navValue === navValue) {
makeWritable(this).navValue = undefined;
this.element.setAttribute(navAttribute.name, '');
}
}
/** Attach default mouse and focus listeners. */
attachListeners() {
this.element.addEventListener('mousemove', this.eventListener, true);
this.element.addEventListener('mouseleave', this.eventListener, true);
this.element.addEventListener('mousedown', this.eventListener, true);
this.element.addEventListener('mouseup', this.eventListener, true);
this.element.addEventListener('focus', this.eventListener, true);
this.element.addEventListener('blur', this.eventListener, true);
}
}