Mooeypoo has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/344669 )

Change subject: [wip] Create a TagMultiselectWidget
......................................................................

[wip] Create a TagMultiselectWidget

Change-Id: Ic216769f48e4677da5b7274f491aa08a95aa8076
---
M build/modules.yaml
M demos/pages/widgets.js
M src/styles/empty-theme.less
M src/styles/widgets.less
A src/styles/widgets/TagItemWidget.less
A src/styles/widgets/TagMultiselectWidget.less
M src/themes/mediawiki/widgets.less
A src/widgets/MenuTagMultiselectWidget.js
M src/widgets/MultioptionWidget.js
A src/widgets/PopupTagMultiselectWidget.js
A src/widgets/TagItemWidget.js
A src/widgets/TagMultiselectWidget.js
M tests/index.php
A tests/widgets/TagMultiselectWidget.test.js
14 files changed, 1,824 insertions(+), 1 deletion(-)


  git pull ssh://gerrit.wikimedia.org:29418/oojs/ui refs/changes/69/344669/1

diff --git a/build/modules.yaml b/build/modules.yaml
index 39ade8b..5ab8b92 100644
--- a/build/modules.yaml
+++ b/build/modules.yaml
@@ -117,6 +117,10 @@
 
                        "src/widgets/CapsuleItemWidget.js",
                        "src/widgets/CapsuleMultiselectWidget.js",
+                       "src/widgets/TagItemWidget.js",
+                       "src/widgets/TagMultiselectWidget.js",
+                       "src/widgets/PopupTagMultiselectWidget.js",
+                       "src/widgets/MenuTagMultiselectWidget.js",
 
                        "src/widgets/SelectFileWidget.js",
                        "src/widgets/SearchWidget.js",
