|
@@ -0,0 +1,420 @@
|
|
|
+/**
|
|
|
+ * @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 log from "@converse/headless/log";
|
|
|
+import u from './utils/html';
|
|
|
+
|
|
|
+
|
|
|
+/**
|
|
|
+ * Indicates if a given element is fully visible in the viewport.
|
|
|
+ * @param { Element } el The element to check.
|
|
|
+ * @return { Boolean } True if the given element is fully visible in the viewport, otherwise false.
|
|
|
+ */
|
|
|
+function inViewport(el) {
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ return (
|
|
|
+ rect.top >= 0 &&
|
|
|
+ rect.left >= 0 &&
|
|
|
+ rect.bottom <= window.innerHeight &&
|
|
|
+ rect.right <= window.innerWidth
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Return the absolute offset top of an element.
|
|
|
+ * @param el { Element } The element.
|
|
|
+ * @return { Number } The offset top.
|
|
|
+ */
|
|
|
+function absoluteOffsetTop(el) {
|
|
|
+ let offsetTop = 0;
|
|
|
+ do {
|
|
|
+ if (!isNaN(el.offsetTop)) {
|
|
|
+ offsetTop += el.offsetTop;
|
|
|
+ }
|
|
|
+ } while ((el = el.offsetParent));
|
|
|
+ return offsetTop;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Return the absolute offset left of an element.
|
|
|
+ * @param el { Element } The element.
|
|
|
+ * @return { Number } The offset left.
|
|
|
+ */
|
|
|
+function absoluteOffsetLeft(el) {
|
|
|
+ let offsetLeft = 0;
|
|
|
+ do {
|
|
|
+ if (!isNaN(el.offsetLeft)) {
|
|
|
+ offsetLeft += el.offsetLeft;
|
|
|
+ }
|
|
|
+ } while ((el = el.offsetParent));
|
|
|
+ return offsetLeft;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+/**
|
|
|
+ * Adds the ability to navigate the DOM with the arrow keys
|
|
|
+ * @class
|
|
|
+ * @namespace DOMNavigator
|
|
|
+ */
|
|
|
+class DOMNavigator {
|
|
|
+ /**
|
|
|
+ * Directions.
|
|
|
+ * @returns {{left: string, up: string, right: string, down: string}}
|
|
|
+ * @constructor
|
|
|
+ */
|
|
|
+ static get DIRECTION () {
|
|
|
+ return {
|
|
|
+ left: 'left',
|
|
|
+ up: 'up',
|
|
|
+ right: 'right',
|
|
|
+ down: 'down'
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The default options for the DOM navigator.
|
|
|
+ * @returns {{
|
|
|
+ * down: number
|
|
|
+ * getSelector: null,
|
|
|
+ * jump_to_picked: null,
|
|
|
+ * jump_to_picked_direction: null,
|
|
|
+ * jump_to_picked_selector: string,
|
|
|
+ * left: number,
|
|
|
+ * onSelected: null,
|
|
|
+ * right: number,
|
|
|
+ * selected: string,
|
|
|
+ * up: number,
|
|
|
+ * }}
|
|
|
+ */
|
|
|
+ static get DEFAULTS () {
|
|
|
+ return {
|
|
|
+ down: 40,
|
|
|
+ getSelector: null,
|
|
|
+ jump_to_picked: null,
|
|
|
+ jump_to_picked_direction: null,
|
|
|
+ jump_to_picked_selector: 'picked',
|
|
|
+ left: 37,
|
|
|
+ onSelected: null,
|
|
|
+ right: 39,
|
|
|
+ selected: 'selected',
|
|
|
+ selector: 'li',
|
|
|
+ up: 38,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Create a new DOM Navigator.
|
|
|
+ * @param { Element } container The container of the element to navigate.
|
|
|
+ * @param { Object } options The options to configure the DOM navigator.
|
|
|
+ * @param { Function } options.getSelector
|
|
|
+ * @param { Number } [options.down] - The keycode for navigating down
|
|
|
+ * @param { Number } [options.left] - The keycode for navigating left
|
|
|
+ * @param { Number } [options.right] - The keycode for navigating right
|
|
|
+ * @param { Number } [options.up] - The keycode for navigating up
|
|
|
+ * @param { String } [options.selected] - The class that should be added to the currently selected DOM element.
|
|
|
+ * @param { String } [options.jump_to_picked] - A selector, which if
|
|
|
+ * matched by the next element being navigated to, based on the direction
|
|
|
+ * given by `jump_to_picked_direction`, will cause navigation
|
|
|
+ * to jump to the element that matches the `jump_to_picked_selector`.
|
|
|
+ * For example, this is useful when navigating to tabs. You want to
|
|
|
+ * immediately navigate to the currently active tab instead of just
|
|
|
+ * navigating to the first tab.
|
|
|
+ * @param { String } [options.jump_to_picked_selector=picked] - The selector
|
|
|
+ * indicating the currently picked element to jump to.
|
|
|
+ * @param { String } [options.jump_to_picked_direction] - The direction for
|
|
|
+ * which jumping to the picked element should be enabled.
|
|
|
+ * @param { Function } [options.onSelected] - The callback function which
|
|
|
+ * should be called when en element gets selected.
|
|
|
+ * @constructor
|
|
|
+ */
|
|
|
+ constructor (container, options) {
|
|
|
+ this.doc = window.document;
|
|
|
+ this.container = container;
|
|
|
+ this.scroll_container = options.scroll_container || container;
|
|
|
+ this.options = Object.assign({}, DOMNavigator.DEFAULTS, options);
|
|
|
+ this.init();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Initialize the navigator.
|
|
|
+ * @method DOMNavigator#init
|
|
|
+ */
|
|
|
+ init () {
|
|
|
+ this.selected = null;
|
|
|
+ this.keydownHandler = null;
|
|
|
+ this.elements = {};
|
|
|
+ // Create hotkeys map.
|
|
|
+ this.keys = {};
|
|
|
+ this.keys[this.options.left] = DOMNavigator.DIRECTION.left;
|
|
|
+ this.keys[this.options.up] = DOMNavigator.DIRECTION.up;
|
|
|
+ this.keys[this.options.right] = DOMNavigator.DIRECTION.right;
|
|
|
+ this.keys[this.options.down] = DOMNavigator.DIRECTION.down;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Enable this navigator.
|
|
|
+ * @method DOMNavigator#enable
|
|
|
+ */
|
|
|
+ enable () {
|
|
|
+ this.getElements();
|
|
|
+ this.keydownHandler = event => this.handleKeydown(event);
|
|
|
+ this.doc.addEventListener('keydown', this.keydownHandler);
|
|
|
+ this.enabled = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Disable this navigator.
|
|
|
+ * @method DOMNavigator#disable
|
|
|
+ */
|
|
|
+ disable () {
|
|
|
+ if (this.keydownHandler) {
|
|
|
+ this.doc.removeEventListener('keydown', this.keydownHandler);
|
|
|
+ }
|
|
|
+ this.unselect();
|
|
|
+ this.elements = {};
|
|
|
+ this.enabled = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Destroy this navigator removing any event registered and any other data.
|
|
|
+ * @method DOMNavigator#destroy
|
|
|
+ */
|
|
|
+ destroy () {
|
|
|
+ this.disable();
|
|
|
+ if (this.container.domNavigator) {
|
|
|
+ delete this.container.domNavigator;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ });
|
|
|
+ return next.element;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @method DOMNavigator#getNextElement
|
|
|
+ * @param {'down'|'right'|'left'|'up'} direction
|
|
|
+ * @returns { HTMLElement }
|
|
|
+ */
|
|
|
+ getNextElement (direction) {
|
|
|
+ let el;
|
|
|
+ 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 = this.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 = this.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.
|
|
|
+ * @method DOMNavigator#select
|
|
|
+ * @param { Element } 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
|
|
|
+ * @method DOMNavigator#unselect
|
|
|
+ */
|
|
|
+ unselect () {
|
|
|
+ if (this.selected) {
|
|
|
+ u.removeClass(this.options.selected, this.selected);
|
|
|
+ delete this.selected;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Scroll the container to an element.
|
|
|
+ * @method DOMNavigator#scrollTo
|
|
|
+ * @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.
|
|
|
+ * @method DOMNavigator#inScrollContainerViewport
|
|
|
+ * @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;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Find and store the navigable elements
|
|
|
+ * @method DOMNavigator#getElements
|
|
|
+ */
|
|
|
+ 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];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Return an array of navigable elements after an offset.
|
|
|
+ * @method DOMNavigator#elementsAfter
|
|
|
+ * @param { number } left The left offset.
|
|
|
+ * @param { number } top The top offset.
|
|
|
+ * @return { Array } An array of elements.
|
|
|
+ */
|
|
|
+ elementsAfter (left, top) {
|
|
|
+ return this.getElements(DOMNavigator.DIRECTION.down).filter(el => el.offsetLeft >= left && el.offsetTop >= top);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Return an array of navigable elements before an offset.
|
|
|
+ * @method DOMNavigator#elementsBefore
|
|
|
+ * @param { number } left The left offset.
|
|
|
+ * @param { number } top The top offset.
|
|
|
+ * @return { Array } 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.
|
|
|
+ * @method DOMNavigator#handleKeydown
|
|
|
+ * @param { Event } event The event object.
|
|
|
+ */
|
|
|
+ handleKeydown (event) {
|
|
|
+ const direction = this.keys[event.which];
|
|
|
+ if (direction) {
|
|
|
+ event.preventDefault();
|
|
|
+ event.stopPropagation();
|
|
|
+ let next;
|
|
|
+ if (event.shiftKey && direction === DOMNavigator.DIRECTION.up) {
|
|
|
+ // shift-up goes to the first element
|
|
|
+ next = this.getElements(direction)[0];
|
|
|
+ } else if (event.shiftKey && direction === DOMNavigator.DIRECTION.down) {
|
|
|
+ // shift-down goes to the last element
|
|
|
+ next = Array.from(this.getElements(direction)).pop();
|
|
|
+ } else {
|
|
|
+ next = this.getNextElement(direction, event);
|
|
|
+ }
|
|
|
+ this.select(next, direction);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default DOMNavigator;
|