瀏覽代碼

Bugfix: Arrow navigation stopped working in the emoji picker.

The problem was that Bootstrap was stopping propagation of `keydown`
events for the down and up arrow keys.

To fix it, I deregistered Bootstrap's event handler and use Converse's
own DOMNavigator instead.

Updates #2495
JC Brand 3 月之前
父節點
當前提交
3fb503541a

+ 2 - 1
src/plugins/chatview/tests/chatbox.js

@@ -13,7 +13,8 @@ describe("Chatboxes", function () {
 
     describe("A Chatbox", function () {
 
-        it("has a /help command to show the available commands", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+        it("has a /help command to show the available commands", mock.initConverse(['chatBoxesFetched'],
+                {view_mode: 'fullscreen'}, async function (_converse) {
 
             const { api } = _converse;
             await mock.waitForRoster(_converse, 'current', 1);

+ 1 - 1
src/plugins/controlbox/loginform.js

@@ -1,4 +1,4 @@
-import { Popover } from 'bootstrap';
+import Popover from 'bootstrap/js/src/popover.js';
 import { _converse, api, converse, constants } from '@converse/headless';
 import { CustomElement } from 'shared/components/element.js';
 import { updateSettingsWithFormData, validateJID } from './utils.js';

+ 1 - 1
src/plugins/modal/modal.js

@@ -1,7 +1,7 @@
 import { html } from 'lit';
+import Modal from "bootstrap/js/src/modal.js";
 import { getOpenPromise } from '@converse/openpromise';
 import { Model} from '@converse/skeletor';
-import { Modal } from "bootstrap";
 import { CustomElement } from 'shared/components/element.js';
 import { u } from '@converse/headless';
 import { modal_close_button } from "./templates/buttons.js";

+ 1 - 1
src/plugins/modal/popover.js

@@ -1,6 +1,6 @@
 import '@popperjs/core';
 import { html } from "lit";
-import { Popover as BootstrapPopover } from "bootstrap";
+import { default as BootstrapPopover } from "bootstrap/js/src/popover.js";
 import { api } from "@converse/headless";
 import { CustomElement } from "shared/components/element.js";
 

+ 0 - 1
src/shared/autocomplete/autocomplete.js

@@ -237,7 +237,6 @@ export class AutoComplete extends EventEmitter(Object) {
                 this.close({'reason': 'esc'});
                 return true;
             } else if ([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW].includes(ev.key)) {
-                debugger;
                 ev.preventDefault();
                 ev.stopPropagation();
                 this[ev.key === converse.keycodes.UP_ARROW ? "previous" : "next"]();

+ 6 - 8
src/shared/chat/emoji-picker.js

@@ -5,7 +5,7 @@
  */
 import debounce from "lodash-es/debounce";
 import { api, converse, u, constants } from "@converse/headless";
-import DOMNavigator from "shared/dom-navigator";
+import { DOMNavigator } from "shared/dom-navigator";
 import { CustomElement } from "shared/components/element.js";
 import { FILTER_CONTAINS } from "shared/autocomplete/utils.js";
 import { getTonedEmojis } from "./utils.js";
@@ -123,8 +123,7 @@ export default class EmojiPicker extends CustomElement {
     registerEvents() {
         this.onGlobalKeyDown = (ev) => this.#onGlobalKeyDown(ev);
         this.dropdown.addEventListener("hide.bs.dropdown", () => this.onDropdownHide());
-        const body = document.querySelector("body");
-        body.addEventListener("keydown", this.onGlobalKeyDown);
+        this.addEventListener("keydown", this.onGlobalKeyDown);
     }
 
     connectedCallback() {
@@ -133,8 +132,7 @@ export default class EmojiPicker extends CustomElement {
     }
 
     disconnectedCallback() {
-        const body = document.querySelector("body");
-        body.removeEventListener("keydown", this.onGlobalKeyDown);
+        this.removeEventListener("keydown", this.onGlobalKeyDown);
         this.disableArrowNavigation();
         super.disconnectedCallback();
     }
@@ -143,11 +141,11 @@ export default class EmojiPicker extends CustomElement {
      * @param {KeyboardEvent} ev
      */
     #onGlobalKeyDown(ev) {
-        if (!this.navigator) return;
+        if (!this.navigator || !u.isVisible(this)) return;
 
-        if (ev.key === KEYCODES.ENTER && u.isVisible(this)) {
+        if (ev.key === KEYCODES.ENTER) {
             this.onEnterPressed(ev);
-        } else if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled && u.isVisible(this)) {
+        } else if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled) {
             this.enableArrowNavigation(ev);
         }
     }

+ 50 - 13
src/shared/components/dropdown.js

@@ -4,7 +4,7 @@
 import { html } from "lit";
 import { until } from "lit/directives/until.js";
 import { api, constants, u } from "@converse/headless";
-import DOMNavigator from "shared/dom-navigator.js";
+import { DOMNavigator } from "shared/dom-navigator";
 import DropdownBase from "shared/components/dropdownbase.js";
 import "shared/components/icons.js";
 import { __ } from "i18n";
@@ -26,8 +26,6 @@ export default class Dropdown extends DropdownBase {
         this.icon_classes = "fa fa-bars";
         this.items = [];
         this.id = u.getUniqueId();
-        this.addEventListener("hidden.bs.dropdown", () => this.onHidden());
-        this.addEventListener("keyup", (ev) => this.handleKeyUp(ev));
     }
 
     render() {
@@ -52,25 +50,48 @@ export default class Dropdown extends DropdownBase {
         this.initArrowNavigation();
     }
 
-    onHidden() {
-        this.navigator?.disable();
+    connectedCallback() {
+        super.connectedCallback();
+        this.registerEvents();
+    }
+
+    disconnectedCallback() {
+        this.removeEventListener("keydown", this.onGlobalKeyDown);
+        this.disableArrowNavigation();
+        super.disconnectedCallback();
+    }
+
+    registerEvents() {
+        this.onGlobalKeyDown = (ev) => this.#onGlobalKeyDown(ev);
+        this.addEventListener("hide.bs.dropdown", () => this.onDropdownHide());
+        this.addEventListener("keydown", this.onGlobalKeyDown);
+    }
+
+    onDropdownHide() {
+        this.disableArrowNavigation();
     }
 
     initArrowNavigation() {
         if (!this.navigator) {
             const options = /** @type DOMNavigatorOptions */ ({
-                "selector": ".dropdown-item",
-                "onSelected": (el) => el.focus(),
+                selector: ".dropdown-menu li",
+                onSelected: (el) => el.focus(),
             });
             this.navigator = new DOMNavigator(/** @type HTMLElement */ (this.menu), options);
         }
     }
 
+    disableArrowNavigation() {
+        this.navigator?.disable();
+    }
+
+    /**
+     * @param {KeyboardEvent} [ev]
+     */
     enableArrowNavigation(ev) {
-        if (ev) {
-            ev.preventDefault();
-            ev.stopPropagation();
-        }
+        ev?.preventDefault();
+        ev?.stopPropagation();
+        this.disableArrowNavigation();
         this.navigator.enable();
         this.navigator.select(/** @type HTMLElement */ (this.menu.firstElementChild));
     }
@@ -78,9 +99,25 @@ export default class Dropdown extends DropdownBase {
     /**
      * @param {KeyboardEvent} ev
      */
-    handleKeyUp(ev) {
-        if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled) {
+    onEnterPressed(ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        this.navigator.selected?.querySelector('a')?.click();
+        this.dropdown.hide();
+    }
+
+    /**
+     * @param {KeyboardEvent} ev
+     */
+    #onGlobalKeyDown(ev) {
+        if (!this.navigator || !u.isVisible(this)) return;
+
+        if (ev.key === KEYCODES.ENTER) {
+            this.onEnterPressed(ev);
+        } else if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled) {
             this.enableArrowNavigation(ev);
+        } else if (ev.key === KEYCODES.ESCAPE) {
+            this.dropdown.hide();
         }
     }
 }

+ 13 - 7
src/shared/components/dropdownbase.js

@@ -1,12 +1,18 @@
-import { Dropdown as BootstrapDropdown } from 'bootstrap';
-import { CustomElement } from './element.js';
+import { default as BootstrapDropdown } from "bootstrap/js/src/dropdown.js";
+import EventHandler from "bootstrap/js/src/dom/event-handler.js";
+import { CustomElement } from "./element.js";
 
 export default class DropdownBase extends CustomElement {
-
-    firstUpdated (changed) {
+    firstUpdated(changed) {
         super.firstUpdated(changed);
-        this.menu = this.querySelector('.dropdown-menu');
-        this.button = this.querySelector('button');
-        this.dropdown = new BootstrapDropdown(/** @type {HTMLElement} */(this.button));
+        this.menu = this.querySelector(".dropdown-menu");
+        this.button = this.querySelector("button");
+        this.dropdown = new BootstrapDropdown(/** @type {HTMLElement} */ (this.button));
     }
 }
+
+const DATA_KEY = "bs.dropdown";
+const EVENT_KEY = `.${DATA_KEY}`;
+const DATA_API_KEY = ".data-api";
+const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`;
+EventHandler.off(document, EVENT_KEYDOWN_DATA_API);

+ 3 - 0
src/shared/components/styles/dropdown.scss

@@ -27,6 +27,9 @@
             background: var(--background-color);
             margin-top: -0.2em !important;
             box-shadow: var(--raised-el-shadow);
+            .selected {
+                background: var(--highlight-color-hover);
+            }
         }
 
         .dropdown-item {

+ 8 - 43
src/shared/dom-navigator.js → src/shared/dom-navigator/dom-navigator.js

@@ -4,48 +4,12 @@
  * This module started as a fork of Rubens Mariuzzo's dom-navigator.
  * @copyright Rubens Mariuzzo, JC Brand
  */
-import u from "../utils/html";
+import u from "utils/html";
 import { converse } from "@converse/headless";
+import {absoluteOffsetLeft, absoluteOffsetTop, inViewport} from "./utils";
 
 const { keycodes } = converse;
 
-/**
- * @param {Element} el
- * @returns {boolean}
- */
-function inViewport(el) {
-    const rect = el.getBoundingClientRect();
-    return rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
-}
-
-/**
- * @param {HTMLElement} el
- * @returns {number}
- */
-function absoluteOffsetTop(el) {
-    let offsetTop = 0;
-    do {
-        if (!isNaN(el.offsetTop)) {
-            offsetTop += el.offsetTop;
-        }
-    } while ((el = /** @type {HTMLElement} */ (el.offsetParent)));
-    return offsetTop;
-}
-
-/**
- * @param {HTMLElement} el
- * @returns {number}
- */
-function absoluteOffsetLeft(el) {
-    let offsetLeft = 0;
-    do {
-        if (!isNaN(el.offsetLeft)) {
-            offsetLeft += el.offsetLeft;
-        }
-    } while ((el = /** @type {HTMLElement} */ (el.offsetParent)));
-    return offsetLeft;
-}
-
 /**
  * Adds the ability to navigate the DOM with the arrow keys
  */
@@ -122,7 +86,6 @@ class DOMNavigator {
      * @param {DOMNavigatorOptions} options The options to configure the DOM navigator.
      */
     constructor(container, options) {
-        this.doc = window.document;
         this.container = container;
         this.scroll_container = options.scroll_container || container;
 
@@ -148,14 +111,16 @@ class DOMNavigator {
 
     enable() {
         this.getElements();
-        this.keydownHandler = /** @param {KeyboardEvent} ev */(ev) => this.handleKeydown(ev);
-        this.doc.addEventListener("keydown", this.keydownHandler);
+        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) {
-            this.doc.removeEventListener("keydown", this.keydownHandler);
+            const root = u.getRootElement();
+            root.removeEventListener("keydown", this.keydownHandler);
         }
         this.unselect();
         this.elements = {};
@@ -371,7 +336,7 @@ class DOMNavigator {
      */
     handleKeydown(ev) {
         const keys = keycodes;
-        const direction = ev.shiftKey ? this.keys[`${keys.SHIFT}+${ev.key}`] : this.keys[ev.key];
+        const direction = ev.shiftKey ? this.keys[`${keys.SHIFT}${ev.key}`] : this.keys[ev.key];
         if (direction) {
             ev.preventDefault();
             ev.stopPropagation();

+ 1 - 0
src/shared/dom-navigator/index.js

@@ -0,0 +1 @@
+export { default as DOMNavigator } from './dom-navigator.js';

+ 0 - 0
src/shared/types.ts → src/shared/dom-navigator/types.ts


+ 36 - 0
src/shared/dom-navigator/utils.js

@@ -0,0 +1,36 @@
+/**
+ * @param {Element} el
+ * @returns {boolean}
+ */
+export function inViewport(el) {
+    const rect = el.getBoundingClientRect();
+    return rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
+}
+
+/**
+ * @param {HTMLElement} el
+ * @returns {number}
+ */
+export function absoluteOffsetTop(el) {
+    let offsetTop = 0;
+    do {
+        if (!isNaN(el.offsetTop)) {
+            offsetTop += el.offsetTop;
+        }
+    } while ((el = /** @type {HTMLElement} */ (el.offsetParent)));
+    return offsetTop;
+}
+
+/**
+ * @param {HTMLElement} el
+ * @returns {number}
+ */
+export function absoluteOffsetLeft(el) {
+    let offsetLeft = 0;
+    do {
+        if (!isNaN(el.offsetLeft)) {
+            offsetLeft += el.offsetLeft;
+        }
+    } while ((el = /** @type {HTMLElement} */ (el.offsetParent)));
+    return offsetLeft;
+}

+ 23 - 0
src/shared/types/bootstrap.d.ts

@@ -0,0 +1,23 @@
+declare module 'bootstrap/js/src/dropdown.js' {
+    import { Dropdown } from 'bootstrap';
+    export = Dropdown;
+}
+
+declare module 'bootstrap/js/src/modal.js' {
+    import { Modal } from 'bootstrap';
+    export = Modal;
+}
+
+declare module 'bootstrap/js/src/popover.js' {
+    import { Popover } from 'bootstrap';
+    export = Popover;
+}
+
+declare module 'bootstrap/js/src/dom/event-handler.js' {
+    const EventHandler: {
+        on(element: HTMLElement | Document, event: string, handler: (e: Event) => void): void;
+        off(element: HTMLElement | Document, event: string, handler?: (e: Event) => void): void;
+        trigger(element: HTMLElement | Document, event: string, args?: any): void;
+    };
+    export = EventHandler;
+}

+ 1 - 1
src/types/plugins/modal/modal.d.ts

@@ -45,6 +45,6 @@ declare class BaseModal extends CustomElement {
     #private;
 }
 import { CustomElement } from 'shared/components/element.js';
-import { Modal } from "bootstrap";
+import Modal from "bootstrap/js/src/modal.js";
 import { Model } from '@converse/skeletor';
 //# sourceMappingURL=modal.d.ts.map

+ 0 - 1
src/types/shared/chat/emoji-dropdown.d.ts

@@ -15,7 +15,6 @@ export default class EmojiDropdown extends DropdownBase {
     model: any;
     initModel(): Promise<void>;
     init_promise: Promise<void>;
-    connectedCallback(): void;
     onShown(): Promise<void>;
 }
 import DropdownBase from "shared/components/dropdown.js";

+ 1 - 1
src/types/shared/chat/emoji-picker.d.ts

@@ -85,5 +85,5 @@ export default class EmojiPicker extends CustomElement {
 export type DOMNavigatorOptions = any;
 export type DOMNavigatorDirection = any;
 import { CustomElement } from "shared/components/element.js";
-import DOMNavigator from "shared/dom-navigator";
+import { DOMNavigator } from "shared/dom-navigator";
 //# sourceMappingURL=emoji-picker.d.ts.map

+ 13 - 5
src/types/shared/components/dropdown.d.ts

@@ -11,16 +11,24 @@ export default class Dropdown extends DropdownBase {
     items: any[];
     render(): import("lit").TemplateResult<1>;
     firstUpdated(): void;
-    onHidden(): void;
+    connectedCallback(): void;
+    registerEvents(): void;
+    onGlobalKeyDown: (ev: any) => void;
+    onDropdownHide(): void;
     initArrowNavigation(): void;
     navigator: DOMNavigator;
-    enableArrowNavigation(ev: any): void;
+    disableArrowNavigation(): void;
+    /**
+     * @param {KeyboardEvent} [ev]
+     */
+    enableArrowNavigation(ev?: KeyboardEvent): void;
     /**
      * @param {KeyboardEvent} ev
      */
-    handleKeyUp(ev: KeyboardEvent): void;
+    onEnterPressed(ev: KeyboardEvent): void;
+    #private;
 }
 export type DOMNavigatorOptions = any;
-import DropdownBase from 'shared/components/dropdownbase.js';
-import DOMNavigator from "shared/dom-navigator.js";
+import DropdownBase from "shared/components/dropdownbase.js";
+import { DOMNavigator } from "shared/dom-navigator";
 //# sourceMappingURL=dropdown.d.ts.map

+ 2 - 2
src/types/shared/components/dropdownbase.d.ts

@@ -4,6 +4,6 @@ export default class DropdownBase extends CustomElement {
     button: HTMLButtonElement;
     dropdown: BootstrapDropdown;
 }
-import { CustomElement } from './element.js';
-import { Dropdown as BootstrapDropdown } from 'bootstrap';
+import { CustomElement } from "./element.js";
+import { default as BootstrapDropdown } from "bootstrap/js/src/dropdown.js";
 //# sourceMappingURL=dropdownbase.d.ts.map

+ 0 - 1
src/types/shared/dom-navigator.d.ts

@@ -28,7 +28,6 @@ declare class DOMNavigator {
      * @param {DOMNavigatorOptions} options The options to configure the DOM navigator.
      */
     constructor(container: HTMLElement, options: import("./types").DOMNavigatorOptions);
-    doc: Document;
     container: HTMLElement;
     scroll_container: HTMLElement;
     /** @type {DOMNavigatorOptions} */

+ 98 - 0
src/types/shared/dom-navigator/dom-navigator.d.ts

@@ -0,0 +1,98 @@
+export default DOMNavigator;
+/**
+ * Adds the ability to navigate the DOM with the arrow keys
+ */
+declare class DOMNavigator {
+    /**
+     * @typedef {import('./types').DOMNavigatorOptions} DOMNavigatorOptions
+     * @typedef {import('./types').DOMNavigatorDirection} DOMNavigatorDirection
+     */
+    /**
+     * @returns {DOMNavigatorDirection}
+     */
+    static get DIRECTION(): import("./types").DOMNavigatorDirection;
+    /**
+     * @returns {DOMNavigatorOptions}
+     */
+    static get DEFAULTS(): import("./types").DOMNavigatorOptions;
+    /**
+     * 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: HTMLElement[], getDistance: (arg0: HTMLElement) => number): HTMLElement;
+    /**
+     * 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: HTMLElement, options: import("./types").DOMNavigatorOptions);
+    container: HTMLElement;
+    scroll_container: HTMLElement;
+    /** @type {DOMNavigatorOptions} */
+    options: import("./types").DOMNavigatorOptions;
+    init(): void;
+    selected: any;
+    keydownHandler: (ev: KeyboardEvent) => void;
+    elements: {};
+    keys: {};
+    enable(): void;
+    enabled: boolean;
+    disable(): void;
+    destroy(): void;
+    /**
+     * @param {'down'|'right'|'left'|'up'} direction
+     * @returns {HTMLElement}
+     */
+    getNextElement(direction: "down" | "right" | "left" | "up"): HTMLElement;
+    /**
+     * Select the given element.
+     * @param {HTMLElement} el The DOM element to select.
+     * @param {string} [direction] The direction.
+     */
+    select(el: HTMLElement, direction?: string): void;
+    /**
+     * Remove the current selection
+     */
+    unselect(): void;
+    /**
+     * 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: HTMLElement, direction: string): void;
+    /**
+     * 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: HTMLElement): boolean;
+    /**
+     * Finds and stores the navigable elements.
+     * @param {string} [direction] - The navigation direction.
+     * @returns {HTMLElement[]} The navigable elements.
+     */
+    getElements(direction?: string): HTMLElement[];
+    /**
+     * 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: number, top: number): HTMLElement[];
+    /**
+     * 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: number, top: number): HTMLElement[];
+    /**
+     * Handle the key down event.
+     * @param {KeyboardEvent} ev - The event object.
+     */
+    handleKeydown(ev: KeyboardEvent): void;
+}
+//# sourceMappingURL=dom-navigator.d.ts.map

+ 2 - 0
src/types/shared/dom-navigator/index.d.ts

@@ -0,0 +1,2 @@
+export { default as DOMNavigator } from "./dom-navigator.js";
+//# sourceMappingURL=index.d.ts.map

+ 0 - 0
src/types/shared/types.d.ts → src/types/shared/dom-navigator/types.d.ts


+ 16 - 0
src/types/shared/dom-navigator/utils.d.ts

@@ -0,0 +1,16 @@
+/**
+ * @param {Element} el
+ * @returns {boolean}
+ */
+export function inViewport(el: Element): boolean;
+/**
+ * @param {HTMLElement} el
+ * @returns {number}
+ */
+export function absoluteOffsetTop(el: HTMLElement): number;
+/**
+ * @param {HTMLElement} el
+ * @returns {number}
+ */
+export function absoluteOffsetLeft(el: HTMLElement): number;
+//# sourceMappingURL=utils.d.ts.map

+ 1 - 1
src/utils/html.js

@@ -528,7 +528,7 @@ let root;
 
 export function getRootElement() {
     if (!root) {
-        root = document.createElement("converse-root");
+        root = document.querySelector('converse-root') || document.createElement("converse-root");
     }
     return root;
 }