]> BookStack Code Mirror - bookstack/blob - resources/js/services/keyboard-navigation.js
0e1dcf1a756cf6ec5cd39d41c355265e6bbd7cce
[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
61         // Ignore certain key events in inputs to allow text editing.
62         if (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  (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      * @returns {Element[]}
86      */
87     #getFocusable() {
88         const focusable = [];
89         const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
90         for (const container of this.containers) {
91             focusable.push(...container.querySelectorAll(selector))
92         }
93         return focusable;
94     }
95 }