| // Copyright 2016 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. |
| |
| /** |
| * @typedef {{ |
| * top: (number|undefined), |
| * left: (number|undefined), |
| * width: (number|undefined), |
| * height: (number|undefined), |
| * anchorAlignmentX: (number|undefined), |
| * anchorAlignmentY: (number|undefined), |
| * minX: (number|undefined), |
| * minY: (number|undefined), |
| * maxX: (number|undefined), |
| * maxY: (number|undefined), |
| * }} |
| */ |
| let ShowAtConfig; |
| |
| /** |
| * @typedef {{ |
| * top: number, |
| * left: number, |
| * width: (number|undefined), |
| * height: (number|undefined), |
| * anchorAlignmentX: (number|undefined), |
| * anchorAlignmentY: (number|undefined), |
| * minX: (number|undefined), |
| * minY: (number|undefined), |
| * maxX: (number|undefined), |
| * maxY: (number|undefined), |
| * }} |
| */ |
| let ShowAtPositionConfig; |
| |
| /** |
| * @enum {number} |
| * @const |
| */ |
| const AnchorAlignment = { |
| BEFORE_START: -2, |
| AFTER_START: -1, |
| CENTER: 0, |
| BEFORE_END: 1, |
| AFTER_END: 2, |
| }; |
| |
| /** @const {string} */ |
| const DROPDOWN_ITEM_CLASS = 'dropdown-item'; |
| |
| (function() { |
| |
| /** @const {number} */ |
| const AFTER_END_OFFSET = 10; |
| |
| /** |
| * Returns the point to start along the X or Y axis given a start and end |
| * point to anchor to, the length of the target and the direction to anchor |
| * in. If honoring the anchor would force the menu outside of min/max, this |
| * will ignore the anchor position and try to keep the menu within min/max. |
| * @private |
| * @param {number} start |
| * @param {number} end |
| * @param {number} menuLength |
| * @param {AnchorAlignment} anchorAlignment |
| * @param {number} min |
| * @param {number} max |
| * @return {number} |
| */ |
| function getStartPointWithAnchor( |
| start, end, menuLength, anchorAlignment, min, max) { |
| let startPoint = 0; |
| switch (anchorAlignment) { |
| case AnchorAlignment.BEFORE_START: |
| startPoint = -menuLength; |
| break; |
| case AnchorAlignment.AFTER_START: |
| startPoint = start; |
| break; |
| case AnchorAlignment.CENTER: |
| startPoint = (start + end - menuLength) / 2; |
| break; |
| case AnchorAlignment.BEFORE_END: |
| startPoint = end - menuLength; |
| break; |
| case AnchorAlignment.AFTER_END: |
| startPoint = end; |
| break; |
| } |
| |
| if (startPoint + menuLength > max) { |
| startPoint = end - menuLength; |
| } |
| if (startPoint < min) { |
| startPoint = start; |
| } |
| |
| startPoint = Math.max(min, Math.min(startPoint, max - menuLength)); |
| |
| return startPoint; |
| } |
| |
| /** |
| * @private |
| * @return {!ShowAtPositionConfig} |
| */ |
| function getDefaultShowConfig() { |
| const doc = document.scrollingElement; |
| return { |
| top: 0, |
| left: 0, |
| height: 0, |
| width: 0, |
| anchorAlignmentX: AnchorAlignment.AFTER_START, |
| anchorAlignmentY: AnchorAlignment.AFTER_START, |
| minX: 0, |
| minY: 0, |
| maxX: 0, |
| maxY: 0, |
| }; |
| } |
| |
| Polymer({ |
| is: 'cr-action-menu', |
| |
| /** |
| * The element which the action menu will be anchored to. Also the element |
| * where focus will be returned after the menu is closed. Only populated if |
| * menu is opened with showAt(). |
| * @private {?Element} |
| */ |
| anchorElement_: null, |
| |
| /** |
| * Bound reference to an event listener function such that it can be removed |
| * on detach. |
| * @private {?Function} |
| */ |
| boundClose_: null, |
| |
| /** @private {boolean} */ |
| hasMousemoveListener_: false, |
| |
| /** @private {?PolymerDomApi.ObserveHandle} */ |
| contentObserver_: null, |
| |
| /** @private {?ResizeObserver} */ |
| resizeObserver_: null, |
| |
| /** @private {?ShowAtPositionConfig} */ |
| lastConfig_: null, |
| |
| properties: { |
| // Setting this flag will make the menu listen for content size changes and |
| // reposition to its anchor accordingly. |
| autoReposition: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| open: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| ariaLabel: String, |
| }, |
| |
| listeners: { |
| 'keydown': 'onKeyDown_', |
| 'mouseover': 'onMouseover_', |
| 'tap': 'onTap_', |
| }, |
| |
| /** override */ |
| detached: function() { |
| this.removeListeners_(); |
| }, |
| |
| /** |
| * Exposing internal <dialog> elements for tests. |
| * @return {!HTMLDialogElement} |
| */ |
| getDialog: function() { |
| return this.$.dialog; |
| }, |
| |
| /** @private */ |
| removeListeners_: function() { |
| window.removeEventListener('resize', this.boundClose_); |
| window.removeEventListener('popstate', this.boundClose_); |
| if (this.contentObserver_) { |
| Polymer.dom(this.$.contentNode).unobserveNodes(this.contentObserver_); |
| this.contentObserver_ = null; |
| } |
| |
| if (this.resizeObserver_) { |
| this.resizeObserver_.disconnect(); |
| this.resizeObserver_ = null; |
| } |
| }, |
| |
| /** |
| * @param {!Event} e |
| * @private |
| */ |
| onNativeDialogClose_: function(e) { |
| // Ignore any 'close' events not fired directly by the <dialog> element. |
| if (e.target !== this.$.dialog) { |
| return; |
| } |
| |
| // TODO(dpapad): This is necessary to make the code work both for Polymer 1 |
| // and Polymer 2. Remove once migration to Polymer 2 is completed. |
| e.stopPropagation(); |
| |
| // Catch and re-fire the 'close' event such that it bubbles across Shadow |
| // DOM v1. |
| this.fire('close'); |
| }, |
| |
| /** |
| * @param {!Event} e |
| * @private |
| */ |
| onTap_: function(e) { |
| if (e.target == this) { |
| this.close(); |
| e.stopPropagation(); |
| } |
| }, |
| |
| /** |
| * @param {!KeyboardEvent} e |
| * @private |
| */ |
| onKeyDown_: function(e) { |
| e.stopPropagation(); |
| |
| if (e.key == 'Tab' || e.key == 'Escape') { |
| this.close(); |
| e.preventDefault(); |
| return; |
| } |
| |
| let selectNext = e.key == 'ArrowDown'; |
| if (e.key == 'Enter') { |
| // If a menu item has focus, don't change focus or close menu on 'Enter'. |
| const options = this.querySelectorAll('.dropdown-item'); |
| const focusedIndex = |
| Array.prototype.indexOf.call(options, getDeepActiveElement()); |
| if (focusedIndex != -1) { |
| return; |
| } |
| |
| if (cr.isWindows || cr.isMac) { |
| this.close(); |
| e.preventDefault(); |
| return; |
| } |
| selectNext = true; |
| } |
| |
| if (e.key !== 'ArrowUp' && !selectNext) { |
| return; |
| } |
| |
| const nextOption = this.getNextOption_(selectNext ? 1 : -1); |
| if (nextOption) { |
| if (!this.hasMousemoveListener_) { |
| this.hasMousemoveListener_ = true; |
| listenOnce(this, 'mousemove', e => { |
| this.onMouseover_(e); |
| this.hasMousemoveListener_ = false; |
| }); |
| } |
| nextOption.focus(); |
| } |
| |
| e.preventDefault(); |
| }, |
| |
| /** |
| * @param {!Event} e |
| * @private |
| */ |
| onMouseover_: function(e) { |
| // TODO(scottchen): Using "focus" to determine selected item might mess |
| // with screen readers in some edge cases. |
| let i = 0; |
| let target; |
| do { |
| target = e.path[i++]; |
| if (target.classList && target.classList.contains('dropdown-item') && |
| !target.disabled) { |
| target.focus(); |
| return; |
| } |
| } while (this != target); |
| |
| // The user moved the mouse off the options. Reset focus to the dialog. |
| this.$.dialog.focus(); |
| }, |
| |
| /** |
| * @param {number} step -1 for getting previous option (up), 1 for getting |
| * next option (down). |
| * @return {?Element} The next focusable option, taking into account |
| * disabled/hidden attributes, or null if no focusable option exists. |
| * @private |
| */ |
| getNextOption_: function(step) { |
| // Using a counter to ensure no infinite loop occurs if all elements are |
| // hidden/disabled. |
| let counter = 0; |
| let nextOption = null; |
| const options = this.querySelectorAll('.dropdown-item'); |
| const numOptions = options.length; |
| let focusedIndex = |
| Array.prototype.indexOf.call(options, getDeepActiveElement()); |
| |
| // Handle case where nothing is focused and up is pressed. |
| if (focusedIndex === -1 && step === -1) { |
| focusedIndex = 0; |
| } |
| |
| do { |
| focusedIndex = (numOptions + focusedIndex + step) % numOptions; |
| nextOption = options[focusedIndex]; |
| if (nextOption.disabled || nextOption.hidden) { |
| nextOption = null; |
| } |
| counter++; |
| } while (!nextOption && counter < numOptions); |
| |
| return nextOption; |
| }, |
| |
| close: function() { |
| // Removing 'resize' and 'popstate' listeners when dialog is closed. |
| this.removeListeners_(); |
| this.$.dialog.close(); |
| this.open = false; |
| if (this.anchorElement_) { |
| cr.ui.focusWithoutInk(assert(this.anchorElement_)); |
| this.anchorElement_ = null; |
| } |
| if (this.lastConfig_) { |
| this.lastConfig_ = null; |
| } |
| }, |
| |
| /** |
| * Shows the menu anchored to the given element. |
| * @param {!Element} anchorElement |
| * @param {ShowAtConfig=} opt_config |
| */ |
| showAt: function(anchorElement, opt_config) { |
| this.anchorElement_ = anchorElement; |
| // Scroll the anchor element into view so that the bounding rect will be |
| // accurate for where the menu should be shown. |
| this.anchorElement_.scrollIntoViewIfNeeded(); |
| |
| const rect = this.anchorElement_.getBoundingClientRect(); |
| |
| let height = rect.height; |
| if (opt_config && |
| opt_config.anchorAlignmentY == AnchorAlignment.AFTER_END) { |
| // When an action menu is positioned after the end of an element, the |
| // action menu can appear too far away from the anchor element, typically |
| // because anchors tend to have padding. So we offset the height a bit |
| // so the menu shows up slightly closer to the content of anchor. |
| height -= AFTER_END_OFFSET; |
| } |
| |
| this.showAtPosition(/** @type {ShowAtPositionConfig} */ (Object.assign( |
| { |
| top: rect.top, |
| left: rect.left, |
| height: height, |
| width: rect.width, |
| // Default to anchoring towards the left. |
| anchorAlignmentX: AnchorAlignment.BEFORE_END, |
| }, |
| opt_config))); |
| }, |
| |
| /** |
| * Shows the menu anchored to the given box. The anchor alignment is |
| * specified as an X and Y alignment which represents a point in the anchor |
| * where the menu will align to, which can have the menu either before or |
| * after the given point in each axis. Center alignment places the center of |
| * the menu in line with the center of the anchor. Coordinates are relative to |
| * the top-left of the viewport. |
| * |
| * y-start |
| * _____________ |
| * | | |
| * | | |
| * | CENTER | |
| * x-start | x | x-end |
| * | | |
| * |anchor box | |
| * |___________| |
| * |
| * y-end |
| * |
| * For example, aligning the menu to the inside of the top-right edge of |
| * the anchor, extending towards the bottom-left would use a alignment of |
| * (BEFORE_END, AFTER_START), whereas centering the menu below the bottom |
| * edge of the anchor would use (CENTER, AFTER_END). |
| * |
| * @param {!ShowAtPositionConfig} config |
| */ |
| showAtPosition: function(config) { |
| // Save the scroll position of the viewport. |
| const doc = document.scrollingElement; |
| const scrollLeft = doc.scrollLeft; |
| const scrollTop = doc.scrollTop; |
| |
| // Reset position so that layout isn't affected by the previous position, |
| // and so that the dialog is positioned at the top-start corner of the |
| // document. |
| this.resetStyle_(); |
| this.$.dialog.showModal(); |
| this.open = true; |
| |
| config.top += scrollTop; |
| config.left += scrollLeft; |
| |
| this.positionDialog_(/** @type {ShowAtPositionConfig} */ (Object.assign( |
| { |
| minX: scrollLeft, |
| minY: scrollTop, |
| maxX: scrollLeft + doc.clientWidth, |
| maxY: scrollTop + doc.clientHeight, |
| }, |
| config))); |
| |
| // Restore the scroll position. |
| doc.scrollTop = scrollTop; |
| doc.scrollLeft = scrollLeft; |
| this.addListeners_(); |
| }, |
| |
| /** @private */ |
| resetStyle_: function() { |
| this.$.dialog.style.left = ''; |
| this.$.dialog.style.right = ''; |
| this.$.dialog.style.top = '0'; |
| }, |
| |
| /** |
| * Position the dialog using the coordinates in config. Coordinates are |
| * relative to the top-left of the viewport when scrolled to (0, 0). |
| * @param {!ShowAtPositionConfig} config |
| * @private |
| */ |
| positionDialog_: function(config) { |
| this.lastConfig_ = config; |
| const c = Object.assign(getDefaultShowConfig(), config); |
| |
| const top = c.top; |
| const left = c.left; |
| const bottom = top + c.height; |
| const right = left + c.width; |
| |
| // Flip the X anchor in RTL. |
| const rtl = getComputedStyle(this).direction == 'rtl'; |
| if (rtl) { |
| c.anchorAlignmentX *= -1; |
| } |
| |
| const offsetWidth = this.$.dialog.offsetWidth; |
| const menuLeft = getStartPointWithAnchor( |
| left, right, offsetWidth, c.anchorAlignmentX, c.minX, c.maxX); |
| |
| if (rtl) { |
| const menuRight = |
| document.scrollingElement.clientWidth - menuLeft - offsetWidth; |
| this.$.dialog.style.right = menuRight + 'px'; |
| } else { |
| this.$.dialog.style.left = menuLeft + 'px'; |
| } |
| |
| const menuTop = getStartPointWithAnchor( |
| top, bottom, this.$.dialog.offsetHeight, c.anchorAlignmentY, c.minY, |
| c.maxY); |
| this.$.dialog.style.top = menuTop + 'px'; |
| }, |
| |
| /** |
| * @private |
| */ |
| addListeners_: function() { |
| this.boundClose_ = this.boundClose_ || function() { |
| if (this.$.dialog.open) { |
| this.close(); |
| } |
| }.bind(this); |
| window.addEventListener('resize', this.boundClose_); |
| window.addEventListener('popstate', this.boundClose_); |
| |
| this.contentObserver_ = |
| Polymer.dom(this.$.contentNode).observeNodes((info) => { |
| info.addedNodes.forEach((node) => { |
| if (node.classList && |
| node.classList.contains(DROPDOWN_ITEM_CLASS) && |
| !node.getAttribute('role')) { |
| node.setAttribute('role', 'menuitem'); |
| } |
| }); |
| }); |
| |
| if (this.autoReposition) { |
| this.resizeObserver_ = new ResizeObserver(() => { |
| if (this.lastConfig_) { |
| this.positionDialog_(this.lastConfig_); |
| this.fire('cr-action-menu-repositioned'); // For easier testing. |
| } |
| }); |
| |
| this.resizeObserver_.observe(this.$.dialog); |
| } |
| }, |
| }); |
| })(); |