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