diff --git a/demos/pages/widgets.js b/demos/pages/widgets.js
index d859bbf..9e8dd8f 100644
--- a/demos/pages/widgets.js
+++ b/demos/pages/widgets.js
@@ -1330,6 +1330,76 @@
                                        }
                                ),
                                new OO.ui.FieldLayout(
+                                       new OO.ui.TagMultiselectWidget( {
+                                               allowArbitrary: true
+                                       } ),
+                                       {
+                                               label: 'TagMultiselectWidget 
(allowArbitrary, inline input)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.TagMultiselectWidget( {
+                                               allowArbitrary: false,
+                                               allowDisplayInvalidTags: true,
+                                               allowedValues: [ 'foo', 'bar', 
'baz' ]
+                                       } ),
+                                       {
+                                               label: 'TagMultiselectWidget 
(inline input, allowed values: [ \'foo\', \'bar\', \'baz\' ], 
allowDisplayInvalidTags)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.TagMultiselectWidget( {
+                                               allowArbitrary: true,
+                                               inputPosition: 'outline'
+                                       } ),
+                                       {
+                                               label: 'TagMultiselectWidget 
(allowArbitrary, outline input)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.PopupTagMultiselectWidget( {
+                                               allowArbitrary: true,
+                                               icon: 'tag',
+                                               indicator: 'alert'
+                                       } ),
+                                       {
+                                               label: 
'PopupTagMultiselectWidget (icon, indicator, allowArbitrary)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.MenuTagMultiselectWidget( {
+                                               allowArbitrary: false,
+                                               options: [
+                                                       { data: 'abc', label: 
'Label for abc' },
+                                                       { data: 'asd', label: 
'Label for asd' },
+                                                       { data: 'jkl', label: 
'Label for jkl' },
+                                               ]
+                                       } ),
+                                       {
+                                               label: 
'MenuTagMultiselectWidget (allowArbitrary:false)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.MenuTagMultiselectWidget( {
+                                               inputPosition: 'outline',
+                                               allowArbitrary: false,
+                                               options: [
+                                                       { data: 'abc', label: 
'Label for abc' },
+                                                       { data: 'asd', label: 
'Label for asd' },
+                                                       { data: 'jkl', label: 
'Label for jkl' },
+                                               ]
+                                       } ),
+                                       {
+                                               label: 
'MenuTagMultiselectWidget (inputPosition:outline, allowArbitrary:false)',
+                                               align: 'top'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
                                        new OO.ui.CapsuleMultiselectWidget( {
                                                menu: {
                                                        items: [
diff --git a/src/styles/empty-theme.less b/src/styles/empty-theme.less
index 3134697..c75904a 100644
--- a/src/styles/empty-theme.less
+++ b/src/styles/empty-theme.less
@@ -77,6 +77,8 @@
 .theme-oo-ui-comboBoxInputWidget () {}
 .theme-oo-ui-capsuleMultiselectWidget () {}
 .theme-oo-ui-capsuleItemWidget () {}
+.theme-oo-ui-tagMultiselectWidget () {}
+.theme-oo-ui-tagItemWidget () {}
 .theme-oo-ui-labelWidget () {}
 .theme-oo-ui-optionWidget () {}
 .theme-oo-ui-decoratedOptionWidget () {}
diff --git a/src/styles/widgets.less b/src/styles/widgets.less
index 9275bd6..697c9da 100644
--- a/src/styles/widgets.less
+++ b/src/styles/widgets.less
@@ -32,5 +32,7 @@
 
 @import 'widgets/CapsuleMultiselectWidget.less';
 @import 'widgets/CapsuleItemWidget.less';
+@import 'widgets/TagMultiselectWidget.less';
+@import 'widgets/TagItemWidget.less';
 @import 'widgets/SearchWidget.less';
 @import 'widgets/NumberInputWidget.less';
diff --git a/src/styles/widgets/TagItemWidget.less 
b/src/styles/widgets/TagItemWidget.less
new file mode 100644
index 0000000..5f11587
--- /dev/null
+++ b/src/styles/widgets/TagItemWidget.less
@@ -0,0 +1,17 @@
+@import '../common';
+
+.oo-ui-tagItemWidget {
+       position: relative;
+       display: inline-block;
+       cursor: default;
+       white-space: nowrap;
+
+       &.oo-ui-labelElement .oo-ui-labelElement-label {
+               display: inline-block;
+               text-overflow: ellipsis;
+               overflow: hidden;
+               cursor: text;
+       }
+
+       .theme-oo-ui-tagItemWidget();
+}
diff --git a/src/styles/widgets/TagMultiselectWidget.less 
b/src/styles/widgets/TagMultiselectWidget.less
new file mode 100644
index 0000000..c6221b3
--- /dev/null
+++ b/src/styles/widgets/TagMultiselectWidget.less
@@ -0,0 +1,52 @@
+@import '../common';
+
+.oo-ui-tagMultiselectWidget {
+       display: inline-block;
+       position: relative;
+
+       &-handle {
+               width: 100%;
+               display: block;
+               position: relative;
+
+               > .oo-ui-iconElement-icon,
+               > .oo-ui-indicatorElement-indicator {
+                       position: absolute;
+                       top: 0;
+                       height: 100%;
+               }
+       }
+
+       &-content {
+               position: relative;
+       }
+
+       &.oo-ui-widget-disabled &-content > input {
+               display: none;
+       }
+
+       &-group {
+               display: inline;
+       }
+
+       &-inputPosition {
+               &-outline {
+                       width: 100%;
+               }
+       }
+
+       // &-popup > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body > * {
+       //      display: block;
+       // }
+
+       // Support: Chromium
+       // Ensure the focus trap is not 0x0px, which causes 
OO.ui.isFocusableElement() to return false,
+       // preventing the TagMultiselectWidget from being programatically 
focussed in BookletLayout.
+       &-focusTrap {
+               display: inline-block;
+               height: 1px;
+               width: 1px;
+       }
+
+       .theme-oo-ui-tagMultiselectWidget();
+}
diff --git a/src/themes/mediawiki/widgets.less 
b/src/themes/mediawiki/widgets.less
index 83d3f89..9b8ac9a 100644
--- a/src/themes/mediawiki/widgets.less
+++ b/src/themes/mediawiki/widgets.less
@@ -298,6 +298,182 @@
        }
 }
 
+.theme-oo-ui-tagMultiselectWidget () {
+       width: 100%;
+       max-width: @max-width-input;
+
+       &-handle {
+               min-height: 2.4em;
+               .oo-ui-inline-spacing( 0.5em );
+               padding: 0.15em 0.25em;
+               border: @border-default;
+               border-radius: @border-radius-default;
+               .oo-ui-box-sizing( border-box );
+
+               > .oo-ui-tagMultiselectWidget-content {
+                       > input {
+                               border: 0;
+                               line-height: 1.675;
+                               margin: 0 0 0 0.2em;
+                               padding: 0;
+                               font-size: inherit;
+                               font-family: inherit;
+                               background-color: transparent;
+                               color: @color-emphasized;
+                               vertical-align: middle;
+
+                               .mw-placeholder();
+
+                               &:focus {
+                                       // Chrome
+                                       outline: 0;
+                               }
+                       }
+               }
+       }
+
+       &.oo-ui-iconElement &-handle {
+               padding-left: @size-icon + 2 * 0.3em;
+
+               > .oo-ui-iconElement-icon {
+                       left: 0;
+                       margin: 0 0.3em;
+               }
+       }
+
+       &.oo-ui-indicatorElement &-handle {
+               padding-right: @size-indicator + 2 * 0.775em;
+
+               > .oo-ui-indicatorElement-indicator {
+                       right: 0;
+                       margin: 0 0.775em;
+               }
+       }
+
+       &-popup {
+               margin-top: -1px;
+
+               > .oo-ui-popupWidget-popup {
+                       border: 0;
+               }
+       }
+
+       &.oo-ui-widget-enabled &-handle {
+               background-color: @background-color-default;
+               cursor: text;
+               .oo-ui-transition(
+                       border-color @transition-ease-out-sine-medium,
+                       box-shadow @transition-ease-out-sine-medium
+               );
+       }
+       &.oo-ui-widget-enabled:hover &-handle {
+               border-color: @border-color-input-hover;
+       }
+       &.oo-ui-widget-enabled.oo-ui-tagMultiselectWidget-open &-handle {
+               border-color: @border-color-default-focus;
+               outline: 0;
+               box-shadow: @box-shadow-widget-focus;
+       }
+
+       &.oo-ui-widget-disabled &-handle {
+               color: @color-disabled;
+               text-shadow: @text-shadow-disabled;
+               border-color: @border-color-disabled;
+               background-color: @background-color-disabled;
+
+               > .oo-ui-iconElement-icon {
+                       opacity: @opacity-disabled;
+               }
+
+               > .oo-ui-indicatorElement-indicator {
+                       opacity: @opacity-disabled-indicator;
+               }
+       }
+}
+
+.theme-oo-ui-tagItemWidget () {
+       .oo-ui-box-sizing( border-box );
+       width: auto;
+       max-width: 100%;
+       height: 1.7em;
+       margin: 0.1em;
+       border: @border-default;
+       border-radius: @border-radius-default;
+       padding: 0 0.4em;
+       line-height: 1.7;
+       vertical-align: middle;
+
+       &.oo-ui-widget-enabled {
+               background-color: @background-color-framed;
+               color: @color-default;
+               padding-right: @size-indicator + 0.6em;
+               .oo-ui-transition(
+                       background-color @transition-ease-quick,
+                       color @transition-ease-quick,
+                       border-color @transition-ease-quick,
+                       box-shadow @transition-ease-quick
+               );
+
+               &:hover {
+                       background-color: @background-color-framed-hover;
+                       color: @color-default-hover;
+                       border-color: @border-color-default-hover;
+               }
+
+               &:focus {
+                       border-color: @border-color-default-focus;
+                       box-shadow: @box-shadow-widget-focus;
+                       outline: 0;
+               }
+
+               // Clear button
+               > .oo-ui-buttonElement {
+                       display: block;
+                       position: absolute;
+                       top: 0;
+                       right: 0;
+                       bottom: 0;
+               }
+               & .oo-ui-buttonElement-button {
+                       display: block;
+                       width: @size-indicator + 0.6em; // equals to `padding` 
on each side of the indicator
+                       height: 100%; // Firefox height calculation fix
+
+                       & .oo-ui-indicator-clear {
+                               position: absolute;
+                               top: 0;
+                               right: 0.3em;
+                               bottom: 0;
+                               height: auto;
+                       }
+               }
+       }
+
+       &.oo-ui-flaggedElement-invalid {
+               border-color: @border-color-erroneous;
+
+               &:hover {
+                       border-color: @border-color-erroneous;
+               }
+               &:focus {
+                       border-color: @border-color-erroneous;
+                       box-shadow: @box-shadow-erroneous-focus;
+               }
+       }
+
+       &.oo-ui-widget-disabled {
+               background-color: @background-color-disabled;
+               color: @color-disabled;
+               border-color: @border-color-disabled;
+               text-shadow: @text-shadow-disabled;
+
+               // Clear button
+               > .oo-ui-buttonElement {
+                       display: none;
+               }
+       }
+}
+
 .theme-oo-ui-checkboxInputWidget () {
        position: relative;
        line-height: @size-input-binary;
diff --git a/src/widgets/MenuTagMultiselectWidget.js 
b/src/widgets/MenuTagMultiselectWidget.js
new file mode 100644
index 0000000..5264ab6
--- /dev/null
+++ b/src/widgets/MenuTagMultiselectWidget.js
@@ -0,0 +1,177 @@
+/**
+ * A menu tag multiselect widget, extending TagMultiselectWidget to use a menu
+ *
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] An overlay for the menu
+ * @cfg {Object} [options] Menu options
+ */
+OO.ui.MenuTagMultiselectWidget = function OoUiMenuTagMultiselectWidget( config 
) {
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.MenuTagMultiselectWidget.parent.call( this, config );
+
+       this.$overlay = config.$overlay || this.$element;
+
+       this.menu = this.createMenuWidget( config.options );
+       this.menu.connect( this, {
+               choose: 'onMenuChoose'
+       } );
+
+       if ( this.hasInput ) {
+               this.input.connect( this, { change: 'onInputChange' } );
+       }
+
+       // Initialization
+       this.$overlay
+               .append( this.menu.$element );
+       this.$element
+               .addClass( 'oo-ui-menuTagMultiselectWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( OO.ui.MenuTagMultiselectWidget, OO.ui.TagMultiselectWidget );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.onInputFocus = function () {
+       this.menu.toggle( true );
+};
+
+/**
+ * Respond to input change event
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.onInputChange = function () {
+       this.menu.toggle( true );
+};
+
+/**
+ * Respond to menu choose event
+ *
+ * @param {OO.ui.OptionWidget} menuItem Chosen menu item
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.onMenuChoose = function ( menuItem ) {
+       // Add tag
+       this.addTag( menuItem.getData(), menuItem.getLabel() );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+       // Select the menu item
+       this.menu.selectItem( this.menu.getItemFromData( tagItem.getData() ) );
+       this.menu.toggle( true );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.doInputEnter = function () {
+       var selectedItem,
+               inputValue = this.input.getValue();
+
+       if ( this.allowArbitrary ) {
+               this.addTag( inputValue );
+               return;
+       }
+
+       selectedItem = this.menu.getSelectedItem();
+       if ( selectedItem ) {
+               this.addTag( selectedItem.getData(), selectedItem.getLabel() );
+               return;
+       }
+
+       // If there are no selections in the menu, check if it might still
+       // be valid
+       if ( this.isAllowedData( inputValue ) ) {
+               this.addTag( inputValue );
+       }
+};
+
+/**
+ * Return the visible items in the menu. This is mainly used for when
+ * the menu is filtering results.
+ *
+ * @return {OO.ui.MenuOptionWidget[]} Visible results
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.getMenuVisibleItems = function () {
+       return this.menu.getItems().filter( function ( menuItem ) {
+               return menuItem.isVisible();
+       } );
+};
+
+/**
+ * Create the menu for this widget. This is in a separate widget so that
+ * child classes can override this without polluting the constructor with
+ * unnecessary extra objects that will be overidden.
+ *
+ * @param {Object[]} menuOptions Menu options
+ * @return {OO.ui.MenuSelectWidget} Menu widget
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.createMenuWidget = function ( 
menuOptions ) {
+       var widget = this,
+               config = {
+                       widget: this,
+                       $container: this.hasInput && this.inputPosition === 
'outline' ?
+                               this.input.$element : this.$element,
+                       $overlay: this.$overlay,
+                       disabled: this.isDisabled(),
+                       items: menuOptions.map( function ( obj ) {
+                               return widget.createMenuOptionWidget( obj.data, 
obj.label );
+                       } )
+               };
+
+       if ( this.hasInput ) {
+               config = $.extend( config, {
+                       $input: this.input.$input,
+                       filterFromInput: true,
+               } );
+       }
+
+       return new OO.ui.FloatingMenuSelectWidget( config );
+};
+
+/**
+ * Create a menu option widget.
+ *
+ * @param {string} data Item data
+ * @param {string} [label] Item label
+ * @return {OO.ui.OptionWidget} Option widget
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.createMenuOptionWidget = function ( 
data, label ) {
+       return new OO.ui.MenuOptionWidget( {
+               data: data,
+               label: label || data
+       } );
+};
+
+/**
+ * Get the menu
+ *
+ * @return {OO.ui.MenuSelectWidget} Menu
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.getMenu = function () {
+       return this.menu;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+       return 
OO.ui.MenuTagMultiselectWidget.parent.prototype.isAllowedData.call( this, data 
) ||
+               !!this.menu.getItemFromData( data );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuTagMultiselectWidget.prototype.focus = function () {
+       if ( !this.isDisabled() ) {
+               this.menu.toggle( true );
+       }
+};
diff --git a/src/widgets/MultioptionWidget.js b/src/widgets/MultioptionWidget.js
index 646dc75..07e93fa 100644
--- a/src/widgets/MultioptionWidget.js
+++ b/src/widgets/MultioptionWidget.js
@@ -68,8 +68,9 @@
  * should be handled by the SelectWidget’s {@link 
OO.ui.SelectWidget#selectItem selectItem( [item] )}
  * method instead of this method.
  *
- * @param {boolean} [state=false] Select option
+ * @param {boolean} [state] Select option
  * @chainable
+ * @fires change
  */
 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
        state = !!state;
diff --git a/src/widgets/PopupTagMultiselectWidget.js 
b/src/widgets/PopupTagMultiselectWidget.js
new file mode 100644
index 0000000..9401bcc
--- /dev/null
+++ b/src/widgets/PopupTagMultiselectWidget.js
@@ -0,0 +1,135 @@
+/**
+ * A popup tag multiselect widget, extending TagMultiselectWidget to use a 
popup
+ *
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] An overlay for the popup
+ * @cfg {OO.ui.TextInputWidget} [popupInput] An input widget inside the popup 
that will be
+ *  focused when the popup is opened and will be used as replacement for the
+ *  general input in the widget.
+ */
+OO.ui.PopupTagMultiselectWidget = function OoUiPopupTagMultiselectWidget( 
config ) {
+       var defaultInput,
+               defaultConfig = {},
+               widget = this;
+
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.PopupTagMultiselectWidget.parent.call( this, $.extend( { 
inputPosition: 'none' }, config ) );
+
+       this.$overlay = config.$overlay || this.$element;
+
+       if ( !config.popup ) {
+               // For the default base implementation, we give a popup
+               // with an input widget inside it. For any other use cases
+               // the popup needs to be populated externally and the
+               // event handled to add tags separately and manually
+               defaultInput = new OO.ui.TextInputWidget();
+               defaultConfig = {
+                       popupInput: defaultInput,
+                       popup: {
+                               $content: defaultInput.$element
+                       }
+               };
+
+               this.$element.addClass( 
'oo-ui-popupTagMultiselectWidget-defaultPopup' );
+       }
+
+       defaultConfig.$overlay = this.$overlay;
+       defaultConfig.$autoCloseIgnore = this.hasInput ?
+               this.input.$element.add( this.$overlay ) : this.$overlay;
+
+       config = $.extend( defaultConfig, config );
+
+       // Mixin constructors
+       OO.ui.mixin.PopupElement.call( this, config );
+
+       if ( this.hasInput ) {
+               this.input.$input.on( 'focus', this.popup.toggle.bind( 
this.popup, true ) );
+       }
+
+       // Configuration options
+       this.popupInput = config.popupInput;
+       if ( this.popupInput ) {
+               this.popupInput.connect( this, {
+                       enter: 'onPopupInputEnter'
+               } );
+       }
+
+       // Events
+       this.popup.connect( this, { toggle: 'onPopupToggle' } );
+       this.$element.find( 'oo-ui-tagMultiselectWidget-focusTrap' )
+               .on( 'focus', this.focus.bind( this ) );
+
+       // Initialize
+       this.$element
+               .append( this.popup.$element )
+               .addClass( 'oo-ui-popupTagMultiselectWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( OO.ui.PopupTagMultiselectWidget, OO.ui.TagMultiselectWidget );
+OO.mixinClass( OO.ui.PopupTagMultiselectWidget, OO.ui.mixin.PopupElement );
+
+/* Methods */
+
+/**
+ * Focus the widget
+ */
+OO.ui.PopupTagMultiselectWidget.prototype.focus = function () {
+       if ( !this.popupInput && this.hasInput ) {
+               this.input.focus();
+       }
+
+       this.popup.toggle( true );
+};
+
+/**
+ * Respond to popup toggle event
+ *
+ * @param {boolean} isVisible Popup is visible
+ */
+OO.ui.PopupTagMultiselectWidget.prototype.onPopupToggle = function ( isVisible 
) {
+       if ( isVisible && this.popupInput ) {
+               this.popupInput.$input.focus();
+       }
+};
+
+/**
+ * Respond to popup input enter event
+ */
+OO.ui.PopupTagMultiselectWidget.prototype.onPopupInputEnter = function () {
+       if ( this.popupInput ) {
+               this.addTagByPopupValue( this.popupInput.getValue() );
+               this.popupInput.setValue( '' );
+       }
+};
+
+/**
+ * Respond to item select
+ *
+ * @param {OO.ui.TagItemWidget} item Tag item
+ */
+OO.ui.PopupTagMultiselectWidget.prototype.onTagSelect = function ( item ) {
+       if ( this.popupInput ) {
+               this.removeItems( [ item ] );
+               this.popupInput.setValue( item.getData() );
+
+               this.popup.toggle( true );
+               this.popupInput.$input.focus();
+       }
+};
+
+/**
+ * Add a tag by the popup value.
+ * Whatever is responsible for setting the value in the popup should call
+ * this method to add a tag, or use the regular methods like #addTag or
+ * #setValue directly.
+ *
+ * @param {mixed} data The value of item
+ * @param {string} [label] The label of the tag. If not given, the data is 
used.
+ */
+OO.ui.PopupTagMultiselectWidget.prototype.addTagByPopupValue = function ( 
data, label ) {
+       this.addTag( data, label );
+};
diff --git a/src/widgets/TagItemWidget.js b/src/widgets/TagItemWidget.js
new file mode 100644
index 0000000..9765284
--- /dev/null
+++ b/src/widgets/TagItemWidget.js
@@ -0,0 +1,190 @@
+/**
+ * [TagItemWidget description]
+ * @param {[type]} config [description]
+ * @cfg {boolean} [valid=true] Item is valid
+ */
+OO.ui.TagItemWidget = function OoUiTagItemWidget( config ) {
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.TagItemWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ItemWidget.call( this );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+       OO.ui.mixin.DraggableElement.call( this, config );
+
+       this.valid = config.valid === undefined ? true : !!config.valid;
+
+       this.closeButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               indicator: 'clear',
+               tabIndex: -1
+       } );
+
+       // Events
+       this.closeButton
+               .connect( this, { click: 'remove' } );
+       this.connect( this, { disable: 'onDisable' } );
+       this.$element
+               .on( 'click', this.select.bind( this ) )
+               .on( 'keydown', this.onKeyDown.bind( this ) )
+               // Prevent propagation of mousedown; the tag item "lives" in the
+               // clickable area of the TagMultiselectWidget, which listens to
+               // mousedown to open the menu or popup. We want to prevent that
+               // for clicks specifically on the tag itself, so the actions 
taken
+               // are more deliberate. When the tag is clicked, it will emit 
the
+               // selection event (based on #OO.ui.MultioptionWidget 'change')
+               // and can be handled separately.
+               .on( 'mousedown', function ( e ) { e.stopPropagation(); } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-tagItemWidget' )
+               .append( this.$label, this.closeButton.$element );
+};
+
+/* Initialization */
+
+OO.inheritClass( OO.ui.TagItemWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.ItemWidget );
+OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.DraggableElement );
+
+/* Events */
+
+/**
+ * @event remove
+ *
+ * A remove action was performed on the item
+ */
+
+/**
+ * @event navigate
+ * @param {string} direction Direction of the movement, forward or backwards
+ *
+ * A navigate action was performed on the item
+ */
+
+/**
+ * @event select
+ *
+ * The tag widget was selected. This can occur when the widget
+ * is either clicked or an enter was pressed on it.
+ */
+
+/**
+ * @event valid
+ * @param {boolean} isValid Item is valid
+ *
+ * Item is valid
+ */
+
+/* Methods */
+
+/**
+ * Handle disable event on the widget
+ */
+OO.ui.TagItemWidget.prototype.onDisable = function () {
+       this.closeButton.setDisable( this.isDisabled() );
+};
+
+/**
+ * Handle removal of the item
+ *
+ * This is mainly for extensibility concerns, so other children
+ * of this class can change the behavior if they need to. This
+ * is being called by both clicking the 'remove' button but also
+ * on keypress, which is harder to override if needed.
+ *
+ * @fires remove
+ */
+OO.ui.TagItemWidget.prototype.remove = function () {
+       this.emit( 'remove' );
+};
+
+/**
+ * Handle a keydown event on the widget
+ *
+ * @fires navigate
+ * @fires remove
+ */
+OO.ui.TagItemWidget.prototype.onKeyDown = function ( e ) {
+       var movement;
+
+       if ( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === 
OO.ui.Keys.DELETE ) {
+               this.remove();
+               return false;
+       } else if ( e.keyCode === OO.ui.Keys.ENTER ) {
+               this.select();
+               return false;
+       } else if (
+               e.keyCode === OO.ui.Keys.LEFT ||
+               e.keyCode === OO.ui.Keys.RIGHT
+       ) {
+               if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
+                       movement = {
+                               left: 'forwards',
+                               right: 'backwards'
+                       };
+               } else {
+                       movement = {
+                               left: 'backwards',
+                               right: 'forwards'
+                       };
+               }
+
+               this.emit(
+                       'navigate',
+                       e.keyCode === OO.ui.Keys.LEFT ?
+                               movement.left : movement.right
+               );
+       }
+};
+
+/**
+ * Focuses the capsule
+ */
+OO.ui.TagItemWidget.prototype.focus = function () {
+       this.$element.focus();
+};
+
+/**
+ * Select this item
+ *
+ * @fires select
+ */
+OO.ui.TagItemWidget.prototype.select = function () {
+       this.emit( 'select' );
+};
+
+/**
+ * Set the valid state of this item
+ *
+ * @param {boolean} [valid] Item is valid
+ * @fires valid
+ */
+OO.ui.TagItemWidget.prototype.toggleValid = function ( valid ) {
+       valid = valid === undefined ? !this.valid : !!valid;
+
+       if ( this.valid !== valid ) {
+               this.valid = valid;
+
+               this.setFlags( { invalid: !this.valid } );
+
+               this.emit( 'valid', this.valid );
+       }
+};
+
+/**
+ * Check whether the item is valid
+ *
+ * @return {boolean} Item is valid
+ */
+OO.ui.TagItemWidget.prototype.isValid = function () {
+       return this.valid;
+};
diff --git a/src/widgets/TagMultiselectWidget.js 
b/src/widgets/TagMultiselectWidget.js
new file mode 100644
index 0000000..f97e1fa
--- /dev/null
+++ b/src/widgets/TagMultiselectWidget.js
@@ -0,0 +1,752 @@
+/**
+ * A basic tag multiselect widget, allowing the user to add tags to a display
+ * area.
+ *
+ * @param {[type]} config [description]
+ * @cfg {boolean} [inputPosition='inline'] Position the input. Options are: 
'inline', 'outline', 'none'
+ *  to add tags by typing near the tag list display
+ * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if 
not present in the menu.
+ * @cfg {Object[]} [allowedValues] An array representing the allowed items by 
their datas.
+ *  If menu is used through config.useMenu, these will be the items in the 
menu.
+ * @cfg {boolean} [allowDuplicates=false] Allow duplicate items to be added.
+ * @cfg {boolean} [allowDisplayInvalidTags=false] Allow the display of
+ *  invalid tags. These tags will display with an invalid state, and
+ *  the widget as a whole will have an invalid state.
+ * @cfg {boolean} [allowReordering=true] Allow reordering of the items
+ */
+OO.ui.TagMultiselectWidget = function OoUiTagMultiselectWidget( config ) {
+       var inputEvents,
+               $tabFocus = $( '<span>' )
+               .addClass( 'oo-ui-tagMultiselectWidget-focusTrap' );
+
+       config = config || {};
+       config.allowReordering = config.allowReordering === undefined ? true : 
!!config.allowReordering;
+
+       // Parent constructor
+       OO.ui.TagMultiselectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupWidget.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       if ( config.allowReordering ) {
+               // TODO: DraggableGroupElement should have a 'disable dragging' 
configuration
+               // or method to allow the disabling of draggability, even 
temporarily
+               OO.ui.mixin.DraggableGroupElement.call( this, config );
+       }
+
+       this.inputPosition = config.inputPosition === undefined ?
+               'inline' : (
+                       this.constructor.static.allowedInputPositions.indexOf( 
config.inputPosition ) > -1 ?
+                       config.inputPosition : 'inline'
+               );
+       this.allowArbitrary = !!config.allowArbitrary;
+       this.allowDuplicates = !!config.allowDuplicates;
+       this.allowedValues = config.allowedValues || [];
+       this.allowDisplayInvalidTags = config.allowDisplayInvalidTags;
+       this.hasInput = this.inputPosition !== 'none';
+       this.height = null;
+       this.valid = true;
+
+       this.$content = $( '<div>' )
+               .addClass( 'oo-ui-tagMultiselectWidget-content' );
+       this.$handle = $( '<div>' )
+               .addClass( 'oo-ui-tagMultiselectWidget-handle' )
+               .append(
+                       this.$indicator,
+                       this.$icon,
+                       this.$content
+                               .append(
+                                       this.$group
+                                               .addClass( 
'oo-ui-tagMultiselectWidget-group' )
+                               )
+               );
+
+       // Events
+       this.aggregate( {
+               remove: 'itemRemove',
+               navigate: 'itemNavigate',
+               select: 'itemSelect'
+       } );
+       this.connect( this, {
+               itemRemove: 'onTagRemove',
+               itemSelect: 'onTagSelect',
+               itemNavigate: 'onTagNavigate',
+               change: 'onChangeTags',
+               disable: 'onDisable'
+       } );
+       this.$handle.on( {
+       mousedown: this.onMouseDown.bind( this )
+       } );
+
+       // Initialize
+       this.$element
+               .addClass( 'oo-ui-tagMultiselectWidget' )
+               .addClass( 'oo-ui-tagMultiselectWidget-inputPosition-' + 
this.inputPosition )
+               .append( this.$handle );
+
+       if ( this.hasInput ) {
+               this.input = new OO.ui.TextInputWidget( {
+                       placeholder: config.placeholder,
+                       classes: [ 'oo-ui-tagMultiselectWidget-input' ]
+               } );
+               this.input.setDisabled( this.isDisabled() );
+
+               inputEvents = {
+                       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.onInputKeyDown.bind( this ),
+                       keypress: this.onInputKeyPress.bind( this )
+               };
+               if ( this.inputPosition === 'inline' ) {
+                       this.$content.append( this.input.$input );
+                       this.input.$input.on( inputEvents );
+               } else if ( this.inputPosition === 'outline' ) {
+                       this.$element.append( this.input.$element );
+                       this.input.$element.on( inputEvents );
+               }
+       }
+
+       this.setTabIndexedElement(
+               this.hasInput ?
+                       this.input.$element : $tabFocus
+       );
+
+       // HACK: Input size needs to be calculated after everything
+       // else is rendered
+       setTimeout( function () {
+               if ( this.$input ) {
+                       this.updateInputSize();
+               }
+       }.bind( this ) );
+};
+
+/* Initialization */
+
+OO.inheritClass( OO.ui.TagMultiselectWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.GroupWidget );
+// Note: DraggableGroupElement already mixes in GroupElement
+// But our implementation makes DraggableGroupElement optional, which means
+// we need to mixin GroupElement individually as well
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.DraggableGroupElement );
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.FlaggedElement );
+
+/* Static properties */
+
+/**
+ * Allowed input positions.
+ * - inline: The input is inside the tag list
+ * - outline: The input is under the tag list
+ * - none: There is no input
+ *
+ * @property {Array}
+ */
+OO.ui.TagMultiselectWidget.static.allowedInputPositions = [ 'inline', 
'outline', 'none' ];
+
+/* Methods */
+
+/**
+ * Handle mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ */
+OO.ui.TagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
+               this.focus();
+               return false;
+       }
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.TagMultiselectWidget.prototype.onInputKeyPress = function ( e ) {
+       var withMetaKey = e.metaKey || e.ctrlKey;
+
+       if ( !this.isDisabled() ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       this.doInputKey( 'escape', withMetaKey );
+                       return false;
+               }
+
+               if ( e.which === OO.ui.Keys.ENTER ) {
+                       this.doInputKey( 'enter', withMetaKey );
+                       return false;
+               }
+
+               // Make sure the input gets resized.
+               setTimeout( this.updateInputSize.bind( this ), 0 );
+       }
+};
+
+/**
+ * Respond to input focus event
+ */
+OO.ui.TagMultiselectWidget.prototype.onInputFocus = function () {};
+
+/**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.TagMultiselectWidget.prototype.onInputKeyDown = function ( e ) {
+       var movement, direction,
+               withMetaKey = e.metaKey || e.ctrlKey;
+
+       if (
+               !this.isDisabled() &&
+               this.input.getValue() === '' &&
+               this.items.length
+       ) {
+               // 'keypress' event is not triggered for Backspace
+               if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
+                       this.doInputKey( 'backspace', withMetaKey );
+                       return false;
+               } else if (
+                       e.keyCode === OO.ui.Keys.LEFT ||
+                       e.keyCode === OO.ui.Keys.RIGHT
+               ) {
+                       if ( OO.ui.Element.static.getDir( this.$element ) === 
'rtl' ) {
+                               movement = {
+                                       left: 'forwards',
+                                       right: 'backwards'
+                               };
+                       } else {
+                               movement = {
+                                       left: 'backwards',
+                                       right: 'forwards'
+                               };
+                       }
+                       direction = e.keyCode === OO.ui.Keys.LEFT ?
+                               movement.left : movement.right;
+
+                       this.doInputKey( 'arrow', withMetaKey, direction );
+               }
+       }
+};
+
+/**
+ * Perform an action based on a pressed key in the input.
+ * This is an additional method for the purpose of being available
+ * to be extended, so the child classes don't need to touch the
+ * 'raw' operation of the #onInputKeyDown and #onInputKeyPress
+ *
+ * @private
+ * @param {string} key A symbolic representation of the key.
+ *  Available keys are 'enter', 'backspace', 'arrow', 'escape'
+ * @param {boolean} [withMetaKey] Whether this key was pressed with
+ * a meta key like 'ctrl'
+ * @param {mixed} [data] Data attached to this operation. For example,
+ *  if the key was 'arrow', this will be the direction clicked
+ */
+OO.ui.TagMultiselectWidget.prototype.doInputKey = function ( key, withMetaKey, 
data ) {
+       if ( key === 'enter' ) {
+               this.doInputEnter( withMetaKey );
+               // this.addTagFromInput();
+       } else if ( key === 'backspace' ) {
+               this.doInputBackspace( withMetaKey );
+       } else if ( key === 'escape' ) {
+               this.doInputEscape();
+       } else if ( key === 'arrow' ) {
+               this.doInputArrow( data, withMetaKey );
+       }
+};
+
+/**
+ * Perform an action after the enter key on the input
+ *
+ * @param {boolean} [withMataKey] Whether this key was pressed with
+ * a meta key like 'ctrl'
+ */
+OO.ui.TagMultiselectWidget.prototype.doInputEnter = function ( withMetaKey ) {
+       this.addTagFromInput();
+};
+
+/**
+ * Perform an action responding to the enter key on the input
+ *
+ * @param {boolean} [withMataKey] Whether this key was pressed with
+ * a meta key like 'ctrl'
+ */
+OO.ui.TagMultiselectWidget.prototype.doInputBackspace = function ( withMetaKey 
) {
+       var items, item;
+
+       if (
+               this.inputPosition === 'inline' &&
+               !this.isEmpty()
+       ) {
+               // Delete the last item
+               items = this.getItems();
+               item = items[ items.length - 1 ];
+               this.input.setValue( item.getData() );
+               this.removeItems( [ item ] );
+       }
+};
+
+/**
+ * Perform an action after the escape key on the input
+ *
+ * @param {boolean} [withMataKey] Whether this key was pressed with
+ * a meta key like 'ctrl'
+ */
+OO.ui.TagMultiselectWidget.prototype.doInputEscape = function ( withMetaKey ) {
+       this.clearInput();
+};
+
+/**
+ * Perform an action after the arrow key on the input
+ *
+ * @param {string} direction Direction of the movement; forwards or backwards
+ * @param {boolean} [withMataKey] Whether this key was pressed with
+ * a meta key like 'ctrl'
+ */
+OO.ui.TagMultiselectWidget.prototype.doInputArrow = function ( direction, 
withMetaKey ) {
+       if (
+               this.inputPosition === 'inline' &&
+               !this.isEmpty()
+       ) {
+               if ( direction === 'backwards' ) {
+                       // Get previous item
+                       this.getPreviousItem();
+               } else {
+                       this.getNextItem();
+               }
+       }
+};
+
+/**
+ * Respond to item select event
+ */
+OO.ui.TagMultiselectWidget.prototype.onTagSelect = function ( item ) {
+       if ( this.hasInput ) {
+               // Base behavior: If allow arbitrary, and if input exists:
+               // 1. Get the label of the tag into the input
+               this.input.setValue( item.getData() );
+               // 2. Remove the tag
+               this.removeItems( [ item ] );
+               // 3. Focus the input
+               this.focus();
+       }
+};
+
+/**
+ * Respond to change event, where items were added, removed, or cleared.
+ */
+OO.ui.TagMultiselectWidget.prototype.onChangeTags = function () {
+       this.toggleValid( this.checkValidity() );
+       if ( this.hasInput && this.inputPosition === 'inline' ) {
+               this.updateInputSize();
+       }
+};
+
+/**
+ * Respond to disable event
+ *
+ * @param {boolean} isDisabled Widget is disabled
+ */
+OO.ui.TagMultiselectWidget.prototype.onDisable = function ( isDisabled ) {
+       this.input.setDisabled( isDisabled );
+       this.getItems().forEach( function ( item ) {
+               item.updateDisabled( isDisabled );
+       } );
+};
+
+OO.ui.TagMultiselectWidget.prototype.onTagRemove = function ( item ) {
+       this.removeTagByData( item.getData() );
+};
+
+OO.ui.TagMultiselectWidget.prototype.onTagNavigate = function ( item, 
direction ) {
+       if ( direction === 'forwards' ) {
+               this.getNextItem( item ).focus();
+       } else {
+               this.getPreviousItem( item ).focus();
+       }
+};
+
+/**
+ * Add tag from input value
+ */
+OO.ui.TagMultiselectWidget.prototype.addTagFromInput = function () {
+       var val = this.input.getValue();
+
+       if ( !val ) {
+               return;
+       }
+
+       // Hide the input
+       this.input.toggle( false );
+       if ( this.addTag( val ) ) {
+               this.clearInput();
+       }
+       // Show the input
+       this.input.toggle( true );
+       this.focus();
+};
+
+/**
+ * Clear the input
+ */
+OO.ui.TagMultiselectWidget.prototype.clearInput = function () {
+       this.input.setValue( '' );
+};
+
+/**
+ * Check whether the given value is a duplicate of an existing
+ * tag already in the list.
+ *
+ * @param {string|Object} data Requested value
+ * @return {boolean} Value is duplicate
+ */
+OO.ui.TagMultiselectWidget.prototype.isDuplicateData = function ( data ) {
+       return this.getItems().some( function ( item ) {
+               return item.getData() === data;
+       } );
+};
+
+/**
+ * Check whether a given value is allowed, in case the widget is
+ * set to not allow arbitrary values.
+ *
+ * @param {string|Object} data Requested value
+ * @return {boolean} Value exists in the allowed values list
+ */
+OO.ui.TagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+       var i, len, item,
+               hash = OO.getHash( data );
+
+       if ( this.allowArbitrary ) {
+               return true;
+       }
+
+       if (
+               !this.allowDuplicates &&
+               this.isDuplicateData( data )
+       ) {
+               return false;
+       }
+
+       // Check with allowed values
+       for ( i = 0, len = this.allowedValues.length; i < len; i++ ) {
+               item = this.allowedValues[ i ];
+               if ( hash === OO.getHash( item ) ) {
+                       return true;
+               }
+       }
+
+       return false;
+};
+
+/**
+ * Focus the widget
+ */
+OO.ui.TagMultiselectWidget.prototype.focus = function () {
+       if ( this.hasInput ) {
+               this.input.focus();
+       }
+};
+
+/**
+ * Get the datas of the currently selected items
+ *
+ * @return {string[]|Object[]} Datas of currently selected items
+ */
+OO.ui.TagMultiselectWidget.prototype.getValue = function () {
+       return this.getItems()
+               .filter( function ( item ) {
+                       return item.isValid();
+               } )
+               .map( function ( item ) {
+                       return item.getData();
+               } );
+};
+
+/**
+ * Set the value of this widget by datas.
+ *
+ * @param {string|string[]|Object|Object[]} value An object representing the 
data
+ *  and label of the value. If the widget allows arbitrary values,
+ *  the items will be added as-is. Otherwise, the data value will
+ *  be checked against allowedValues.
+ *  This object must contain at least a data key. Example:
+ *  { data: 'foo', label: 'Foo item' }
+ *  For multiple items, use an array of objects. For example:
+ *  [
+ *     { data: 'foo', label: 'Foo item' },
+ *     { data: 'bar', label: 'Bar item' }
+ *     ]
+ *     Value can also be added with plaintext array, for example:
+ *     [ 'foo', 'bar', 'bla' ]
+ *     or a single string, like 'foo'
+ */
+OO.ui.TagMultiselectWidget.prototype.setValue = function ( valueObject ) {
+       valueObject = Array.isArray( valueObject ) ? valueObject : [ 
valueObject ];
+
+       this.clearItems();
+       valueObject.forEach( function ( obj ) {
+               if ( typeof obj === 'string' ) {
+                       this.addTag( obj );
+               } else {
+                       this.addTag( obj.data, obj.label );
+               }
+       }.bind( this ) );
+};
+
+/**
+ * Add tag to the display area
+ *
+ * @param {string|Object} data Tag data
+ * @param {string} [label] Tag label. If no label is provided, the
+ *  stringified version of the data will be used instead.
+ * @return {boolean} Item was added successfully
+ */
+OO.ui.TagMultiselectWidget.prototype.addTag = function ( data, label ) {
+       var newitemWidget, isValid;
+
+       if (
+               !this.allowDisplayInvalidTags &&
+               !this.allowDuplicates &&
+               this.getItems().some( function ( item ) {
+                       return item.getData() === data;
+               } )
+       ) {
+               return false;
+       }
+
+       isValid = (
+               this.isAllowedData( data ) &&
+               (
+                       !this.allowDuplicates ?
+                               !this.isDuplicateData( data ) : true
+               )
+       );
+
+       if (
+               this.allowArbitrary ||
+               this.allowDisplayInvalidTags ||
+               isValid
+       ) {
+               newitemWidget = this.createTagItemWidget( data, label );
+               newitemWidget.toggleValid( isValid );
+
+               this.addItems( [ newitemWidget ] );
+
+               return true;
+       }
+
+       return false;
+};
+
+/**
+ * Remove tag by its data property.
+ *
+ * @param {string|Object} data Tag data
+ */
+OO.ui.TagMultiselectWidget.prototype.removeTagByData = function ( data ) {
+       var item = this.getItemFromData( data );
+
+       this.removeItems( [ item ] );
+};
+
+/**
+ * Construct a OO.ui.TagItemWidget (or a subclass thereof) from given label 
and data.
+ *
+ * @protected
+ * @param {Mixed} data Custom data of any type.
+ * @param {string} label The label text.
+ * @return {OO.ui.TagItemWidget}
+ */
+OO.ui.TagMultiselectWidget.prototype.createTagItemWidget = function ( data, 
label ) {
+       label = label || ( typeof data === 'string' ? data : OO.getHash( data ) 
);
+
+       return new OO.ui.TagItemWidget( { data: data, label: label } );
+};
+
+/**
+ * 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.
+ *
+ * @protected
+ * @param {OO.ui.TagItemWidget} [item]
+ * @return {OO.ui.TagItemWidget|OO.ui.TextInputWidget} The next
+ *  item available. False if item is not in the list.
+ */
+OO.ui.TagMultiselectWidget.prototype.getNextItem = function ( item ) {
+       var itemIndex;
+
+       if (
+               item === undefined ||
+               this.items.indexOf( item ) === -1
+       ) {
+               return this.items[ 0 ];
+       }
+
+       itemIndex = this.items.indexOf( item );
+       if ( itemIndex === this.items.length - 1 ) { // Last item
+               if ( this.hasInput ) {
+                       return this.input.$input;
+               } else {
+                       // Return first item
+                       return this.items[ 0 ];
+               }
+       } 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.
+ *
+ * @protected
+ * @param {OO.ui.TagItemWidget} [item]
+ * @return {OO.ui.TagItemWidget|jQuery}
+ */
+OO.ui.TagMultiselectWidget.prototype.getPreviousItem = function ( item ) {
+       var itemIndex;
+
+       if ( item === undefined || this.items.indexOf( item ) === -1 ) {
+               return this.items[ this.items.length - 1 ];
+       }
+
+       itemIndex = this.items.indexOf( item );
+       if ( itemIndex === 0 ) {
+               if ( this.hasInput ) {
+                       return this.input.$input;
+               } else {
+                       // Return the last item
+                       return this.items[ this.items.length - 1 ];
+               }
+       } else {
+               return this.items[ itemIndex - 1 ];
+       }
+};
+/**
+ * Update the dimensions of the text input field to encompass all available 
area.
+ *
+ * @private
+ */
+OO.ui.TagMultiselectWidget.prototype.updateInputSize = function () {
+       var $lastItem, direction, contentWidth, currentWidth, bestWidth;
+       if ( this.inputPosition === 'inline' && !this.isDisabled() ) {
+               this.input.$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.getValue() === '' &&
+                       this.input.$input.attr( 'placeholder' ) !== undefined
+               ) {
+                       this.input.setValue( this.input.$input.attr( 
'placeholder' ) );
+                       this.contentWidthWithPlaceholder = this.input.$input[ 0 
].scrollWidth;
+                       this.input.setValue( '' );
+
+               }
+
+               // Always keep the input wide enough for the placeholder text
+               contentWidth = Math.max(
+                       this.input.$input[ 0 ].scrollWidth,
+                       // undefined arguments in Math.max lead to NaN
+                       ( this.contentWidthWithPlaceholder === undefined ) ?
+                               0 : this.contentWidthWithPlaceholder
+               );
+               currentWidth = this.input.$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.$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.TagMultiselectWidget.prototype.updateIfHeightChanged = function () {
+       var height = this.$element.height();
+       if ( height !== this.height ) {
+               this.height = height;
+               this.emit( 'resize' );
+       }
+};
+
+/**
+ * Check whether all items in the widget are valid
+ *
+ * @return {boolean} Widget is valid
+ */
+OO.ui.TagMultiselectWidget.prototype.checkValidity = function () {
+       // We're checking 'backwards' condition (if any item is invalid)
+       // rather than if all items are valid so that the loop breaks
+       // when it needs to and we don't have to go over all items
+       // unnecessarily
+       return !this.getItems().some( function ( item ) {
+               return !item.isValid();
+       } );
+};
+
+/**
+ * Set the valid state of this item
+ *
+ * @param {boolean} [valid] Item is valid
+ * @fires valid
+ */
+OO.ui.TagMultiselectWidget.prototype.toggleValid = function ( valid ) {
+       valid = valid === undefined ? !this.valid : !!valid;
+
+       if ( this.valid !== valid ) {
+               this.valid = valid;
+
+               this.setFlags( { invalid: !this.valid } );
+
+               this.emit( 'valid', this.valid );
+       }
+};
+
+/**
+ * Get the current valid state of the widget
+ *
+ * @return {boolean} Widget is valid
+ */
+OO.ui.TagMultiselectWidget.prototype.isValid = function () {
+       return this.valid;
+};
diff --git a/tests/index.php b/tests/index.php
index 8ef56cc..1fbd210 100644
--- a/tests/index.php
+++ b/tests/index.php
@@ -39,6 +39,7 @@
        <script src="./Element.test.js"></script>
        <script src="./Process.test.js"></script>
        <script src="./mixins/FlaggedElement.test.js"></script>
+       <script src="./widgets/TagMultiselectWidget.test.js"></script>
        <!-- JS/PHP comparison tests -->
        <script>OO.ui.JSPHPTestSuite = <?php echo $testSuiteJSON; ?></script>
        <script src="./JSPHP.test.standalone.js"></script>
diff --git a/tests/widgets/TagMultiselectWidget.test.js 
b/tests/widgets/TagMultiselectWidget.test.js
new file mode 100644
index 0000000..ec95168
--- /dev/null
+++ b/tests/widgets/TagMultiselectWidget.test.js
@@ -0,0 +1,244 @@
+( function () {
+       QUnit.module( 'TagMultiselectWidget' );
+
+       QUnit.test( 'Input positioning', 8, function ( assert ) {
+               var widget;
+
+               widget = new OO.ui.TagMultiselectWidget();
+               assert.ok(
+                       widget.$element.find( 'input' ).length === 1,
+                       'Basic widget (inputPosition:inline) has an input'
+               );
+               assert.ok(
+                       widget.$element.hasClass( 
'oo-ui-tagMultiselectWidget-inputPosition-inline' ),
+                       'Basic widget (inputPosition:inline) has an inline 
input class'
+               );
+               assert.ok(
+                       widget.$content.children( 'input' ).length,
+                       'Basic widget (inputPosition:inline) has its input 
placed inside the content'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { inputPosition: 
'outline' } );
+               assert.ok(
+                       widget.$element.find( 'input' ).length === 1,
+                       'Widget with inputPosition:outline has an input'
+               );
+               assert.ok(
+                       widget.$element.hasClass( 
'oo-ui-tagMultiselectWidget-inputPosition-outline' ),
+                       'Widget with inputPosition:outline has the correct 
class'
+               );
+               assert.ok(
+                       widget.$element.children( '.oo-ui-textInputWidget' 
).length,
+                       'Widget with inputPosition:outline has its input placed 
in the widget\'s element itself'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { inputPosition: 
'none' } );
+               assert.ok(
+                       widget.$element.find( 'input' ).length === 0,
+                       'Widget with inputPosition:none does not have an input'
+               );
+               assert.ok(
+                       widget.$element.hasClass( 
'oo-ui-tagMultiselectWidget-inputPosition-none' ),
+                       'Widget with inputPosition:none has the correct class'
+               );
+       } );
+
+       QUnit.test( 'addTag', 7, function ( assert ) {
+               var widget,
+                       getItemDatas = function ( items ) {
+                               return items.map( function ( item ) { return 
item.getData() } );
+                       };
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
true, allowedValues: [ 'foo', 'bar' ] } );
+               widget.addTag( 'foo' ); // In allowed list
+               widget.addTag( 'blip' ); // Not in allowed list
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'blip' ],
+                       'addTag: allowArbitrary:true Allows adding values 
outside the allowed list.'
+               );
+               assert.ok(
+                       widget.isValid(),
+                       'addTag: allowArbitrary:true The widget is still valid 
even with values outside the allowed list.'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowedValues: [ 'foo', 'bar' ] } );
+               widget.addTag( 'foo' ); // In allowed list
+               widget.addTag( 'blip' ); // Not in allowed list
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo' ],
+                       'addTag: allowArbitrary:false Prevents adding values 
outside the allowed list.'
+               );
+               assert.ok(
+                       widget.isValid(),
+                       'addTag: allowArbitrary:false The widget is always 
valid because non-allowed values are unacceptable.'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowDisplayInvalidTags: true, allowedValues: [ 'foo', 'bar' ] } );
+               widget.addTag( 'foo' ); // In allowed list
+               widget.addTag( 'blip' ); // Not in allowed list
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'blip' ],
+                       'addTag: allowArbitrary:false with 
allowDisplayInvalidTags:true Allows adding values outside the allowed list.'
+               );
+               assert.deepEqual(
+                       widget.getValue(),
+                       [ 'foo' ],
+                       'addTag: allowArbitrary:false with 
allowDisplayInvalidTags:true The value of the widget is only the tags with 
allowed values.'
+               );
+               assert.ok(
+                       !widget.isValid(),
+                       'addTag: allowArbitrary:false with 
allowDisplayInvalidTags:true The widget is invalid if it has non-allowed 
values.'
+               );
+       } );
+
+       QUnit.test( 'setValue', 8, function ( assert ) {
+               var widget,
+                       getItemDatas = function ( items ) {
+                               return items.map( function ( item ) { return 
item.getData() } );
+                       };
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: true 
} );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar', 'baz' ],
+                       'setValue with string array adds the tags to the 
widget.'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowedValues: [ 'foo', 'bar' ] } );
+               widget.setValue( [ 'foo', 'bar', 'foo' ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar' ],
+                       'setValue with string array and allowArbitrary:false 
ignores duplicates.'
+               );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar' ],
+                       'setValue with string array and allowArbitrary:false 
adds only allowed tags to the widget.'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowDuplicates: true, allowedValues: [ 'foo', 'bar' ] } );
+               widget.setValue( [ 'foo', 'bar', 'foo' ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar', 'foo' ],
+                       'setValue with string array and allowArbitrary:false 
with allowDuplicates:true adds duplicates.'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowDisplayInvalidTags: true, allowedValues: [ 'foo', 'bar' ] } );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar', 'baz' ],
+                       'setValue with string array and allowArbitrary:false 
with allowDisplayInvalidTags:true adds all tags to the widget.'
+               );
+
+               // Objects
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: true 
} );
+               widget.setValue( 'foo' );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo' ],
+                       'setValue with a single string adds the tag by its 
attributes.'
+               );
+
+               widget.setValue( { data: 'foo' } );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo' ],
+                       'setValue with a single object adds the tag by its 
attributes.'
+               );
+
+               widget.setValue( [ { data: 'foo' }, { data: 'bar' } ] );
+               assert.deepEqual(
+                       getItemDatas( widget.getItems() ),
+                       [ 'foo', 'bar' ],
+                       'setValue with an array of objects adds the tags by 
their attributes.'
+               );
+       } );
+
+       QUnit.test( 'getValue', 2, function ( assert ) {
+               var widget;
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: true 
} );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               assert.deepEqual(
+                       widget.getValue(),
+                       [ 'foo', 'bar', 'baz' ],
+                       'getValue with allowArbitrary:true outputs all inserted 
items\' datas'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
false, allowedValues: [ 'foo', 'bar' ] } );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               assert.deepEqual(
+                       widget.getValue(),
+                       [ 'foo', 'bar' ],
+                       'getValue with allowArbitrary:false and allowedValues, 
outputs only the legal items\' datas'
+               );
+       } );
+
+       QUnit.test( 'getNextItem', 3, function ( assert ) {
+               var items, widget;
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: true 
} );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               items = widget.getItems();
+
+               assert.deepEqual(
+                       widget.getNextItem( items[ 0 ] ).getData(),
+                       items[ 1 ].getData(),
+                       'Getting the next item from the first item.'
+               );
+
+               assert.equalDomElement(
+                       widget.getNextItem( items[ items.length - 1 ] )[ 0 ],
+                       widget.input.$input[ 0 ],
+                       'Getting the next item from the last item, returns the 
input.$input element (if inputPosition:inline or inputPosition:outline)'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
true, inputPosition: 'none' } );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               items = widget.getItems();
+
+               assert.deepEqual(
+                       widget.getNextItem( items[ items.length - 1 ] 
).getData(),
+                       items[ 0 ].getData(),
+                       'Getting the next item from the last item, returns the 
first item (if inputPosition:none)'
+               );
+       } );
+
+       QUnit.test( 'getPreviousItem', 3, function ( assert ) {
+               var items, widget;
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: true 
} );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               items = widget.getItems();
+
+               assert.equalDomElement(
+                       widget.getPreviousItem( items[ 0 ] )[ 0 ],
+                       widget.input.$input[ 0 ],
+                       'Getting the previous item from the first item returns 
the input.$input element (if inputPosition:inline or inputPosition:outline)'
+               );
+               assert.strictEqual(
+                       widget.getPreviousItem( items[ items.length - 1 ] ),
+                       items[ items.length - 2 ],
+                       'Getting the previous item from the last item'
+               );
+
+               widget = new OO.ui.TagMultiselectWidget( { allowArbitrary: 
true, inputPosition: 'none' } );
+               widget.setValue( [ 'foo', 'bar', 'baz' ] );
+               items = widget.getItems();
+
+               assert.strictEqual(
+                       widget.getPreviousItem( items[ 0 ] ),
+                       items[ items.length - 1 ],
+                       'Getting the previous item from the first item returns 
the last item (if inputPosition:none)'
+               );
+       } );
+
+} )();

-- 
To view, visit https://gerrit.wikimedia.org/r/344669
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ic216769f48e4677da5b7274f491aa08a95aa8076
Gerrit-PatchSet: 1
Gerrit-Project: oojs/ui
Gerrit-Branch: master
Gerrit-Owner: Mooeypoo <mor...@gmail.com>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to