| 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'; | 
| +    }, | 
| +  }); | 
| +})(); | 
|  |