// Copyright 2007 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 A menu class for showing popups. A single popup can be * attached to multiple anchor points. The menu will try to reposition itself * if it goes outside the viewport. * * Decoration is the same as goog.ui.Menu except that the outer DIV can have a * 'for' property, which is the ID of the element which triggers the popup. * * Decorate Example: * *
* * TESTED=FireFox 2.0, IE6, Opera 9, Chrome. * TODO(user): Key handling is flakey in Opera and Chrome * * @see ../demos/popupmenu.html */ goog.provide('goog.ui.PopupMenu'); goog.require('goog.events'); goog.require('goog.events.BrowserEvent'); goog.require('goog.events.EventType'); goog.require('goog.events.KeyCodes'); goog.require('goog.positioning.AnchoredViewportPosition'); goog.require('goog.positioning.Corner'); goog.require('goog.positioning.MenuAnchoredPosition'); goog.require('goog.positioning.Overflow'); goog.require('goog.positioning.ViewportClientPosition'); goog.require('goog.structs.Map'); goog.require('goog.style'); goog.require('goog.ui.Component'); goog.require('goog.ui.Menu'); goog.require('goog.ui.PopupBase'); /** * A basic menu class. * @param {?goog.dom.DomHelper=} opt_domHelper Optional DOM helper. * @param {?goog.ui.MenuRenderer=} opt_renderer Renderer used to render or * decorate the container; defaults to {@link goog.ui.MenuRenderer}. * @extends {goog.ui.Menu} * @constructor */ goog.ui.PopupMenu = function(opt_domHelper, opt_renderer) { goog.ui.Menu.call(this, opt_domHelper, opt_renderer); this.setAllowAutoFocus(true); // Popup menus are hidden by default. this.setVisible(false, true); /** * Map of attachment points for the menu. Key -> Object * @type {!goog.structs.Map} * @private */ this.targets_ = new goog.structs.Map(); }; goog.inherits(goog.ui.PopupMenu, goog.ui.Menu); goog.tagUnsealableClass(goog.ui.PopupMenu); /** * If true, then if the menu will toggle off if it is already visible. * @type {boolean} * @private */ goog.ui.PopupMenu.prototype.toggleMode_ = false; /** * Time that the menu was last shown. * @type {number} * @private */ goog.ui.PopupMenu.prototype.lastHide_ = 0; /** * Current element where the popup menu is anchored. * @type {?Element} * @private */ goog.ui.PopupMenu.prototype.currentAnchor_ = null; /** * Decorate an existing HTML structure with the menu. Menu items will be * constructed from elements with classname 'goog-menuitem', separators will be * made from HR elements. * @param {?Element} element Element to decorate. * @override */ goog.ui.PopupMenu.prototype.decorateInternal = function(element) { goog.ui.PopupMenu.superClass_.decorateInternal.call(this, element); // 'for' is a custom attribute for attaching the menu to a click target var htmlFor = element.getAttribute('for') || element.htmlFor; if (htmlFor) { this.attach( this.getDomHelper().getElement(htmlFor), goog.positioning.Corner.BOTTOM_LEFT); } }; /** @override */ goog.ui.PopupMenu.prototype.enterDocument = function() { goog.ui.PopupMenu.superClass_.enterDocument.call(this); this.targets_.forEach(this.attachEvent_, this); var handler = this.getHandler(); handler.listen(this, goog.ui.Component.EventType.ACTION, this.onAction_); handler.listen( this.getDomHelper().getDocument(), goog.events.EventType.MOUSEDOWN, this.onDocClick, true); }; /** * Attaches the menu to a new popup position and anchor element. A menu can * only be attached to an element once, since attaching the same menu for * multiple positions doesn't make sense. * * @param {?Element} element Element whose click event should trigger the menu. * @param {?goog.positioning.Corner=} opt_targetCorner Corner of the target that * the menu should be anchored to. * @param {goog.positioning.Corner=} opt_menuCorner Corner of the menu that * should be anchored. * @param {boolean=} opt_contextMenu Whether the menu should show on * {@link goog.events.EventType.CONTEXTMENU} events, false if it should * show on {@link goog.events.EventType.MOUSEDOWN} events. Default is * MOUSEDOWN. * @param {?goog.math.Box=} opt_margin Margin for the popup used in positioning * algorithms. */ goog.ui.PopupMenu.prototype.attach = function( element, opt_targetCorner, opt_menuCorner, opt_contextMenu, opt_margin) { if (this.isAttachTarget(element)) { // Already in the popup, so just return. return; } var target = this.createAttachTarget( element, opt_targetCorner, opt_menuCorner, opt_contextMenu, opt_margin); if (this.isInDocument()) { this.attachEvent_(target); } // Add a listener for keyboard actions on the menu. var handler = goog.partial(this.onMenuKeyboardAction_, element); if (this.getElement()) { this.getHandler().listen( this.getElement(), goog.events.EventType.KEYDOWN, handler); } }; /** * Handles keyboard actions on the PopupMenu, according to * http://www.w3.org/WAI/PF/aria-practices/#menubutton. * *If the ESC key is pressed, the menu is hidden (which is handled by * this.onAction_), and the focus is returned to the element whose click event * triggered opening of the menu. * *
If the SPACE or ENTER keys are pressed, the highlighted menu item's * listeners are fired. * * @param {Element} element Element whose click event triggered the menu. * @param {!goog.events.BrowserEvent} e The key down event. * @private */ goog.ui.PopupMenu.prototype.onMenuKeyboardAction_ = function(element, e) { if (e.keyCode == goog.events.KeyCodes.ESC) { element.focus(); return; } var highlightedItem = this.getChildAt(this.getHighlightedIndex()); if (!highlightedItem) { return; } var targetElement = highlightedItem.getElement(); // Create an event to pass to the menu item's listener. var event = new goog.events.BrowserEvent(e.getBrowserEvent(), targetElement); event.target = targetElement; // If an item is highlighted and the user presses the SPACE/ENTER key, the // event target is the menu rather than the menu item, so we manually fire // the listener of the correct menu item. if (e.keyCode == goog.events.KeyCodes.SPACE || e.keyCode == goog.events.KeyCodes.ENTER) { goog.events.fireListeners( targetElement, goog.events.EventType.KEYDOWN, false, event); } // After activating a menu item the PopupMenu should be hidden (already // implemented in this.onAction_ for ENTER/MOUSEDOWN). if (e.keyCode == goog.events.KeyCodes.SPACE) { this.hide(); } }; /** * Creates an object describing how the popup menu should be attached to the * anchoring element based on the given parameters. The created object is * stored, keyed by {@code element} and is retrievable later by invoking * {@link #getAttachTarget(element)} at a later point. * * Subclass may add more properties to the returned object, as needed. * * @param {?Element} element Element whose click event should trigger the menu. * @param {?goog.positioning.Corner=} opt_targetCorner Corner of the target that * the menu should be anchored to. * @param {?goog.positioning.Corner=} opt_menuCorner Corner of the menu that * should be anchored. * @param {boolean=} opt_contextMenu Whether the menu should show on * {@link goog.events.EventType.CONTEXTMENU} events, false if it should * show on {@link goog.events.EventType.MOUSEDOWN} events. Default is * MOUSEDOWN. * @param {?goog.math.Box=} opt_margin Margin for the popup used in positioning * algorithms. * * @return {?Object} An object that describes how the popup menu should be * attached to the anchoring element. * * @protected */ goog.ui.PopupMenu.prototype.createAttachTarget = function( element, opt_targetCorner, opt_menuCorner, opt_contextMenu, opt_margin) { if (!element) { return null; } var target = { element_: element, targetCorner_: opt_targetCorner, menuCorner_: opt_menuCorner, eventType_: opt_contextMenu ? goog.events.EventType.CONTEXTMENU : goog.events.EventType.MOUSEDOWN, margin_: opt_margin }; this.targets_.set(goog.getUid(element), target); return target; }; /** * Returns the object describing how the popup menu should be attach to given * element or {@code null}. The object is created and the association is formed * when {@link #attach} is invoked. * * @param {?Element} element DOM element. * @return {?Object} The object created when {@link attach} is invoked on * {@code element}. Returns {@code null} if the element does not trigger * the menu (i.e. {@link attach} has never been invoked on * {@code element}). * @protected */ goog.ui.PopupMenu.prototype.getAttachTarget = function(element) { return element ? /** @type {?Object} */ (this.targets_.get(goog.getUid(element))) : null; }; /** * @param {?Element} element Any DOM element. * @return {boolean} Whether clicking on the given element will trigger the * menu. * * @protected */ goog.ui.PopupMenu.prototype.isAttachTarget = function(element) { return element ? this.targets_.containsKey(goog.getUid(element)) : false; }; /** * @return {?Element} The current element where the popup is anchored, if it's * visible. */ goog.ui.PopupMenu.prototype.getAttachedElement = function() { return this.currentAnchor_; }; /** * Attaches two event listeners to a target. One with corresponding event type, * and one with the KEYDOWN event type for accessibility purposes. * @param {?Object} target The target to attach an event to. * @private */ goog.ui.PopupMenu.prototype.attachEvent_ = function(target) { this.getHandler().listen( target.element_, target.eventType_, this.onTargetClick_); if (target.eventType_ != goog.events.EventType.CONTEXTMENU) { this.getHandler().listen( target.element_, goog.events.EventType.KEYDOWN, this.onTargetKeyboardAction_); } }; /** * Detaches all listeners */ goog.ui.PopupMenu.prototype.detachAll = function() { if (this.isInDocument()) { var keys = this.targets_.getKeys(); for (var i = 0; i < keys.length; i++) { this.detachEvent_(/** @type {!Object} */ (this.targets_.get(keys[i]))); } } this.targets_.clear(); }; /** * Detaches a menu from a given element. * @param {?Element} element Element whose click event should trigger the menu. */ goog.ui.PopupMenu.prototype.detach = function(element) { if (!this.isAttachTarget(element)) { throw Error('Menu not attached to provided element, unable to detach.'); } var key = goog.getUid(element); if (this.isInDocument()) { this.detachEvent_(/** @type {!Object} */ (this.targets_.get(key))); } this.targets_.remove(key); }; /** * Detaches an event listener to a target * @param {!Object} target The target to detach events from. * @private */ goog.ui.PopupMenu.prototype.detachEvent_ = function(target) { this.getHandler().unlisten( target.element_, target.eventType_, this.onTargetClick_); }; /** * Sets whether the menu should toggle if it is already open. For context * menus this should be false, for toolbar menus it makes more sense to be true. * @param {boolean} toggle The new toggle mode. */ goog.ui.PopupMenu.prototype.setToggleMode = function(toggle) { this.toggleMode_ = toggle; }; /** * Gets whether the menu is in toggle mode * @return {boolean} toggle. */ goog.ui.PopupMenu.prototype.getToggleMode = function() { return this.toggleMode_; }; /** * Show the menu using given positioning object. * @param {?goog.positioning.AbstractPosition} position The positioning * instance. * @param {goog.positioning.Corner=} opt_menuCorner The corner of the menu to be * positioned. * @param {?goog.math.Box=} opt_margin A margin specified in pixels. * @param {?Element=} opt_anchor The element which acts as visual anchor for * this menu. */ goog.ui.PopupMenu.prototype.showWithPosition = function( position, opt_menuCorner, opt_margin, opt_anchor) { var isVisible = this.isVisible(); if (this.isOrWasRecentlyVisible() && this.toggleMode_) { this.hide(); return; } // Set current anchor before dispatching BEFORE_SHOW. This is typically useful // when we would need to make modifications based on the current anchor to the // menu just before displaying it. this.currentAnchor_ = opt_anchor || null; // Notify event handlers that the menu is about to be shown. if (!this.dispatchEvent(goog.ui.Component.EventType.BEFORE_SHOW)) { return; } var menuCorner = typeof opt_menuCorner != 'undefined' ? opt_menuCorner : goog.positioning.Corner.TOP_START; // This is a little hacky so that we can position the menu with minimal // flicker. if (!isVisible) { // On IE, setting visibility = 'hidden' on a visible menu // will cause a blur, forcing the menu to close immediately. this.getElement().style.visibility = 'hidden'; } goog.style.setElementShown(this.getElement(), true); position.reposition(this.getElement(), menuCorner, opt_margin); if (!isVisible) { this.getElement().style.visibility = 'visible'; } this.setHighlightedIndex(-1); // setVisible dispatches a goog.ui.Component.EventType.SHOW event, which may // be canceled to prevent the menu from being shown. this.setVisible(true); }; /** * Show the menu at a given attached target. * @param {!Object} target Popup target. * @param {number} x The client-X associated with the show event. * @param {number} y The client-Y associated with the show event. * @protected */ goog.ui.PopupMenu.prototype.showMenu = function(target, x, y) { var position = goog.isDef(target.targetCorner_) ? new goog.positioning.AnchoredViewportPosition( target.element_, target.targetCorner_, true) : new goog.positioning.ViewportClientPosition(x, y); if (position.setLastResortOverflow) { // This is a ViewportClientPosition, so we can set the overflow policy. // Allow the menu to slide from the corner rather than clipping if it is // completely impossible to fit it otherwise. position.setLastResortOverflow( goog.positioning.Overflow.ADJUST_X | goog.positioning.Overflow.ADJUST_Y); } this.showWithPosition( position, target.menuCorner_, target.margin_, target.element_); }; /** * Shows the menu immediately at the given client coordinates. * @param {number} x The client-X associated with the show event. * @param {number} y The client-Y associated with the show event. * @param {goog.positioning.Corner=} opt_menuCorner Corner of the menu that * should be anchored. */ goog.ui.PopupMenu.prototype.showAt = function(x, y, opt_menuCorner) { this.showWithPosition( new goog.positioning.ViewportClientPosition(x, y), opt_menuCorner); }; /** * Shows the menu immediately attached to the given element * @param {?Element} element The element to show at. * @param {goog.positioning.Corner} targetCorner The corner of the target to * anchor to. * @param {goog.positioning.Corner=} opt_menuCorner Corner of the menu that * should be anchored. */ goog.ui.PopupMenu.prototype.showAtElement = function( element, targetCorner, opt_menuCorner) { this.showWithPosition( new goog.positioning.MenuAnchoredPosition(element, targetCorner, true), opt_menuCorner, null, element); }; /** * Hides the menu. */ goog.ui.PopupMenu.prototype.hide = function() { if (!this.isVisible()) { return; } // setVisible dispatches a goog.ui.Component.EventType.HIDE event, which may // be canceled to prevent the menu from being hidden. this.setVisible(false); if (!this.isVisible()) { // HIDE event wasn't canceled; the menu is now hidden. this.lastHide_ = goog.now(); this.currentAnchor_ = null; } }; /** * Returns whether the menu is currently visible or was visible within about * 150 ms ago. This stops the menu toggling back on if the toggleMode == false. * @return {boolean} Whether the popup is currently visible or was visible * within about 150 ms ago. */ goog.ui.PopupMenu.prototype.isOrWasRecentlyVisible = function() { return this.isVisible() || this.wasRecentlyHidden(); }; /** * Used to stop the menu toggling back on if the toggleMode == false. * @return {boolean} Whether the menu was recently hidden. * @protected */ goog.ui.PopupMenu.prototype.wasRecentlyHidden = function() { return goog.now() - this.lastHide_ < goog.ui.PopupBase.DEBOUNCE_DELAY_MS; }; /** * Dismiss the popup menu when an action fires. * @param {?goog.events.Event=} opt_e The optional event. * @private */ goog.ui.PopupMenu.prototype.onAction_ = function(opt_e) { this.hide(); }; /** * Handles a browser click event on one of the popup targets. * @param {?goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.PopupMenu.prototype.onTargetClick_ = function(e) { this.onTargetActivation_(e); }; /** * Handles a KEYDOWN browser event on one of the popup targets. * @param {!goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.PopupMenu.prototype.onTargetKeyboardAction_ = function(e) { if (e.keyCode == goog.events.KeyCodes.SPACE || e.keyCode == goog.events.KeyCodes.ENTER || e.keyCode == goog.events.KeyCodes.DOWN) { this.onTargetActivation_(e); } // If the popupmenu is opened using the DOWN key, the focus should be on the // first menu item. if (e.keyCode == goog.events.KeyCodes.DOWN) { this.highlightFirst(); } }; /** * Handles a browser event on one of the popup targets. * @param {?goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.PopupMenu.prototype.onTargetActivation_ = function(e) { var keys = this.targets_.getKeys(); for (var i = 0; i < keys.length; i++) { var target = /** @type {!Object} */ (this.targets_.get(keys[i])); if (target.element_ == e.currentTarget) { this.showMenu(target, (e.clientX), (e.clientY)); e.preventDefault(); e.stopPropagation(); return; } } }; /** * Handles click events that propagate to the document. * @param {!goog.events.BrowserEvent} e The browser event. * @protected */ goog.ui.PopupMenu.prototype.onDocClick = function(e) { if (this.isVisible() && !this.containsElement(/** @type {!Element} */ (e.target))) { this.hide(); } }; /** * Handles the key event target losing focus. * @param {?goog.events.BrowserEvent} e The browser event. * @protected * @override */ goog.ui.PopupMenu.prototype.handleBlur = function(e) { goog.ui.PopupMenu.superClass_.handleBlur.call(this, e); this.hide(); }; /** @override */ goog.ui.PopupMenu.prototype.disposeInternal = function() { // Always call the superclass' disposeInternal() first (Bug 715885). goog.ui.PopupMenu.superClass_.disposeInternal.call(this); // Disposes of the attachment target map. if (this.targets_) { this.targets_.clear(); delete this.targets_; } };