Converse converse.js

Source: shared/dom-navigator.js

/**
 * @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;