/**
* @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;
};