/**
* @license
* Copyright The Closure Library Authors.
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview A class that supports single selection from a dropdown menu,
* with semantics similar to the native HTML <code><select></code>
* element.
*
* @see ../demos/select.html
*/
goog.provide('goog.ui.Select');
goog.require('goog.a11y.aria');
goog.require('goog.a11y.aria.Role');
goog.require('goog.a11y.aria.State');
goog.require('goog.events.EventType');
goog.require('goog.ui.Component');
goog.require('goog.ui.IdGenerator');
goog.require('goog.ui.MenuButton');
goog.require('goog.ui.MenuItem');
goog.require('goog.ui.MenuRenderer');
goog.require('goog.ui.SelectionModel');
goog.require('goog.ui.registry');
goog.requireType('goog.dom.DomHelper');
goog.requireType('goog.events.Event');
goog.requireType('goog.ui.ButtonRenderer');
goog.requireType('goog.ui.Control');
goog.requireType('goog.ui.ControlContent');
goog.requireType('goog.ui.Menu');
goog.requireType('goog.ui.MenuSeparator');
/**
* A selection control. Extends {@link goog.ui.MenuButton} by composing a
* menu with a selection model, and automatically updating the button's caption
* based on the current selection.
*
* Select fires the following events:
* CHANGE - after selection changes.
*
* @param {goog.ui.ControlContent=} opt_caption Default caption or existing DOM
* structure to display as the button's caption when nothing is selected.
* Defaults to no caption.
* @param {goog.ui.Menu=} opt_menu Menu containing selection options.
* @param {goog.ui.ButtonRenderer=} opt_renderer Renderer used to render or
* decorate the control; defaults to {@link goog.ui.MenuButtonRenderer}.
* @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper, used for
* document interaction.
* @param {!goog.ui.MenuRenderer=} opt_menuRenderer Renderer used to render or
* decorate the menu; defaults to {@link goog.ui.MenuRenderer}.
* @constructor
* @extends {goog.ui.MenuButton}
*/
goog.ui.Select = function(
opt_caption, opt_menu, opt_renderer, opt_domHelper, opt_menuRenderer) {
'use strict';
goog.ui.Select.base(
this, 'constructor', opt_caption, opt_menu, opt_renderer, opt_domHelper,
opt_menuRenderer ||
new goog.ui.MenuRenderer(goog.a11y.aria.Role.LISTBOX));
/**
* Default caption to show when no option is selected.
* @private {goog.ui.ControlContent}
*/
this.defaultCaption_ = this.getContent();
/**
* The initial value of the aria label of the content element. This will be
* null until the caption is first populated and will be non-null thereafter.
* @private {?string}
*/
this.initialAriaLabel_ = null;
this.setPreferredAriaRole(goog.a11y.aria.Role.LISTBOX);
};
goog.inherits(goog.ui.Select, goog.ui.MenuButton);
/**
* The selection model controlling the items in the menu.
* @type {?goog.ui.SelectionModel}
* @private
*/
goog.ui.Select.prototype.selectionModel_ = null;
/** @override */
goog.ui.Select.prototype.enterDocument = function() {
'use strict';
goog.ui.Select.superClass_.enterDocument.call(this);
this.updateCaption();
this.listenToSelectionModelEvents_();
};
/**
* Decorates the given element with this control. Overrides the superclass
* implementation by initializing the default caption on the select button.
* @param {Element} element Element to decorate.
* @override
*/
goog.ui.Select.prototype.decorateInternal = function(element) {
'use strict';
goog.ui.Select.superClass_.decorateInternal.call(this, element);
var caption = this.getCaption();
if (caption) {
// Initialize the default caption.
this.setDefaultCaption(caption);
} else if (!this.getSelectedItem()) {
// If there is no default caption and no selected item, select the first
// option (this is technically an arbitrary choice, but what most people
// would expect to happen).
this.setSelectedIndex(0);
}
};
/** @override */
goog.ui.Select.prototype.disposeInternal = function() {
'use strict';
goog.ui.Select.superClass_.disposeInternal.call(this);
if (this.selectionModel_) {
this.selectionModel_.dispose();
this.selectionModel_ = null;
}
this.defaultCaption_ = null;
};
/**
* Handles {@link goog.ui.Component.EventType.ACTION} events dispatched by
* the menu item clicked by the user. Updates the selection model, calls
* the superclass implementation to hide the menu, stops the propagation of
* the event, and dispatches an ACTION event on behalf of the select control
* itself. Overrides {@link goog.ui.MenuButton#handleMenuAction}.
* @param {goog.events.Event} e Action event to handle.
* @override
*/
goog.ui.Select.prototype.handleMenuAction = function(e) {
'use strict';
this.setSelectedItem(/** @type {goog.ui.MenuItem} */ (e.target));
goog.ui.Select.base(this, 'handleMenuAction', e);
// NOTE(chrishenry): We should not stop propagation and then fire
// our own ACTION event. Fixing this without breaking anyone
// relying on this event is hard though.
e.stopPropagation();
this.dispatchEvent(goog.ui.Component.EventType.ACTION);
};
/**
* Handles {@link goog.events.EventType.SELECT} events raised by the
* selection model when the selection changes. Updates the contents of the
* select button.
* @param {goog.events.Event} e Selection event to handle.
*/
goog.ui.Select.prototype.handleSelectionChange = function(e) {
'use strict';
var item = this.getSelectedItem();
goog.ui.Select.superClass_.setValue.call(this, item && item.getValue());
this.updateCaption();
};
/**
* Replaces the menu currently attached to the control (if any) with the given
* argument, and updates the selection model. Does nothing if the new menu is
* the same as the old one. Overrides {@link goog.ui.MenuButton#setMenu}.
* @param {goog.ui.Menu} menu New menu to be attached to the menu button.
* @return {goog.ui.Menu|undefined} Previous menu (undefined if none).
* @override
*/
goog.ui.Select.prototype.setMenu = function(menu) {
'use strict';
// Call superclass implementation to replace the menu.
var oldMenu = goog.ui.Select.superClass_.setMenu.call(this, menu);
// Do nothing unless the new menu is different from the current one.
if (menu != oldMenu) {
// Clear the old selection model (if any).
if (this.selectionModel_) {
this.selectionModel_.clear();
}
// Initialize new selection model (unless the new menu is null).
if (menu) {
if (this.selectionModel_) {
menu.forEachChild(function(child, index) {
'use strict';
this.setCorrectAriaRole_(
/** @type {goog.ui.MenuItem|goog.ui.MenuSeparator} */ (child));
this.selectionModel_.addItem(child);
}, this);
} else {
this.createSelectionModel_(menu);
}
}
}
return oldMenu;
};
/**
* Returns the default caption to be shown when no option is selected.
* @return {goog.ui.ControlContent} Default caption.
*/
goog.ui.Select.prototype.getDefaultCaption = function() {
'use strict';
return this.defaultCaption_;
};
/**
* Sets the default caption to the given string or DOM structure.
* @param {goog.ui.ControlContent} caption Default caption to be shown
* when no option is selected.
*/
goog.ui.Select.prototype.setDefaultCaption = function(caption) {
'use strict';
this.defaultCaption_ = caption;
this.updateCaption();
};
/**
* Adds a new menu item at the end of the menu.
* @param {goog.ui.Control} item Menu item to add to the menu.
* @override
*/
goog.ui.Select.prototype.addItem = function(item) {
'use strict';
this.setCorrectAriaRole_(
/** @type {goog.ui.MenuItem|goog.ui.MenuSeparator} */ (item));
goog.ui.Select.superClass_.addItem.call(this, item);
if (this.selectionModel_) {
this.selectionModel_.addItem(item);
} else {
this.createSelectionModel_(this.getMenu());
}
this.updateAriaActiveDescendant_();
};
/**
* Adds a new menu item at a specific index in the menu.
* @param {goog.ui.MenuItem|goog.ui.MenuSeparator} item Menu item to add to the
* menu.
* @param {number} index Index at which to insert the menu item.
* @override
*/
goog.ui.Select.prototype.addItemAt = function(item, index) {
'use strict';
this.setCorrectAriaRole_(
/** @type {goog.ui.MenuItem|goog.ui.MenuSeparator} */ (item));
goog.ui.Select.superClass_.addItemAt.call(this, item, index);
if (this.selectionModel_) {
this.selectionModel_.addItemAt(item, index);
} else {
this.createSelectionModel_(this.getMenu());
}
};
/**
* Removes an item from the menu and disposes it.
* @param {goog.ui.MenuItem|goog.ui.MenuSeparator} item The menu item to remove.
* @override
*/
goog.ui.Select.prototype.removeItem = function(item) {
'use strict';
goog.ui.Select.superClass_.removeItem.call(this, item);
if (this.selectionModel_) {
this.selectionModel_.removeItem(item);
}
};
/**
* Removes a menu item at a given index in the menu and disposes it.
* @param {number} index Index of item.
* @override
*/
goog.ui.Select.prototype.removeItemAt = function(index) {
'use strict';
goog.ui.Select.superClass_.removeItemAt.call(this, index);
if (this.selectionModel_) {
this.selectionModel_.removeItemAt(index);
}
};
/**
* Selects the specified option (assumed to be in the select menu), and
* deselects the previously selected option, if any. A null argument clears
* the selection.
* @param {goog.ui.MenuItem} item Option to be selected (null to clear
* the selection).
*/
goog.ui.Select.prototype.setSelectedItem = function(item) {
'use strict';
if (this.selectionModel_) {
var prevItem = this.getSelectedItem();
this.selectionModel_.setSelectedItem(item);
if (item != prevItem) {
this.dispatchEvent(goog.ui.Component.EventType.CHANGE);
}
}
};
/**
* Selects the option at the specified index, or clears the selection if the
* index is out of bounds.
* @param {number} index Index of the option to be selected.
*/
goog.ui.Select.prototype.setSelectedIndex = function(index) {
'use strict';
if (this.selectionModel_) {
this.setSelectedItem(/** @type {goog.ui.MenuItem} */
(this.selectionModel_.getItemAt(index)));
}
};
/**
* Selects the first option found with an associated value equal to the
* argument, or clears the selection if no such option is found. A null
* argument also clears the selection. Overrides {@link
* goog.ui.Button#setValue}.
* @param {*} value Value of the option to be selected (null to clear
* the selection).
* @override
*/
goog.ui.Select.prototype.setValue = function(value) {
'use strict';
if (value != null && this.selectionModel_) {
for (var i = 0, item; item = this.selectionModel_.getItemAt(i); i++) {
if (item && typeof item.getValue == 'function' &&
item.getValue() == value) {
this.setSelectedItem(/** @type {!goog.ui.MenuItem} */ (item));
return;
}
}
}
this.setSelectedItem(null);
};
/**
* Gets the value associated with the currently selected option (null if none).
*
* Note that unlike {@link goog.ui.Button#getValue} which this method overrides,
* the "value" of a Select instance is the value of its selected menu item, not
* its own value. This makes a difference because the "value" of a Button is
* reset to the value of the element it decorates when it's added to the DOM
* (via ButtonRenderer), whereas the value of the selected item is unaffected.
* So while setValue() has no effect on a Button before it is added to the DOM,
* it will make a persistent change to a Select instance (which is consistent
* with any changes made by {@link goog.ui.Select#setSelectedItem} and
* {@link goog.ui.Select#setSelectedIndex}).
*
* @override
*/
goog.ui.Select.prototype.getValue = function() {
'use strict';
var selectedItem = this.getSelectedItem();
return selectedItem ? selectedItem.getValue() : null;
};
/**
* Returns the currently selected option.
* @return {goog.ui.MenuItem} The currently selected option (null if none).
*/
goog.ui.Select.prototype.getSelectedItem = function() {
'use strict';
return this.selectionModel_ ?
/** @type {goog.ui.MenuItem} */ (this.selectionModel_.getSelectedItem()) :
null;
};
/**
* Returns the index of the currently selected option.
* @return {number} 0-based index of the currently selected option (-1 if none).
*/
goog.ui.Select.prototype.getSelectedIndex = function() {
'use strict';
return this.selectionModel_ ? this.selectionModel_.getSelectedIndex() : -1;
};
/**
* @return {goog.ui.SelectionModel} The selection model.
* @protected
*/
goog.ui.Select.prototype.getSelectionModel = function() {
'use strict';
return this.selectionModel_;
};
/**
* Creates a new selection model and sets up an event listener to handle
* {@link goog.events.EventType.SELECT} events dispatched by it.
* @param {goog.ui.Component=} opt_component If provided, will add the
* component's children as items to the selection model.
* @private
*/
goog.ui.Select.prototype.createSelectionModel_ = function(opt_component) {
'use strict';
this.selectionModel_ = new goog.ui.SelectionModel();
if (opt_component) {
opt_component.forEachChild(function(child, index) {
'use strict';
this.setCorrectAriaRole_(
/** @type {goog.ui.MenuItem|goog.ui.MenuSeparator} */ (child));
this.selectionModel_.addItem(child);
}, this);
}
this.listenToSelectionModelEvents_();
};
/**
* Subscribes to events dispatched by the selection model.
* @private
*/
goog.ui.Select.prototype.listenToSelectionModelEvents_ = function() {
'use strict';
if (this.selectionModel_) {
this.getHandler().listen(
this.selectionModel_, goog.events.EventType.SELECT,
this.handleSelectionChange);
}
};
/**
* Updates the caption to be shown in the select button. If no option is
* selected and a default caption is set, sets the caption to the default
* caption; otherwise to the empty string.
* @protected
*/
goog.ui.Select.prototype.updateCaption = function() {
'use strict';
var item = this.getSelectedItem();
this.setContent(item ? item.getCaption() : this.defaultCaption_);
var contentElement = this.getRenderer().getContentElement(this.getElement());
// Despite the ControlRenderer interface indicating the return value is
// {Element}, many renderers cast element.firstChild to {Element} when it is
// really {Node}. Checking tagName verifies this is an {!Element}.
if (contentElement && this.getDomHelper().isElement(contentElement)) {
if (this.initialAriaLabel_ == null) {
this.initialAriaLabel_ = goog.a11y.aria.getLabel(contentElement);
}
var itemElement = item ? item.getElement() : null;
goog.a11y.aria.setLabel(
contentElement, itemElement ? goog.a11y.aria.getLabel(itemElement) :
this.initialAriaLabel_);
this.updateAriaActiveDescendant_();
}
};
/**
* Updates the aria active descendant attribute.
* @private
*/
goog.ui.Select.prototype.updateAriaActiveDescendant_ = function() {
'use strict';
var renderer = this.getRenderer();
if (renderer) {
var contentElement = renderer.getContentElement(this.getElement());
if (contentElement) {
var buttonElement = this.getElementStrict();
if (!contentElement.id) {
contentElement.id = goog.ui.IdGenerator.getInstance().getNextUniqueId();
}
goog.a11y.aria.setRole(contentElement, goog.a11y.aria.Role.OPTION);
// Set 'aria-selected' to true since the content element represents the
// currently selected option.
goog.a11y.aria.setState(
contentElement, goog.a11y.aria.State.SELECTED, true);
goog.a11y.aria.setState(
buttonElement, goog.a11y.aria.State.ACTIVEDESCENDANT,
contentElement.id);
if (this.selectionModel_) {
// We can't use selectionmodel's getItemCount here because we need to
// skip separators.
var items = this.selectionModel_.getItems();
goog.a11y.aria.setState(
contentElement, goog.a11y.aria.State.SETSIZE,
this.getNumMenuItems_(items));
// Set a human-readable selection index, excluding menu separators.
var index = this.selectionModel_.getSelectedIndex();
goog.a11y.aria.setState(
contentElement, goog.a11y.aria.State.POSINSET,
index >= 0 ? this.getNumMenuItems_(items.slice(0, index + 1)) : 0);
}
}
}
};
/**
* Gets the number of menu items in the array.
* @param {!Array<?Object>} items The items.
* @return {number}
* @private
*/
goog.ui.Select.prototype.getNumMenuItems_ = function(items) {
'use strict';
return items
.filter(function(item) {
'use strict';
return item instanceof goog.ui.MenuItem;
})
.length;
};
/**
* Sets the correct ARIA role for the menu item or separator.
* @param {goog.ui.MenuItem|goog.ui.MenuSeparator} item The item to set.
* @private
*/
goog.ui.Select.prototype.setCorrectAriaRole_ = function(item) {
'use strict';
item.setPreferredAriaRole(
item instanceof goog.ui.MenuItem ? goog.a11y.aria.Role.OPTION :
goog.a11y.aria.Role.SEPARATOR);
};
/**
* Opens or closes the menu. Overrides {@link goog.ui.MenuButton#setOpen} by
* highlighting the currently selected option on open.
* @param {boolean} open Whether to open or close the menu.
* @param {goog.events.Event=} opt_e Mousedown event that caused the menu to
* be opened.
* @override
*/
goog.ui.Select.prototype.setOpen = function(open, opt_e) {
'use strict';
goog.ui.Select.superClass_.setOpen.call(this, open, opt_e);
if (this.isOpen()) {
this.getMenu().setHighlightedIndex(this.getSelectedIndex());
} else {
this.updateAriaActiveDescendant_();
}
};
// Register a decorator factory function for goog.ui.Selects.
goog.ui.registry.setDecoratorByClassName(
goog.getCssName('goog-select'), function() {
'use strict';
// Select defaults to using MenuButtonRenderer, since it shares its L&F.
return new goog.ui.Select(null);
});