chromium/third_party/google-closure-library/closure/goog/ui/attachablemenu.js

/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Definition of the AttachableMenu class.
 */

goog.provide('goog.ui.AttachableMenu');

goog.require('goog.a11y.aria');
goog.require('goog.a11y.aria.State');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.dom.classlist');
goog.require('goog.events.Event');
goog.require('goog.events.KeyCodes');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.ui.ItemEvent');
goog.require('goog.ui.MenuBase');
goog.require('goog.ui.PopupBase');
goog.require('goog.userAgent');
goog.requireType('goog.events.KeyEvent');



/**
 * An implementation of a menu that can attach itself to DOM element that
 * are annotated appropriately.
 *
 * The following attributes are used by the AttachableMenu
 *
 * menu-item - Should be set on DOM elements that function as items in the
 * menu that can be selected.
 * classNameSelected - A class that will be added to the element's class names
 * when the item is selected via keyboard or mouse.
 *
 * @param {Element=} opt_element A DOM element for the popup.
 * @constructor
 * @extends {goog.ui.MenuBase}
 * @deprecated Use goog.ui.PopupMenu.
 * @final
 */
goog.ui.AttachableMenu = function(opt_element) {
  'use strict';
  goog.ui.MenuBase.call(this, opt_element);
};
goog.inherits(goog.ui.AttachableMenu, goog.ui.MenuBase);


/**
 * The currently selected element (mouse was moved over it or keyboard arrows)
 * @type {?HTMLElement}
 * @private
 */
goog.ui.AttachableMenu.prototype.selectedElement_ = null;


/**
 * Class name to append to a menu item's class when it's selected
 * @type {string}
 * @private
 */
goog.ui.AttachableMenu.prototype.itemClassName_ = 'menu-item';


/**
 * Class name to append to a menu item's class when it's selected
 * @type {string}
 * @private
 */
goog.ui.AttachableMenu.prototype.selectedItemClassName_ = 'menu-item-selected';


/**
 * Keep track of when the last key was pressed so that a keydown-scroll doesn't
 * trigger a mouseover event
 * @type {number}
 * @private
 */
goog.ui.AttachableMenu.prototype.lastKeyDown_ = Date.now();


/** @override */
goog.ui.AttachableMenu.prototype.disposeInternal = function() {
  'use strict';
  goog.ui.AttachableMenu.superClass_.disposeInternal.call(this);
  this.selectedElement_ = null;
};


/**
 * Sets the class name to use for menu items
 *
 * @return {string} The class name to use for items.
 */
goog.ui.AttachableMenu.prototype.getItemClassName = function() {
  'use strict';
  return this.itemClassName_;
};


/**
 * Sets the class name to use for menu items
 *
 * @param {string} name The class name to use for items.
 */
goog.ui.AttachableMenu.prototype.setItemClassName = function(name) {
  'use strict';
  this.itemClassName_ = name;
};


/**
 * Sets the class name to use for selected menu items
 * todo(jonp) - reevaluate if we can simulate pseudo classes in IE
 *
 * @return {string} The class name to use for selected items.
 */
goog.ui.AttachableMenu.prototype.getSelectedItemClassName = function() {
  'use strict';
  return this.selectedItemClassName_;
};


/**
 * Sets the class name to use for selected menu items
 * todo(jonp) - reevaluate if we can simulate pseudo classes in IE
 *
 * @param {string} name The class name to use for selected items.
 */
goog.ui.AttachableMenu.prototype.setSelectedItemClassName = function(name) {
  'use strict';
  this.selectedItemClassName_ = name;
};


/**
 * Returns the selected item
 *
 * @return {Element} The item selected or null if no item is selected.
 * @override
 */
goog.ui.AttachableMenu.prototype.getSelectedItem = function() {
  'use strict';
  return this.selectedElement_;
};


