blob: e7e37d808123336737e5b130ef6c077052334004 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2016 Leif Olsen. All Rights Reserved.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17 * This code is built with Google Material Design Lite,
18 * which is Licensed under the Apache License, Version 2.0
19 */
20
21
22/**
23 * A menu button is a button that opens a menu. It is often styled as a
24 * typical push button with a downward pointing arrow or triangle to hint
25 * that activating the button will display a menu.
26 */
27import { randomString } from '../utils/string-utils';
28import fullThrottle from '../utils/full-throttle';
29import {
30 VK_TAB,
31 VK_ENTER,
32 VK_ESC,
33 VK_SPACE,
34 VK_END,
35 VK_HOME,
36 VK_ARROW_LEFT,
37 VK_ARROW_UP,
38 VK_ARROW_RIGHT,
39 VK_ARROW_DOWN,
40 IS_UPGRADED,
41} from '../utils/constants';
42
43import { getScrollParents, tether } from '../utils/dom-utils';
44
45const JS_MENU_BUTTON = 'mdlext-js-menu-button';
46const MENU_BUTTON_MENU = 'mdlext-menu';
47const MENU_BUTTON_MENU_ITEM = 'mdlext-menu__item';
48const MENU_BUTTON_MENU_ITEM_SEPARATOR = 'mdlext-menu__item-separator';
49//const MDL_LAYOUT_CONTENT = 'mdl-layout__content';
50
51/**
52 * Creates the menu controlled by the menu button
53 * @param element
54 * @return {{element: Element, selected: Element, open: (function(*=)), removeListeners: (function()), downgrade: (function())}}
55 */
56
57const menuFactory = element => {
58
59 let ariaControls = null;
60 let parentNode = null;
61
62 const removeAllSelected = () => {
63 [...element.querySelectorAll(`.${MENU_BUTTON_MENU_ITEM}[aria-selected="true"]`)]
64 .forEach(selectedItem => selectedItem.removeAttribute('aria-selected'));
65 };
66
67 const setSelected = (item, force=false) => {
68 if(force || (item && !item.hasAttribute('aria-selected'))) {
69 removeAllSelected();
70 if(item) {
71 item.setAttribute('aria-selected', 'true');
72 }
73 }
74 };
75
76 const getSelected = () => {
77 return element.querySelector(`.${MENU_BUTTON_MENU_ITEM}[aria-selected="true"]`);
78 };
79
80 const isDisabled = item => item && item.hasAttribute('disabled');
81
82 const isSeparator = item => item && item.classList.contains(MENU_BUTTON_MENU_ITEM_SEPARATOR);
83
84 const focus = item => {
85 if(item) {
86 item = item.closest(`.${MENU_BUTTON_MENU_ITEM}`);
87 }
88 if(item) {
89 item.focus();
90 }
91 };
92
93 const nextItem = current => {
94 let n = current.nextElementSibling;
95 if(!n) {
96 n = element.firstElementChild;
97 }
98 if(!isDisabled(n) && !isSeparator(n)) {
99 focus(n);
100 }
101 else {
102 let i = element.children.length;
103 while(n && i-- > 0) {
104 if(isDisabled(n) || isSeparator(n)) {
105 n = n.nextElementSibling;
106 if(!n) {
107 n = element.firstElementChild;
108 }
109 }
110 else {
111 focus(n);
112 break;
113 }
114 }
115 }
116 };
117
118 const previousItem = current => {
119 let p = current.previousElementSibling;
120 if(!p) {
121 p = element.lastElementChild;
122 }
123 if(!isDisabled(p) && !isSeparator(p)) {
124 focus(p);
125 }
126 else {
127 let i = element.children.length;
128 while(p && i-- > 0) {
129 if(isDisabled(p) || isSeparator(p)) {
130 p = p.previousElementSibling;
131 if(!p) {
132 p = element.lastElementChild;
133 }
134 }
135 else {
136 focus(p);
137 break;
138 }
139 }
140 }
141 };
142
143 const firstItem = () => {
144 const item = element.firstElementChild;
145 if(isDisabled(item) || isSeparator(item) ) {
146 nextItem(item);
147 }
148 else {
149 focus(item);
150 }
151 };
152
153 const lastItem = () => {
154 const item = element.lastElementChild;
155 if(isDisabled(item) || isSeparator(item)) {
156 previousItem(item);
157 }
158 else {
159 focus(item);
160 }
161 };
162
163 const selectItem = item => {
164 if(item && !isDisabled(item) && !isSeparator(item)) {
165 setSelected(item);
166 close(true, item);
167 }
168 };
169
170 const keyDownHandler = event => {
171
172 const item = event.target.closest(`.${MENU_BUTTON_MENU_ITEM}`);
173
174 switch (event.keyCode) {
175 case VK_ARROW_UP:
176 case VK_ARROW_LEFT:
177 if(item) {
178 previousItem(item);
179 }
180 else {
181 firstItem();
182 }
183 break;
184
185 case VK_ARROW_DOWN:
186 case VK_ARROW_RIGHT:
187 if(item) {
188 nextItem(item);
189 }
190 else {
191 lastItem();
192 }
193 break;
194
195 case VK_HOME:
196 firstItem();
197 break;
198
199 case VK_END:
200 lastItem();
201 break;
202
203 case VK_SPACE:
204 case VK_ENTER:
205 selectItem(item);
206 break;
207
208 case VK_ESC:
209 close(true);
210 break;
211
212 case VK_TAB:
213 // We do not have a "natural" tab order from menu, so the best we can do is to set focus back to the button
214 close(true);
215 break;
216
217 default:
218 return;
219 }
220 event.preventDefault();
221 };
222
223
224 const blurHandler = event => {
225
226 // See: https://github.com/facebook/react/issues/2011
227 const t = event.relatedTarget ||
228 event.explicitOriginalTarget || // FF
229 document.activeElement; // IE11
230
231 //console.log('***** blur, target, relatedTarget', event.target, t);
232
233 try {
234 if (t) {
235 if (t.closest(`.${MENU_BUTTON_MENU}`) !== element && shouldClose(t)) {
236 close();
237 }
238 }
239 else {
240 close();
241 }
242 }
243 catch(err) {
244 // FF throws error: "TypeError: n.closest is not a function" if related target is a text node
245 close();
246 }
247 };
248
249 const clickHandler = event => {
250 //console.log('***** click, target', event.target);
251
252 event.preventDefault();
253 const t = event.target;
254 if (t && t.closest(`.${MENU_BUTTON_MENU}`) === element) {
255 const item = t.closest(`.${MENU_BUTTON_MENU_ITEM}`);
256 if (item) {
257 selectItem(item);
258 }
259 }
260 else {
261 if (shouldClose(t)) {
262 close();
263 }
264 }
265 };
266
267 const touchStartHandler = event => {
268 //console.log('***** touchStart, target', event.target);
269
270 const t = event.target;
271 if(!(t && t.closest(`.${MENU_BUTTON_MENU}`) === element)) {
272 if (event.type === 'touchstart') {
273 event.preventDefault();
274 }
275 close();
276 }
277 };
278
279 const addListeners = () => {
280 element.addEventListener('keydown', keyDownHandler, false);
281 element.addEventListener('blur', blurHandler, true);
282 element.addEventListener('click', clickHandler, true);
283 document.documentElement.addEventListener('touchstart', touchStartHandler, true);
284 };
285
286 const removeListeners = () => {
287 element.removeEventListener('keydown', keyDownHandler, false);
288 element.removeEventListener('blur', blurHandler, true);
289 element.removeEventListener('click', clickHandler, true);
290 document.documentElement.removeEventListener('touchstart', touchStartHandler, true);
291 };
292
293 const open = (controlElement, position='first') => {
294
295 ariaControls = controlElement.closest(`.${JS_MENU_BUTTON}`);
296
297 element.style['min-width'] = `${Math.max(124, controlElement.getBoundingClientRect().width)}px`;
298 element.removeAttribute('hidden');
299 tether(controlElement, element);
300
301 let item;
302 switch (position.toLowerCase()) {
303 case 'first':
304 firstItem();
305 break;
306
307 case 'last':
308 lastItem();
309 break;
310
311 case 'selected':
312 item = getSelected();
313 if(item && !item.hasAttribute('disabled')) {
314 focus(item);
315 }
316 else {
317 firstItem();
318 }
319 break;
320 }
321
322 addListeners();
323 };
324
325
326 const shouldClose = target => {
327 //console.log('***** shouldClose');
328
329 let result = false;
330 const btn = (target && target.closest(`.${JS_MENU_BUTTON}`)) || null;
331 if(!btn) {
332 result = true;
333 }
334 else if(btn.getAttribute('aria-controls') === element.id) {
335 if(btn !== ariaControls) {
336 result = true;
337 }
338 }
339 else {
340 result = true;
341 }
342 return result;
343 };
344
345 const close = (forceFocus = false, item = null) => {
346 removeListeners();
347
348 element.dispatchEvent(
349 new CustomEvent('_closemenu', {
350 bubbles: true,
351 cancelable: true,
352 detail: { forceFocus: forceFocus, item: item }
353 })
354 );
355 };
356
357 const addWaiAria = () => {
358 if (!element.hasAttribute('id')) {
359 // Generate a random id
360 element.id = `menu-button-${randomString()}`;
361 }
362 element.setAttribute('tabindex', '-1');
363 element.setAttribute('role', 'menu');
364 element.setAttribute('hidden', '');
365
366 [...element.querySelectorAll(`.${MENU_BUTTON_MENU_ITEM}`)].forEach( menuitem => {
367 menuitem.setAttribute('tabindex', '-1');
368 menuitem.setAttribute('role', 'menuitem');
369 });
370
371 [...element.querySelectorAll(`.${MENU_BUTTON_MENU_ITEM_SEPARATOR}`)].forEach( menuitem => {
372 menuitem.setAttribute('role', 'separator');
373 });
374 };
375
376 const init = () => {
377 addWaiAria();
378 parentNode = element.parentNode;
379 element.classList.add('is-upgraded');
380 };
381
382 const downgrade = () => {
383 removeListeners();
384 if(element.parentNode !== parentNode) {
385 parentNode.appendChild(element);
386 }
387 element.classList.remove('is-upgraded');
388 };
389
390 init();
391
392 return {
393 /**
394 * Get the menu element
395 * @returns {Element} the menu element
396 */
397 get element() {
398 return element;
399 },
400
401 /**
402 * Set selected menu item
403 * @param item
404 */
405 set selected(item) {
406 setSelected(item, true);
407 },
408
409 /**
410 * Open menu
411 * @param {Element} controlElement the element where the menu should be aligned to
412 * @param {String} position menuElement item to receive focus after menu element is opened
413 */
414 open: (controlElement, position='first') => open(controlElement, position),
415
416 /**
417 * Remove event listeners.
418 */
419 removeListeners: () => removeListeners(),
420
421 /**
422 * Downgrade menu
423 */
424 downgrade: () => downgrade(),
425 };
426};
427
428
429/**
430 * The menubutton component
431 */
432
433class MenuButton {
434
435 constructor(element) {
436 this.element = element;
437 this.focusElement = undefined;
438 this.focusElementLastScrollPosition = undefined;
439 this.scrollElements = [];
440 this.menu = undefined;
441 this.selectedItem = null;
442 this.init();
443 }
444
445 keyDownHandler = event => {
446 if(!this.isDisabled()) {
447 switch (event.keyCode) {
448 case VK_ARROW_UP:
449 this.openMenu('last');
450 break;
451
452 case VK_ARROW_DOWN:
453 this.openMenu();
454 break;
455
456 case VK_SPACE:
457 case VK_ENTER:
458 this.openMenu('selected');
459 break;
460
461 case VK_ESC:
462 this.closeMenu();
463 break;
464
465 case VK_TAB:
466 this.closeMenu();
467 return;
468
469 default:
470 return;
471 }
472 }
473 //event.stopPropagation();
474 event.preventDefault();
475 };
476
477 clickHandler = () => {
478 if(!this.isDisabled()) {
479 if(this.element.getAttribute('aria-expanded').toLowerCase() === 'true') {
480 this.closeMenu(true);
481 }
482 else {
483 this.openMenu('selected');
484 }
485 }
486 };
487
488 /**
489 * Re-position menu if content is scrolled, window is resized or orientation change
490 * @see https://javascriptweblog.wordpress.com/2015/11/02/of-classes-and-arrow-functions-a-cautionary-tale/
491 */
492 recalcMenuPosition = fullThrottle( () => {
493 const c = this.focusElement.getBoundingClientRect();
494 const dx = this.focusElementLastScrollPosition.left - c.left;
495 const dy = this.focusElementLastScrollPosition.top - c.top;
496 const left = (parseFloat(this.menu.element.style.left) || 0) - dx;
497 const top = (parseFloat(this.menu.element.style.top) || 0) - dy;
498
499 this.menu.element.style.left = `${left}px`;
500 this.menu.element.style.top = `${top}px`;
501 this.focusElementLastScrollPosition = c;
502 });
503
504
505 positionChangeHandler = () => {
506 this.recalcMenuPosition(this);
507 };
508
509 closeMenuHandler = event => {
510 if(event && event.detail) {
511 if(event.detail.item && event.detail.item !== this.selectedItem) {
512 this.selectedItem = event.detail.item;
513 this.dispatchMenuSelect();
514 }
515 this.closeMenu(event.detail.forceFocus);
516 }
517 };
518
519 dispatchMenuSelect() {
520 this.element.dispatchEvent(
521 new CustomEvent('menuselect', {
522 bubbles: true,
523 cancelable: true,
524 detail: { source: this.selectedItem }
525 })
526 );
527 }
528
529 isDisabled() {
530 return this.element.hasAttribute('disabled');
531 }
532
533 removeListeners() {
534 this.element.removeEventListener('keydown', this.keyDownHandler);
535 this.element.removeEventListener('click', this.clickHandler);
536 }
537
538 openMenu(position='first') {
539
540 if(!this.isDisabled() && this.menu) {
541
542 // Close the menu if button position change
543 this.scrollElements = getScrollParents(this.element);
544 this.scrollElements.forEach(el => el.addEventListener('scroll', this.positionChangeHandler));
545
546 window.addEventListener('resize', this.positionChangeHandler);
547 window.addEventListener('orientationchange', this.positionChangeHandler);
548 this.menu.element.addEventListener('_closemenu', this.closeMenuHandler);
549
550 this.menu.selected = this.selectedItem;
551 this.menu.open(this.focusElement, position);
552 this.element.setAttribute('aria-expanded', 'true');
553
554 this.focusElementLastScrollPosition = this.focusElement.getBoundingClientRect();
555 }
556 }
557
558 closeMenu(forceFocus = false) {
559 if(this.menu) {
560 this.menu.removeListeners();
561 this.scrollElements.forEach(el => el.removeEventListener('scroll', this.positionChangeHandler));
562 window.removeEventListener('resize', this.positionChangeHandler);
563 window.removeEventListener('orientationchange', this.positionChangeHandler);
564 this.menu.element.removeEventListener('_closemenu', this.closeMenuHandler);
565
566 if (forceFocus) {
567 this.focus();
568 }
569 this.element.setAttribute('aria-expanded', 'false');
570 this.menu.element.setAttribute('hidden', '');
571 }
572 }
573
574 focus() {
575 if(!this.isDisabled()) {
576 this.focusElement.focus();
577 }
578 }
579
580 init() {
581 const addListeners = () => {
582 this.element.addEventListener('keydown', this.keyDownHandler);
583 this.element.addEventListener('click', this.clickHandler);
584 };
585
586 const addWaiAria = () => {
587 this.element.setAttribute('role', 'button');
588 this.element.setAttribute('aria-expanded', 'false');
589 this.element.setAttribute('aria-haspopup', 'true');
590 };
591
592 const addFocusElement = () => {
593 this.focusElement = this.element.querySelector('input[type="text"]');
594 if(!this.focusElement) {
595 this.focusElement = this.element;
596
597 if(!(this.focusElement.tagName.toLowerCase() === 'button' || this.focusElement.tagName.toLowerCase() === 'input')) {
598 if (!this.focusElement.hasAttribute('tabindex')) {
599 this.focusElement.setAttribute('tabindex', '0');
600 }
601 }
602 }
603 };
604
605 const moveElementToDocumentBody = (element) => {
606 // To position an element on top of all other z-indexed elements, the element should be moved to document.body
607 // See: https://philipwalton.com/articles/what-no-one-told-you-about-z-index/
608
609 if(element.parentNode !== document.body) {
610 return document.body.appendChild(element);
611 }
612 return element;
613 };
614
615 const findMenuElement = () => {
616 let menuElement;
617 const menuElementId = this.element.getAttribute('aria-controls');
618 if(menuElementId !== null) {
619 menuElement = document.querySelector(`#${menuElementId }`);
620 }
621 else {
622 menuElement = this.element.parentNode.querySelector(`.${MENU_BUTTON_MENU}`);
623 }
624 return menuElement;
625 };
626
627 const addMenu = () => {
628 const menuElement = findMenuElement();
629 if(menuElement) {
630 if(menuElement.componentInstance) {
631 this.menu = menuElement.componentInstance;
632 }
633 else {
634 this.menu = menuFactory(menuElement);
635 menuElement.componentInstance = this.menu;
636 moveElementToDocumentBody(menuElement);
637 }
638 this.element.setAttribute('aria-controls', this.menu.element.id);
639 }
640 };
641
642 addFocusElement();
643 addWaiAria();
644 addMenu();
645 this.removeListeners();
646 addListeners();
647 }
648
649 downgrade() {
650 if(this.menu) {
651 // Do not downgrade menu if there are other buttons sharing this menu
652 const related = [...document.querySelectorAll(`.${JS_MENU_BUTTON}[aria-controls="${this.element.getAttribute('aria-controls')}"]`)];
653 if(related.filter( c => c !== this.element && c.getAttribute('data-upgraded').indexOf('MaterialExtMenuButton') >= 0).length === 0) {
654 this.menu.downgrade();
655 }
656 }
657 this.removeListeners();
658 }
659
660}
661
662(function() {
663 'use strict';
664
665 /**
666 * https://github.com/google/material-design-lite/issues/4205
667 * @constructor
668 * @param {Element} element The element that will be upgraded.
669 */
670 const MaterialExtMenuButton = function MaterialExtMenuButton(element) {
671 this.element_ = element;
672 this.menuButton_ = null;
673
674 // Initialize instance.
675 this.init();
676 };
677 window['MaterialExtMenuButton'] = MaterialExtMenuButton;
678
679
680 // Public methods.
681
682 /**
683 * Get the menu element controlled by this button, null if no menu is controlled by this button
684 * @public
685 */
686 MaterialExtMenuButton.prototype.getMenuElement = function() {
687 return this.menuButton_.menu ? this.menuButton_.menu.element : null;
688 };
689 MaterialExtMenuButton.prototype['getMenuElement'] = MaterialExtMenuButton.prototype.getMenuElement;
690
691 /**
692 * Open menu
693 * @public
694 * @param {String} position one of "first", "last" or "selected"
695 */
696 MaterialExtMenuButton.prototype.openMenu = function(position) {
697 this.menuButton_.openMenu(position);
698 };
699 MaterialExtMenuButton.prototype['openMenu'] = MaterialExtMenuButton.prototype.openMenu;
700
701 /**
702 * Close menu
703 * @public
704 */
705 MaterialExtMenuButton.prototype.closeMenu = function() {
706 this.menuButton_.closeMenu(true);
707 };
708 MaterialExtMenuButton.prototype['closeMenu'] = MaterialExtMenuButton.prototype.closeMenu;
709
710 /**
711 * Get selected menu item
712 * @public
713 * @returns {Element} The selected menu item or null if no item selected
714 */
715 MaterialExtMenuButton.prototype.getSelectedMenuItem = function() {
716 return this.menuButton_.selectedItem;
717 };
718 MaterialExtMenuButton.prototype['getSelectedMenuItem'] = MaterialExtMenuButton.prototype.getSelectedMenuItem;
719
720
721 /**
722 * Set (default) selected menu item
723 * @param {Element} item
724 */
725 MaterialExtMenuButton.prototype.setSelectedMenuItem = function(item) {
726 this.menuButton_.selectedItem = item;
727 };
728 MaterialExtMenuButton.prototype['setSelectedMenuItem'] = MaterialExtMenuButton.prototype.setSelectedMenuItem;
729
730 /**
731 * Initialize component
732 */
733 MaterialExtMenuButton.prototype.init = function() {
734 if (this.element_) {
735 this.menuButton_ = new MenuButton(this.element_);
736 this.element_.addEventListener('mdl-componentdowngraded', this.mdlDowngrade_.bind(this));
737 this.element_.classList.add(IS_UPGRADED);
738 }
739 };
740
741 /**
742 * Downgrade component
743 * E.g remove listeners and clean up resources
744 */
745 MaterialExtMenuButton.prototype.mdlDowngrade_ = function() {
746 this.menuButton_.downgrade();
747 };
748
749 // The component registers itself. It can assume componentHandler is available
750 // in the global scope.
751 /* eslint no-undef: 0 */
752 componentHandler.register({
753 constructor: MaterialExtMenuButton,
754 classAsString: 'MaterialExtMenuButton',
755 cssClass: JS_MENU_BUTTON,
756 widget: true
757 });
758})();