]> BookStack Code Mirror - bookstack/blob - resources/js/components/dropdown.js
Updated a batch of JS components
[bookstack] / resources / js / components / dropdown.js
1 import {onSelect} from "../services/dom";
2 import {Component} from "./component";
3
4 /**
5  * Dropdown
6  * Provides some simple logic to create simple dropdown menus.
7  */
8 export class Dropdown extends Component {
9
10     setup() {
11         this.container = this.$el;
12         this.menu = this.$refs.menu;
13         this.toggle = this.$refs.toggle;
14         this.moveMenu = this.$opts.moveMenu;
15         this.bubbleEscapes = this.$opts.bubbleEscapes === 'true';
16
17         this.direction = (document.dir === 'rtl') ? 'right' : 'left';
18         this.body = document.body;
19         this.showing = false;
20         this.setupListeners();
21         this.hide = this.hide.bind(this);
22     }
23
24     show(event = null) {
25         this.hideAll();
26
27         this.menu.style.display = 'block';
28         this.menu.classList.add('anim', 'menuIn');
29         this.toggle.setAttribute('aria-expanded', 'true');
30
31         const menuOriginalRect = this.menu.getBoundingClientRect();
32         let heightOffset = 0;
33         const toggleHeight = this.toggle.getBoundingClientRect().height;
34         const dropUpwards = menuOriginalRect.bottom > window.innerHeight;
35
36         // If enabled, Move to body to prevent being trapped within scrollable sections
37         if (this.moveMenu) {
38             this.body.appendChild(this.menu);
39             this.menu.style.position = 'fixed';
40             this.menu.style.width = `${menuOriginalRect.width}px`;
41             this.menu.style.left = `${menuOriginalRect.left}px`;
42             heightOffset = dropUpwards ? (window.innerHeight - menuOriginalRect.top  - toggleHeight / 2) : menuOriginalRect.top;
43         }
44
45         // Adjust menu to display upwards if near the bottom of the screen
46         if (dropUpwards) {
47             this.menu.style.top = 'initial';
48             this.menu.style.bottom = `${heightOffset}px`;
49         } else {
50             this.menu.style.top = `${heightOffset}px`;
51             this.menu.style.bottom = 'initial';
52         }
53
54         // Set listener to hide on mouse leave or window click
55         this.menu.addEventListener('mouseleave', this.hide.bind(this));
56         window.addEventListener('click', event => {
57             if (!this.menu.contains(event.target)) {
58                 this.hide();
59             }
60         });
61
62         // Focus on first input if existing
63         const input = this.menu.querySelector('input');
64         if (input !== null) input.focus();
65
66         this.showing = true;
67
68         const showEvent = new Event('show');
69         this.container.dispatchEvent(showEvent);
70
71         if (event) {
72             event.stopPropagation();
73         }
74     }
75
76     hideAll() {
77         for (let dropdown of window.$components.get('dropdown')) {
78             dropdown.hide();
79         }
80     }
81
82     hide() {
83         this.menu.style.display = 'none';
84         this.menu.classList.remove('anim', 'menuIn');
85         this.toggle.setAttribute('aria-expanded', 'false');
86         this.menu.style.top = '';
87         this.menu.style.bottom = '';
88
89         if (this.moveMenu) {
90             this.menu.style.position = '';
91             this.menu.style[this.direction] = '';
92             this.menu.style.width = '';
93             this.menu.style.left = '';
94             this.container.appendChild(this.menu);
95         }
96
97         this.showing = false;
98     }
99
100     getFocusable() {
101         return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
102     }
103
104     focusNext() {
105         const focusable = this.getFocusable();
106         const currentIndex = focusable.indexOf(document.activeElement);
107         let newIndex = currentIndex + 1;
108         if (newIndex >= focusable.length) {
109             newIndex = 0;
110         }
111
112         focusable[newIndex].focus();
113     }
114
115     focusPrevious() {
116         const focusable = this.getFocusable();
117         const currentIndex = focusable.indexOf(document.activeElement);
118         let newIndex = currentIndex - 1;
119         if (newIndex < 0) {
120             newIndex = focusable.length - 1;
121         }
122
123         focusable[newIndex].focus();
124     }
125
126     setupListeners() {
127         // Hide menu on option click
128         this.container.addEventListener('click', event => {
129              const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
130              if (possibleChildren.includes(event.target)) {
131                  this.hide();
132              }
133         });
134
135         onSelect(this.toggle, event => {
136             event.stopPropagation();
137             this.show(event);
138             if (event instanceof KeyboardEvent) {
139                 this.focusNext();
140             }
141         });
142
143         // Keyboard navigation
144         const keyboardNavigation = event => {
145             if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
146                 this.focusNext();
147                 event.preventDefault();
148             } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
149                 this.focusPrevious();
150                 event.preventDefault();
151             } else if (event.key === 'Escape') {
152                 this.hide();
153                 this.toggle.focus();
154                 if (!this.bubbleEscapes) {
155                     event.stopPropagation();
156                 }
157             }
158         };
159         this.container.addEventListener('keydown', keyboardNavigation);
160         if (this.moveMenu) {
161             this.menu.addEventListener('keydown', keyboardNavigation);
162         }
163
164         // Hide menu on enter press or escape
165         this.menu.addEventListener('keydown ', event => {
166             if (event.key === 'Enter') {
167                 event.preventDefault();
168                 event.stopPropagation();
169                 this.hide();
170             }
171         });
172     }
173
174 }