1 |
|
2 | import {
|
3 | addEvent,
|
4 | removeEvent,
|
5 | addEventOnce,
|
6 | isEventCursorInside,
|
7 | onClickOutside,
|
8 | removeClickOutside,
|
9 | addEventKeyNavigation,
|
10 | removeEventKeyNavigation,
|
11 | isElementInside
|
12 | } from './utils/DOM';
|
13 |
|
14 | import { pushCallbackToElement, callElementCallbacks, initObjectExtensions } from './utils/core';
|
15 |
|
16 | export const DropdownConfig = {
|
17 |
|
18 |
|
19 |
|
20 | useTagNames: false,
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | factoryAddClassNames: true,
|
26 | factoryAltClassNameMenu: 'w-100',
|
27 |
|
28 | useHiddenAttribute: false,
|
29 |
|
30 | tagName: 'dropdown',
|
31 | tagNameToggleBtn: 'button',
|
32 | tagNameMenu: 'menu',
|
33 | tagNameItem: 'item',
|
34 |
|
35 | className: 'dropdown',
|
36 | classNameToggleBtn: 'dropdown-toggle',
|
37 | classNameMenu: 'dropdown-menu',
|
38 | classNameItem: 'dropdown-item',
|
39 | classNameActive: 'active',
|
40 |
|
41 | roleMenu: 'menu',
|
42 | roleMenuItem: 'menuitem',
|
43 |
|
44 | additionalClassNameMenu: 'w-100',
|
45 |
|
46 | classNameOpened: 'open',
|
47 | applyOpenedClassToDropdown: true
|
48 |
|
49 | };
|
50 |
|
51 | export const DropdownUI = {
|
52 |
|
53 | Config: DropdownConfig,
|
54 |
|
55 | _getElement(dropdown, name) {
|
56 | if (this.Config.useTagNames) {
|
57 | return dropdown.getElementsByTagName(this.Config['tagName' + name])[0] || false;
|
58 | }
|
59 | return dropdown.getElementsByClassName(this.Config['className' + name])[0] || false;
|
60 | },
|
61 |
|
62 | _createElement(name) {
|
63 | let el = null;
|
64 | if (this.Config.useTagNames) {
|
65 | el = document.createElement(this.Config['tagName' + name]);
|
66 |
|
67 | if (this.Config['tagName' + name] === 'button') {
|
68 | el.setAttribute('type', 'button');
|
69 | }
|
70 | } else {
|
71 | el = document.createElement('div');
|
72 | }
|
73 | if (this.Config.factoryAddClassNames) {
|
74 | el.classList.add(this.Config['className' + name]);
|
75 | }
|
76 | return el;
|
77 | },
|
78 |
|
79 | getToggleBtn(dropdown) {
|
80 | const children = dropdown.children;
|
81 | for (let k = 0; k < children.length; k++) {
|
82 | if (children[k].tagName === this.Config.tagNameToggleBtn.toUpperCase()) {
|
83 | return children[k];
|
84 | }
|
85 | }
|
86 | if (dropdown.classList.contains(this.Config.classNameToggleBtn)) {
|
87 | return dropdown;
|
88 | }
|
89 | return dropdown.getElementsByClassName(this.Config.classNameToggleBtn)[0] || false;
|
90 | },
|
91 |
|
92 | getTriggerElement(dropdown) {
|
93 | return this.getToggleBtn(dropdown);
|
94 | },
|
95 |
|
96 | getMenu(dropdown) {
|
97 | return this._getElement(dropdown, 'Menu');
|
98 | },
|
99 |
|
100 | createMenu() {
|
101 | const menu = this._createElement('Menu');
|
102 | if (this.Config.factoryAltClassNameMenu) {
|
103 | menu.classList.add(this.Config.factoryAltClassNameMenu);
|
104 | }
|
105 | return menu;
|
106 | },
|
107 |
|
108 | removeMenu(dropdown) {
|
109 | const menu = this.getMenu(dropdown);
|
110 | if (menu) {
|
111 | menu.parentNode.removeChild(menu);
|
112 | }
|
113 | },
|
114 |
|
115 | getMenuItems(dropdown) {
|
116 | const menu = this.getMenu(dropdown);
|
117 | if (!menu) {
|
118 | return [];
|
119 | }
|
120 | let queryStr = '';
|
121 | if (this.Config.useTagNames) {
|
122 | queryStr += this.Config.tagNameItem;
|
123 | } else {
|
124 | queryStr += '.' + this.Config.classNameItem;
|
125 | }
|
126 | queryStr += ', [role="menuitem"]';
|
127 | return menu.querySelectorAll(queryStr);
|
128 | },
|
129 |
|
130 | isMenuItem(dropdown, item) {
|
131 | const items = this.getMenuItems(dropdown);
|
132 | for (let k = 0; k < items.length; k++) {
|
133 | if (items[k] === item || isElementInside(items[k], item)) {
|
134 | return true;
|
135 | }
|
136 | }
|
137 |
|
138 | return false;
|
139 | },
|
140 |
|
141 | removeMenuItems(dropdown) {
|
142 | let menu = this.getMenu(dropdown);
|
143 | if (!menu) {
|
144 | menu = this.createMenu();
|
145 | dropdown.appendChild(menu);
|
146 | } else {
|
147 | menu.innerHTML = '';
|
148 | }
|
149 | },
|
150 |
|
151 | setMenuItems(dropdown, newItems) {
|
152 | let menu = this.getMenu(dropdown);
|
153 | if (!menu) {
|
154 | menu = this.createMenu();
|
155 | dropdown.appendChild(menu);
|
156 | } else {
|
157 |
|
158 | menu.innerHTML = '';
|
159 | }
|
160 |
|
161 | menu.appendChild(newItems);
|
162 | },
|
163 |
|
164 | getItemValue(item) {
|
165 | if (item === false) {
|
166 | return false;
|
167 | }
|
168 | return item.dataset.value;
|
169 | },
|
170 |
|
171 | setItemValue(item, value) {
|
172 | item.dataset.value = value;
|
173 | },
|
174 |
|
175 | createMenuItems(items, callback = null) {
|
176 | const f = document.createDocumentFragment();
|
177 | for (let id in items) {
|
178 | const i = this._createElement('Item');
|
179 | i.dataset.value = id;
|
180 | if (callback !== null) {
|
181 | f.appendChild(callback(i, id, items[id]));
|
182 | } else {
|
183 | this.setItemValue(i, id);
|
184 | i.textContent = items[id];
|
185 | f.appendChild(i);
|
186 | }
|
187 | }
|
188 | return f;
|
189 | },
|
190 |
|
191 | getDropdownByToggleBtn(btn) {
|
192 | return btn.parentNode;
|
193 | },
|
194 |
|
195 | getAllDropdowns() {
|
196 | if (this.Config.useTagNames) {
|
197 | return document.getElementsByTagName(this.Config.tagName);
|
198 | }
|
199 | return document.getElementsByClassName(this.Config.className);
|
200 | },
|
201 |
|
202 | show(dropdown) {
|
203 | const trigger = this.getTriggerElement(dropdown);
|
204 | if (trigger) {
|
205 | trigger.setAttribute('aria-expanded', 'true');
|
206 | }
|
207 |
|
208 | const menu = this.getMenu(dropdown);
|
209 | if (this.Config.useHiddenAttribute) {
|
210 | if (menu.hasAttribute('hidden')) {
|
211 | menu.removeAttribute('hidden');
|
212 | return true;
|
213 | }
|
214 | return false;
|
215 | } else {
|
216 | const target = this.Config.applyOpenedClassToDropdown ? dropdown : menu;
|
217 | if (!target.classList.contains(this.Config.classNameOpened)) {
|
218 | target.classList.add(this.Config.classNameOpened);
|
219 | return true;
|
220 | }
|
221 | return false;
|
222 | }
|
223 | },
|
224 |
|
225 | hide(dropdown) {
|
226 |
|
227 | const menu = this.getMenu(dropdown);
|
228 | const classTarget = this.Config.applyOpenedClassToDropdown ? dropdown : menu;
|
229 | let closed = false;
|
230 | if (this.Config.useHiddenAttribute && !menu.hasAttribute('hidden')) {
|
231 | menu.setAttribute('hidden', '');
|
232 | closed = true;
|
233 | } else if (classTarget.classList.contains(this.Config.classNameOpened)) {
|
234 | classTarget.classList.remove(this.Config.classNameOpened);
|
235 | closed = true;
|
236 | }
|
237 |
|
238 | if (closed) {
|
239 | const trigger = this.getTriggerElement(dropdown);
|
240 | if (trigger) {
|
241 | trigger.setAttribute('aria-expanded', 'false');
|
242 |
|
243 |
|
244 | if (this.isMenuItem(dropdown, document.activeElement)) {
|
245 | trigger.focus();
|
246 | }
|
247 | }
|
248 | }
|
249 | return closed;
|
250 | },
|
251 |
|
252 | |
253 |
|
254 |
|
255 |
|
256 |
|
257 | isOpened(dropdown) {
|
258 | const menu = this.getMenu(dropdown);
|
259 | if (this.Config.useHiddenAttribute) {
|
260 | return !menu.hasAttribute('hidden');
|
261 | } else {
|
262 | const target = this.Config.applyOpenedClassToDropdown ? dropdown : menu;
|
263 | return target.classList.contains(this.Config.classNameOpened);
|
264 | }
|
265 | },
|
266 |
|
267 | setItemActive(item) {
|
268 | item.classList.add(this.Config.classNameActive);
|
269 | item.setAttribute('aria-selected', 'true');
|
270 | },
|
271 |
|
272 | setItemInactive(item) {
|
273 | item.classList.remove(this.Config.classNameActive);
|
274 | item.removeAttribute('aria-selected');
|
275 | }
|
276 |
|
277 | };
|
278 |
|
279 | export const Dropdown = {
|
280 |
|
281 | UI: DropdownUI,
|
282 | Config: DropdownConfig,
|
283 |
|
284 | init(dropdown) {
|
285 | if (dropdown.__bunny_dropdown !== undefined) {
|
286 | return false;
|
287 | }
|
288 | dropdown.__bunny_dropdown = {};
|
289 | this._addToggleClickEvent(dropdown);
|
290 | this._setARIA(dropdown);
|
291 |
|
292 | initObjectExtensions(this, dropdown);
|
293 |
|
294 | return true;
|
295 | },
|
296 |
|
297 | initAll() {
|
298 | const dropdowns = this.UI.getAllDropdowns();
|
299 | [].forEach.call(dropdowns, dropdown => {
|
300 | this.init(dropdown);
|
301 | })
|
302 | },
|
303 |
|
304 |
|
305 |
|
306 |
|
307 | isHoverable(dropdown) {
|
308 | return dropdown.hasAttribute('dropdown-hover');
|
309 | },
|
310 |
|
311 | isClosableOnItemClick(dropdown) {
|
312 | return !dropdown.hasAttribute('dropdown-keep-inside');
|
313 | },
|
314 |
|
315 |
|
316 |
|
317 |
|
318 | open(dropdown) {
|
319 | if (this.UI.show(dropdown)) {
|
320 |
|
321 |
|
322 | setTimeout(() => {
|
323 | dropdown.__bunny_dropdown_cancel = onClickOutside(dropdown, () => {
|
324 | this._callCancelCallbacks(dropdown);
|
325 | this.close(dropdown);
|
326 | });
|
327 | }, 100);
|
328 |
|
329 | const items = this.UI.getMenuItems(dropdown);
|
330 | [].forEach.call(items, item => {
|
331 | item.__bunny_dropdown_click = addEvent(item, 'click', () => {
|
332 | this._callItemSelectCallbacks(dropdown, item);
|
333 | if (this.isClosableOnItemClick(dropdown)) {
|
334 | this.close(dropdown);
|
335 | }
|
336 | });
|
337 | });
|
338 |
|
339 | dropdown.__bunny_dropdown_key = addEventKeyNavigation(dropdown, items, (selectedItem) => {
|
340 |
|
341 | if (selectedItem === false) {
|
342 | this.close(dropdown);
|
343 | this._callCancelCallbacks(dropdown);
|
344 | } |
345 |
|
346 |
|
347 |
|
348 |
|
349 | }, (switchedItem) => {
|
350 |
|
351 | this._callSwitchCallbacks(dropdown, switchedItem);
|
352 | });
|
353 |
|
354 |
|
355 |
|
356 | dropdown.dispatchEvent(this._getOpenEvent(dropdown));
|
357 | }
|
358 | },
|
359 |
|
360 | close(dropdown) {
|
361 | if (this.UI.hide(dropdown)) {
|
362 |
|
363 | removeClickOutside(dropdown, dropdown.__bunny_dropdown_cancel);
|
364 | delete dropdown.__bunny_dropdown_cancel;
|
365 |
|
366 | const items = this.UI.getMenuItems(dropdown);
|
367 | [].forEach.call(items, item => {
|
368 | removeEvent(item, 'click', item.__bunny_dropdown_click);
|
369 | delete item.__bunny_dropdown_click;
|
370 | });
|
371 |
|
372 | removeEventKeyNavigation(dropdown, dropdown.__bunny_dropdown_key);
|
373 | delete dropdown.__bunny_dropdown_key;
|
374 |
|
375 |
|
376 |
|
377 | dropdown.dispatchEvent(this._getCloseEvent(dropdown));
|
378 | }
|
379 | },
|
380 |
|
381 |
|
382 | |
383 |
|
384 |
|
385 |
|
386 |
|
387 |
|
388 |
|
389 |
|
390 | onItemSelect(dropdown, callback) {
|
391 | pushCallbackToElement(dropdown, 'dropdown_item', callback);
|
392 | },
|
393 |
|
394 | |
395 |
|
396 |
|
397 |
|
398 |
|
399 | onCancel(dropdown, callback) {
|
400 | pushCallbackToElement(dropdown, 'dropdown_cancel', callback);
|
401 | },
|
402 |
|
403 | |
404 |
|
405 |
|
406 |
|
407 |
|
408 | onItemSwitched(dropdown, callback) {
|
409 | pushCallbackToElement(dropdown, 'dropdown_switch', callback);
|
410 | },
|
411 |
|
412 |
|
413 |
|
414 |
|
415 | _addToggleClickEvent(dropdown) {
|
416 |
|
417 | const btn = this.UI.getToggleBtn(dropdown);
|
418 | if (btn) {
|
419 | addEvent(btn, 'click', this._onToggleClick.bind(this, dropdown));
|
420 | addEvent(btn, 'keydown', (e) => {
|
421 | if (e.keyCode === KEY_ENTER || e.keyCode === KEY_SPACE) {
|
422 | if (e.target === btn) {
|
423 | btn.click();
|
424 | }
|
425 | }
|
426 | });
|
427 |
|
428 | if (this.isHoverable(dropdown)) {
|
429 | const menu = this.UI.getMenu(dropdown);
|
430 | addEventOnce(dropdown, 'mouseover', (e) => {
|
431 | this.open(dropdown);
|
432 | }, 50);
|
433 |
|
434 | addEventOnce(dropdown, 'mouseout', (e) => {
|
435 | if (!isEventCursorInside(e, btn) && !isEventCursorInside(e, menu)) {
|
436 |
|
437 | this.close(dropdown);
|
438 | }
|
439 | }, 500);
|
440 | }
|
441 | }
|
442 | },
|
443 |
|
444 |
|
445 | _callItemSelectCallbacks(dropdown, item) {
|
446 | callElementCallbacks(dropdown, 'dropdown_item', callback => {
|
447 | const res = callback(item);
|
448 | if (res instanceof Promise) {
|
449 | return new Promise(resolve => {
|
450 | res.then(promiseResult => {
|
451 | if (promiseResult === false) {
|
452 | resolve(false);
|
453 | } else {
|
454 | resolve(true);
|
455 | }
|
456 | })
|
457 | })
|
458 | } else if (res === false) {
|
459 | return false;
|
460 | }
|
461 | });
|
462 | },
|
463 |
|
464 | _callCancelCallbacks(dropdown) {
|
465 | callElementCallbacks(dropdown, 'dropdown_cancel', callback => {
|
466 | callback();
|
467 | });
|
468 | },
|
469 |
|
470 | _callSwitchCallbacks(dropdown, item) {
|
471 | callElementCallbacks(dropdown, 'dropdown_switch', callback => {
|
472 | callback(item);
|
473 | });
|
474 | },
|
475 |
|
476 |
|
477 |
|
478 |
|
479 | _setARIA(dropdown) {
|
480 | const btn = this.UI.getToggleBtn(dropdown);
|
481 | if (btn) {
|
482 | btn.setAttribute('aria-haspopup', 'true');
|
483 | btn.setAttribute('aria-expanded', 'false');
|
484 | }
|
485 | const menu = this.UI.getMenu(dropdown);
|
486 | if (menu) {
|
487 | menu.setAttribute('role', this.Config.roleMenu);
|
488 | menu.setAttribute('tabindex', '-1');
|
489 | const menuItems = this.UI.getMenuItems(dropdown);
|
490 | if (menuItems) {
|
491 | [].forEach.call(menuItems, menuItem => {
|
492 | menuItem.setAttribute('role', this.Config.roleMenuItem);
|
493 | menuItem.setAttribute('tabindex', '-1');
|
494 | })
|
495 | }
|
496 | }
|
497 | },
|
498 |
|
499 | _onToggleClick(dropdown) {
|
500 | if (this.UI.isOpened(dropdown)) {
|
501 | this.close(dropdown);
|
502 | } else {
|
503 | this.open(dropdown);
|
504 | }
|
505 | },
|
506 |
|
507 |
|
508 |
|
509 |
|
510 | _getCloseEvent(dropdown) {
|
511 | return new CustomEvent('close', {detail: {dropdown: dropdown}});
|
512 | },
|
513 |
|
514 | _getOpenEvent(dropdown) {
|
515 | return new CustomEvent('open', {detail: {dropdown: dropdown}});
|
516 | }
|
517 |
|
518 | };
|
519 |
|
520 | document.addEventListener('DOMContentLoaded', () => {
|
521 | Dropdown.initAll();
|
522 | });
|