| // Copyright (c) 2018 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. |
| |
| cr.define('cr.ui', () => { |
| /** |
| * Creates a menu that supports sub-menus. |
| * |
| * This works almost identically to cr.ui.Menu apart from supporting |
| * sub menus hanging off a <cr-menu-item> element. To add a sub menu |
| * to a top level menu item, add a 'sub-menu' attribute which has as |
| * its value an id selector for another <cr-menu> element. |
| * (e.g. <cr-menu-item sub-menu="other-menu">). |
| * @extends {cr.ui.Menu} |
| * @implements {EventListener} |
| */ |
| class MultiMenu { |
| constructor() { |
| /** |
| * Whether a sub-menu is positioned on the left of its parent. |
| * @private {?boolean} Used to direct the arrow key navigation. |
| */ |
| this.subMenuOnLeft = null; |
| |
| /** |
| * Property that hosts sub-menus for filling with overflow items. |
| * @public {?cr.ui.Menu} Used for menu-items that overflow parent |
| * menu. |
| */ |
| this.overflow = null; |
| |
| /** |
| * Reference to the menu that the user is currently navigating. |
| * @private {?cr.ui.MultiMenu|cr.ui.Menu} Used to route events to the |
| * correct menu. |
| */ |
| this.currentMenu = null; |
| |
| /** @private {?cr.ui.Menu} Sub menu being used. */ |
| this.subMenu = null; |
| |
| /** @private {?cr.ui.MenuItem} Menu item hosting a sub menu. */ |
| this.parentMenuItem = null; |
| |
| /** @private {?cr.ui.MenuItem} Selected item in a menu. */ |
| this.selectedItem = 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; |
| |
| /** TODO(adanilo) Annotate these for closure checking. */ |
| this.contains_ = undefined; |
| this.handleKeyDown_ = undefined; |
| this.hide_ = undefined; |
| this.show_ = undefined; |
| } |
| |
| /** |
| * Initializes the multi menu. |
| * @param {!Element} element Element to be decorated. |
| * @return {!cr.ui.MultiMenu} Decorated element. |
| */ |
| static decorate(element) { |
| // Decorate the menu as a single level menu. |
| cr.ui.decorate(element, cr.ui.Menu); |
| // Grab cr.ui.Menu functions we want to override. |
| // TODO(adanilo) Try to work around this suppress. |
| // It's needed when monkey patching an in-built DOM method |
| // that closure doesn't understand. |
| /** @suppress {checkTypes} */ |
| element.contains_ = cr.ui.Menu.prototype['contains']; |
| element.handleKeyDown_ = cr.ui.Menu.prototype['handleKeyDown']; |
| element.hide_ = cr.ui.Menu.prototype['hide']; |
| element.show_ = cr.ui.Menu.prototype['show']; |
| // Add the MultiMenuButton methods to the element we're decorating. |
| Object.getOwnPropertyNames(MultiMenu.prototype).forEach(name => { |
| if (name !== 'constructor' && |
| !Object.getOwnPropertyDescriptor(element, name)) { |
| element[name] = MultiMenu.prototype[name]; |
| } |
| }); |
| element = /** @type {!cr.ui.MultiMenu} */ (element); |
| element.decorate(); |
| return element; |
| } |
| |
| decorate() { |
| // Event tracker for the sub-menu specific listeners. |
| this.showingEvents_ = new EventTracker(); |
| this.currentMenu = this; |
| this.menuEndGap_ = 18; // padding on cr.menu + 2px |
| } |
| |
| /** |
| * Handles event callbacks. |
| * @param {Event} e The event object. |
| */ |
| handleEvent(e) { |
| switch (e.type) { |
| case 'activate': |
| if (e.currentTarget === this) { |
| // Don't activate if there's a sub-menu to show |
| const item = this.findMenuItem(e.target); |
| if (item) { |
| const subMenuId = item.getAttribute('sub-menu'); |
| if (subMenuId) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| // Show the sub menu if needed. |
| if (!item.getAttribute('sub-menu-shown')) { |
| this.showSubMenu(); |
| } |
| } |
| } |
| } else { |
| // If the event was fired by the sub-menu, send an activate event to |
| // the top level menu. |
| const activationEvent = document.createEvent('Event'); |
| activationEvent.initEvent('activate', true, true); |
| activationEvent.originalEvent = e.originalEvent; |
| this.dispatchEvent(activationEvent); |
| } |
| break; |
| case 'keydown': |
| switch (e.key) { |
| case 'ArrowLeft': |
| case 'ArrowRight': |
| if (!this.currentMenu) { |
| break; |
| } |
| if (this.currentMenu === this) { |
| const menuItem = this.currentMenu.selectedItem; |
| const subMenu = this.getSubMenuFromItem(menuItem); |
| if (subMenu) { |
| if (subMenu.hidden) { |
| break; |
| } |
| if (this.subMenuOnLeft && e.key == 'ArrowLeft') { |
| this.moveSelectionToSubMenu_(subMenu); |
| } else if ( |
| this.subMenuOnLeft === false && e.key == 'ArrowRight') { |
| this.moveSelectionToSubMenu_(subMenu); |
| } |
| } |
| } else { |
| const subMenu = /** @type {cr.ui.Menu} */ (this.currentMenu); |
| // We only move off the sub-menu if we're on the top item |
| if (subMenu.selectedIndex == 0) { |
| if (this.subMenuOnLeft && e.key == 'ArrowRight') { |
| this.moveSelectionToTopMenu_(subMenu); |
| } else if ( |
| this.subMenuOnLeft === false && e.key == 'ArrowLeft') { |
| this.moveSelectionToTopMenu_(subMenu); |
| } |
| } |
| } |
| break; |
| case 'ArrowDown': |
| case 'ArrowUp': |
| // Hide any showing sub-menu if we're moving in the parent. |
| if (this.currentMenu === this) { |
| this.hideSubMenu_(); |
| } |
| break; |
| } |
| break; |
| case 'mouseover': |
| case 'mouseout': |
| this.manageSubMenu(e); |
| break; |
| } |
| } |
| |
| /** |
| * This event handler is used to redirect keydown events to |
| * the top level and sub-menus when they're active. |
| * cr.ui.Menu has a handleKeyDown() method and to support |
| * sub-menus we monkey patch the cr.ui.menu call via |
| * this.handleKeyDown_() and if any sub menu is active, by |
| * calling the cr.ui.Menu method directly. |
| * @param {Event} e The keydown event object. |
| * @return {boolean} Whether the event was handled be the menu. |
| */ |
| handleKeyDown(e) { |
| if (!this.currentMenu) { |
| return false; |
| } |
| if (this.currentMenu === this) { |
| return this.handleKeyDown_(e); |
| } else { |
| return this.currentMenu.handleKeyDown(e); |
| } |
| } |
| |
| /** |
| * Position the sub menu adjacent to the cr-menu-item that triggered it. |
| * @param {cr.ui.MenuItem} item The menu item to position against. |
| * @param {cr.ui.Menu} subMenu The child (sub) menu to be positioned. |
| */ |
| positionSubMenu_(item, subMenu) { |
| const style = subMenu.style; |
| |
| if (util.isFilesNg()) { |
| style.marginTop = '0'; // crbug.com/1066727 |
| } |
| |
| // The sub-menu needs to sit aligned to the top and side of |
| // the menu-item passed in. It also needs to fit inside the viewport |
| const itemRect = item.getBoundingClientRect(); |
| const viewportWidth = window.innerWidth; |
| const viewportHeight = window.innerHeight; |
| const childRect = subMenu.getBoundingClientRect(); |
| const maxShift = itemRect.width / 2; |
| |
| // See if it fits on the right, if not position on the left |
| // if there's more room on the left. |
| style.left = style.right = style.top = style.bottom = 'auto'; |
| if ((itemRect.right + childRect.width) > viewportWidth && |
| ((viewportWidth - itemRect.right) < itemRect.left)) { |
| let leftPosition = itemRect.left - childRect.width; |
| // Allow some menu overlap if sub menu will be clipped off. |
| if (leftPosition < 0) { |
| if (leftPosition < -maxShift) { |
| leftPosition += maxShift; |
| } else { |
| leftPosition = 0; |
| } |
| } |
| this.subMenuOnLeft = true; |
| style.left = leftPosition + 'px'; |
| } else { |
| let rightPosition = itemRect.right; |
| // Allow overlap on the right to reduce sub menu clip. |
| if ((rightPosition + childRect.width) > viewportWidth) { |
| if ((rightPosition + childRect.width - viewportWidth) > maxShift) { |
| rightPosition -= maxShift; |
| } else { |
| rightPosition = viewportWidth - childRect.width; |
| } |
| } |
| this.subMenuOnLeft = false; |
| style.left = rightPosition + 'px'; |
| } |
| style.top = itemRect.top + 'px'; |
| // Size the subMenu to fit inside the height of the viewport |
| // Always set the maximum height so that expanding the window |
| // allows the menu height to grow crbug/934207 |
| style.maxHeight = |
| (viewportHeight - itemRect.top - this.menuEndGap_) + 'px'; |
| // Let the browser deal with scroll bar generation. |
| style.overflowY = 'auto'; |
| } |
| |
| /** |
| * Get the subMenu hanging off a menu-item if it exists. |
| * @param {cr.ui.MenuItem} item The menu item. |
| * @return {cr.ui.Menu|null} |
| */ |
| getSubMenuFromItem(item) { |
| if (!item) { |
| return null; |
| } |
| const subMenuId = item.getAttribute('sub-menu'); |
| if (subMenuId === null) { |
| return null; |
| } |
| return /** @type {!cr.ui.Menu|null} */ ( |
| document.querySelector(subMenuId)); |
| } |
| |
| /** |
| * Display any sub-menu hanging off the current selection. |
| */ |
| showSubMenu() { |
| const item = this.selectedItem; |
| const subMenu = this.getSubMenuFromItem(item); |
| if (subMenu) { |
| this.subMenu = subMenu; |
| item.setAttribute('sub-menu-shown', 'shown'); |
| this.positionSubMenu_(item, subMenu); |
| subMenu.show(); |
| subMenu.parentMenuItem = item; |
| this.moveSelectionToSubMenu_(subMenu); |
| } |
| } |
| |
| /** |
| * Find any ancestor menu item from a node. |
| * TODO(adanilo) refactor this with the menu code to get rid of it. |
| * @param {EventTarget} node The node to start searching from. |
| * @return {?cr.ui.MenuItem} The found menu item or null. |
| * @private |
| */ |
| findMenuItem(node) { |
| const MenuItem = cr.ui.MenuItem; |
| while (node && node.parentNode !== this && !(node instanceof MenuItem)) { |
| node = node.parentNode; |
| } |
| return node ? assertInstanceof(node, MenuItem) : null; |
| } |
| |
| /** |
| * Find any sub-menu hanging off the event target and show/hide it. |
| * @param {Event} e The event object. |
| */ |
| manageSubMenu(e) { |
| const item = this.findMenuItem(e.target); |
| const subMenu = this.getSubMenuFromItem(item); |
| if (!subMenu) { |
| return; |
| } |
| this.subMenu = subMenu; |
| switch (e.type) { |
| case 'activate': |
| case 'mouseover': |
| // Hide any other sub menu being shown. |
| const showing = /** @type {cr.ui.MenuItem} */ ( |
| this.querySelector('cr-menu-item[sub-menu-shown]')); |
| if (showing && showing !== item) { |
| showing.removeAttribute('sub-menu-shown'); |
| const shownSubMenu = this.getSubMenuFromItem(showing); |
| if (shownSubMenu) { |
| shownSubMenu.hide(); |
| } |
| } |
| item.setAttribute('sub-menu-shown', 'shown'); |
| this.positionSubMenu_(item, subMenu); |
| subMenu.show(); |
| break; |
| case 'mouseout': |
| // If we're on top of the sub-menu, we don't want to dismiss it |
| const childRect = subMenu.getBoundingClientRect(); |
| if (childRect.left <= e.clientX && e.clientX < childRect.right && |
| childRect.top <= e.clientY && e.clientY < childRect.bottom) { |
| this.currentMenu = subMenu; |
| break; |
| } |
| item.removeAttribute('sub-menu-shown'); |
| subMenu.hide(); |
| this.subMenu = null; |
| this.currentMenu = this; |
| break; |
| } |
| } |
| |
| /** |
| * Change the selection from the top level menu to the first item |
| * in the subMenu passed in. |
| * @param {cr.ui.Menu} subMenu sub-menu that should take selection. |
| * @private |
| */ |
| moveSelectionToSubMenu_(subMenu) { |
| this.selectedItem = null; |
| this.currentMenu = subMenu; |
| subMenu.selectedIndex = 0; |
| subMenu.focusSelectedItem(); |
| } |
| |
| /** |
| * TODO(adanilo) Get rid of this - just used to satisfy closure. |
| */ |
| actAsMenu_() { |
| return this; |
| } |
| |
| /** |
| * Change the selection from the sub menu to the top level menu. |
| * @param {cr.ui.Menu} subMenu sub-menu that should lose selection. |
| * @private |
| */ |
| moveSelectionToTopMenu_(subMenu) { |
| subMenu.selectedItem = null; |
| this.currentMenu = this; |
| this.selectedItem = subMenu.parentMenuItem; |
| const menu = /** @type {!cr.ui.Menu} */ (this.actAsMenu_()); |
| menu.focusSelectedItem(); |
| } |
| |
| /** |
| * Add event listeners to any sub menus. |
| */ |
| addSubMenuListeners() { |
| const items = this.querySelectorAll('cr-menu-item[sub-menu]'); |
| items.forEach((menuItem) => { |
| const subMenuId = menuItem.getAttribute('sub-menu'); |
| if (subMenuId) { |
| const subMenu = document.querySelector(subMenuId); |
| if (subMenu) { |
| this.showingEvents_.add(subMenu, 'activate', this); |
| } |
| } |
| }); |
| } |
| |
| /** @param {{x: number, y: number}=} opt_mouseDownPos */ |
| show(opt_mouseDownPos) { |
| this.show_(opt_mouseDownPos); |
| // When the menu is shown we steal all keyboard events. |
| const doc = /** @type {EventTarget} */ (this.ownerDocument); |
| if (doc) { |
| this.showingEvents_.add(doc, 'keydown', this, true); |
| } |
| this.showingEvents_.add(this, 'activate', this, true); |
| // Handle mouse-over to trigger sub menu opening on hover. |
| this.showingEvents_.add(this, 'mouseover', this); |
| this.showingEvents_.add(this, 'mouseout', this); |
| this.addSubMenuListeners(); |
| } |
| |
| /** |
| * Hides any sub-menu that is active. |
| */ |
| hideSubMenu_() { |
| const items = |
| this.querySelectorAll('cr-menu-item[sub-menu][sub-menu-shown]'); |
| items.forEach((menuItem) => { |
| const subMenuId = menuItem.getAttribute('sub-menu'); |
| if (subMenuId) { |
| const subMenu = /** @type {!cr.ui.Menu|null} */ |
| (document.querySelector(subMenuId)); |
| if (subMenu) { |
| subMenu.hide(); |
| } |
| menuItem.removeAttribute('sub-menu-shown'); |
| } |
| }); |
| this.currentMenu = this; |
| } |
| |
| hide() { |
| this.showingEvents_.removeAll(); |
| // Hide any visible sub-menus first |
| this.hideSubMenu_(); |
| this.hide_(); |
| } |
| |
| /** |
| * Check if a DOM element is containd within the main top |
| * level menu or any sub-menu hanging off the top level menu. |
| * @param {Node} node Node being tested for containment. |
| */ |
| contains(node) { |
| return this.contains_(node) || |
| (this.subMenu && this.subMenu.contains(node)); |
| } |
| } |
| |
| // Export |
| return {MultiMenu}; |
| }); |