/** * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of * select widgets, including {@link OO.ui.ButtonSelectWidget button selects}, * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget * menu selects}. * * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more * information, please see the [OOjs UI documentation on MediaWiki][1]. * * @example * // Example of a select widget with three options * var select = new OO.ui.SelectWidget( { * items: [ * new OO.ui.OptionWidget( { * data: 'a', * label: 'Option One', * } ), * new OO.ui.OptionWidget( { * data: 'b', * label: 'Option Two', * } ), * new OO.ui.OptionWidget( { * data: 'c', * label: 'Option Three', * } ) * ] * } ); * $( 'body' ).append( select.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options * * @abstract * @class * @extends OO.ui.Widget * @mixins OO.ui.GroupElement * * @constructor * @param {Object} [config] Configuration options * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select. * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See * the [OOjs UI documentation on MediaWiki] [2] for examples. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options */ OO.ui.SelectWidget = function OoUiSelectWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.SelectWidget.super.call( this, config ); // Mixin constructors OO.ui.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Properties this.pressed = false; this.selecting = null; this.onMouseUpHandler = this.onMouseUp.bind( this ); this.onMouseMoveHandler = this.onMouseMove.bind( this ); this.onKeyDownHandler = this.onKeyDown.bind( this ); // Events this.$element.on( { mousedown: this.onMouseDown.bind( this ), mouseover: this.onMouseOver.bind( this ), mouseleave: this.onMouseLeave.bind( this ) } ); // Initialization this.$element .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' ) .attr( 'role', 'listbox' ); if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; /* Setup */ OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget ); // Need to mixin base class as well OO.mixinClass( OO.ui.SelectWidget, OO.ui.GroupElement ); OO.mixinClass( OO.ui.SelectWidget, OO.ui.GroupWidget ); /* Events */ /** * @event highlight * * A `highlight` event is emitted when the highlight is changed with the #highlightItem method. * * @param {OO.ui.OptionWidget|null} item Highlighted item */ /** * @event press * * A `press` event is emitted when the #pressItem method is used to programmatically modify the * pressed state of an option. * * @param {OO.ui.OptionWidget|null} item Pressed item */ /** * @event select * * A `select` event is emitted when the selection is modified programmatically with the #selectItem method. * * @param {OO.ui.OptionWidget|null} item Selected item */ /** * @event choose * A `choose` event is emitted when an item is chosen with the #chooseItem method. * @param {OO.ui.OptionWidget} item Chosen item */ /** * @event add * * An `add` event is emitted when options are added to the select with the #addItems method. * * @param {OO.ui.OptionWidget[]} items Added items * @param {number} index Index of insertion point */ /** * @event remove * * A `remove` event is emitted when options are removed from the select with the #clearItems * or #removeItems methods. * * @param {OO.ui.OptionWidget[]} items Removed items */ /* Methods */ /** * Handle mouse down events. * * @private * @param {jQuery.Event} e Mouse down event */ OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) { var item; if ( !this.isDisabled() && e.which === 1 ) { this.togglePressed( true ); item = this.getTargetItem( e ); if ( item && item.isSelectable() ) { this.pressItem( item ); this.selecting = item; this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true ); this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true ); } } return false; }; /** * Handle mouse up events. * * @private * @param {jQuery.Event} e Mouse up event */ OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) { var item; this.togglePressed( false ); if ( !this.selecting ) { item = this.getTargetItem( e ); if ( item && item.isSelectable() ) { this.selecting = item; } } if ( !this.isDisabled() && e.which === 1 && this.selecting ) { this.pressItem( null ); this.chooseItem( this.selecting ); this.selecting = null; } this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true ); this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true ); return false; }; /** * Handle mouse move events. * * @private * @param {jQuery.Event} e Mouse move event */ OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) { var item; if ( !this.isDisabled() && this.pressed ) { item = this.getTargetItem( e ); if ( item && item !== this.selecting && item.isSelectable() ) { this.pressItem( item ); this.selecting = item; } } return false; }; /** * Handle mouse over events. * * @private * @param {jQuery.Event} e Mouse over event */ OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) { var item; if ( !this.isDisabled() ) { item = this.getTargetItem( e ); this.highlightItem( item && item.isHighlightable() ? item : null ); } return false; }; /** * Handle mouse leave events. * * @private * @param {jQuery.Event} e Mouse over event */ OO.ui.SelectWidget.prototype.onMouseLeave = function () { if ( !this.isDisabled() ) { this.highlightItem( null ); } return false; }; /** * Handle key down events. * * @protected * @param {jQuery.Event} e Key down event */ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { var nextItem, handled = false, currentItem = this.getHighlightedItem() || this.getSelectedItem(); if ( !this.isDisabled() && this.isVisible() ) { switch ( e.keyCode ) { case OO.ui.Keys.ENTER: if ( currentItem && currentItem.constructor.static.highlightable ) { // Was only highlighted, now let's select it. No-op if already selected. this.chooseItem( currentItem ); handled = true; } break; case OO.ui.Keys.UP: case OO.ui.Keys.LEFT: nextItem = this.getRelativeSelectableItem( currentItem, -1 ); handled = true; break; case OO.ui.Keys.DOWN: case OO.ui.Keys.RIGHT: nextItem = this.getRelativeSelectableItem( currentItem, 1 ); handled = true; break; case OO.ui.Keys.ESCAPE: case OO.ui.Keys.TAB: if ( currentItem && currentItem.constructor.static.highlightable ) { currentItem.setHighlighted( false ); } this.unbindKeyDownListener(); // Don't prevent tabbing away / defocusing handled = false; break; } if ( nextItem ) { if ( nextItem.constructor.static.highlightable ) { this.highlightItem( nextItem ); } else { this.chooseItem( nextItem ); } nextItem.scrollElementIntoView(); } if ( handled ) { // Can't just return false, because e is not always a jQuery event e.preventDefault(); e.stopPropagation(); } } }; /** * Bind key down listener. * * @protected */ OO.ui.SelectWidget.prototype.bindKeyDownListener = function () { this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true ); }; /** * Unbind key down listener. * * @protected */ OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () { this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true ); }; /** * Get the closest item to a jQuery.Event. * * @private * @param {jQuery.Event} e * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found */ OO.ui.SelectWidget.prototype.getTargetItem = function ( e ) { return $( e.target ).closest( '.oo-ui-optionWidget' ).data( 'oo-ui-optionWidget' ) || null; }; /** * Get selected item. * * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected */ OO.ui.SelectWidget.prototype.getSelectedItem = function () { var i, len; for ( i = 0, len = this.items.length; i < len; i++ ) { if ( this.items[ i ].isSelected() ) { return this.items[ i ]; } } return null; }; /** * Get highlighted item. * * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted */ OO.ui.SelectWidget.prototype.getHighlightedItem = function () { var i, len; for ( i = 0, len = this.items.length; i < len; i++ ) { if ( this.items[ i ].isHighlighted() ) { return this.items[ i ]; } } return null; }; /** * Toggle pressed state. * * Press is a state that occurs when a user mouses down on an item, but * has not yet let go of the mouse. The item may appear selected, but it will not be selected * until the user releases the mouse. * * @param {boolean} pressed An option is being pressed */ OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) { if ( pressed === undefined ) { pressed = !this.pressed; } if ( pressed !== this.pressed ) { this.$element .toggleClass( 'oo-ui-selectWidget-pressed', pressed ) .toggleClass( 'oo-ui-selectWidget-depressed', !pressed ); this.pressed = pressed; } }; /** * Highlight an option. If the `item` param is omitted, no options will be highlighted * and any existing highlight will be removed. The highlight is mutually exclusive. * * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight * @fires highlight * @chainable */ OO.ui.SelectWidget.prototype.highlightItem = function ( item ) { var i, len, highlighted, changed = false; for ( i = 0, len = this.items.length; i < len; i++ ) { highlighted = this.items[ i ] === item; if ( this.items[ i ].isHighlighted() !== highlighted ) { this.items[ i ].setHighlighted( highlighted ); changed = true; } } if ( changed ) { this.emit( 'highlight', item ); } return this; }; /** * Programmatically select an option by its data. If the `data` parameter is omitted, * or if the item does not exist, all options will be deselected. * * @param {Object|string} [data] Value of the item to select, omit to deselect all * @fires select * @chainable */ OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) { var itemFromData = this.getItemFromData( data ); if ( data === undefined || !itemFromData ) { return this.selectItem(); } return this.selectItem( itemFromData ); }; /** * Programmatically select an option by its reference. If the `item` parameter is omitted, * all options will be deselected. * * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all * @fires select * @chainable */ OO.ui.SelectWidget.prototype.selectItem = function ( item ) { var i, len, selected, changed = false; for ( i = 0, len = this.items.length; i < len; i++ ) { selected = this.items[ i ] === item; if ( this.items[ i ].isSelected() !== selected ) { this.items[ i ].setSelected( selected ); changed = true; } } if ( changed ) { this.emit( 'select', item ); } return this; }; /** * Press an item. * * Press is a state that occurs when a user mouses down on an item, but has not * yet let go of the mouse. The item may appear selected, but it will not be selected until the user * releases the mouse. * * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all * @fires press * @chainable */ OO.ui.SelectWidget.prototype.pressItem = function ( item ) { var i, len, pressed, changed = false; for ( i = 0, len = this.items.length; i < len; i++ ) { pressed = this.items[ i ] === item; if ( this.items[ i ].isPressed() !== pressed ) { this.items[ i ].setPressed( pressed ); changed = true; } } if ( changed ) { this.emit( 'press', item ); } return this; }; /** * Choose an item. * * Note that ‘choose’ should never be modified programmatically. A user can choose * an option with the keyboard or mouse and it becomes selected. To select an item programmatically, * use the #selectItem method. * * This method is identical to #selectItem, but may vary in subclasses that take additional action * when users choose an item with the keyboard or mouse. * * @param {OO.ui.OptionWidget} item Item to choose * @fires choose * @chainable */ OO.ui.SelectWidget.prototype.chooseItem = function ( item ) { this.selectItem( item ); this.emit( 'choose', item ); return this; }; /** * Get an option by its position relative to the specified item (or to the start of the option array, * if item is `null`). The direction in which to search through the option array is specified with a * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or * `null` if there are no options in the array. * * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array. * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select */ OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction ) { var currentIndex, nextIndex, i, increase = direction > 0 ? 1 : -1, len = this.items.length; if ( item instanceof OO.ui.OptionWidget ) { currentIndex = $.inArray( item, this.items ); nextIndex = ( currentIndex + increase + len ) % len; } else { // If no item is selected and moving forward, start at the beginning. // If moving backward, start at the end. nextIndex = direction > 0 ? 0 : len - 1; } for ( i = 0; i < len; i++ ) { item = this.items[ nextIndex ]; if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) { return item; } nextIndex = ( nextIndex + increase + len ) % len; } return null; }; /** * Get the next selectable item or `null` if there are no selectable items. * Disabled options and menu-section markers and breaks are not selectable. * * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items */ OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () { var i, len, item; for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) { return item; } } return null; }; /** * Add an array of options to the select. Optionally, an index number can be used to * specify an insertion point. * * @param {OO.ui.OptionWidget[]} items Items to add * @param {number} [index] Index to insert items after * @fires add * @chainable */ OO.ui.SelectWidget.prototype.addItems = function ( items, index ) { // Mixin method OO.ui.GroupWidget.prototype.addItems.call( this, items, index ); // Always provide an index, even if it was omitted this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index ); return this; }; /** * Remove the specified array of options from the select. Options will be detached * from the DOM, not removed, so they can be reused later. To remove all options from * the select, you may wish to use the #clearItems method instead. * * @param {OO.ui.OptionWidget[]} items Items to remove * @fires remove * @chainable */ OO.ui.SelectWidget.prototype.removeItems = function ( items ) { var i, len, item; // Deselect items being removed for ( i = 0, len = items.length; i < len; i++ ) { item = items[ i ]; if ( item.isSelected() ) { this.selectItem( null ); } } // Mixin method OO.ui.GroupWidget.prototype.removeItems.call( this, items ); this.emit( 'remove', items ); return this; }; /** * Clear all options from the select. Options will be detached from the DOM, not removed, * so that they can be reused later. To remove a subset of options from the select, use * the #removeItems method. * * @fires remove * @chainable */ OO.ui.SelectWidget.prototype.clearItems = function () { var items = this.items.slice(); // Mixin method OO.ui.GroupWidget.prototype.clearItems.call( this ); // Clear selection this.selectItem( null ); this.emit( 'remove', items ); return this; };