Index: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js |
diff --git a/ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js b/ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js |
index 537cfebeffd835bd7968ab911db64321cccfbe96..7a31ab59156b8be22702c457e40bda81a1c3a3cc 100644 |
--- a/ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js |
+++ b/ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js |
@@ -2,196 +2,337 @@ |
// Use of this source code is governed by a BSD-style license that can be |
// found in the LICENSE file. |
-Polymer({ |
- is: 'cr-action-menu', |
- extends: 'dialog', |
+/** |
+ * @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), |
+ * }} |
+ */ |
+var ShowConfig; |
- /** |
- * List of all options in this action menu. |
- * @private {?NodeList<!Element>} |
- */ |
- options_: null, |
+/** |
+ * @enum {number} |
+ * @const |
+ */ |
+var AnchorAlignment = { |
+ BEFORE_START: -2, |
+ AFTER_START: -1, |
+ CENTER: 0, |
+ BEFORE_END: 1, |
+ AFTER_END: 2, |
+}; |
+(function() { |
/** |
- * The element which the action menu will be anchored to. Also the element |
- * where focus will be returned after the menu is closed. |
- * @private {?Element} |
+ * 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} length |
+ * @param {AnchorAlignment} anchorAlignment |
+ * @param {number} min |
+ * @param {number} max |
+ * @return {number} |
*/ |
- anchorElement_: null, |
+ function getStartPointWithAnchor( |
+ start, end, length, anchorAlignment, min, max) { |
+ var startPoint = 0; |
+ switch (anchorAlignment) { |
+ case AnchorAlignment.BEFORE_START: |
+ startPoint = -length; |
+ break; |
+ case AnchorAlignment.AFTER_START: |
+ startPoint = start; |
+ break; |
+ case AnchorAlignment.CENTER: |
+ startPoint = (start + end - length) / 2; |
+ break; |
+ case AnchorAlignment.BEFORE_END: |
+ startPoint = end - length; |
+ break; |
+ case AnchorAlignment.AFTER_END: |
+ startPoint = end; |
+ break; |
+ } |
+ |
+ if (startPoint + length > max) |
+ startPoint = end - length; |
+ if (startPoint < min) |
+ startPoint = start; |
+ return startPoint; |
+ } |
+ |
/** |
- * Bound reference to an event listener function such that it can be removed |
- * on detach. |
- * @private {?Function} |
+ * @private |
+ * @return {!ShowConfig} |
*/ |
- boundClose_: null, |
+ function getDefaultShowConfig() { |
+ return { |
+ top: 0, |
+ left: 0, |
+ height: 0, |
+ width: 0, |
+ anchorAlignmentX: AnchorAlignment.AFTER_START, |
+ anchorAlignmentY: AnchorAlignment.AFTER_START, |
+ minX: 0, |
+ minY: 0, |
+ maxX: window.innerWidth, |
+ maxY: window.innerHeight, |
+ }; |
+ } |
- /** @private {boolean} */ |
- hasMousemoveListener_: false, |
+ Polymer({ |
+ is: 'cr-action-menu', |
+ extends: 'dialog', |
- hostAttributes: { |
- tabindex: 0, |
- }, |
+ /** |
+ * List of all options in this action menu. |
+ * @private {?NodeList<!Element>} |
+ */ |
+ options_: null, |
- listeners: { |
- 'keydown': 'onKeyDown_', |
- 'mouseover': 'onMouseover_', |
- 'tap': 'onTap_', |
- }, |
+ /** |
+ * 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, |
- /** override */ |
- attached: function() { |
- this.options_ = this.querySelectorAll('.dropdown-item'); |
- }, |
+ /** |
+ * Bound reference to an event listener function such that it can be removed |
+ * on detach. |
+ * @private {?Function} |
+ */ |
+ boundClose_: null, |
- /** override */ |
- detached: function() { |
- this.removeListeners_(); |
- }, |
+ /** @private {boolean} */ |
+ hasMousemoveListener_: false, |
- /** @private */ |
- removeListeners_: function() { |
- window.removeEventListener('resize', this.boundClose_); |
- window.removeEventListener('popstate', this.boundClose_); |
- }, |
+ hostAttributes: { |
+ tabindex: 0, |
+ }, |
- /** |
- * @param {!Event} e |
- * @private |
- */ |
- onTap_: function(e) { |
- if (e.target == this) { |
- this.close(); |
- e.stopPropagation(); |
- } |
- }, |
+ listeners: { |
+ 'keydown': 'onKeyDown_', |
+ 'mouseover': 'onMouseover_', |
+ 'tap': 'onTap_', |
+ }, |
- /** |
- * @param {!KeyboardEvent} e |
- * @private |
- */ |
- onKeyDown_: function(e) { |
- if (e.key == 'Tab' || e.key == 'Escape') { |
- this.close(); |
- e.preventDefault(); |
- return; |
- } |
+ /** override */ |
+ attached: function() { |
+ this.options_ = this.querySelectorAll('.dropdown-item'); |
+ }, |
- if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') |
- return; |
- |
- var nextOption = this.getNextOption_(e.key == 'ArrowDown' ? 1 : -1); |
- if (nextOption) { |
- if (!this.hasMousemoveListener_) { |
- this.hasMousemoveListener_ = true; |
- listenOnce(this, 'mousemove', function(e) { |
- this.onMouseover_(e); |
- this.hasMousemoveListener_ = false; |
- }.bind(this)); |
+ /** override */ |
+ detached: function() { |
+ this.removeListeners_(); |
+ }, |
+ |
+ /** @private */ |
+ removeListeners_: function() { |
+ window.removeEventListener('resize', this.boundClose_); |
+ window.removeEventListener('popstate', this.boundClose_); |
+ }, |
+ |
+ /** |
+ * @param {!Event} e |
+ * @private |
+ */ |
+ onTap_: function(e) { |
+ if (e.target == this) { |
+ this.close(); |
+ e.stopPropagation(); |
} |
- nextOption.focus(); |
- } |
+ }, |
- e.preventDefault(); |
- }, |
+ /** |
+ * @param {!KeyboardEvent} e |
+ * @private |
+ */ |
+ onKeyDown_: function(e) { |
+ if (e.key == 'Tab' || e.key == 'Escape') { |
+ this.close(); |
+ e.preventDefault(); |
+ return; |
+ } |
- /** |
- * @param {!Event} e |
- * @private |
- */ |
- onMouseover_: function(e) { |
- // TODO(scottchen): Using "focus" to determine selected item might mess |
- // with screen readers in some edge cases. |
- var i = 0; |
- do { |
- var target = e.path[i++]; |
- if (target.classList && target.classList.contains('dropdown-item')) { |
- target.focus(); |
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') |
return; |
+ |
+ var nextOption = this.getNextOption_(e.key == 'ArrowDown' ? 1 : -1); |
+ if (nextOption) { |
+ if (!this.hasMousemoveListener_) { |
+ this.hasMousemoveListener_ = true; |
+ listenOnce(this, 'mousemove', function(e) { |
+ this.onMouseover_(e); |
+ this.hasMousemoveListener_ = false; |
+ }.bind(this)); |
+ } |
+ nextOption.focus(); |
} |
- } while (this != target); |
- // The user moved the mouse off the options. Reset focus to the dialog. |
- this.focus(); |
- }, |
+ e.preventDefault(); |
+ }, |
- /** |
- * @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. |
- var counter = 0; |
- var nextOption = null; |
- var numOptions = this.options_.length; |
- var focusedIndex = |
- Array.prototype.indexOf.call(this.options_, this.root.activeElement); |
- |
- // Handle case where nothing is focused and up is pressed. |
- if (focusedIndex === -1 && step === -1) |
- focusedIndex = 0; |
- |
- do { |
- focusedIndex = (numOptions + focusedIndex + step) % numOptions; |
- nextOption = this.options_[focusedIndex]; |
- if (nextOption.disabled || nextOption.hidden) |
- nextOption = null; |
- counter++; |
- } while (!nextOption && counter < numOptions); |
- |
- return nextOption; |
- }, |
- |
- /** @override */ |
- close: function() { |
- // Removing 'resize' and 'popstate' listeners when dialog is closed. |
- this.removeListeners_(); |
- HTMLDialogElement.prototype.close.call(this); |
- cr.ui.focusWithoutInk(assert(this.anchorElement_)); |
- this.anchorElement_ = null; |
- }, |
+ /** |
+ * @param {!Event} e |
+ * @private |
+ */ |
+ onMouseover_: function(e) { |
+ // TODO(scottchen): Using "focus" to determine selected item might mess |
+ // with screen readers in some edge cases. |
+ var i = 0; |
+ do { |
+ var target = e.path[i++]; |
+ if (target.classList && target.classList.contains('dropdown-item')) { |
+ target.focus(); |
+ return; |
+ } |
+ } while (this != target); |
- /** |
- * Shows the menu anchored to the given element. |
- * @param {!Element} anchorElement |
- */ |
- showAt: function(anchorElement) { |
- this.anchorElement_ = anchorElement; |
- this.boundClose_ = this.boundClose_ || function() { |
- if (this.open) |
- this.close(); |
- }.bind(this); |
- window.addEventListener('resize', this.boundClose_); |
- window.addEventListener('popstate', this.boundClose_); |
- |
- // Reset position to prevent previous values from affecting layout. |
- this.style.left = ''; |
- this.style.right = ''; |
- this.style.top = ''; |
- |
- this.anchorElement_.scrollIntoViewIfNeeded(); |
- this.showModal(); |
- |
- var rect = this.anchorElement_.getBoundingClientRect(); |
- if (getComputedStyle(this.anchorElement_).direction == 'rtl') { |
- var right = window.innerWidth - rect.left - this.offsetWidth; |
- this.style.right = right + 'px'; |
- } else { |
- var left = rect.right - this.offsetWidth; |
- this.style.left = left + 'px'; |
- } |
+ // The user moved the mouse off the options. Reset focus to the dialog. |
+ this.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. |
+ var counter = 0; |
+ var nextOption = null; |
+ var numOptions = this.options_.length; |
+ var focusedIndex = |
+ Array.prototype.indexOf.call(this.options_, this.root.activeElement); |
+ |
+ // Handle case where nothing is focused and up is pressed. |
+ if (focusedIndex === -1 && step === -1) |
+ focusedIndex = 0; |
+ |
+ do { |
+ focusedIndex = (numOptions + focusedIndex + step) % numOptions; |
+ nextOption = this.options_[focusedIndex]; |
+ if (nextOption.disabled || nextOption.hidden) |
+ nextOption = null; |
+ counter++; |
+ } while (!nextOption && counter < numOptions); |
- // Attempt to show the menu starting from the top of the rectangle and |
- // extending downwards. If that does not fit within the window, fallback to |
- // starting from the bottom and extending upwards. |
- var top = rect.top + this.offsetHeight <= window.innerHeight ? rect.top : |
- rect.bottom - |
- this.offsetHeight - Math.max(rect.bottom - window.innerHeight, 0); |
+ return nextOption; |
+ }, |
+ |
+ /** @override */ |
+ close: function() { |
+ // Removing 'resize' and 'popstate' listeners when dialog is closed. |
+ this.removeListeners_(); |
+ HTMLDialogElement.prototype.close.call(this); |
+ if (this.anchorElement_) { |
+ cr.ui.focusWithoutInk(assert(this.anchorElement_)); |
+ this.anchorElement_ = null; |
+ } |
+ }, |
+ |
+ /** |
+ * Shows the menu anchored to the given element. |
+ * @param {!Element} anchorElement |
+ */ |
+ showAt: function(anchorElement) { |
+ this.anchorElement_ = anchorElement; |
+ this.anchorElement_.scrollIntoViewIfNeeded(); |
+ var rect = this.anchorElement_.getBoundingClientRect(); |
+ this.showAtPosition({ |
+ top: rect.top, |
+ left: rect.left, |
+ height: rect.height, |
+ width: rect.width, |
+ // Default to anchoring towards the left. |
+ anchorAlignmentX: AnchorAlignment.BEFORE_END, |
+ }); |
+ }, |
+ |
+ /** |
+ * 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. |
+ * |
+ * 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 {!ShowConfig} config |
+ */ |
+ showAtPosition: function(config) { |
+ var c = Object.assign(getDefaultShowConfig(), config); |
+ |
+ var top = c.top; |
+ var left = c.left; |
+ var bottom = top + c.height; |
+ var right = left + c.width; |
+ |
+ this.boundClose_ = this.boundClose_ || function() { |
+ if (this.open) |
+ this.close(); |
+ }.bind(this); |
+ window.addEventListener('resize', this.boundClose_); |
+ window.addEventListener('popstate', this.boundClose_); |
+ |
+ // Reset position to prevent previous values from affecting layout. |
+ this.style.left = ''; |
+ this.style.right = ''; |
+ this.style.top = ''; |
+ |
+ this.showModal(); |
+ |
+ // Flip the X anchor in RTL. |
+ var rtl = getComputedStyle(this).direction == 'rtl'; |
+ if (rtl) |
+ c.anchorAlignmentX *= -1; |
+ |
+ var menuLeft = getStartPointWithAnchor( |
+ left, right, this.offsetWidth, c.anchorAlignmentX, c.minX, c.maxX); |
+ |
+ if (rtl) { |
+ var menuRight = window.innerWidth - menuLeft - this.offsetWidth; |
+ this.style.right = menuRight + 'px'; |
+ } else { |
+ this.style.left = menuLeft + 'px'; |
+ } |
- this.style.top = top + 'px'; |
- }, |
-}); |
+ var menuTop = getStartPointWithAnchor( |
+ top, bottom, this.offsetHeight, c.anchorAlignmentY, c.minY, c.maxY); |
+ this.style.top = menuTop + 'px'; |
+ }, |
+ }); |
+})(); |