/**
* CapsuleMultiselectWidgets are something like a {@link OO.ui.ComboBoxInputWidget combo box widget}
* that allows for selecting multiple values.
*
* For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
*
* @example
* // Example: A CapsuleMultiselectWidget.
* var capsule = new OO.ui.CapsuleMultiselectWidget( {
* label: 'CapsuleMultiselectWidget',
* selected: [ 'Option 1', 'Option 3' ],
* menu: {
* items: [
* new OO.ui.MenuOptionWidget( {
* data: 'Option 1',
* label: 'Option One'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 2',
* label: 'Option Two'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 3',
* label: 'Option Three'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 4',
* label: 'Option Four'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 5',
* label: 'Option Five'
* } )
* ]
* }
* } );
* $( 'body' ).append( capsule.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
*
* @class
* @extends OO.ui.Widget
* @mixes OO.ui.mixin.GroupElement
* @mixes OO.ui.mixin.PopupElement
* @mixes OO.ui.mixin.TabIndexedElement
* @mixes OO.ui.mixin.IndicatorElement
* @mixes OO.ui.mixin.IconElement
* @uses OO.ui.CapsuleItemWidget
* @uses OO.ui.MenuSelectWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @param {string} [config.placeholder] Placeholder text
* @param {boolean} [config.allowArbitrary=false] Allow data items to be added even if not present in the menu.
* @param {boolean} [config.allowDuplicates=false] Allow duplicate items to be added.
* @param {Object} [config.menu] (required) Configuration options to pass to the
* {@link OO.ui.MenuSelectWidget menu select widget}.
* @param {Object} [config.popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
* If specified, this popup will be shown instead of the menu (but the menu
* will still be used for item labels and allowArbitrary=false). The widgets
* in the popup should use {@link #addItemsFromData} or {@link #addItems} as necessary.
* @param {jQuery} [config.$overlay=this.$element] Render the menu or popup into a separate layer.
* This configuration is useful in cases where the expanded menu is larger than
* its containing `<div>`. The specified overlay layer is usually on top of
* the containing `<div>` and has a larger area. By default, the menu uses
* relative positioning.
* See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
*/
OO.ui.CapsuleMultiselectWidget = function OoUiCapsuleMultiselectWidget( config ) {
var $tabFocus;
// Parent constructor
OO.ui.CapsuleMultiselectWidget.parent.call( this, config );
// Configuration initialization
config = $.extend( {
allowArbitrary: false,
allowDuplicates: false
}, config );
// Properties (must be set before mixin constructor calls)
this.$handle = $( '<div>' );
this.$input = config.popup ? null : $( '<input>' );
if ( config.placeholder !== undefined && config.placeholder !== '' ) {
this.$input.attr( 'placeholder', config.placeholder );
}
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, config );
if ( config.popup ) {
config.popup = $.extend( {}, config.popup, {
align: 'forwards',
anchor: false
} );
OO.ui.mixin.PopupElement.call( this, config );
$tabFocus = $( '<span>' );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
} else {
this.popup = null;
$tabFocus = null;
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
}
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.IconElement.call( this, config );
// Properties
this.$content = $( '<div>' );
this.allowArbitrary = config.allowArbitrary;
this.allowDuplicates = config.allowDuplicates;
this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
this.menu = new OO.ui.MenuSelectWidget( $.extend(
{
widget: this,
$input: this.$input,
$floatableContainer: this.$element,
filterFromInput: true,
disabled: this.isDisabled()
},
config.menu
) );
// Events
if ( this.popup ) {
$tabFocus.on( {
focus: this.focus.bind( this )
} );
this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
if ( this.popup.$autoCloseIgnore ) {
this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
}
this.popup.connect( this, {
toggle: function ( visible ) {
$tabFocus.toggle( !visible );
}
} );
} else {
this.$input.on( {
focus: this.onInputFocus.bind( this ),
blur: this.onInputBlur.bind( this ),
'propertychange change click mouseup keydown keyup input cut paste select focus':
OO.ui.debounce( this.updateInputSize.bind( this ) ),
keydown: this.onKeyDown.bind( this ),
keypress: this.onKeyPress.bind( this )
} );
}
this.menu.connect( this, {
choose: 'onMenuChoose',
toggle: 'onMenuToggle',
add: 'onMenuItemsChange',
remove: 'onMenuItemsChange'
} );
this.$handle.on( {
mousedown: this.onMouseDown.bind( this )
} );
// Initialization
if ( this.$input ) {
this.$input.prop( 'disabled', this.isDisabled() );
this.$input.attr( {
role: 'combobox',
'aria-owns': this.menu.getElementId(),
'aria-autocomplete': 'list'
} );
}
if ( config.data ) {
this.setItemsFromData( config.data );
}
this.$content.addClass( 'oo-ui-capsuleMultiselectWidget-content' )
.append( this.$group );
this.$group.addClass( 'oo-ui-capsuleMultiselectWidget-group' );
this.$handle.addClass( 'oo-ui-capsuleMultiselectWidget-handle' )
.append( this.$indicator, this.$icon, this.$content );
this.$element.addClass( 'oo-ui-capsuleMultiselectWidget' )
.append( this.$handle );
if ( this.popup ) {
this.popup.$element.addClass( 'oo-ui-capsuleMultiselectWidget-popup' );
this.$content.append( $tabFocus );
this.$overlay.append( this.popup.$element );
} else {
this.$content.append( this.$input );
this.$overlay.append( this.menu.$element );
}
if ( $tabFocus ) {
$tabFocus.addClass( 'oo-ui-capsuleMultiselectWidget-focusTrap' );
}
// Input size needs to be calculated after everything else is rendered
setTimeout( function () {
if ( this.$input ) {
this.updateInputSize();
}
}.bind( this ) );
this.onMenuItemsChange();
};
/* Setup */
OO.inheritClass( OO.ui.CapsuleMultiselectWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.GroupElement );
OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.PopupElement );
OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.TabIndexedElement );
OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IconElement );
/* Events */
/**
* @event change
*
* A change event is emitted when the set of selected items changes.
*
* @param {Mixed[]} datas Data of the now-selected items
*/
/**
* @event resize
*
* A resize event is emitted when the widget's dimensions change to accomodate newly added items or
* current user input.
*/
/* Methods */
/**
* Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
* May return `null` if the given label and data are not valid.
*
* @protected
* @param {Mixed} data Custom data of any type.
* @param {string} label The label text.
* @return {OO.ui.CapsuleItemWidget|null}
*/
OO.ui.CapsuleMultiselectWidget.prototype.createItemWidget = function ( data, label ) {
if ( label === '' ) {
return null;
}
return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
};
/**
* @inheritdoc
*/
OO.ui.CapsuleMultiselectWidget.prototype.getInputId = function () {
if ( !this.$input ) {
return null;
}
return OO.ui.mixin.TabIndexedElement.prototype.getInputId.call( this );
};
/**
* Get the data of the items in the capsule
*
* @return {Mixed[]}
*/
OO.ui.CapsuleMultiselectWidget.prototype.getItemsData = function () {
return this.getItems().map( function ( item ) {
return item.data;
} );
};
/**
* Set the items in the capsule by providing data
*
* @chainable
* @param {Mixed[]} datas
* @return {OO.ui.CapsuleMultiselectWidget}
*/
OO.ui.CapsuleMultiselectWidget.prototype.setItemsFromData = function ( datas ) {
var widget = this,
menu = this.menu,
items = this.getItems();
$.each( datas, function ( i, data ) {
var j, label,
item = menu.findItemFromData( data );
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 = widget.createItemWidget( data, label );
}
if ( item ) {
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.findItemFromData( data ) || widget.allowDuplicates ) {
item = menu.findItemFromData( data );
if ( item ) {
item = widget.createItemWidget( data, item.label );
} else if ( widget.allowArbitrary ) {
item = widget.createItemWidget( data, String( data ) );
}
if ( item ) {
items.push( item );
}
}
} );
if ( items.length ) {
this.addItems( items );
}
return this;
};
/**
* Add items to the capsule by providing a label
*
* @param {string} label
* @return {boolean} Whether the item was added or not
*/
OO.ui.CapsuleMultiselectWidget.prototype.addItemFromLabel = function ( label ) {
var item, items;
item = this.menu.getItemFromLabel( label, true );
if ( item ) {
this.addItemsFromData( [ item.data ] );
return true;
} else if ( this.allowArbitrary ) {
items = this.getItems();
this.addItemsFromData( [ label ] );
return !OO.compare( this.getItems(), items );
}
return false;
};
/**
* 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.findItemFromData( 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() );
this.updateInputSize();
}
return this;
};
/**
* Removes the item from the list and copies its label to `this.$input`.
*
* @param {Object} item
*/
OO.ui.CapsuleMultiselectWidget.prototype.editItem = function ( item ) {
this.addItemFromLabel( this.$input.val() );
this.clearInput();
this.$input.val( item.label );
this.updateInputSize();
this.focus();
this.menu.updateItemVisibility(); // Hack, we shouldn't be calling this method directly
this.removeItems( [ item ] );
};
/**
* @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() );
this.updateInputSize();
}
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() );
this.updateInputSize();
}
return this;
};
/**
* Given an item, returns the item after it. If its the last item,
* returns `this.$input`. If no item is passed, returns the very first
* item.
*
* @param {OO.ui.CapsuleItemWidget} [item]
* @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
*/
OO.ui.CapsuleMultiselectWidget.prototype.getNextItem = function ( item ) {
var itemIndex;
if ( item === undefined ) {
return this.items[ 0 ];
}
itemIndex = this.items.indexOf( item );
if ( itemIndex < 0 ) { // Item not in list
return false;
} else if ( itemIndex === this.items.length - 1 ) { // Last item
return this.$input;
} else {
return this.items[ itemIndex + 1 ];
}
};
/**
* Given an item, returns the item before it. If its the first item,
* returns `this.$input`. If no item is passed, returns the very last
* item.
*
* @param {OO.ui.CapsuleItemWidget} [item]
* @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
*/
OO.ui.CapsuleMultiselectWidget.prototype.getPreviousItem = function ( item ) {
var itemIndex;
if ( item === undefined ) {
return this.items[ this.items.length - 1 ];
}
itemIndex = this.items.indexOf( item );
if ( itemIndex < 0 ) { // Item not in list
return false;
} else if ( itemIndex === 0 ) { // First item
return this.$input;
} else {
return this.items[ itemIndex - 1 ];
}
};
/**
* 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.updateInputSize();
this.menu.toggle( true );
}
};
/**
* Handle blur events
*
* @private
* @param {jQuery.Event} event
*/
OO.ui.CapsuleMultiselectWidget.prototype.onInputBlur = function () {
this.addItemFromLabel( this.$input.val() );
this.clearInput();
};
/**
* Handles popup focus out events.
*
* @private
* @param {jQuery.Event} e Focus out event
*/
OO.ui.CapsuleMultiselectWidget.prototype.onPopupFocusOut = function () {
var widget = this.popup;
setTimeout( function () {
if (
widget.isVisible() &&
!OO.ui.contains( widget.$element.add( widget.$autoCloseIgnore ).get(), document.activeElement, true )
) {
widget.toggle( false );
}
} );
};
/**
* Handle mouse down events.
*
* @private
* @param {jQuery.Event} e Mouse down event
*/
OO.ui.CapsuleMultiselectWidget.prototype.onMouseDown = function ( e ) {
if ( e.which === OO.ui.MouseButtons.LEFT ) {
this.focus();
return false;
} else {
this.updateInputSize();
}
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.CapsuleMultiselectWidget.prototype.onKeyPress = function ( e ) {
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 ) {
if ( this.addItemFromLabel( this.$input.val() ) ) {
this.clearInput();
}
return false;
}
// Make sure the input gets resized.
setTimeout( this.updateInputSize.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() &&
this.$input.val() === '' &&
this.items.length
) {
// 'keypress' event is not triggered for Backspace
if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
if ( e.metaKey || e.ctrlKey ) {
this.removeItems( this.items.slice( -1 ) );
} else {
this.editItem( this.items[ this.items.length - 1 ] );
}
return false;
} else if ( e.keyCode === OO.ui.Keys.LEFT ) {
this.getPreviousItem().focus();
} else if ( e.keyCode === OO.ui.Keys.RIGHT ) {
this.getNextItem().focus();
}
}
};
/**
* Update the dimensions of the text input field to encompass all available area.
*
* @private
* @param {jQuery.Event} e Event of some sort
*/
OO.ui.CapsuleMultiselectWidget.prototype.updateInputSize = function () {
var $lastItem, direction, contentWidth, currentWidth, bestWidth;
if ( this.$input && !this.isDisabled() ) {
this.$input.css( 'width', '1em' );
$lastItem = this.$group.children().last();
direction = OO.ui.Element.static.getDir( this.$handle );
// Get the width of the input with the placeholder text as
// the value and save it so that we don't keep recalculating
if (
this.contentWidthWithPlaceholder === undefined &&
this.$input.val() === '' &&
this.$input.attr( 'placeholder' ) !== undefined
) {
this.$input.val( this.$input.attr( 'placeholder' ) );
this.contentWidthWithPlaceholder = this.$input[ 0 ].scrollWidth;
this.$input.val( '' );
}
// Always keep the input wide enough for the placeholder text
contentWidth = Math.max(
this.$input[ 0 ].scrollWidth,
// undefined arguments in Math.max lead to NaN
( this.contentWidthWithPlaceholder === undefined ) ?
0 : this.contentWidthWithPlaceholder
);
currentWidth = this.$input.width();
if ( contentWidth < currentWidth ) {
this.updateIfHeightChanged();
// All is fine, don't perform expensive calculations
return;
}
if ( $lastItem.length === 0 ) {
bestWidth = this.$content.innerWidth();
} else {
bestWidth = direction === 'ltr' ?
this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
$lastItem.position().left;
}
// Some safety margin for sanity, because I *really* don't feel like finding out where the few
// pixels this is off by are coming from.
bestWidth -= 10;
if ( contentWidth > bestWidth ) {
// This will result in the input getting shifted to the next line
bestWidth = this.$content.innerWidth() - 10;
}
this.$input.width( Math.floor( bestWidth ) );
this.updateIfHeightChanged();
} else {
this.updateIfHeightChanged();
}
};
/**
* Determine if widget height changed, and if so, update menu position and emit 'resize' event.
*
* @private
*/
OO.ui.CapsuleMultiselectWidget.prototype.updateIfHeightChanged = function () {
var height = this.$element.height();
if ( height !== this.height ) {
this.height = height;
this.menu.position();
if ( this.popup ) {
this.popup.updateDimensions();
}
this.emit( 'resize' );
}
};
/**
* 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 toggle events.
*
* @private
* @param {boolean} isVisible Open state of the menu
*/
OO.ui.CapsuleMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
this.$element.toggleClass( 'oo-ui-capsuleMultiselectWidget-open', isVisible );
};
/**
* 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.updateInputSize();
}
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
*/
OO.ui.CapsuleMultiselectWidget.prototype.focus = function () {
if ( !this.isDisabled() ) {
if ( this.popup ) {
this.popup.setSize( this.$handle.outerWidth() );
this.popup.toggle( true );
OO.ui.findFocusable( this.popup.$element ).focus();
} else {
OO.ui.mixin.TabIndexedElement.prototype.focus.call( this );
}
}
return this;
};