UNPKG

converse.js

Version:
350 lines (326 loc) 12.9 kB
/** * @module dom-navigator * @description A class for navigating the DOM with the keyboard * This module started as a fork of Rubens Mariuzzo's dom-navigator. * @copyright Rubens Mariuzzo, JC Brand */ import u from "utils/html"; import { converse } from "@converse/headless"; import {absoluteOffsetLeft, absoluteOffsetTop, inViewport} from "./utils"; const { keycodes } = converse; /** * Adds the ability to navigate the DOM with the arrow keys */ class DOMNavigator { /** * @typedef {import('./types').DOMNavigatorOptions} DOMNavigatorOptions * @typedef {import('./types').DOMNavigatorDirection} DOMNavigatorDirection */ /** * @returns {DOMNavigatorDirection} */ static get DIRECTION() { return { down: "down", end: "end", home: "home", left: "left", right: "right", up: "up", }; } /** * @returns {DOMNavigatorOptions} */ static get DEFAULTS() { return { home: [`${keycodes.SHIFT}${keycodes.UP_ARROW}`], end: [`${keycodes.SHIFT}${keycodes.DOWN_ARROW}`], up: [keycodes.UP_ARROW], down: [keycodes.DOWN_ARROW], left: [keycodes.LEFT_ARROW, `${keycodes.SHIFT}${keycodes.TAB}`], right: [keycodes.RIGHT_ARROW, keycodes.TAB], getSelector: null, jump_to_picked: null, jump_to_picked_direction: null, jump_to_picked_selector: "picked", onSelected: null, selected: "selected", selector: "li", }; } /** * Gets the closest element based on the provided distance function. * @param {HTMLElement[]} els - The elements to evaluate. * @param {function(HTMLElement): number} getDistance - The function to calculate distance. * @returns {HTMLElement} The closest element. */ static getClosestElement(els, getDistance) { const next = els.reduce( (prev, curr) => { const current_distance = getDistance(curr); if (current_distance < prev.distance) { return { distance: current_distance, element: curr, }; } return prev; }, { distance: Infinity, element: null, } ); return next.element; } /** * Create a new DOM Navigator. * @param {HTMLElement} container The container of the element to navigate. * @param {DOMNavigatorOptions} options The options to configure the DOM navigator. */ constructor(container, options) { this.container = container; this.scroll_container = options.scroll_container || container; /** @type {DOMNavigatorOptions} */ this.options = Object.assign({}, DOMNavigator.DEFAULTS, options); this.init(); } init() { this.selected = null; this.keydownHandler = null; this.elements = {}; // Create hotkeys map. this.keys = {}; this.options.down.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.down)); this.options.end.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.end)); this.options.home.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.home)); this.options.left.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.left)); this.options.right.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.right)); this.options.up.forEach((key) => (this.keys[key] = DOMNavigator.DIRECTION.up)); } enable() { this.getElements(); this.keydownHandler = /** @param {KeyboardEvent} ev */ (ev) => this.handleKeydown(ev); const root = u.getRootElement(); root.addEventListener("keydown", this.keydownHandler); this.enabled = true; } disable() { if (this.keydownHandler) { const root = u.getRootElement(); root.removeEventListener("keydown", this.keydownHandler); } this.unselect(); this.elements = {}; this.enabled = false; } destroy() { this.disable(); } /** * @param {'down'|'right'|'left'|'up'} direction * @returns {HTMLElement} */ getNextElement(direction) { let el; if (direction === DOMNavigator.DIRECTION.home) { el = this.getElements(direction)[0]; } else if (direction === DOMNavigator.DIRECTION.end) { el = Array.from(this.getElements(direction)).pop(); } else if (this.selected) { if (direction === DOMNavigator.DIRECTION.right) { const els = this.getElements(direction); el = els.slice(els.indexOf(this.selected))[1]; } else if (direction == DOMNavigator.DIRECTION.left) { const els = this.getElements(direction); el = els.slice(0, els.indexOf(this.selected)).pop() || this.selected; } else if (direction == DOMNavigator.DIRECTION.down) { const left = this.selected.offsetLeft; const top = this.selected.offsetTop + this.selected.offsetHeight; const els = this.elementsAfter(0, top); const getDistance = (el) => Math.abs(el.offsetLeft - left) + Math.abs(el.offsetTop - top); el = DOMNavigator.getClosestElement(els, getDistance); } else if (direction == DOMNavigator.DIRECTION.up) { const left = this.selected.offsetLeft; const top = this.selected.offsetTop - 1; const els = this.elementsBefore(Infinity, top); const getDistance = (el) => Math.abs(left - el.offsetLeft) + Math.abs(top - el.offsetTop); el = DOMNavigator.getClosestElement(els, getDistance); } else { throw new Error("getNextElement: invalid direction value"); } } else { if (direction === DOMNavigator.DIRECTION.right || direction === DOMNavigator.DIRECTION.down) { // If nothing is selected, we pretend that the first element is // selected, so we return the next. el = this.getElements(direction)[1]; } else { el = this.getElements(direction)[0]; } } if ( this.options.jump_to_picked && el && el.matches(this.options.jump_to_picked) && direction === this.options.jump_to_picked_direction ) { el = this.container.querySelector(this.options.jump_to_picked_selector) || el; } return el; } /** * Select the given element. * @param {HTMLElement} el The DOM element to select. * @param {string} [direction] The direction. */ select(el, direction) { if (!el || el === this.selected) { return; } this.unselect(); direction && this.scrollTo(el, direction); if (el.matches("input")) { el.focus(); } else { u.addClass(this.options.selected, el); } this.selected = el; this.options.onSelected && this.options.onSelected(el); } /** * Remove the current selection */ unselect() { if (this.selected) { u.removeClass(this.options.selected, this.selected); delete this.selected; } } /** * Scroll the container to an element. * @param {HTMLElement} el The destination element. * @param {String} direction The direction of the current navigation. * @return void. */ scrollTo(el, direction) { if (!this.inScrollContainerViewport(el)) { const container = this.scroll_container; if (!container.contains(el)) { return; } switch (direction) { case DOMNavigator.DIRECTION.left: container.scrollLeft = el.offsetLeft - container.offsetLeft; container.scrollTop = el.offsetTop - container.offsetTop; break; case DOMNavigator.DIRECTION.up: container.scrollTop = el.offsetTop - container.offsetTop; break; case DOMNavigator.DIRECTION.right: container.scrollLeft = el.offsetLeft - container.offsetLeft - (container.offsetWidth - el.offsetWidth); container.scrollTop = el.offsetTop - container.offsetTop - (container.offsetHeight - el.offsetHeight); break; case DOMNavigator.DIRECTION.down: container.scrollTop = el.offsetTop - container.offsetTop - (container.offsetHeight - el.offsetHeight); break; } } else if (!inViewport(el)) { switch (direction) { case DOMNavigator.DIRECTION.left: document.body.scrollLeft = absoluteOffsetLeft(el) - document.body.offsetLeft; break; case DOMNavigator.DIRECTION.up: document.body.scrollTop = absoluteOffsetTop(el) - document.body.offsetTop; break; case DOMNavigator.DIRECTION.right: document.body.scrollLeft = absoluteOffsetLeft(el) - document.body.offsetLeft - (document.documentElement.clientWidth - el.offsetWidth); break; case DOMNavigator.DIRECTION.down: document.body.scrollTop = absoluteOffsetTop(el) - document.body.offsetTop - (document.documentElement.clientHeight - el.offsetHeight); break; } } } /** * Indicate if an element is in the container viewport. * @param {HTMLElement} el The element to check. * @return {Boolean} true if the given element is in the container viewport, otherwise false. */ inScrollContainerViewport(el) { const container = this.scroll_container; // Check on left side. if (el.offsetLeft - container.scrollLeft < container.offsetLeft) { return false; } // Check on top side. if (el.offsetTop - container.scrollTop < container.offsetTop) { return false; } // Check on right side. if (el.offsetLeft + el.offsetWidth - container.scrollLeft > container.offsetLeft + container.offsetWidth) { return false; } // Check on down side. if (el.offsetTop + el.offsetHeight - container.scrollTop > container.offsetTop + container.offsetHeight) { return false; } return true; } /** * Finds and stores the navigable elements. * @param {string} [direction] - The navigation direction. * @returns {HTMLElement[]} The navigable elements. */ getElements(direction) { const selector = this.options.getSelector ? this.options.getSelector(direction) : this.options.selector; if (!this.elements[selector]) { this.elements[selector] = Array.from(this.container.querySelectorAll(selector)); } return this.elements[selector]; } /** * Gets navigable elements after a specified offset. * @param {number} left - The left offset. * @param {number} top - The top offset. * @returns {HTMLElement[]} An array of elements. */ elementsAfter(left, top) { return this.getElements(DOMNavigator.DIRECTION.down).filter( (el) => el.offsetLeft >= left && el.offsetTop >= top ); } /** * Gets navigable elements before a specified offset. * @param {number} left - The left offset. * @param {number} top - The top offset. * @returns {HTMLElement[]} An array of elements. */ elementsBefore(left, top) { return this.getElements(DOMNavigator.DIRECTION.up).filter((el) => el.offsetLeft <= left && el.offsetTop <= top); } /** * Handle the key down event. * @param {KeyboardEvent} ev - The event object. */ handleKeydown(ev) { const keys = keycodes; const direction = ev.shiftKey ? this.keys[`${keys.SHIFT}${ev.key}`] : this.keys[ev.key]; if (direction) { ev.preventDefault(); ev.stopPropagation(); const next = this.getNextElement(direction); this.select(next, direction); } } } export default DOMNavigator;