/** @override */
goog.ui.AttachableMenu.prototype.setSelectedItem = function(obj) {
  'use strict';
  var elt = /** @type {HTMLElement} */ (obj);
  if (this.selectedElement_) {
    goog.dom.classlist.remove(
        this.selectedElement_, this.selectedItemClassName_);
  }

  this.selectedElement_ = elt;

  var el = /** @type {HTMLElement} */ (this.getElement());
  goog.asserts.assert(el, 'The attachable menu DOM element cannot be null.');
  if (this.selectedElement_) {
    goog.dom.classlist.add(this.selectedElement_, this.selectedItemClassName_);

    if (elt.id) {
      // Update activedescendant to reflect the new selection. ARIA roles for
      // menu and menuitem can be set statically (through Soy templates, for
      // example) whereas this needs to be updated as the selection changes.
      goog.a11y.aria.setState(
          el, goog.a11y.aria.State.ACTIVEDESCENDANT, elt.id);
    }

    var top = this.selectedElement_.offsetTop;
    var height = this.selectedElement_.offsetHeight;
    var scrollTop = el.scrollTop;
    var scrollHeight = el.offsetHeight;

    // If the menu is scrollable this scrolls the selected item into view
    // (this has no effect when the menu doesn't scroll)
    if (top < scrollTop) {
      el.scrollTop = top;
    } else if (top + height > scrollTop + scrollHeight) {
      el.scrollTop = top + height - scrollHeight;
    }
  } else {
    // Clear off activedescendant to reflect no selection.
    goog.a11y.aria.setState(el, goog.a11y.aria.State.ACTIVEDESCENDANT, '');
  }
};


/** @override */
goog.ui.AttachableMenu.prototype.showPopupElement = function() {
  'use strict';
  // The scroll position cannot be set for hidden (display: none) elements in
  // gecko browsers.
  var el = /** @type {Element} */ (this.getElement());
  goog.style.setElementShown(el, true);
  el.scrollTop = 0;
  el.style.visibility = 'visible';
};


/**
 * Called after the menu is shown.
 * @override
 * @protected
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.AttachableMenu.prototype.onShow = function() {
  'use strict';
  goog.ui.AttachableMenu.superClass_.onShow.call(this);

  // In IE, focusing the menu causes weird scrolling to happen. Focusing the
  // first child makes the scroll behavior better, and the key handling still
  // works. In FF, focusing the first child causes us to lose key events, so we
  // still focus the menu.
  var el = this.getElement();
  goog.userAgent.IE ? el.firstChild.focus() : el.focus();
};


/**
 * Returns the next or previous item. Used for up/down arrows.
 *
 * @param {boolean} prev True to go to the previous element instead of next.
 * @return {Element} The next or previous element.
 * @protected
 */
goog.ui.AttachableMenu.prototype.getNextPrevItem = function(prev) {
  'use strict';
  // first find the index of the next element
  var elements = this.getElement().getElementsByTagName('*');
  var elementCount = elements.length;
  var index;
  // if there is a selected element, find its index and then inc/dec by one
  if (this.selectedElement_) {
    for (var i = 0; i < elementCount; i++) {
      if (elements[i] == this.selectedElement_) {
        index = prev ? i - 1 : i + 1;
        break;
      }
    }
  }

  // if no selected element, start from beginning or end
  if (index === undefined) {
    index = prev ? elementCount - 1 : 0;
  }

  // iterate forward or backwards through the elements finding the next
  // menu item
  for (var i = 0; i < elementCount; i++) {
    var multiplier = prev ? -1 : 1;
    var nextIndex = index + (multiplier * i) % elementCount;

    // if overflowed/underflowed, wrap around
    if (nextIndex < 0) {
      nextIndex += elementCount;
    } else if (nextIndex >= elementCount) {
      nextIndex -= elementCount;
    }

    if (this.isMenuItem_(elements[nextIndex])) {
      return elements[nextIndex];
    }
  }
  return null;
};


/**
 * Mouse over handler for the menu.
 * @param {goog.events.Event} e The event object.
 * @protected
 * @override
 */
goog.ui.AttachableMenu.prototype.onMouseOver = function(e) {
  'use strict';
  var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  if (eltItem == null) {
    return;
  }

  // Stop the keydown triggering a mouseover in FF.
  if (Date.now() - this.lastKeyDown_ > goog.ui.PopupBase.DEBOUNCE_DELAY_MS) {
    this.setSelectedItem(eltItem);
  }
};


/**
 * Mouse out handler for the menu.
 * @param {goog.events.Event} e The event object.
 * @protected
 * @override
 */
goog.ui.AttachableMenu.prototype.onMouseOut = function(e) {
  'use strict';
  var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  if (eltItem == null) {
    return;
  }

  // Stop the keydown triggering a mouseout in FF.
  if (Date.now() - this.lastKeyDown_ > goog.ui.PopupBase.DEBOUNCE_DELAY_MS) {
    this.setSelectedItem(null);
  }
};


/**
 * Mouse down handler for the menu. Prevents default to avoid text selection.
 * @param {!goog.events.Event} e The event object.
 * @protected
 * @override
 */
goog.ui.AttachableMenu.prototype.onMouseDown = goog.events.Event.preventDefault;


/**
 * Mouse up handler for the menu.
 * @param {goog.events.Event} e The event object.
 * @protected
 * @override
 */
