// 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. // Copyright 2007 Google Inc. All Rights Reserved. /** * @fileoverview A combo box control that allows user input with * auto-suggestion from a limited set of options. * * @see ../demos/combobox.html */ goog.provide('goog.ui.ComboBox'); goog.provide('goog.ui.ComboBoxItem'); goog.require('goog.Timer'); goog.require('goog.array'); goog.require('goog.debug.Logger'); goog.require('goog.dom.classes'); goog.require('goog.dom.selection'); goog.require('goog.events'); goog.require('goog.events.InputHandler'); goog.require('goog.events.KeyCodes'); goog.require('goog.events.KeyHandler'); goog.require('goog.string'); goog.require('goog.style'); goog.require('goog.ui.Component'); goog.require('goog.ui.ItemEvent'); goog.require('goog.ui.LabelInput'); goog.require('goog.ui.Menu'); goog.require('goog.ui.MenuItem'); goog.require('goog.userAgent'); /** * A ComboBox control. * @param {goog.dom.DomHelper} opt_domHelper Optional DOM helper. * @extends {goog.ui.Component} * @constructor */ goog.ui.ComboBox = function(opt_domHelper) { goog.ui.Component.call(this, opt_domHelper); this.labelInput_ = new goog.ui.LabelInput(); // TODO: Allow lazy creation of menus/menu items this.menu_ = this.createMenu_(); }; goog.inherits(goog.ui.ComboBox, goog.ui.Component); /** * Number of milliseconds to wait before dismissing combobox after blur. * @type {number} */ goog.ui.ComboBox.BLUR_DISMISS_TIMER_MS = 250; /** * A logger to help debugging of combo box behavior. * @type {goog.debug.Logger} * @private */ goog.ui.ComboBox.prototype.logger_ = goog.debug.Logger.getLogger('goog.ui.ComboBox'); /** * Keyboard event handler to manage key events dispatched by the input element. * @type {goog.events.KeyHandler} * @private */ goog.ui.ComboBox.prototype.keyHandler_; /** * Input handler to take care of firing events when the user inputs text in * the input. * @type {goog.events.InputHandler?} * @private */ goog.ui.ComboBox.prototype.inputHandler_ = null; /** * The last input token. * @type {string?} * @private */ goog.ui.ComboBox.prototype.lastToken_ = null; /** * A LabelInput control that manages the focus/blur state of the input box. * @type {goog.ui.LabelInput?} * @private */ goog.ui.ComboBox.prototype.labelInput_ = null; /** * Drop down menu for the combo box. Will be created at construction time. * @type {goog.ui.Menu?} * @private */ goog.ui.ComboBox.prototype.menu_ = null; /** * The cached visible count. * @type {number} * @private */ goog.ui.ComboBox.prototype.visibleCount_ = -1; /** * The input element. * @type {Element?} * @private */ goog.ui.ComboBox.prototype.input_ = null; /** * The match function. The first argument for the match function will be * a MenuItem's caption and the second will be the token to evaluate. * @type {Function} * @private */ goog.ui.ComboBox.prototype.matchFunction_ = goog.string.startsWith; /** * Element used as the combo boxes button. * @type {Element?} * @private */ goog.ui.ComboBox.prototype.button_ = null; /** * Default text content for the input box when it is unchanged and unfocussed. * @type {string} * @private */ goog.ui.ComboBox.prototype.defaultText_ = ''; /** * Name for the input box created * @type {string} * @private */ goog.ui.ComboBox.prototype.fieldName_ = ''; /** * Timer identifier for delaying the dismissal of the combo menu. * @type {number?} * @private */ goog.ui.ComboBox.prototype.dismissTimer_ = null; /** * True if the unicode inverted triangle should be displayed in the dropdown * button. Defaults to false. * @type {boolean} useDropdownArrow * @private */ goog.ui.ComboBox.prototype.useDropdownArrow_ = false; /** * Create the DOM objects needed for the combo box. A span and text input. * @override */ goog.ui.ComboBox.prototype.createDom = function() { this.input_ = this.getDomHelper().createDom( 'input', {'name': this.fieldName_, 'autocomplete': 'off'}); this.button_ = this.getDomHelper().createDom('span', 'goog-combobox-button'); this.setElementInternal(this.getDomHelper().createDom('span', 'goog-combobox', this.input_, this.button_)); if (this.useDropdownArrow_) { this.button_.innerHTML = ' ▼'; goog.style.setUnselectable(this.button_, true /* unselectable */); } this.input_.setAttribute('label', this.defaultText_); this.labelInput_.decorate(this.input_); this.menu_.setFocusable(false); this.addChild(this.menu_, true); }; /** @inheritDoc */ goog.ui.ComboBox.prototype.enterDocument = function() { goog.ui.ComboBox.superClass_.enterDocument.call(this); var handler = this.getHandler(); handler.listen(this.getElement(), goog.events.EventType.MOUSEDOWN, this.onComboMouseDown_); handler.listen(this.getDomHelper().getDocument(), goog.events.EventType.MOUSEDOWN, this.onDocClicked_); handler.listen(this.input_, goog.events.EventType.BLUR, this.onInputBlur_); this.keyHandler_ = new goog.events.KeyHandler(this.input_); handler.listen(this.keyHandler_, goog.events.KeyHandler.EventType.KEY, this.handleKeyEvent); this.inputHandler_ = new goog.events.InputHandler(this.input_); handler.listen(this.inputHandler_, goog.events.InputHandler.EventType.INPUT, this.onInputChange_); handler.listen(this.menu_, goog.ui.Component.EventType.ACTION, this.onMenuSelected_); }; /** @inheritDoc */ goog.ui.ComboBox.prototype.exitDocument = function() { this.keyHandler_.dispose(); delete this.keyHandler_; this.inputHandler_.dispose(); this.inputHandler_ = null; goog.ui.ComboBox.superClass_.exitDocument.call(this); }; /** * Combo box currently can't decorate elements. * @return {boolean} The value false. */ goog.ui.ComboBox.prototype.canDecorate = function() { return false; }; /** @inheritDoc */ goog.ui.ComboBox.prototype.disposeInternal = function() { goog.ui.ComboBox.superClass_.disposeInternal.call(this); this.clearDismissTimer_(); this.labelInput_.dispose(); this.menu_.dispose(); this.labelInput_ = null; this.menu_ = null; this.input_ = null; this.button_ = null; }; /** * Dismisses the menu and resets the value of the edit field. */ goog.ui.ComboBox.prototype.dismiss = function() { this.clearDismissTimer_(); this.hideMenu_(); this.menu_.setHighlightedIndex(-1); }; /** * Adds a new menu item at the end of the menu. * @param {goog.ui.MenuItem} item Menu item to add to the menu. */ goog.ui.ComboBox.prototype.addItem = function(item) { this.menu_.addChild(item, true); }; /** * Adds a new menu item at a specific index in the menu. * @param {goog.ui.MenuItem} item Menu item to add to the menu. * @param {number} n Index at which to insert the menu item. */ goog.ui.ComboBox.prototype.addItemAt = function(item, n) { this.menu_.addChildAt(item, n, true); }; /** * Removes an item from the menu and disposes it. * @param {goog.ui.MenuItem} item The menu item to remove. */ goog.ui.ComboBox.prototype.removeItem = function(item) { var child = this.menu_.removeChild(item, true); if (child) { child.dispose(); } }; /** * Remove all of the items from the ComboBox menu */ goog.ui.ComboBox.prototype.removeAllItems = function() { for (var i = this.getItemCount() - 1; i >= 0; --i) { this.removeItem(this.getItemAt(i)); } }; /** * Removes a menu item at a given index in the menu. * @param {number} n Index of item. */ goog.ui.ComboBox.prototype.removeItemAt = function(n) { var child = this.menu_.removeChildAt(n, true); if (child) { child.dispose(); } }; /** * Returns a reference to the menu item at a given index. * @param {number} n Index of menu item. * @return {goog.ui.MenuItem?} Reference to the menu item. */ goog.ui.ComboBox.prototype.getItemAt = function(n) { return /** @type {goog.ui.MenuItem?} */(this.menu_.getChildAt(n)); }; /** * Returns the number of items in the list, including non-visible items, * such as separators. * @return {number} Number of items in the menu for this combobox. */ goog.ui.ComboBox.prototype.getItemCount = function() { return this.menu_.getChildCount(); }; /** * @return {goog.ui.Menu} The menu that pops up. */ goog.ui.ComboBox.prototype.getMenu = function() { return this.menu_; }; /** * @return {number} The number of visible items in the menu. * @private */ goog.ui.ComboBox.prototype.getNumberOfVisibleItems_ = function() { if (this.visibleCount_ == -1) { var count = 0; for (var i = 0, n = this.menu_.getChildCount(); i < n; i++) { var item = this.menu_.getChildAt(i); if (!(item instanceof goog.ui.MenuSeparator) && item.isVisible()) { count++; } } this.visibleCount_ = count; } this.logger_.info('getNumberOfVisibleItems() - ' + this.visibleCount_); return this.visibleCount_; }; /** * Sets the match function to be used when filtering the combo box menu. * @param {Function} matchFunction The match function to be used when filtering * the combo box menu. */ goog.ui.ComboBox.prototype.setMatchFunction = function(matchFunction) { this.matchFunction_ = matchFunction; }; /** * @return {Function} The match function for the combox box. */ goog.ui.ComboBox.prototype.getMatchFunction = function() { return this.matchFunction_; }; /** * Sets the default text for the combo box. * @param {string} text The default text for the combo box. */ goog.ui.ComboBox.prototype.setDefaultText = function(text) { this.defaultText_ = text; }; /** * @return {string} text The default text for the combox box. */ goog.ui.ComboBox.prototype.getDefaultText = function() { return this.defaultText_; }; /** * Sets the field name for the combo box. * @param {string} fieldName The field name for the combo box. */ goog.ui.ComboBox.prototype.setFieldName = function(fieldName) { this.fieldName_ = fieldName; }; /** * @return {string} The field name for the combo box. */ goog.ui.ComboBox.prototype.getFieldName = function() { return this.fieldName_; }; /** * Set to true if a unicode inverted triangle should be displayed in the * dropdown button. * This option defaults to false for backwards compatibility. * @param {boolean} useDropdownArrow True to use the dropdown arrow. */ goog.ui.ComboBox.prototype.setUseDropdownArrow = function(useDropdownArrow) { this.useDropdownArrow_ = !!useDropdownArrow; }; /** * Sets the current value of the combo box. * @param {string} value The new value. */ goog.ui.ComboBox.prototype.setValue = function(value) { this.logger_.info('setValue() - ' + value); if (this.labelInput_.getValue() != value) { this.labelInput_.setValue(value); this.dispatchEvent(goog.ui.Component.EventType.CHANGE); } }; /** * @return {string} The current value of the combo box. */ goog.ui.ComboBox.prototype.getValue = function() { return this.labelInput_.getValue(); }; /** * @return {string} The token for the current cursor position in the input box, * when multi-input is disabled it will be the full input value. */ goog.ui.ComboBox.prototype.getToken = function() { // TODO: Implement multi-input such that getToken returns a substring // of the whole input delimited by commas. return goog.string.htmlEscape( goog.string.trim(this.labelInput_.getValue().toLowerCase())); }; /** * @return {goog.ui.Menu} A created and set up menu. * @private */ goog.ui.ComboBox.prototype.createMenu_ = function() { var sm = new goog.ui.Menu(this.getDomHelper()); sm.setVisible(false); sm.setAllowAutoFocus(false); sm.setAllowHighlightDisabled(true); return sm; }; /** * Shows the menu if it isn't already showing. Also positions the menu * correctly, resets the menu item visibilities and highlights the relevent * item. * @private */ goog.ui.ComboBox.prototype.maybeShowMenu_ = function() { var isVisible = this.menu_.isVisible(); var numVisibleItems = this.getNumberOfVisibleItems_(); if (isVisible && numVisibleItems == 0) { this.logger_.fine('no matching items, hiding'); this.hideMenu_(); } else if (!isVisible && numVisibleItems > 0) { this.logger_.fine('showing menu'); this.setItemVisibilityFromToken_(''); this.setItemHighlightFromToken_(this.getToken()); // In Safari 2.0, when clicking on the combox box, the blur event is // received after the click event that invokes this function. Since we want // to cancel the dismissal after the blur event is processed, we have to // wait for all event processing to happen. goog.Timer.callOnce(this.clearDismissTimer_, 1, this); var pos = goog.style.getPageOffset(this.getElement()); this.menu_.setPosition(pos.x, pos.y + this.getElement().offsetHeight); this.showMenu_(); } }; /** * Show the menu and add an active class to the combo box's element. * @private */ goog.ui.ComboBox.prototype.showMenu_ = function() { this.menu_.setVisible(true); goog.dom.classes.add(this.getElement(), 'goog-combobox-active'); }; /** * Hide the menu and remove the active class from the combo box's element. * @private */ goog.ui.ComboBox.prototype.hideMenu_ = function() { this.menu_.setVisible(false); goog.dom.classes.remove(this.getElement(), 'goog-combobox-active'); }; /** * Clears the dismiss timer if it's active. * @private */ goog.ui.ComboBox.prototype.clearDismissTimer_ = function() { if (this.dismissTimer_) { goog.Timer.clear(this.dismissTimer_); this.dismissTimer_ = null; } }; /** * Event handler for when the combo box area has been clicked. * @param {goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.ComboBox.prototype.onComboMouseDown_ = function(e) { // We only want this event on the element itself or the input or the button. if (e.target == this.getElement() || e.target == this.input_ || goog.dom.contains(this.button_, e.target)) { if (this.menu_.isVisible()) { this.logger_.fine('Menu is visible, dismissing'); this.dismiss(); } else { this.logger_.fine('Opening dropdown'); this.maybeShowMenu_(); if (goog.userAgent.OPERA) { // select() doesn't focus <input> elements in Opera. this.input_.focus(); } this.input_.select(); this.menu_.setMouseButtonPressed(true); // Stop the click event from stealing focus e.preventDefault(); } } // Stop the event from propagating outside of the combo box e.stopPropagation(); }; /** * Event handler for when the document is clicked. * @param {goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.ComboBox.prototype.onDocClicked_ = function(e) { this.logger_.info('onDocClicked_() - dismissing immediately'); this.dismiss(); }; /** * Handle the menu's select event. * @param {goog.events.Event} e The event. * @private */ goog.ui.ComboBox.prototype.onMenuSelected_ = function(e) { this.logger_.info('onMenuSelected_()'); // Stop propagation of the original event and redispatch to allow the menu // select to be cancelled at this level. i.e. if a menu item should cause // some behavior such as a user prompt instead of assigning the caption as // the value. if (this.dispatchEvent(new goog.ui.ItemEvent( goog.ui.Component.EventType.ACTION, this, /** @type {goog.events.EventTarget} */ (e.target)))) { var value = e.target.getValue(); this.logger_.fine('Menu selection: ' + value + '. Dismissing menu'); this.setValue(goog.string.unescapeEntities(value)); this.dismiss(); } e.stopPropagation(); }; /** * Event handler for when the input box looses focus -- hide the menu * @param {goog.events.BrowserEvent} e The browser event. * @private */ goog.ui.ComboBox.prototype.onInputBlur_ = function(e) { this.logger_.info('onInputBlur_() - delayed dismiss'); this.clearDismissTimer_(); this.dismissTimer_ = goog.Timer.callOnce( this.dismiss, goog.ui.ComboBox.BLUR_DISMISS_TIMER_MS, this); }; /** * Handles keyboard events from the input box. Returns true if the combo box * was able to handle the event, false otherwise. * @param {goog.events.KeyEvent} e Key event to handle. * @return {boolean} Whether the event was handled by the combo box. * @protected */ goog.ui.ComboBox.prototype.handleKeyEvent = function(e) { var isMenuVisible = this.menu_.isVisible(); // Give the menu a chance to handle the event. if (isMenuVisible && this.menu_.handleKeyEvent(e)) { return true; } // The menu is either hidden or didn't handle the event. var handled = false; switch (e.keyCode) { case goog.events.KeyCodes.ESC: // If the menu is visible and the user hit Esc, dismiss the menu. if (isMenuVisible) { this.logger_.fine('Dismiss on Esc: ' + this.labelInput_.getValue()); this.dismiss(); handled = true; } break; case goog.events.KeyCodes.TAB: // If the menu is open and an option is highlighted, activate it. if (isMenuVisible) { var highlighted = this.menu_.getHighlighted(); if (highlighted) { this.logger_.fine('Select on Tab: ' + this.labelInput_.getValue()); highlighted.performActionInternal(e); handled = true; } } break; case goog.events.KeyCodes.UP: case goog.events.KeyCodes.DOWN: // If the menu is hidden and the user hit the up/down arrow, show it. if (!isMenuVisible) { this.logger_.fine('Up/Down - maybe show menu'); this.maybeShowMenu_(); handled = true; } break; } if (handled) { e.preventDefault(); } return handled; }; /** * Handles the content of the input box changing. * @param {goog.events.Event} e The INPUT event to handle. * @private */ goog.ui.ComboBox.prototype.onInputChange_ = function(e) { // If the key event is text-modifying, update the menu. this.logger_.fine('Key is modifying: ' + this.labelInput_.getValue()); var token = this.getToken(); this.setItemVisibilityFromToken_(token); this.maybeShowMenu_(); var highlighted = this.menu_.getHighlighted(); if (token == '' || !highlighted || !highlighted.isVisible()) { this.setItemHighlightFromToken_(token); } this.lastToken_ = token; this.dispatchEvent(goog.ui.Component.EventType.CHANGE); }; /** * Loops through all menu items setting their visibility according to a token. * @param {string} token The token. * @private */ goog.ui.ComboBox.prototype.setItemVisibilityFromToken_ = function(token) { this.logger_.info('setItemVisibilityFromToken_() - ' + token); var isVisibleItem = false; var count = 0; var recheckHidden = !this.matchFunction_(token, this.lastToken_); for (var i = 0, n = this.menu_.getChildCount(); i < n; i++) { var item = this.menu_.getChildAt(i); if (item instanceof goog.ui.MenuSeparator) { // Ensure that separators are only shown if there is at least one visible // item before them. item.setVisible(isVisibleItem); isVisibleItem = false; } else if (item instanceof goog.ui.MenuItem) { if (!item.isVisible() && !recheckHidden) continue; var caption = item.getCaption(); var visible = this.isItemSticky_(item) || caption && this.matchFunction_(caption.toLowerCase(), token); if (typeof item.setFormatFromToken == 'function') { item.setFormatFromToken(token); } item.setVisible(!!visible); isVisibleItem = visible || isVisibleItem; } else { // Assume all other items are correctly using their visibility. isVisibleItem = item.isVisible() || isVisibleItem; } if (!(item instanceof goog.ui.MenuSeparator) && item.isVisible()) { count++; } } this.visibleCount_ = count; }; /** * Highlights the first token that matches the given token. * @param {string} token The token. * @private */ goog.ui.ComboBox.prototype.setItemHighlightFromToken_ = function(token) { this.logger_.info('setItemHighlightFromToken_() - ' + token); if (token == '') { this.menu_.setHighlightedIndex(-1); return; } for (var i = 0, n = this.menu_.getChildCount(); i < n; i++) { var item = this.menu_.getChildAt(i); var caption = item.getCaption(); if (caption && this.matchFunction_(caption.toLowerCase(), token)) { this.menu_.setHighlightedIndex(i); if (item.setFormatFromToken) { item.setFormatFromToken(token); } return; } } this.menu_.setHighlightedIndex(-1); }; /** * Returns true if the item has an isSticky method and the method returns true. * @param {goog.ui.MenuItem} item The item. * @return {boolean} Whether the item has an isSticky method and the method * returns true. * @private */ goog.ui.ComboBox.prototype.isItemSticky_ = function(item) { return typeof item.isSticky == 'function' && item.isSticky(); }; /** * Class for combo box items. * @param {string} caption Text caption for the menu item. * @param {Object} opt_data Identifying data for the menu item. * @param {goog.dom.DomHelper} opt_domHelper Optional dom helper used for dom * interactions. * @constructor * @extends {goog.ui.MenuItem} */ goog.ui.ComboBoxItem = function(caption, opt_data, opt_domHelper) { goog.ui.MenuItem.call(this, caption, opt_data, opt_domHelper); }; goog.inherits(goog.ui.ComboBoxItem, goog.ui.MenuItem); /** * Whether the menu item is sticky, non-sticky items will be hidden as the * user types. * @type {boolean} * @private */ goog.ui.ComboBoxItem.prototype.isSticky_ = false; /** * Sets the menu item to be sticky or not sticky. * @param {boolean} sticky Whether the menu item should be sticky. */ goog.ui.ComboBoxItem.prototype.setSticky = function(sticky) { this.isSticky_ = sticky; }; /** * @return {boolean} Whether the menu item is sticky. */ goog.ui.ComboBoxItem.prototype.isSticky = function() { return this.isSticky_; }; /** * Sets the format for a menu item based on a token, bolding the token. * @param {string} token The token. */ goog.ui.ComboBoxItem.prototype.setFormatFromToken = function(token) { if (this.isEnabled()) { var escapedToken = goog.string.regExpEscape(token); var caption = this.getCaption(); if (caption) { this.getElement().innerHTML = caption.replace(new RegExp(escapedToken, 'i'), function(m) { return '<b>' + m + '</b>'; }); } } };