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 <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits