UNPKG

13.6 kBJavaScriptView Raw
1
2import {
3 addEvent,
4 removeEvent,
5 addEventOnce,
6 isEventCursorInside,
7 onClickOutside,
8 removeClickOutside,
9 addEventKeyNavigation,
10 removeEventKeyNavigation,
11 isElementInside
12} from './utils/DOM';
13
14import { pushCallbackToElement, callElementCallbacks, initObjectExtensions } from './utils/core';
15
16export const DropdownConfig = {
17
18 // true - use tag names to determine component elements instead of div . class names;
19 // false - use class names. Elements with class names must have <div> tag name
20 useTagNames: false,
21
22 // when using DropdownUI to create new elements
23 // true - add class names to new elements
24 // false - do not add classes
25 factoryAddClassNames: true,
26 factoryAltClassNameMenu: 'w-100',
27
28 useHiddenAttribute: false, // true - use 'hidden' HTML5 attr; false - use classNameOpened instead
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 // false - apply to menu
48
49};
50
51export 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 // if element is a <button>, add type="button" to avoid form submit if dropdown inside a <form>
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 //while (menu.firstChild) menu.removeChild(menu.firstChild);
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 // restore focus back to trigger element (toggle button by default)
243 // only if menu item was selected
244 if (this.isMenuItem(dropdown, document.activeElement)) {
245 trigger.focus();
246 }
247 }
248 }
249 return closed;
250 },
251
252 /**
253 *
254 * @param {HTMLElement} dropdown
255 * @returns {boolean}
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
279export 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 // add small delay so this handler wouldn't be attached and called immediately
321 // with toggle btn click event handler and instantly close dropdown menu
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 // item selected callback
341 if (selectedItem === false) {
342 this.close(dropdown);
343 this._callCancelCallbacks(dropdown);
344 } /*else {
345 not needed anymore since click() called on item pick
346 this._callItemSelectCallbacks(dropdown, selectedItem);
347 }*/
348 //this.close(dropdown);
349 }, (switchedItem) => {
350 // item switched callback
351 this._callSwitchCallbacks(dropdown, switchedItem);
352 });
353
354 //BunnyElement.scrollToIfNeeded(items[0], 100, false, 200, -50);
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 //BunnyElement.scrollToIfNeeded(dropdown, -100, true, 200, -50);
376
377 dropdown.dispatchEvent(this._getCloseEvent(dropdown));
378 }
379 },
380
381
382 /**
383 * Fired when user clicks on item or presses Enter when item is active
384 *
385 * item is null if user pressed Enter and no item was active (for example custom value entered)
386 *
387 * @param dropdown
388 * @param callback
389 */
390 onItemSelect(dropdown, callback) {
391 pushCallbackToElement(dropdown, 'dropdown_item', callback);
392 },
393
394 /**
395 * Fired when user clicks outside or presses Esc
396 * @param dropdown
397 * @param callback
398 */
399 onCancel(dropdown, callback) {
400 pushCallbackToElement(dropdown, 'dropdown_cancel', callback);
401 },
402
403 /**
404 * Fired when user switches to next/prev item through keyboard up/down arrow keys
405 * @param dropdown
406 * @param callback
407 */
408 onItemSwitched(dropdown, callback) {
409 pushCallbackToElement(dropdown, 'dropdown_switch', callback);
410 },
411
412
413
414
415 _addToggleClickEvent(dropdown) {
416 // open dropdown on toggle btn click or hover
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 // cursor is outside toggle btn and menu => close menu if required
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
520document.addEventListener('DOMContentLoaded', () => {
521 Dropdown.initAll();
522});