| // Copyright (c) 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // clang-format off |
| // #import {assert} from 'chrome://resources/js/assert.m.js'; |
| // #import {Menu} from 'chrome://resources/js/cr/ui/menu.m.js'; |
| // #import {AnchorType, positionPopupAroundElement} from 'chrome://resources/js/cr/ui/position_util.m.js'; |
| // #import {util} from '../../../common/js/util.m.js'; |
| // #import {HideType} from 'chrome://resources/js/cr/ui/menu_button.m.js'; |
| // #import {MenuItem} from 'chrome://resources/js/cr/ui/menu_item.m.js'; |
| // #import {MultiMenu} from './multi_menu.m.js'; |
| // #import {decorate} from 'chrome://resources/js/cr/ui.m.js'; |
| // #import {EventTracker} from 'chrome://resources/js/event_tracker.m.js'; |
| // clang-format on |
| |
| cr.define('cr.ui', () => { |
| /* #ignore */ /** @const */ const HideType = cr.ui.HideType; |
| |
| /** |
| * A button that displays a MultiMenu (menu with sub-menus). |
| * @extends {HTMLButtonElement} |
| * @implements {EventListener} |
| */ |
| /* #export */ class MultiMenuButton { |
| constructor() { |
| /** |
| * Property that hosts sub-menus for filling with overflow items. |
| * @public {cr.ui.Menu|null} Used for menu-items that overflow parent |
| * menu. |
| */ |
| this.overflow = null; |
| |
| /** |
| * Padding used when restricting menu height when the window is too small |
| * to show the entire menu. |
| * @private {number} |
| */ |
| this.menuEndGap_ = 0; // padding on cr.menu + 2px |
| |
| /** @private {?EventTracker} */ |
| this.showingEvents_ = null; |
| |
| /** @private {?cr.ui.Menu} */ |
| this.menu_ = null; |
| |
| /** @private {?ResizeObserver} */ |
| this.observer_ = null; |
| |
| /** @private {?Element} */ |
| this.observedElement_ = null; |
| |
| throw new Error('Designed to decorate elements'); |
| } |
| |
| /** |
| * Decorates the element. |
| * @param {!Element} element Element to be decorated. |
| * @return {!cr.ui.MultiMenuButton} Decorated element. |
| */ |
| static decorate(element) { |
| // Add the MultiMenuButton methods to the element we're |
| // decorating, leaving it's prototype chain intact. |
| // Don't copy 'constructor' or property get/setters. |
| Object.getOwnPropertyNames(MultiMenuButton.prototype).forEach(name => { |
| if (name !== 'constructor' && |
| !Object.getOwnPropertyDescriptor(element, name)) { |
| element[name] = MultiMenuButton.prototype[name]; |
| } |
| }); |
| // Set up the 'menu' property & setter/getter. |
| Object.defineProperty(element, 'menu', { |
| get() { |
| return this.menu_; |
| }, |
| set(menu) { |
| this.setMenu_(menu); |
| }, |
| enumerable: true, |
| configurable: true |
| }); |
| element = /** @type {!cr.ui.MultiMenuButton} */ (element); |
| element.decorate(); |
| return element; |
| } |
| |
| /** |
| * Initializes the menu button. |
| */ |
| decorate() { |
| this.setAttribute('aria-expanded', 'false'); |
| |
| // Listen to the touch events on the document so that we can handle it |
| // before cancelled by other UI components. |
| this.ownerDocument.addEventListener('touchstart', this, {passive: true}); |
| this.addEventListener('mousedown', this); |
| this.addEventListener('keydown', this); |
| this.addEventListener('dblclick', this); |
| this.addEventListener('blur', this); |
| |
| this.menuEndGap_ = 18; // padding on cr.menu + 2px |
| |
| // Adding the 'custom-appearance' class prevents widgets.css from |
| // changing the appearance of this element. |
| this.classList.add('custom-appearance'); |
| this.classList.add('menu-button'); // For styles in menu_button.css. |
| |
| this.menu = this.getAttribute('menu'); |
| |
| // Align the menu if the button moves. When the button moves, the parent |
| // container resizes. |
| this.observer_ = new ResizeObserver(() => { |
| this.positionMenu_(); |
| }); |
| |
| // An event tracker for events we only connect to while the menu is |
| // displayed. |
| this.showingEvents_ = new EventTracker(); |
| } |
| |
| /** |
| * TODO(adanilo) Get rid of the getter/setter duplication. |
| * The menu associated with the menu button. |
| * @type {cr.ui.Menu} |
| */ |
| get menu() { |
| return this.menu_; |
| } |
| setMenu_(menu) { |
| if (typeof menu == 'string' && menu[0] == '#') { |
| menu = assert(this.ownerDocument.body.querySelector(menu)); |
| cr.ui.decorate(menu, cr.ui.MultiMenu); |
| } |
| |
| this.menu_ = menu; |
| if (menu) { |
| if (menu.id) { |
| this.setAttribute('menu', '#' + menu.id); |
| } |
| } |
| } |
| set menu(menu) { |
| this.setMenu_(menu); |
| } |
| |
| /** |
| * Checks if the menu(s) should be closed based on the target of a mouse |
| * click or a touch event target. |
| * @param {Event} e The event object. |
| * @return {boolean} |
| * @private |
| */ |
| shouldDismissMenu_(e) { |
| // All menus are dismissed when clicking outside the menus. If we are |
| // showing a sub-menu, we need to detect if the target is the top |
| // level menu, or in the sub menu when the sub menu is being shown. |
| // The button is excluded here because it should toggle show/hide the |
| // menu and handled separately. |
| return e.target instanceof Node && !this.contains(e.target) && |
| !this.menu.contains(e.target); |
| } |
| |
| /** |
| * Display any sub-menu hanging off the current selection. |
| */ |
| showSubMenu() { |
| if (!this.isMenuShown()) { |
| return; |
| } |
| this.menu.showSubMenu(); |
| } |
| |
| /** |
| * Do we have a menu visible to handle a keyboard event. |
| * @return {boolean} True if there's a visible menu. |
| * @private |
| */ |
| hasVisibleMenu_() { |
| if (this.isMenuShown()) { |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Handles event callbacks. |
| * @param {Event} e The event object. |
| */ |
| handleEvent(e) { |
| if (!this.menu) { |
| return; |
| } |
| |
| switch (e.type) { |
| case 'touchstart': |
| // Touch on the menu button itself is ignored to avoid that the menu |
| // opened again by the mousedown event following the touch events. |
| if (this.shouldDismissMenu_(e)) { |
| this.hideMenuWithoutTakingFocus_(); |
| } |
| break; |
| case 'mousedown': |
| if (e.currentTarget == this.ownerDocument) { |
| if (this.shouldDismissMenu_(e)) { |
| this.hideMenuWithoutTakingFocus_(); |
| } else { |
| e.preventDefault(); |
| } |
| } else { |
| if (this.isMenuShown()) { |
| this.hideMenuWithoutTakingFocus_(); |
| } else if (e.button == 0) { // Only show the menu when using left |
| // mouse button. |
| this.showMenu(false, {x: e.screenX, y: e.screenY}); |
| // Prevent the button from stealing focus on mousedown unless |
| // focus is on another button or cr-input element. |
| if (!(document.hasFocus() && |
| (document.activeElement.tagName === 'BUTTON' || |
| document.activeElement.tagName === 'CR-BUTTON' || |
| document.activeElement.tagName === 'CR-INPUT'))) { |
| e.preventDefault(); |
| } |
| } |
| } |
| |
| // Hide the focus ring on mouse click. |
| this.classList.add('using-mouse'); |
| break; |
| case 'keydown': |
| this.handleKeyDown(e); |
| // If a menu is visible we let it handle all the keyboard events. |
| if (e.currentTarget == this.ownerDocument && this.hasVisibleMenu_()) { |
| this.menu.handleKeyDown(e); |
| e.preventDefault(); |
| e.stopPropagation(); |
| } |
| |
| // Show the focus ring on keypress. |
| this.classList.remove('using-mouse'); |
| break; |
| case 'focus': |
| if (this.shouldDismissMenu_(e)) { |
| this.hideMenu(); |
| // Show the focus ring on focus - if it's come from a mouse event, |
| // the focus ring will be hidden in the mousedown event handler, |
| // executed after this. |
| this.classList.remove('using-mouse'); |
| } |
| break; |
| case 'blur': |
| // No need to hide the focus ring anymore, without having focus. |
| this.classList.remove('using-mouse'); |
| break; |
| case 'activate': |
| const hideDelayed = |
| e.target instanceof cr.ui.MenuItem && e.target.checkable; |
| const hideType = hideDelayed ? HideType.DELAYED : HideType.INSTANT; |
| // If the menu-item hosts a sub-menu, don't hide |
| if (this.menu.getSubMenuFromItem( |
| /** @type {!cr.ui.MenuItem} */ (e.target)) !== null) { |
| break; |
| } |
| if (e.originalEvent instanceof MouseEvent || |
| e.originalEvent instanceof TouchEvent) { |
| this.hideMenuWithoutTakingFocus_(hideType); |
| } else { |
| // Keyboard. Take focus to continue keyboard operation. |
| this.hideMenu(hideType); |
| } |
| break; |
| case 'popstate': |
| case 'resize': |
| this.hideMenu(); |
| break; |
| case 'contextmenu': |
| if ((!this.menu || !this.menu.contains(e.target))) { |
| this.showMenu(true, {x: e.screenX, y: e.screenY}); |
| } |
| e.preventDefault(); |
| // Don't allow elements further up in the DOM to show their menus. |
| e.stopPropagation(); |
| break; |
| case 'dblclick': |
| // Don't allow double click events to propagate. |
| e.preventDefault(); |
| e.stopPropagation(); |
| break; |
| } |
| } |
| |
| /** |
| * Shows the menu. |
| * @param {boolean} shouldSetFocus Whether to set focus on the |
| * selected menu item. |
| * @param {{x: number, y: number}=} opt_mousePos The position of the mouse |
| * when shown (in screen coordinates). |
| */ |
| showMenu(shouldSetFocus, opt_mousePos) { |
| this.hideMenu(); |
| |
| this.menu.updateCommands(this); |
| |
| const event = new UIEvent( |
| 'menushow', {bubbles: true, cancelable: true, view: window}); |
| if (!this.dispatchEvent(event)) { |
| return; |
| } |
| |
| // Track element for which menu was opened so that command events are |
| // dispatched to the correct element. |
| this.menu.contextElement = this; |
| this.menu.show(opt_mousePos); |
| |
| // Toggle aria and open state. |
| this.setAttribute('aria-expanded', 'true'); |
| this.setAttribute('menu-shown', ''); |
| |
| // When the menu is shown we steal all keyboard events. |
| const doc = this.ownerDocument; |
| const win = assert(doc.defaultView); |
| this.showingEvents_.add(doc, 'keydown', this, true); |
| this.showingEvents_.add(doc, 'mousedown', this, true); |
| this.showingEvents_.add(doc, 'focus', this, true); |
| this.showingEvents_.add(doc, 'scroll', this, true); |
| this.showingEvents_.add(win, 'popstate', this); |
| this.showingEvents_.add(win, 'resize', this); |
| this.showingEvents_.add(this.menu, 'contextmenu', this); |
| this.showingEvents_.add(this.menu, 'activate', this); |
| this.observedElement_ = this.parentElement; |
| this.observer_.observe(assert(this.observedElement_)); |
| this.positionMenu_(); |
| |
| if (shouldSetFocus) { |
| this.menu.focusSelectedItem(); |
| } |
| } |
| |
| /** |
| * Hides the menu. If your menu can go out of scope, make sure to call this |
| * first. |
| * @param {cr.ui.HideType=} opt_hideType Type of hide. |
| * default: cr.ui.HideType.INSTANT. |
| */ |
| hideMenu(opt_hideType) { |
| this.hideMenuInternal_(true, opt_hideType); |
| } |
| |
| /** |
| * Hides the menu. If your menu can go out of scope, make sure to call this |
| * first. |
| * @param {cr.ui.HideType=} opt_hideType Type of hide. |
| * default: cr.ui.HideType.INSTANT. |
| */ |
| hideMenuWithoutTakingFocus_(opt_hideType) { |
| this.hideMenuInternal_(false, opt_hideType); |
| } |
| |
| /** |
| * Hides the menu. If your menu can go out of scope, make sure to call this |
| * first. |
| * @param {boolean} shouldTakeFocus Moves the focus to the button if true. |
| * @param {cr.ui.HideType=} opt_hideType Type of hide. |
| * default: cr.ui.HideType.INSTANT. |
| */ |
| hideMenuInternal_(shouldTakeFocus, opt_hideType) { |
| if (!this.isMenuShown()) { |
| return; |
| } |
| |
| // Toggle aria and open state. |
| this.setAttribute('aria-expanded', 'false'); |
| this.removeAttribute('menu-shown'); |
| |
| if (opt_hideType == HideType.DELAYED) { |
| this.menu.classList.add('hide-delayed'); |
| } else { |
| this.menu.classList.remove('hide-delayed'); |
| } |
| this.menu.hide(); |
| |
| this.showingEvents_.removeAll(); |
| if (shouldTakeFocus) { |
| this.focus(); |
| } |
| |
| this.observer_.unobserve(assert(this.observedElement_)); |
| |
| const event = new UIEvent( |
| 'menuhide', {bubbles: true, cancelable: false, view: window}); |
| this.dispatchEvent(event); |
| } |
| |
| /** |
| * Whether the menu is shown. |
| */ |
| isMenuShown() { |
| return this.hasAttribute('menu-shown'); |
| } |
| |
| /** |
| * Positions the menu below the menu button. We check the menu fits |
| * in the viewport, and enable scrolling if required. |
| * @private |
| */ |
| positionMenu_() { |
| const style = this.menu.style; |
| |
| if (util.isFilesNg()) { |
| style.marginTop = '8px'; // crbug.com/1066727 |
| } |
| |
| // Clear any maxHeight we've set from previous calls into here. |
| style.maxHeight = 'none'; |
| const invertLeftRight = false; |
| /** @type {!cr.ui.AnchorType} */ |
| const anchorType = cr.ui.AnchorType.BELOW; |
| cr.ui.positionPopupAroundElement( |
| this, this.menu, anchorType, invertLeftRight); |
| // Check if menu is larger than the viewport and adjust its height to |
| // enable scrolling if so. Note: style.bottom would have been set to 0. |
| const viewportHeight = window.innerHeight; |
| const menuRect = this.menu.getBoundingClientRect(); |
| // Limit the height to fit in the viewport. |
| style.maxHeight = (viewportHeight - this.menuEndGap_) + 'px'; |
| // If the menu is too tall, position 2px from the bottom of the viewport |
| // so users can see the end of the menu (helps when scroll is needed). |
| if ((menuRect.height + this.menuEndGap_) > viewportHeight) { |
| style.bottom = '2px'; |
| } |
| // Let the browser deal with scroll bar generation. |
| style.overflowY = 'auto'; |
| } |
| |
| /** |
| * Handles the keydown event for the menu button. |
| */ |
| handleKeyDown(e) { |
| switch (e.key) { |
| case 'ArrowDown': |
| case 'ArrowUp': |
| case 'Enter': |
| case ' ': |
| if (!this.isMenuShown()) { |
| this.showMenu(true); |
| } |
| e.preventDefault(); |
| break; |
| case 'Escape': |
| case 'Tab': |
| this.hideMenu(); |
| break; |
| } |
| } |
| } |
| |
| MultiMenuButton.prototype.__proto__ = HTMLButtonElement.prototype; |
| |
| // Export |
| // #cr_define_end |
| return {MultiMenuButton}; |
| }); |