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