]> BookStack Code Mirror - bookstack/blob - resources/js/services/keyboard-navigation.ts
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / services / keyboard-navigation.ts
1 import {isHTMLElement} from "./dom";
2
3 type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null;
4
5 /**
6  * Handle common keyboard navigation events within a given container.
7  */
8 export class KeyboardNavigationHandler {
9
10     protected containers: HTMLElement[];
11     protected onEscape: OptionalKeyEventHandler;
12     protected onEnter: OptionalKeyEventHandler;
13
14     constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) {
15         this.containers = [container];
16         this.onEscape = onEscape;
17         this.onEnter = onEnter;
18         container.addEventListener('keydown', this.#keydownHandler.bind(this));
19     }
20
21     /**
22      * Also share the keyboard event handling to the given element.
23      * Only elements within the original container are considered focusable though.
24      */
25     shareHandlingToEl(element: HTMLElement) {
26         this.containers.push(element);
27         element.addEventListener('keydown', this.#keydownHandler.bind(this));
28     }
29
30     /**
31      * Focus on the next focusable element within the current containers.
32      */
33     focusNext() {
34         const focusable = this.#getFocusable();
35         const activeEl = document.activeElement;
36         const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
37         let newIndex = currentIndex + 1;
38         if (newIndex >= focusable.length) {
39             newIndex = 0;
40         }
41
42         focusable[newIndex].focus();
43     }
44
45     /**
46      * Focus on the previous existing focusable element within the current containers.
47      */
48     focusPrevious() {
49         const focusable = this.#getFocusable();
50         const activeEl = document.activeElement;
51         const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
52         let newIndex = currentIndex - 1;
53         if (newIndex < 0) {
54             newIndex = focusable.length - 1;
55         }
56
57         focusable[newIndex].focus();
58     }
59
60     #keydownHandler(event: KeyboardEvent) {
61         // Ignore certain key events in inputs to allow text editing.
62         if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
63             return;
64         }
65
66         if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
67             this.focusNext();
68             event.preventDefault();
69         } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
70             this.focusPrevious();
71             event.preventDefault();
72         } else if (event.key === 'Escape') {
73             if (this.onEscape) {
74                 this.onEscape(event);
75             } else if (isHTMLElement(document.activeElement)) {
76                 document.activeElement.blur();
77             }
78         } else if (event.key === 'Enter' && this.onEnter) {
79             this.onEnter(event);
80         }
81     }
82
83     /**
84      * Get an array of focusable elements within the current containers.
85      */
86     #getFocusable(): HTMLElement[] {
87         const focusable: HTMLElement[] = [];
88         const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
89         for (const container of this.containers) {
90             const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e));
91             focusable.push(...toAdd);
92         }
93
94         return focusable;
95     }
96
97 }