]> BookStack Code Mirror - bookstack/blob - resources/js/services/keyboard-navigation.js
TS: Converted app file and animations service
[bookstack] / resources / js / services / keyboard-navigation.js
1 /**
2  * Handle common keyboard navigation events within a given container.
3  */
4 export class KeyboardNavigationHandler {
5
6     /**
7      * @param {Element} container
8      * @param {Function|null} onEscape
9      * @param {Function|null} onEnter
10      */
11     constructor(container, onEscape = null, onEnter = null) {
12         this.containers = [container];
13         this.onEscape = onEscape;
14         this.onEnter = onEnter;
15         container.addEventListener('keydown', this.#keydownHandler.bind(this));
16     }
17
18     /**
19      * Also share the keyboard event handling to the given element.
20      * Only elements within the original container are considered focusable though.
21      * @param {Element} element
22      */
23     shareHandlingToEl(element) {
24         this.containers.push(element);
25         element.addEventListener('keydown', this.#keydownHandler.bind(this));
26     }
27
28     /**
29      * Focus on the next focusable element within the current containers.
30      */
31     focusNext() {
32         const focusable = this.#getFocusable();
33         const currentIndex = focusable.indexOf(document.activeElement);
34         let newIndex = currentIndex + 1;
35         if (newIndex >= focusable.length) {
36             newIndex = 0;
37         }
38
39         focusable[newIndex].focus();
40     }
41
42     /**
43      * Focus on the previous existing focusable element within the current containers.
44      */
45     focusPrevious() {
46         const focusable = this.#getFocusable();
47         const currentIndex = focusable.indexOf(document.activeElement);
48         let newIndex = currentIndex - 1;
49         if (newIndex < 0) {
50             newIndex = focusable.length - 1;
51         }
52
53         focusable[newIndex].focus();
54     }
55
56     /**
57      * @param {KeyboardEvent} event
58      */
59     #keydownHandler(event) {
60         // Ignore certain key events in inputs to allow text editing.
61         if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
62             return;
63         }
64
65         if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
66             this.focusNext();
67             event.preventDefault();
68         } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
69             this.focusPrevious();
70             event.preventDefault();
71         } else if (event.key === 'Escape') {
72             if (this.onEscape) {
73                 this.onEscape(event);
74             } else if (document.activeElement) {
75                 document.activeElement.blur();
76             }
77         } else if (event.key === 'Enter' && this.onEnter) {
78             this.onEnter(event);
79         }
80     }
81
82     /**
83      * Get an array of focusable elements within the current containers.
84      * @returns {Element[]}
85      */
86     #getFocusable() {
87         const focusable = [];
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             focusable.push(...container.querySelectorAll(selector));
91         }
92         return focusable;
93     }
94
95 }