/**
* @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/core";
const { keycodes } = converse;
/**
* 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 DOMNavigator
*/
class DOMNavigator {
/**
* Directions.
* @returns {{left: string, up: string, right: string, down: string}}
* @constructor
*/
static get DIRECTION () {
return {
down: 'down',
end: 'end',
home: 'home',
left: 'left',
right: 'right',
up: 'up'
};
}
/**
* 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 {
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',
};
}
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
});
return next.element;
}
/**
* 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.
*/
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 navigator.
*/
enable () {
this.getElements();
this.keydownHandler = event => this.handleKeydown(event);
this.doc.addEventListener('keydown', this.keydownHandler);
this.enabled = true;
}
/**
* Disable this navigator.
*/
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.
*/
destroy () {
this.disable();
if (this.container.domNavigator) {
delete this.container.domNavigator;
}
}
/**
* @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 { 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
*/
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;
}
/**
* Find and store 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];
}
/**
* Return an array of navigable elements after an offset.
* @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.
* @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.
* @param { Event } event The event object.
*/
handleKeydown (ev) {
const keys = keycodes;
const direction = ev.shiftKey ? this.keys[`${keys.SHIFT}+${ev.which}`] : this.keys[ev.which];
if (direction) {
ev.preventDefault();
ev.stopPropagation();
const next = this.getNextElement(direction, ev);
this.select(next, direction);
}
}
}
export default DOMNavigator;