goog.ui.AttachableMenu.prototype.onMouseUp = function(e) {
  'use strict';
  var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  if (eltItem == null) {
    return;
  }
  this.setVisible(false);
  this.onItemSelected_(eltItem);
};


/**
 * Key down handler for the menu.
 * @param {goog.events.KeyEvent} e The event object.
 * @protected
 * @override
 */
goog.ui.AttachableMenu.prototype.onKeyDown = function(e) {
  'use strict';
  switch (e.keyCode) {
    case goog.events.KeyCodes.DOWN:
      this.setSelectedItem(this.getNextPrevItem(false));
      this.lastKeyDown_ = Date.now();
      break;
    case goog.events.KeyCodes.UP:
      this.setSelectedItem(this.getNextPrevItem(true));
      this.lastKeyDown_ = Date.now();
      break;
    case goog.events.KeyCodes.ENTER:
      if (this.selectedElement_) {
        this.onItemSelected_();
        this.setVisible(false);
      }
      break;
    case goog.events.KeyCodes.ESC:
      this.setVisible(false);
      break;
    default:
      if (e.charCode) {
        var charStr = String.fromCharCode(e.charCode);
        this.selectByName_(charStr, 1, true);
      }
      break;
  }
  // Prevent the browser's default keydown behaviour when the menu is open,
  // e.g. keyboard scrolling.
  e.preventDefault();

  // Stop propagation to prevent application level keyboard shortcuts from
  // firing.
  e.stopPropagation();

  this.dispatchEvent(e);
};


/**
 * Find an item that has the given prefix and select it.
 *
 * @param {string} prefix The entered prefix, so far.
 * @param {number=} opt_direction 1 to search forward from the selection
 *     (default), -1 to search backward (e.g. to go to the previous match).
 * @param {boolean=} opt_skip True if should skip the current selection,
 *     unless no other item has the given prefix.
 * @private
 */
goog.ui.AttachableMenu.prototype.selectByName_ = function(
    prefix, opt_direction, opt_skip) {
  'use strict';
  var elements = this.getElement().getElementsByTagName('*');
  var elementCount = elements.length;
  var index;

  if (elementCount == 0) {
    return;
  }

  if (!this.selectedElement_ ||
      (index = Array.prototype.indexOf.call(elements, this.selectedElement_)) ==
          -1) {
    // no selection or selection isn't known => start at the beginning
    index = 0;
  }

  var start = index;
  var re = new RegExp('^' + goog.string.regExpEscape(prefix), 'i');
  var skip = opt_skip && this.selectedElement_;
  var dir = opt_direction || 1;

  do {
    if (elements[index] != skip && this.isMenuItem_(elements[index])) {
      var name = goog.dom.getTextContent(elements[index]);
      if (name.match(re)) {
        break;
      }
    }
    index += dir;
    if (index == elementCount) {
      index = 0;
    } else if (index < 0) {
      index = elementCount - 1;
    }
  } while (index != start);

  if (this.selectedElement_ != elements[index]) {
    this.setSelectedItem(elements[index]);
  }
};


/**
 * Dispatch an ITEM_ACTION event when an item is selected
 * @param {Object=} opt_item Item selected.
 * @private
 */
goog.ui.AttachableMenu.prototype.onItemSelected_ = function(opt_item) {
  'use strict';
  this.dispatchEvent(new goog.ui.ItemEvent(
      goog.ui.MenuBase.Events.ITEM_ACTION, this,
      opt_item || this.selectedElement_));
};


/**
 * Returns whether the specified element is a menu item.
 * @param {Element} elt The element to find a menu item ancestor of.
 * @return {boolean} Whether the specified element is a menu item.
 * @private
 */
goog.ui.AttachableMenu.prototype.isMenuItem_ = function(elt) {
  'use strict';
  return !!elt && goog.dom.classlist.contains(elt, this.itemClassName_);
};


/**
 * Returns the menu-item scoping the specified element, or null if there is
 * none.
 * @param {Element|undefined} elt The element to find a menu item ancestor of.
 * @return {Element} The menu-item scoping the specified element, or null if
 *     there is none.
 * @private
 */
goog.ui.AttachableMenu.prototype.getAncestorMenuItem_ = function(elt) {
  'use strict';
  if (elt) {
    var ownerDocumentBody = goog.dom.getOwnerDocument(elt).body;
    while (elt != null && elt != ownerDocumentBody) {
      if (this.isMenuItem_(elt)) {
        return elt;
      }
      elt = /** @type {Element} */ (elt.parentNode);
    }
  }
  return null;
};