Преглед на файлове

Add arrow navigation to the emoji picker

JC Brand преди 5 години
родител
ревизия
995f2a9997

+ 8 - 1
sass/_emoji.scss

@@ -75,9 +75,13 @@
                     position: relative;
                     &.insert-emoji {
                         margin: 0;
-                        height: 32px;
+                        padding: 3px;
+                        height: 30px;
                         width: 32px;
 
+                        &.selected {
+                            background-color: var(--highlight-color);
+                        }
                         &.picked {
                             background-color: var(--highlight-color);
                         }
@@ -112,6 +116,9 @@
                             border: 1px var(--chat-head-color) solid;
                             border-bottom: none;
                         }
+                        &.selected {
+                            background-color: var(--highlight-color);
+                        }
                         padding: 0.25em;
                         font-size: var(--font-size-huge);
                         &:hover {

+ 1 - 1
sass/_variables.scss

@@ -62,7 +62,7 @@ $mobile_portrait_length: 480px !default;
     --chat-topic-display: block;
     --chat-info-display: block;
 
-    --highlight-color: #DCF9F6;
+    --highlight-color: #B0E8E2;
 
     --primary-color: var(--light-blue);
     --primary-color-dark:  #397491;

+ 3 - 3
spec/emojis.js

@@ -74,7 +74,7 @@
 
                 // Check that ENTER now inserts the match
                 const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
-                view.emoji_picker_view.onKeyDown(enter_event);
+                view.emoji_picker_view._onGlobalKeyDown(enter_event);
                 expect(input.value).toBe('');
                 expect(textarea.value).toBe(':grimacing: ');
 
@@ -136,7 +136,7 @@
 
                 // Check that pressing enter without an unambiguous match does nothing
                 const enter_event = Object.assign({}, event, {'keyCode': 13});
-                view.emoji_picker_view.onKeyDown(enter_event);
+                view.emoji_picker_view._onGlobalKeyDown(enter_event);
                 expect(input.value).toBe('smiley');
 
                 // Test that TAB autocompletes the to first match
@@ -148,7 +148,7 @@
                 expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
 
                 // Check that ENTER now inserts the match
-                view.emoji_picker_view.onKeyDown(enter_event);
+                view.emoji_picker_view._onGlobalKeyDown(enter_event);
                 expect(input.value).toBe('');
                 expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
                 done();

+ 11 - 11
src/converse-autocomplete.js

@@ -282,28 +282,28 @@ converse.plugins.add("converse-autocomplete", {
 
             onKeyDown (ev) {
                 if (this.opened) {
-                    if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) {
+                    if (_.includes([converse.keycodes.ENTER, converse.keycodes.TAB], ev.keyCode) && this.selected) {
                         ev.preventDefault();
                         ev.stopPropagation();
                         this.select();
                         return true;
-                    } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                    } else if (ev.keyCode === converse.keycodes.ESCAPE) {
                         this.close({'reason': 'esc'});
                         return true;
-                    } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) {
+                    } else if (_.includes([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW], ev.keyCode)) {
                         ev.preventDefault();
                         ev.stopPropagation();
-                        this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
+                        this[ev.keyCode === converse.keycodes.UP_ARROW ? "previous" : "next"]();
                         return true;
                     }
                 }
 
                 if (_.includes([
-                            _converse.keycodes.SHIFT,
-                            _converse.keycodes.META,
-                            _converse.keycodes.META_RIGHT,
-                            _converse.keycodes.ESCAPE,
-                            _converse.keycodes.ALT]
+                            converse.keycodes.SHIFT,
+                            converse.keycodes.META,
+                            converse.keycodes.META_RIGHT,
+                            converse.keycodes.ESCAPE,
+                            converse.keycodes.ALT]
                         , ev.keyCode)) {
                     return;
                 }
@@ -323,8 +323,8 @@ converse.plugins.add("converse-autocomplete", {
 
             evaluate (ev) {
                 const selecting = this.selected && ev && (
-                    ev.keyCode === _converse.keycodes.UP_ARROW ||
-                    ev.keyCode === _converse.keycodes.DOWN_ARROW
+                    ev.keyCode === converse.keycodes.UP_ARROW ||
+                    ev.keyCode === converse.keycodes.DOWN_ARROW
                 );
 
                 if (!this.auto_evaluate && !this.auto_completing || selecting) {

+ 10 - 10
src/converse-chatview.js

@@ -869,29 +869,29 @@ converse.plugins.add('converse-chatview', {
                     return;
                 }
                 if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
-                    if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
+                    if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
                         // Forward slash is used to run commands. Nothing to do here.
                         return;
-                    } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                    } else if (ev.keyCode === converse.keycodes.ESCAPE) {
                         return this.onEscapePressed(ev);
-                    } else if (ev.keyCode === _converse.keycodes.ENTER) {
+                    } else if (ev.keyCode === converse.keycodes.ENTER) {
                         return this.onEnterPressed(ev);
-                    } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
+                    } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
                         const textarea = this.el.querySelector('.chat-textarea');
                         if (!textarea.value || u.hasClass('correcting', textarea)) {
                             return this.editEarlierMessage();
                         }
-                    } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW &&
+                    } else if (ev.keyCode === converse.keycodes.DOWN_ARROW &&
                             ev.target.selectionEnd === ev.target.value.length &&
                             u.hasClass('correcting', this.el.querySelector('.chat-textarea'))) {
                         return this.editLaterMessage();
                     }
                 }
