// Copyright 2006 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @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.array'); 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'); /** * 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) { goog.ui.MenuBase.call(this, opt_element); }; goog.inherits(goog.ui.AttachableMenu, goog.ui.MenuBase); goog.tagUnsealableClass(goog.ui.AttachableMenu); /** * 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_ = goog.now(); /** @override */ goog.ui.AttachableMenu.prototype.disposeInternal = function() { 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() { 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) { this.itemClassName_ = name; }; /** * Sets the class name to use for selected menu items * todo(user) - 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() { return this.selectedItemClassName_; }; /** * Sets the class name to use for selected menu items * todo(user) - 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) { 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() { return this.selectedElement_; }; /** @override */ goog.ui.AttachableMenu.prototype.setSelectedItem = function(obj) { 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() { // 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. * @protected * @override */ goog.ui.AttachableMenu.prototype.onShow = function() { 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) { // 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 (!goog.isDef(index)) { 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) { var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target)); if (eltItem == null) { return; } // Stop the keydown triggering a mouseover in FF. if (goog.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) { var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target)); if (eltItem == null) { return; } // Stop the keydown triggering a mouseout in FF. if (goog.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) { 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) { switch (e.keyCode) { case goog.events.KeyCodes.DOWN: this.setSelectedItem(this.getNextPrevItem(false)); this.lastKeyDown_ = goog.now(); break; case goog.events.KeyCodes.UP: this.setSelectedItem(this.getNextPrevItem(true)); this.lastKeyDown_ = goog.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) { var elements = this.getElement().getElementsByTagName('*'); var elementCount = elements.length; var index; if (elementCount == 0) { return; } if (!this.selectedElement_ || (index = goog.array.indexOf(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) { 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) { 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) { 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; };