+ if ( item ) {
+ label = item.label;
+ } else if ( widget.allowArbitrary ) {
+ label = String( data );
+ } else {
+ return;
+ }
+
+ item = null;
+ for ( j = 0; j < items.length; j++ ) {
+ if ( items[j].data === data && items[j].label === label ) {
+ item = items[j];
+ items.splice( j, 1 );
+ break;
+ }
+ }
+ if ( !item ) {
+ item = new OO.ui.CapsuleItemWidget( { data: data, label: label } );
+ }
+ widget.addItems( [ item ], i );
+ } );
+
+ if ( items.length ) {
+ widget.removeItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * Add items to the capsule by providing their data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) {
+ var widget = this,
+ menu = this.menu,
+ items = [];
+
+ $.each( datas, function ( i, data ) {
+ var item;
+
+ if ( !widget.getItemFromData( data ) ) {
+ item = menu.getItemFromData( data );
+ if ( item ) {
+ items.push( new OO.ui.CapsuleItemWidget( { data: data, label: item.label } ) );
+ } else if ( widget.allowArbitrary ) {
+ items.push( new OO.ui.CapsuleItemWidget( { data: data, label: String( data ) } ) );
+ }
+ }
+ } );
+
+ if ( items.length ) {
+ this.addItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * Remove items by data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) {
+ var widget = this,
+ items = [];
+
+ $.each( datas, function ( i, data ) {
+ var item = widget.getItemFromData( data );
+ if ( item ) {
+ items.push( item );
+ }
+ } );
+
+ if ( items.length ) {
+ this.removeItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) {
+ var same, i, l,
+ oldItems = this.items.slice();
+
+ OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
+
+ if ( this.items.length !== oldItems.length ) {
+ same = false;
+ } else {
+ same = true;
+ for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+ same = same && this.items[i] === oldItems[i];
+ }
+ }
+ if ( !same ) {
+ this.emit( 'change', this.getItemsData() );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) {
+ var same, i, l,
+ oldItems = this.items.slice();
+
+ OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
+
+ if ( this.items.length !== oldItems.length ) {
+ same = false;
+ } else {
+ same = true;
+ for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+ same = same && this.items[i] === oldItems[i];
+ }
+ }
+ if ( !same ) {
+ this.emit( 'change', this.getItemsData() );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () {
+ if ( this.items.length ) {
+ OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
+ this.emit( 'change', this.getItemsData() );
+ }
+ return this;
+};
+
+/**
+ * Get the capsule widget's menu.
+ * @return {OO.ui.MenuSelectWidget} Menu widget
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () {
+ return this.menu;
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () {
+ if ( !this.isDisabled() ) {
+ this.menu.toggle( true );
+ }
+};
+
+/**
+ * Handle blur events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () {
+ this.clearInput();
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () {
+ if ( !this.isDisabled() ) {
+ this.popup.setSize( this.$handle.width() );
+ this.popup.toggle( true );
+ this.popup.$element.find( '*' )
+ .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+ .first()
+ .focus();
+ }
+};
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onClick = function ( e ) {
+ if ( e.which === 1 ) {
+ this.focus();
+ return false;
+ }
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) {
+ var item;
+
+ if ( !this.isDisabled() ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
+ this.clearInput();
+ return false;
+ }
+
+ if ( !this.popup ) {
+ this.menu.toggle( true );
+ if ( e.which === OO.ui.Keys.ENTER ) {
+ item = this.menu.getItemFromLabel( this.$input.val(), true );
+ if ( item ) {
+ this.addItemsFromData( [ item.data ] );
+ this.clearInput();
+ } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
+ this.addItemsFromData( [ this.$input.val() ] );
+ this.clearInput();
+ }
+ return false;
+ }
+
+ // Make sure the input gets resized.
+ setTimeout( this.onInputChange.bind( this ), 0 );
+ }
+ }
+};
+
+/**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) {
+ if ( !this.isDisabled() ) {
+ // 'keypress' event is not triggered for Backspace
+ if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) {
+ if ( this.items.length ) {
+ this.removeItems( this.items.slice( -1 ) );
+ }
+ return false;
+ }
+ }
+};
+
+/**
+ * Handle input change events.
+ *
+ * @private
+ * @param {jQuery.Event} e Event of some sort
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputChange = function () {
+ if ( !this.isDisabled() ) {
+ this.$input.width( this.$input.val().length + 'em' );
+ }
+};
+
+/**
+ * Handle menu choose events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget} item Chosen item
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) {
+ if ( item && item.isVisible() ) {
+ this.addItemsFromData( [ item.getData() ] );
+ this.clearInput();
+ }
+};
+
+/**
+ * Handle menu item change events.
+ *
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () {
+ this.setItemsFromData( this.getItemsData() );
+ this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() );
+};
+
+/**
+ * Clear the input field
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () {
+ if ( this.$input ) {
+ this.$input.val( '' );
+ this.$input.width( '1em' );
+ }
+ if ( this.popup ) {
+ this.popup.toggle( false );
+ }
+ this.menu.toggle( false );
+ this.menu.selectItem();
+ this.menu.highlightItem();
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) {
+ var i, len;
+
+ // Parent method
+ OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled );
+
+ if ( this.$input ) {
+ this.$input.prop( 'disabled', this.isDisabled() );
+ }
+ if ( this.menu ) {
+ this.menu.setDisabled( this.isDisabled() );
+ }
+ if ( this.popup ) {
+ this.popup.setDisabled( this.isDisabled() );
+ }
+
+ if ( this.items ) {
+ for ( i = 0, len = this.items.length; i < len; i++ ) {
+ this.items[i].updateDisabled();
+ }
+ }
+
+ return this;
+};
+
+/**
+ * Focus the widget
+ * @chainable
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () {
+ if ( !this.isDisabled() ) {
+ if ( this.popup ) {
+ this.popup.setSize( this.$handle.width() );
+ this.popup.toggle( true );
+ this.popup.$element.find( '*' )
+ .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+ .first()
+ .focus();
+ } else {
+ this.menu.toggle( true );
+ this.$input.focus();
+ }
+ }
+ return this;
+};
+
+/**
+ * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget
+ * CapsuleMultiSelectWidget} to display the selected items.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.ItemWidget
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
+ // Configuration initialization
+ config = config || {};
+
+ // Parent constructor
+ OO.ui.CapsuleItemWidget.parent.call( this, config );
+
+ // Properties (must be set before mixin constructor calls)
+ this.$indicator = $( '<span>' );
+
+ // Mixin constructors
+ OO.ui.mixin.ItemWidget.call( this );
+ OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) );
+ OO.ui.mixin.LabelElement.call( this, config );
+ OO.ui.mixin.FlaggedElement.call( this, config );
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
+
+ // Events
+ this.$indicator.on( {
+ keydown: this.onCloseKeyDown.bind( this ),
+ click: this.onCloseClick.bind( this )
+ } );
+ this.$element.on( 'click', false );
+
+ // Initialization
+ this.$element
+ .addClass( 'oo-ui-capsuleItemWidget' )
+ .append( this.$indicator, this.$label );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Handle close icon clicks
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
+ var element = this.getElementGroup();
+
+ if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) {
+ element.removeItems( [ this ] );
+ element.focus();
+ }
+};
+
+/**
+ * Handle close keyboard events
+ * @param {jQuery.Event} event Key down event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) {
+ if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) {
+ switch ( e.which ) {
+ case OO.ui.Keys.ENTER:
+ case OO.ui.Keys.BACKSPACE:
+ case OO.ui.Keys.SPACE:
+ this.getElementGroup().removeItems( [ this ] );
+ return false;
+ }
+ }
+};
+
+/**
+ * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
+ * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
+ * users can interact with it.
+ *
+ * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * OO.ui.DropdownInputWidget instead.
+ *
+ * @example
+ * // Example: A DropdownWidget with a menu that contains three options
+ * var dropDown = new OO.ui.DropdownWidget( {
+ * label: 'Dropdown menu: Select a menu option',
+ * menu: {
+ * items: [
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'a',
+ * label: 'First'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'b',
+ * label: 'Second'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'c',
+ * label: 'Third'
+ * } )
+ * ]
+ * }
+ * } );
+ *
+ * $( 'body' ).append( dropDown.$element );
+ *
+ * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [menu] Configuration options to pass to menu widget
+ */
+OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
+ // Configuration initialization
+ config = $.extend( { indicator: 'down' }, config );
+
+ // Parent constructor
+ OO.ui.DropdownWidget.parent.call( this, config );
+
+ // Properties (must be set before TabIndexedElement constructor call)
+ this.$handle = this.$( '<span>' );
+
+ // Mixin constructors
+ OO.ui.mixin.IconElement.call( this, config );
+ OO.ui.mixin.IndicatorElement.call( this, config );
+ OO.ui.mixin.LabelElement.call( this, config );
+ OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
+
+ // Properties
+ this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) );
+
+ // Events
+ this.$handle.on( {
+ click: this.onClick.bind( this ),
+ keypress: this.onKeyPress.bind( this )
+ } );
+ this.menu.connect( this, { select: 'onMenuSelect' } );
+
+ // Initialization
+ this.$handle
+ .addClass( 'oo-ui-dropdownWidget-handle' )
+ .append( this.$icon, this.$label, this.$indicator );
+ this.$element
+ .addClass( 'oo-ui-dropdownWidget' )
+ .append( this.$handle, this.menu.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Get the menu.
+ *
+ * @return {OO.ui.MenuSelectWidget} Menu of widget
+ */
+OO.ui.DropdownWidget.prototype.getMenu = function () {
+ return this.menu;
+};
+
+/**
+ * Handles menu select events.
+ *
+ * @private
+ * @param {OO.ui.MenuOptionWidget} item Selected menu item
+ */
+OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
+ var selectedLabel;
+
+ if ( !item ) {
+ this.setLabel( null );
+ return;
+ }
+
+ selectedLabel = item.getLabel();
+
+ // If the label is a DOM element, clone it, because setLabel will append() it
+ if ( selectedLabel instanceof jQuery ) {
+ selectedLabel = selectedLabel.clone();
+ }