-                if ([_converse.keycodes.SHIFT,
-                        _converse.keycodes.META,
-                        _converse.keycodes.META_RIGHT,
-                        _converse.keycodes.ESCAPE,
-                        _converse.keycodes.ALT].includes(ev.keyCode)) {
+                if ([converse.keycodes.SHIFT,
+                        converse.keycodes.META,
+                        converse.keycodes.META_RIGHT,
+                        converse.keycodes.ESCAPE,
+                        converse.keycodes.ALT].includes(ev.keyCode)) {
                     return;
                 }
                 if (this.model.get('chat_state') !== _converse.COMPOSING) {

+ 145 - 50
src/converse-emoji-views.js

@@ -1,15 +1,12 @@
-// Converse.js
-// https://conversejs.org
-//
-// Copyright (c) 2013-2019, the Converse.js developers
-// Licensed under the Mozilla Public License (MPLv2)
-
 /**
  * @module converse-emoji-views
+ * @copyright 2013-2019, the Converse.js developers
+ * @license Mozilla Public License (MPLv2)
  */
 
 import "@converse/headless/converse-emoji";
-import { debounce, find } from "lodash";
+import { debounce, find, get } from "lodash";
+import DOMNavigator from "./dom-navigator";
 import bootstrap from "bootstrap.native";
 import tpl_emoji_button from "templates/emoji_button.html";
 import tpl_emojis from "templates/emojis.html";
@@ -46,8 +43,7 @@ converse.plugins.add('converse-emoji-views', {
             },
 
             onKeyDown (ev) {
-                const { _converse } = this.__super__;
-                if (ev.keyCode === _converse.keycodes.TAB) {
+                if (ev.keyCode === converse.keycodes.TAB) {
                     const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
                     if (value.startsWith(':')) {
                         ev.preventDefault();
@@ -95,6 +91,10 @@ converse.plugins.add('converse-emoji-views', {
             },
 
             createEmojiPicker () {
+                if (this.emoji_picker_view) {
+                    this.insertEmojiPicker();
+                    return;
+                }
                 if (!_converse.emojipicker) {
                     const id = `converse.emoji-${_converse.bare_jid}`;
                     _converse.emojipicker = new _converse.EmojiPicker({'id': id});
@@ -134,18 +134,28 @@ converse.plugins.add('converse-emoji-views', {
         _converse.EmojiPickerView = Backbone.VDOMView.extend({
             className: 'emoji-picker',
             events: {
-                'click .emoji-picker__header li.emoji-category': 'chooseCategory',
+                'click .emoji-picker__header li.emoji-category .pick-category': 'chooseCategory',
                 'click .emoji-skintone-picker li.emoji-skintone': 'chooseSkinTone',
                 'click .insert-emoji': 'insertEmoji',
+                'focus .emoji-search': 'disableArrowNavigation',
                 'keydown .emoji-search': 'onKeyDown'
             },
 
             async initialize () {
+                this.onGlobalKeyDown = ev => this._onGlobalKeyDown(ev);
+                document.addEventListener('keydown', this.onGlobalKeyDown);
+
                 this.search_results = [];
                 this.debouncedFilter = debounce(input => this.filter(input.value), 150);
                 this.listenTo(this.model, 'change:query', this.render)
                 this.listenTo(this.model, 'change:current_skintone', this.render)
-                this.listenTo(this.model, 'change:current_category', this.render)
+                this.listenTo(this.model, 'change:current_category', () => {
+                    this.render();
+                    const category = this.model.get('current_category');
+                    const el = this.el.querySelector(`.emoji-category[data-category="${category}"]`);
+                    this.navigator.select(el);
+                    !this.navigator.enabled && this.navigator.enable();
+                });
                 await _converse.api.waitUntil('emojisInitialized');
                 this.render();
             },
@@ -169,11 +179,45 @@ converse.plugins.add('converse-emoji-views', {
                 );
             },
 
+            remove () {
+                document.removeEventListener('keydown', this.onGlobalKeyDown);
+                Backbone.VDOMView.prototype.remove.call(this);
+            },
+
             afterRender () {
                 this.initIntersectionObserver();
                 const textarea = this.el.querySelector('.emoji-search');
                 textarea.addEventListener('focus', ev => this.chatview.emitFocused(ev));
                 textarea.addEventListener('blur', ev => this.chatview.emitBlurred(ev));
+                this.initArrowNavigation();
+            },
+
+            initArrowNavigation () {
+                if (!this.navigator) {
+                    const default_selector = 'li:not(.hidden):not(.emoji-skintone), .emoji-search';
+                    const options = {
+                        'jump_to_picked': '.emoji-category',
+                        'jump_to_picked_selector': '.emoji-category.picked',
+                        'jump_to_picked_direction': DOMNavigator.DIRECTION.down,
+                        'picked_selector': '.picked',
+                        'scroll_container': this.el.querySelector('.emoji-picker__lists'),
+                        'getSelector': direction => {
+                            if (direction === DOMNavigator.DIRECTION.down) {
+                                const c = this.navigator.selected && this.navigator.selected.getAttribute('data-category');
+                                return c ? `ul[data-category="${c}"] li:not(.hidden):not(.emoji-skintone), .emoji-search` : default_selector;
+                            } else {
+                                return default_selector;
+                            }
+                        },
+                        'onSelected': el => el.matches('.insert-emoji') && this.setCategoryForElement(el.parentElement)
+                    };
+                    this.navigator = new DOMNavigator(this.el, options);
+                    this.listenTo(this.chatview.model, 'destroy', () => this.navigator.destroy());
+                }
+            },
+
+            disableArrowNavigation () {
+                this.navigator.disable();
             },
 
             filter (value, set_property) {
@@ -196,24 +240,34 @@ converse.plugins.add('converse-emoji-views', {
                 }
             },
 
+            setCategoryForElement (el) {
+                const category = el.getAttribute('data-category');
+                const old_category = this.model.get('current_category');
+                if (old_category !== category) {
+                    // XXX: Manually set the classes, it's quicker than using the VDOM
+                    this.model.set(
+                        {'current_category': category},
+                        {'silent': true}
+                    );
+                    const category_els = sizzle('.emoji-picker__header .emoji-category', this.el);
+                    category_els.forEach(el => u.removeClass('picked', el));
+                    const new_el = category_els.filter(el => el.getAttribute('data-category') === category).pop();
+                    new_el && u.addClass('picked', new_el);
+                }
+            },
+
             setCategoryOnVisibilityChange (ev) {
-                const current = ev.filter(e => e.isIntersecting).pop();
-                if (current) {
-                    const category = current.target.getAttribute('data-category');
-                    const old_category = this.model.get('current_category');
-                    if (old_category !== category) {
-                        // XXX: Manually set the classes, it's quicker than using the VDOM
-                        this.model.set(
-                            {'current_category': category},
-                            {'silent': true}
-                        );
-                        const categories = sizzle('.emoji-picker__header .emoji-category', this.el);
-                        const new_el = categories.filter(el => el.getAttribute('data-category') === category).pop();
-                        const old_el = categories.filter(el => el.getAttribute('data-category') === old_category).pop();
-                        new_el && u.addClass('picked', new_el);
-                        old_el && u.removeClass('picked', old_el);
-                    }
+                const selected = this.navigator.selected;
+                const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
+                let current;
+                // Choose the intersection that contains the currently selected
+                // element, or otherwise the one with the largest ratio.
+                if (intersection_with_selected) {
+                    current = intersection_with_selected;
+                } else {
+                    current = ev.reduce((p, c) => c.intersectionRatio >= get(p, 'intersectionRatio', 0) ? c : p, null);
                 }
+                current && current.isIntersecting && this.setCategoryForElement(current.target);
             },
 
             initIntersectionObserver () {
@@ -225,10 +279,9 @@ converse.plugins.add('converse-emoji-views', {
                 } else {
                     const options = {
                         root: this.el.querySelector('.emoji-picker__lists'),
-                        rootMargin: '0px',
-                        threshold: [0.1, 0.2, 0.3, 0.4, 0.5]
+                        threshold: [0.1]
                     }
-                    const handler = debounce((ev) => this.setCategoryOnVisibilityChange(ev), 200);
+                    const handler = ev => this.setCategoryOnVisibilityChange(ev);
                     this.observer = new IntersectionObserver(handler, options);
                 }
                 sizzle('.emoji-picker', this.el).forEach(a => this.observer.observe(a));
@@ -243,24 +296,62 @@ converse.plugins.add('converse-emoji-views', {
                     this.chatview.emoji_dropdown.toggle();
                 }
                 this.filter('', true);
+                this.disableArrowNavigation();
             },
 
-            onKeyDown (ev) {
-                if (ev.keyCode === _converse.keycodes.TAB) {
-                    ev.preventDefault();
-                    const match = find(_converse.emoji_shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value));
-                    if (match) {
-                        this.filter(match, true);
+            _onGlobalKeyDown (ev) {
+                if (ev.keyCode === converse.keycodes.ENTER) {
+                    if (ev.target.matches('.emoji-search') || (
+                            ev.target.matches('body') &&
+                            u.isVisible(this.el) &&
+                            this.navigator.selected
+                    )) {
+                        ev.preventDefault();
+                        ev.stopPropagation();
+                        if (_converse.emoji_shortnames.includes(ev.target.value)) {
+                            this.insertIntoTextArea(ev.target.value);
+                        } else if (this.search_results.length === 1) {
+                            this.insertIntoTextArea(this.search_results[0].sn);
+                        } else if (this.navigator.selected && this.navigator.selected.matches('.insert-emoji')) {
+                            this.insertIntoTextArea(this.navigator.selected.getAttribute('data-emoji'));
+                        } else if (this.navigator.selected && this.navigator.selected.matches('.emoji-category')) {
+                            this.chooseCategory({'target': this.navigator.selected});
+                        }
+                    }
+                } else if (ev.keyCode === converse.keycodes.DOWN_ARROW) {
+                    if (ev.target.matches('.emoji-search') || (
+                            !this.navigator.enabled &&
+                            (ev.target.matches('.pick-category') || ev.target.matches('body')) &&
+                            u.isVisible(this.el)
+                    )) {
+                        ev.preventDefault();
+                        ev.stopPropagation();
+                        ev.target.blur();
+                        const category = this.model.get('current_category');
+                        // If there's no category, we're viewing search results.
+                        const selector = category ? `ul[data-category="${category}"]` : 'ul';
+                        this.disableArrowNavigation();
+                        this.navigator.enable();
+                        this.navigator.handleKeydown(ev);
                     }
-                } else if (ev.keyCode === _converse.keycodes.ENTER) {
+                }
+            },
+
+            onKeyDown (ev) {
+                if (ev.keyCode === converse.keycodes.RIGHT_ARROW) {
                     ev.preventDefault();
                     ev.stopPropagation();
-                    if (_converse.emoji_shortnames.includes(ev.target.value)) {
-                        this.insertIntoTextArea(ev.target.value);
-                    } else if (this.search_results.length === 1) {
-                        this.insertIntoTextArea(this.search_results[0].sn);
-                    }
-                } else {
+                    ev.target.blur();
+                    const first_el = this.el.querySelector('.pick-category');
+                    this.navigator.select(first_el, 'right');
+                } else if (ev.keyCode === converse.keycodes.TAB) {
+                    ev.preventDefault();
+                    const match = find(_converse.emoji_shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value));
+                    match && this.filter(match, true);
+                } else if (
+                    ev.keyCode !== converse.keycodes.ENTER &&
+                    ev.keyCode !== converse.keycodes.DOWN_ARROW
+                ) {
                     this.debouncedFilter(ev.target);
                 }
             },
@@ -307,14 +398,13 @@ converse.plugins.add('converse-emoji-views', {
             },
 
             chooseCategory (ev) {
-                ev.preventDefault();
-                ev.stopPropagation();
-                const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
-                const category = target.getAttribute("data-category").trim();
-                // XXX: See above
+                ev.preventDefault && ev.preventDefault();
+                ev.stopPropagation && ev.stopPropagation();
                 const input = this.el.querySelector('.emoji-search');
                 input.value = '';
-                this.model.save({'current_category': category, 'query': undefined});
+                const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
+                this.setCategoryForElement(target);
+                this.navigator.select(target);
                 this.setScrollPosition();
             },
 
@@ -322,7 +412,9 @@ converse.plugins.add('converse-emoji-views', {
                 const category = this.model.get('current_category');
                 const el = this.el.querySelector('.emoji-picker__lists');
                 const heading = this.el.querySelector(`#emoji-picker-${category}`);
-                el.scrollTop = heading.offsetTop - heading.offsetHeight*3;
+                if (heading) {
+                    el.scrollTop = heading.offsetTop - heading.offsetHeight*3;
+                }
             },
 
             insertEmoji (ev) {
@@ -340,6 +432,9 @@ converse.plugins.add('converse-emoji-views', {
 
 
         /************************ BEGIN Event Handlers ************************/
+
+        _converse.api.listen.on('chatBoxClosed', view => view.emoji_picker_view && view.emoji_picker_view.remove());
+
         _converse.api.listen.on('renderToolbar', view => {
             if (_converse.visible_toolbar_buttons.emoji) {
                 const html = tpl_emoji_button({'tooltip_insert_smiley': __('Insert emojis')});

+ 3 - 8
src/converse-muc-views.js

@@ -1,13 +1,8 @@
-// Converse.js
-// https://conversejs.org
-//
-// Copyright (c) 2013-2019, the Converse.js developers
-// Licensed under the Mozilla Public License (MPLv2)
-//
 /**
  * @module converse-muc-views
- * @description
- * XEP-0045 Multi-User Chat Views
+ * @copyright 2013-2019, the Converse.js developers
+ * @description XEP-0045 Multi-User Chat Views
+ * @license Mozilla Public License (MPLv2)
  */
 import "converse-modal";
 import "backbone.vdomview";

+ 3 - 7
src/converse-singleton.js

@@ -1,12 +1,8 @@
-// Converse.js
-// https://conversejs.org
-//
-// Copyright (c) 2013-2019, the Converse.js developers
-// Licensed under the Mozilla Public License (MPLv2)
 /**
  * @module converse-singleton
- * @description
- * A plugin which restricts Converse to only one chat.
+ * @copyright JC Brand
+ * @license Mozilla Public License (MPLv2)
+ * @description A plugin which restricts Converse to only one chat.
  */
 import converse from "@converse/headless/converse-core";
 

+ 420 - 0
src/dom-navigator.js

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

+ 17 - 15
src/headless/converse-core.js

@@ -149,21 +149,6 @@ _converse.IllegalMessage = IllegalMessage;
 // Make converse pluggable
 pluggable.enable(_converse, '_converse', 'pluggable');
 
-_converse.keycodes = {
-    TAB: 9,
-    ENTER: 13,
-    SHIFT: 16,
-    CTRL: 17,
-    ALT: 18,
-    ESCAPE: 27,
-    UP_ARROW: 38,
-    DOWN_ARROW: 40,
-    FORWARD_SLASH: 47,
-    AT: 50,
-    META: 91,
-    META_RIGHT: 93
-};
-
 // Module-level constants
 _converse.STATUS_WEIGHTS = {
     'offline':      6,
@@ -1732,6 +1717,23 @@ window.converse = window.converse || {};
  * @namespace converse
  */
 Object.assign(window.converse, {
+    keycodes: {
+        TAB: 9,
+        ENTER: 13,
+        SHIFT: 16,
+        CTRL: 17,
+        ALT: 18,
+        ESCAPE: 27,
+        LEFT_ARROW: 37,
+        UP_ARROW: 38,
+        RIGHT_ARROW: 39,
+        DOWN_ARROW: 40,
+        FORWARD_SLASH: 47,
+        AT: 50,
+        META: 91,
+        META_RIGHT: 93
+    },
+
     /**
      * Public API method which initializes Converse.
      * This method must always be called when using Converse.

+ 3 - 8
src/headless/converse-muc.js

@@ -1,13 +1,8 @@
-// Converse.js
-// https://conversejs.org
-//
-// Copyright (c) 2013-2019, the Converse.js developers
-// Licensed under the Mozilla Public License (MPLv2)
-//
 /**
  * @module converse-muc
- * @description
- * Implements the non-view logic for XEP-0045 Multi-User Chat
+ * @copyright The Converse.js developers
+ * @license Mozilla Public License (MPLv2)
+ * @description Implements the non-view logic for XEP-0045 Multi-User Chat
  */
 import "./converse-chat";
 import "./converse-disco";

+ 12 - 0
src/utils/html.js

@@ -279,11 +279,23 @@ u.hasClass = function (className, el) {
     return (el instanceof Element) && el.classList.contains(className);
 };
 
+/**
+ * Add a class to an element.
+ * @method u#addClass
+ * @param {string} className
+ * @param {Element} el
+ */
 u.addClass = function (className, el) {
     (el instanceof Element) && el.classList.add(className);
     return el;
 }
 
+/**
+ * Remove a class from an element.
+ * @method u#removeClass
+ * @param {string} className
+ * @param {Element} el
+ */
 u.removeClass = function (className, el) {
     (el instanceof Element) && el.classList.remove(className);
     return el;