aboutsummaryrefslogtreecommitdiffstats
path: root/resources/lib/oojs-ui/oojs-ui.js
diff options
context:
space:
mode:
authorJames D. Forrester <jforrester@wikimedia.org>2015-08-11 15:39:37 -0700
committerJames D. Forrester <jforrester@wikimedia.org>2015-08-11 15:39:37 -0700
commitf17ae78218685ee55b0a599b1b65932f2a5ca99a (patch)
tree28fe85fa6443f5b8b5a20e3c41eafa48c51c0d40 /resources/lib/oojs-ui/oojs-ui.js
parente8e711cabd1dbb618e30a5a624259fc15ab9b26c (diff)
downloadmediawikicore-f17ae78218685ee55b0a599b1b65932f2a5ca99a.tar.gz
mediawikicore-f17ae78218685ee55b0a599b1b65932f2a5ca99a.zip
Update OOjs UI to v0.12.3
Release notes: https://git.wikimedia.org/blob/oojs%2Fui.git/v0.12.3/History.md Change-Id: Idd3a4e41154837e84d1dcb4622d04b5765e6cbf9
Diffstat (limited to 'resources/lib/oojs-ui/oojs-ui.js')
-rw-r--r--resources/lib/oojs-ui/oojs-ui.js1260
1 files changed, 1208 insertions, 52 deletions
diff --git a/resources/lib/oojs-ui/oojs-ui.js b/resources/lib/oojs-ui/oojs-ui.js
index ed239a6e3eec..dd93fe3ed19b 100644
--- a/resources/lib/oojs-ui/oojs-ui.js
+++ b/resources/lib/oojs-ui/oojs-ui.js
@@ -1,12 +1,12 @@
/*!
- * OOjs UI v0.12.2
+ * OOjs UI v0.12.3
* https://www.mediawiki.org/wiki/OOjs_UI
*
* Copyright 2011–2015 OOjs UI Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
- * Date: 2015-07-28T23:01:32Z
+ * Date: 2015-08-11T22:34:00Z
*/
( function ( OO ) {
@@ -84,11 +84,12 @@ OO.ui.isFocusableElement = function ( $element ) {
!$element.parents().addBack().filter( function () {
return $.css( this, 'visibility' ) === 'hidden';
} ).length
- );
+ ),
+ isTabOk = isNaN( $element.attr( 'tabindex' ) ) || +$element.attr( 'tabindex' ) >= 0;
return (
( isInElementGroup ? !node.disabled : isOtherElement ) &&
- isVisible
+ isVisible && isTabOk
);
};
@@ -318,6 +319,34 @@ OO.ui.infuse = function ( idOrNode ) {
return msg;
};
+ /**
+ * @param {string} url
+ * @return {boolean}
+ */
+ OO.ui.isSafeUrl = function ( url ) {
+ var protocol,
+ // Keep in sync with php/Tag.php
+ whitelist = [
+ 'bitcoin:', 'ftp:', 'ftps:', 'geo:', 'git:', 'gopher:', 'http:', 'https:', 'irc:', 'ircs:',
+ 'magnet:', 'mailto:', 'mms:', 'news:', 'nntp:', 'redis:', 'sftp:', 'sip:', 'sips:', 'sms:', 'ssh:',
+ 'svn:', 'tel:', 'telnet:', 'urn:', 'worldwind:', 'xmpp:'
+ ];
+
+ if ( url.indexOf( ':' ) === -1 ) {
+ // No protocol, safe
+ return true;
+ }
+
+ protocol = url.split( ':', 1 )[0] + ':';
+ if ( !protocol.match( /^([A-za-z0-9\+\.\-])+:/ ) ) {
+ // Not a valid protocol, safe
+ return true;
+ }
+
+ // Safe if in the whitelist
+ return $.inArray( protocol, whitelist ) !== -1;
+ };
+
} )();
/*!
@@ -1076,7 +1105,7 @@ OO.ui.Element.static.tagName = 'div';
* DOM node.
*/
OO.ui.Element.static.infuse = function ( idOrNode ) {
- var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, true );
+ var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
// Verify that the type matches up.
// FIXME: uncomment after T89721 is fixed (see T90929)
/*
@@ -1092,12 +1121,14 @@ OO.ui.Element.static.infuse = function ( idOrNode ) {
* extra property so that only the top-level invocation touches the DOM.
* @private
* @param {string|HTMLElement|jQuery} idOrNode
- * @param {boolean} top True only for top-level invocation.
+ * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
+ * when the top-level widget of this infusion is inserted into DOM,
+ * replacing the original node; or false for top-level invocation.
* @return {OO.ui.Element}
*/
-OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
+OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
// look for a cached result of a previous infusion.
- var id, $elem, data, cls, parts, parent, obj;
+ var id, $elem, data, cls, parts, parent, obj, top, state;
if ( typeof idOrNode === 'string' ) {
id = idOrNode;
$elem = $( document.getElementById( id ) );
@@ -1105,7 +1136,10 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
$elem = $( idOrNode );
id = $elem.attr( 'id' );
}
- data = $elem.data( 'ooui-infused' );
+ if ( !$elem.length ) {
+ throw new Error( 'Widget not found: ' + id );
+ }
+ data = $elem.data( 'ooui-infused' ) || $elem[0].oouiInfused;
if ( data ) {
// cached!
if ( data === true ) {
@@ -1113,9 +1147,6 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
}
return data;
}
- if ( !$elem.length ) {
- throw new Error( 'Widget not found: ' + id );
- }
data = $elem.attr( 'data-ooui' );
if ( !data ) {
throw new Error( 'No infusion data found: ' + id );
@@ -1159,12 +1190,16 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
}
+ if ( domPromise === false ) {
+ top = $.Deferred();
+ domPromise = top.promise();
+ }
$elem.data( 'ooui-infused', true ); // prevent loops
data.id = id; // implicit
data = OO.copy( data, null, function deserialize( value ) {
if ( OO.isPlainObject( value ) ) {
if ( value.tag ) {
- return OO.ui.Element.static.unsafeInfuse( value.tag, false );
+ return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
}
if ( value.html ) {
return new OO.ui.HtmlSnippet( value.html );
@@ -1173,13 +1208,22 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
} );
// jscs:disable requireCapitalizedConstructors
obj = new cls( data ); // rebuild widget
+ // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
+ state = obj.gatherPreInfuseState( $elem );
// now replace old DOM with this new DOM.
if ( top ) {
$elem.replaceWith( obj.$element );
+ // This element is now gone from the DOM, but if anyone is holding a reference to it,
+ // let's allow them to OO.ui.infuse() it and do what they expect (T105828).
+ // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
+ $elem[0].oouiInfused = obj;
+ top.resolve();
}
obj.$element.data( 'ooui-infused', obj );
// set the 'data-ooui' attribute so we can identify infused widgets
obj.$element.attr( 'data-ooui', '' );
+ // restore dynamic state after the new element is inserted into DOM
+ domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
return obj;
};
@@ -1754,11 +1798,40 @@ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
};
/**
+ * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node
+ * (and its children) that represent an Element of the same type and configuration as the current
+ * one, generated by the PHP implementation.
+ *
+ * This method is called just before `node` is detached from the DOM. The return value of this
+ * function will be passed to #restorePreInfuseState after this widget's #$element is inserted into
+ * DOM to replace `node`.
+ *
+ * @protected
+ * @param {HTMLElement} node
+ * @return {Object}
+ */
+OO.ui.Element.prototype.gatherPreInfuseState = function () {
+ return {};
+};
+
+/**
+ * Restore the pre-infusion dynamic state for this widget.
+ *
+ * This method is called after #$element has been inserted into DOM. The parameter is the return
+ * value of #gatherPreInfuseState.
+ *
+ * @protected
+ * @param {Object} state
+ */
+OO.ui.Element.prototype.restorePreInfuseState = function () {
+};
+
+/**
* Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
* that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
* See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
* {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
- * and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
+ * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
*
* @abstract
* @class
@@ -2090,7 +2163,27 @@ OO.ui.Window.prototype.getManager = function () {
* @return {string} Symbolic name of the size: `small`, `medium`, `large`, `larger`, `full`
*/
OO.ui.Window.prototype.getSize = function () {
- return this.size;
+ var viewport = OO.ui.Element.static.getDimensions( this.getElementWindow() ),
+ sizes = this.manager.constructor.static.sizes,
+ size = this.size;
+
+ if ( !sizes[ size ] ) {
+ size = this.manager.constructor.static.defaultSize;
+ }
+ if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
+ size = 'full';
+ }
+
+ return size;
+};
+
+/**
+ * Get the size properties associated with the current window size
+ *
+ * @return {Object} Size properties
+ */
+OO.ui.Window.prototype.getSizeProperties = function () {
+ return this.manager.constructor.static.sizes[ this.getSize() ];
};
/**
@@ -3423,20 +3516,11 @@ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) {
return;
}
- var viewport = OO.ui.Element.static.getDimensions( win.getElementWindow() ),
- sizes = this.constructor.static.sizes,
- size = win.getSize();
+ var isFullscreen = win.getSize() === 'full';
- if ( !sizes[ size ] ) {
- size = this.constructor.static.defaultSize;
- }
- if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
- size = 'full';
- }
-
- this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', size === 'full' );
- this.$element.toggleClass( 'oo-ui-windowManager-floating', size !== 'full' );
- win.setDimensions( sizes[ size ] );
+ this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', isFullscreen );
+ this.$element.toggleClass( 'oo-ui-windowManager-floating', !isFullscreen );
+ win.setDimensions( win.getSizeProperties() );
this.emit( 'resize', win );
@@ -6174,7 +6258,8 @@ OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
* @return {boolean} The flag is set
*/
OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
- return flag in this.flags;
+ // This may be called before the constructor, thus before this.flags is set
+ return this.flags && ( flag in this.flags );
};
/**
@@ -6183,7 +6268,8 @@ OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
* @return {string[]} Flag names
*/
OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
- return Object.keys( this.flags );
+ // This may be called before the constructor, thus before this.flags is set
+ return Object.keys( this.flags || {} );
};
/**
@@ -8169,6 +8255,9 @@ OO.ui.ProcessDialog = function OoUiProcessDialog( config ) {
// Parent constructor
OO.ui.ProcessDialog.parent.call( this, config );
+ // Properties
+ this.fitOnOpen = false;
+
// Initialization
this.$element.addClass( 'oo-ui-processDialog' );
};
@@ -8311,21 +8400,47 @@ OO.ui.ProcessDialog.prototype.executeAction = function ( action ) {
};
/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.setDimensions = function () {
+ // Parent method
+ OO.ui.ProcessDialog.parent.prototype.setDimensions.apply( this, arguments );
+
+ this.fitLabel();
+};
+
+/**
* Fit label between actions.
*
* @private
* @chainable
*/
OO.ui.ProcessDialog.prototype.fitLabel = function () {
- var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth;
+ var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth,
+ size = this.getSizeProperties();
+
+ if ( typeof size.width !== 'number' ) {
+ if ( this.isOpened() ) {
+ navigationWidth = this.$head.width() - 20;
+ } else if ( this.isOpening() ) {
+ if ( !this.fitOnOpen ) {
+ // Size is relative and the dialog isn't open yet, so wait.
+ this.manager.opening.done( this.fitLabel.bind( this ) );
+ this.fitOnOpen = true;
+ }
+ return;
+ } else {
+ return;
+ }
+ } else {
+ navigationWidth = size.width - 20;
+ }
safeWidth = this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0;
primaryWidth = this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0;
biggerWidth = Math.max( safeWidth, primaryWidth );
labelWidth = this.title.$element.width();
- // Is there a better way to calculate this?
- navigationWidth = OO.ui.WindowManager.static.sizes[ this.getSize() ].width - 20;
if ( 2 * biggerWidth + labelWidth < navigationWidth ) {
// We have enough space to center the label
@@ -8419,6 +8534,7 @@ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) {
.first( function () {
// Make sure to hide errors
this.hideErrors();
+ this.fitOnOpen = false;
}, this );
};
@@ -8445,13 +8561,19 @@ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) {
* @class
* @extends OO.ui.Layout
* @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {OO.ui.Widget} fieldWidget Field widget
* @param {Object} [config] Configuration options
* @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
- * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a help icon will appear
- * in the upper-right corner of the rendered field.
+ * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
+ * The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
+ * The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
+ * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
+ * For important messages, you are advised to use `notices`, as they are always shown.
*/
OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
// Allow passing positional parameters inside the config object
@@ -8461,7 +8583,7 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
}
var hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel,
- div;
+ div, i;
// Configuration initialization
config = $.extend( { align: 'left' }, config );
@@ -8471,10 +8593,14 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
// Mixin constructors
OO.ui.mixin.LabelElement.call( this, config );
+ OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
// Properties
this.fieldWidget = fieldWidget;
+ this.errors = config.errors || [];
+ this.notices = config.notices || [];
this.$field = $( '<div>' );
+ this.$messages = $( '<ul>' );
this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' );
this.align = null;
if ( config.help ) {
@@ -8508,12 +8634,23 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
this.$element
.addClass( 'oo-ui-fieldLayout' )
.append( this.$help, this.$body );
+ if ( this.errors.length || this.notices.length ) {
+ this.$element.append( this.$messages );
+ }
this.$body.addClass( 'oo-ui-fieldLayout-body' );
+ this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
this.$field
.addClass( 'oo-ui-fieldLayout-field' )
.toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() )
.append( this.fieldWidget.$element );
+ for ( i = 0; i < this.notices.length; i++ ) {
+ this.$messages.append( this.makeMessage( 'notice', this.notices[i] ) );
+ }
+ for ( i = 0; i < this.errors.length; i++ ) {
+ this.$messages.append( this.makeMessage( 'error', this.errors[i] ) );
+ }
+
this.setAlignment( config.align );
};
@@ -8521,6 +8658,7 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
/* Methods */
@@ -8555,6 +8693,28 @@ OO.ui.FieldLayout.prototype.getField = function () {
};
/**
+ * @param {string} kind 'error' or 'notice'
+ * @param {string|OO.ui.HtmlSnippet} text
+ * @return {jQuery}
+ */
+OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
+ var $listItem, $icon, message;
+ $listItem = $( '<li>' );
+ if ( kind === 'error' ) {
+ $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
+ } else if ( kind === 'notice' ) {
+ $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
+ } else {
+ $icon = '';
+ }
+ message = new OO.ui.LabelWidget( { label: text } );
+ $listItem
+ .append( $icon, message.$element )
+ .addClass( 'oo-ui-fieldLayout-messages-' + kind );
+ return $listItem;
+};
+
+/**
* Set the field alignment mode.
*
* @private
@@ -8829,6 +8989,11 @@ OO.ui.FormLayout = function OoUiFormLayout( config ) {
// Events
this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
+ // Make sure the action is safe
+ if ( config.action !== undefined && !OO.ui.isSafeUrl( config.action ) ) {
+ throw new Error( 'Potentially unsafe action provided: ' + config.action );
+ }
+
// Initialization
this.$element
.addClass( 'oo-ui-formLayout' )
@@ -10577,6 +10742,53 @@ OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem )
};
/**
+ * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
+ * items), with small margins between them. Convenient when you need to put a number of block-level
+ * widgets on a single line next to each other.
+ *
+ * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
+ *
+ * @example
+ * // HorizontalLayout with a text input and a label
+ * var layout = new OO.ui.HorizontalLayout( {
+ * items: [
+ * new OO.ui.LabelWidget( { label: 'Label' } ),
+ * new OO.ui.TextInputWidget( { value: 'Text' } )
+ * ]
+ * } );
+ * $( 'body' ).append( layout.$element );
+ *
+ * @class
+ * @extends OO.ui.Layout
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
+ */
+OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
+ // Configuration initialization
+ config = config || {};
+
+ // Parent constructor
+ OO.ui.HorizontalLayout.parent.call( this, config );
+
+ // Mixin constructors
+ OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+ // Initialization
+ this.$element.addClass( 'oo-ui-horizontalLayout' );
+ if ( Array.isArray( config.items ) ) {
+ this.addItems( config.items );
+ }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
+OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
+
+/**
* BarToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
* create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
* and {@link OO.ui.ListToolGroup ListToolGroup}). The {@link OO.ui.Tool tools} in a BarToolGroup are
@@ -10864,6 +11076,7 @@ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) {
* deactivation.
*/
OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
+ var containerWidth, containerLeft;
value = !!value;
if ( this.active !== value ) {
this.active = value;
@@ -10871,6 +11084,7 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true );
this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true );
+ this.$clippable.css( 'left', '' );
// Try anchoring the popup to the left first
this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' );
this.toggleClipping( true );
@@ -10882,6 +11096,19 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
.addClass( 'oo-ui-popupToolGroup-right' );
this.toggleClipping( true );
}
+ if ( this.isClippedHorizontally() ) {
+ // Anchoring to the right also caused the popup to clip, so just make it fill the container
+ containerWidth = this.$clippableContainer.width();
+ containerLeft = this.$clippableContainer.offset().left;
+
+ this.toggleClipping( false );
+ this.$element.removeClass( 'oo-ui-popupToolGroup-right' );
+
+ this.$clippable.css( {
+ left: -( this.$element.offset().left - containerLeft ),
+ width: containerWidth
+ } );
+ }
} else {
this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true );
this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true );
@@ -11978,6 +12205,12 @@ OO.ui.ButtonWidget.prototype.getNoFollow = function () {
*/
OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
href = typeof href === 'string' ? href : null;
+ if ( href !== null ) {
+ if ( !OO.ui.isSafeUrl( href ) ) {
+ throw new Error( 'Potentially unsafe href provided: ' + href );
+ }
+
+ }
if ( href !== this.href ) {
this.href = href;
@@ -12399,6 +12632,651 @@ OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
};
/**
+ * CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxWidget combo box widget}
+ * that allows for selecting multiple values.
+ *
+ * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * @example
+ * // Example: A CapsuleMultiSelectWidget.
+ * var capsule = new OO.ui.CapsuleMultiSelectWidget( {
+ * label: 'CapsuleMultiSelectWidget',
+ * selected: [ 'Option 1', 'Option 3' ],
+ * menu: {
+ * items: [
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'Option 1',
+ * label: 'Option One'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'Option 2',
+ * label: 'Option Two'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'Option 3',
+ * label: 'Option Three'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'Option 4',
+ * label: 'Option Four'
+ * } ),
+ * new OO.ui.MenuOptionWidget( {
+ * data: 'Option 5',
+ * label: 'Option Five'
+ * } )
+ * ]
+ * }
+ * } );
+ * $( 'body' ).append( capsule.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu.
+ * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
+ * @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
+ * If specified, this popup will be shown instead of the menu (but the menu
+ * will still be used for item labels and allowArbitrary=false). The widgets
+ * in the popup should use this.addItemsFromData() or this.addItems() as necessary.
+ * @cfg {jQuery} [$overlay] Render the menu or popup into a separate layer.
+ * This configuration is useful in cases where the expanded menu is larger than
+ * its containing `<div>`. The specified overlay layer is usually on top of
+ * the containing `<div>` and has a larger area. By default, the menu uses
+ * relative positioning.
+ */
+OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget( config ) {
+ var $tabFocus;
+
+ // Configuration initialization
+ config = config || {};
+
+ // Parent constructor
+ OO.ui.CapsuleMultiSelectWidget.parent.call( this, config );
+
+ // Properties (must be set before mixin constructor calls)
+ this.$input = config.popup ? null : $( '<input>' );
+ this.$handle = $( '<div>' );
+
+ // Mixin constructors
+ OO.ui.mixin.GroupElement.call( this, config );
+ if ( config.popup ) {
+ config.popup = $.extend( {}, config.popup, {
+ align: 'forwards',
+ anchor: false
+ } );
+ OO.ui.mixin.PopupElement.call( this, config );
+ $tabFocus = $( '<span>' );
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
+ } else {
+ this.popup = null;
+ $tabFocus = null;
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
+ }
+ OO.ui.mixin.IndicatorElement.call( this, config );
+ OO.ui.mixin.IconElement.call( this, config );
+
+ // Properties
+ this.allowArbitrary = !!config.allowArbitrary;
+ this.$overlay = config.$overlay || this.$element;
+ this.menu = new OO.ui.MenuSelectWidget( $.extend(
+ {
+ widget: this,
+ $input: this.$input,
+ filterFromInput: true,
+ disabled: this.isDisabled()
+ },
+ config.menu
+ ) );
+
+ // Events
+ if ( this.popup ) {
+ $tabFocus.on( {
+ focus: this.onFocusForPopup.bind( this )
+ } );
+ this.popup.connect( this, {
+ toggle: function ( visible ) {
+ $tabFocus.toggle( !visible );
+ }
+ } );
+ } else {
+ this.$input.on( {
+ focus: this.onInputFocus.bind( this ),
+ blur: this.onInputBlur.bind( this ),
+ 'propertychange change click mouseup keydown keyup input cut paste select': this.onInputChange.bind( this ),
+ keydown: this.onKeyDown.bind( this ),
+ keypress: this.onKeyPress.bind( this )
+ } );
+ }
+ this.menu.connect( this, {
+ choose: 'onMenuChoose',
+ add: 'onMenuItemsChange',
+ remove: 'onMenuItemsChange'
+ } );
+ this.$handle.on( {
+ click: this.onClick.bind( this )
+ } );
+
+ // Initialization
+ if ( this.$input ) {
+ this.$input.prop( 'disabled', this.isDisabled() );
+ this.$input.attr( {
+ role: 'combobox',
+ 'aria-autocomplete': 'list'
+ } );
+ this.$input.width( '1em' );
+ }
+ if ( config.data ) {
+ this.setItemsFromData( config.data );
+ }
+ this.$group.addClass( 'oo-ui-capsuleMultiSelectWidget-group' );
+ this.$handle.addClass( 'oo-ui-capsuleMultiSelectWidget-handle' )
+ .append( this.$indicator, this.$icon, this.$group );
+ this.$element.addClass( 'oo-ui-capsuleMultiSelectWidget' )
+ .append( this.$handle );
+ if ( this.popup ) {
+ this.$handle.append( $tabFocus );
+ this.$overlay.append( this.popup.$element );
+ } else {
+ this.$handle.append( this.$input );
+ this.$overlay.append( this.menu.$element );
+ }
+ this.onMenuItemsChange();
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.GroupElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.PopupElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IconElement );
+
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the set of selected items changes.
+ *
+ * @param {Mixed[]} datas Data of the now-selected items
+ */
+
+/* Methods */
+
+/**
+ * Get the data of the items in the capsule
+ * @return {Mixed[]}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.getItemsData = function () {
+ return $.map( this.getItems(), function ( e ) { return e.data; } );
+};
+
+/**
+ * Set the items in the capsule by providing data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.setItemsFromData = function ( datas ) {
+ var widget = this,
+ menu = this.menu,
+ items = this.getItems();
+
+ $.each( datas, function ( i, data ) {
+ var j, label,
+ item = menu.getItemFromData( data );
+
+ if ( item ) {
+ label = item.label;
+ } else if ( widget.allowArbitrary ) {
+ label = String( data );
+ } else {
+ return;
+ }
+
+ item = null;
+ for ( j = 0; j < items.length; j++ ) {
+ if ( items[j].data === data && items[j].label === label ) {
+ item = items[j];
+ items.splice( j, 1 );
+ break;
+ }
+ }
+ if ( !item ) {
+ item = new OO.ui.CapsuleItemWidget( { data: data, label: label } );
+ }
+ widget.addItems( [ item ], i );
+ } );
+
+ if ( items.length ) {
+ widget.removeItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * Add items to the capsule by providing their data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) {
+ var widget = this,
+ menu = this.menu,
+ items = [];
+
+ $.each( datas, function ( i, data ) {
+ var item;
+
+ if ( !widget.getItemFromData( data ) ) {
+ item = menu.getItemFromData( data );
+ if ( item ) {
+ items.push( new OO.ui.CapsuleItemWidget( { data: data, label: item.label } ) );
+ } else if ( widget.allowArbitrary ) {
+ items.push( new OO.ui.CapsuleItemWidget( { data: data, label: String( data ) } ) );
+ }
+ }
+ } );
+
+ if ( items.length ) {
+ this.addItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * Remove items by data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) {
+ var widget = this,
+ items = [];
+
+ $.each( datas, function ( i, data ) {
+ var item = widget.getItemFromData( data );
+ if ( item ) {
+ items.push( item );
+ }
+ } );
+
+ if ( items.length ) {
+ this.removeItems( items );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) {
+ var same, i, l,
+ oldItems = this.items.slice();
+
+ OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
+
+ if ( this.items.length !== oldItems.length ) {
+ same = false;
+ } else {
+ same = true;
+ for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+ same = same && this.items[i] === oldItems[i];
+ }
+ }
+ if ( !same ) {
+ this.emit( 'change', this.getItemsData() );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) {
+ var same, i, l,
+ oldItems = this.items.slice();
+
+ OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
+
+ if ( this.items.length !== oldItems.length ) {
+ same = false;
+ } else {
+ same = true;
+ for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+ same = same && this.items[i] === oldItems[i];
+ }
+ }
+ if ( !same ) {
+ this.emit( 'change', this.getItemsData() );
+ }
+
+ return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () {
+ if ( this.items.length ) {
+ OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
+ this.emit( 'change', this.getItemsData() );
+ }
+ return this;
+};
+
+/**
+ * Get the capsule widget's menu.
+ * @return {OO.ui.MenuSelectWidget} Menu widget
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () {
+ return this.menu;
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () {
+ if ( !this.isDisabled() ) {
+ this.menu.toggle( true );
+ }
+};
+
+/**
+ * Handle blur events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () {
+ this.clearInput();
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () {
+ if ( !this.isDisabled() ) {
+ this.popup.setSize( this.$handle.width() );
+ this.popup.toggle( true );
+ this.popup.$element.find( '*' )
+ .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+ .first()
+ .focus();
+ }
+};
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onClick = function ( e ) {
+ if ( e.which === 1 ) {
+ this.focus();
+ return false;
+ }
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) {
+ var item;
+
+ if ( !this.isDisabled() ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
+ this.clearInput();
+ return false;
+ }
+
+ if ( !this.popup ) {
+ this.menu.toggle( true );
+ if ( e.which === OO.ui.Keys.ENTER ) {
+ item = this.menu.getItemFromLabel( this.$input.val(), true );
+ if ( item ) {
+ this.addItemsFromData( [ item.data ] );
+ this.clearInput();
+ } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
+ this.addItemsFromData( [ this.$input.val() ] );
+ this.clearInput();
+ }
+ return false;
+ }
+
+ // Make sure the input gets resized.
+ setTimeout( this.onInputChange.bind( this ), 0 );
+ }
+ }
+};
+
+/**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) {
+ if ( !this.isDisabled() ) {
+ // 'keypress' event is not triggered for Backspace
+ if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) {
+ if ( this.items.length ) {
+ this.removeItems( this.items.slice( -1 ) );
+ }
+ return false;
+ }
+ }
+};
+
+/**
+ * Handle input change events.
+ *
+ * @private
+ * @param {jQuery.Event} e Event of some sort
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputChange = function () {
+ if ( !this.isDisabled() ) {
+ this.$input.width( this.$input.val().length + 'em' );
+ }
+};
+
+/**
+ * Handle menu choose events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget} item Chosen item
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) {
+ if ( item && item.isVisible() ) {
+ this.addItemsFromData( [ item.getData() ] );
+ this.clearInput();
+ }
+};
+
+/**
+ * Handle menu item change events.
+ *
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () {
+ this.setItemsFromData( this.getItemsData() );
+ this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() );
+};
+
+/**
+ * Clear the input field
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () {
+ if ( this.$input ) {
+ this.$input.val( '' );
+ this.$input.width( '1em' );
+ }
+ if ( this.popup ) {
+ this.popup.toggle( false );
+ }
+ this.menu.toggle( false );
+ this.menu.selectItem();
+ this.menu.highlightItem();
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) {
+ var i, len;
+
+ // Parent method
+ OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled );
+
+ if ( this.$input ) {
+ this.$input.prop( 'disabled', this.isDisabled() );
+ }
+ if ( this.menu ) {
+ this.menu.setDisabled( this.isDisabled() );
+ }
+ if ( this.popup ) {
+ this.popup.setDisabled( this.isDisabled() );
+ }
+
+ if ( this.items ) {
+ for ( i = 0, len = this.items.length; i < len; i++ ) {
+ this.items[i].updateDisabled();
+ }
+ }
+
+ return this;
+};
+
+/**
+ * Focus the widget
+ * @chainable
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () {
+ if ( !this.isDisabled() ) {
+ if ( this.popup ) {
+ this.popup.setSize( this.$handle.width() );
+ this.popup.toggle( true );
+ this.popup.$element.find( '*' )
+ .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+ .first()
+ .focus();
+ } else {
+ this.menu.toggle( true );
+ this.$input.focus();
+ }
+ }
+ return this;
+};
+
+/**
+ * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget
+ * CapsuleMultiSelectWidget} to display the selected items.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.ItemWidget
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
+ // Configuration initialization
+ config = config || {};
+
+ // Parent constructor
+ OO.ui.CapsuleItemWidget.parent.call( this, config );
+
+ // Properties (must be set before mixin constructor calls)
+ this.$indicator = $( '<span>' );
+
+ // Mixin constructors
+ OO.ui.mixin.ItemWidget.call( this );
+ OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) );
+ OO.ui.mixin.LabelElement.call( this, config );
+ OO.ui.mixin.FlaggedElement.call( this, config );
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
+
+ // Events
+ this.$indicator.on( {
+ keydown: this.onCloseKeyDown.bind( this ),
+ click: this.onCloseClick.bind( this )
+ } );
+ this.$element.on( 'click', false );
+
+ // Initialization
+ this.$element
+ .addClass( 'oo-ui-capsuleItemWidget' )
+ .append( this.$indicator, this.$label );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Handle close icon clicks
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
+ var element = this.getElementGroup();
+
+ if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) {
+ element.removeItems( [ this ] );
+ element.focus();
+ }
+};
+
+/**
+ * Handle close keyboard events
+ * @param {jQuery.Event} event Key down event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) {
+ if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) {
+ switch ( e.which ) {
+ case OO.ui.Keys.ENTER:
+ case OO.ui.Keys.BACKSPACE:
+ case OO.ui.Keys.SPACE:
+ this.getElementGroup().removeItems( [ this ] );
+ return false;
+ }
+ }
+};
+
+/**
* DropdownWidgets are not menus themselves, rather they contain a menu of options created with
* OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
* users can interact with it.
@@ -13088,6 +13966,7 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) {
// Initialization
this.$input
+ .addClass( 'oo-ui-inputWidget-input' )
.attr( 'name', config.name )
.prop( 'disabled', this.isDisabled() );
this.$element
@@ -13261,6 +14140,32 @@ OO.ui.InputWidget.prototype.blur = function () {
};
/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.prototype.gatherPreInfuseState = function ( node ) {
+ var
+ state = OO.ui.InputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
+ $input = state.$input || $( node ).find( '.oo-ui-inputWidget-input' );
+ state.value = $input.val();
+ // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
+ state.focus = $input.is( ':focus' );
+ return state;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
+ OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+ if ( state.value !== undefined && state.value !== this.getValue() ) {
+ this.setValue( state.value );
+ }
+ if ( state.focus ) {
+ this.focus();
+ }
+};
+
+/**
* ButtonInputWidget is used to submit HTML forms and is intended to be used within
* a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
* want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
@@ -13507,6 +14412,28 @@ OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
};
/**
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.prototype.gatherPreInfuseState = function ( node ) {
+ var
+ state = OO.ui.CheckboxInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
+ $input = $( node ).find( '.oo-ui-inputWidget-input' );
+ state.$input = $input; // shortcut for performance, used in InputWidget
+ state.checked = $input.prop( 'checked' );
+ return state;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
+ OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+ if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
+ this.setSelected( state.checked );
+ }
+};
+
+/**
* DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
* within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
* of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
@@ -13533,6 +14460,7 @@ OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
*
* @class
* @extends OO.ui.InputWidget
+ * @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {Object} [config] Configuration options
@@ -13548,6 +14476,9 @@ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
// Parent constructor
OO.ui.DropdownInputWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.TitledElement.call( this, config );
+
// Events
this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
@@ -13561,6 +14492,7 @@ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
/* Setup */
OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
+OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
/* Methods */
@@ -13752,6 +14684,28 @@ OO.ui.RadioInputWidget.prototype.isSelected = function () {
};
/**
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.prototype.gatherPreInfuseState = function ( node ) {
+ var
+ state = OO.ui.RadioInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
+ $input = $( node ).find( '.oo-ui-inputWidget-input' );
+ state.$input = $input; // shortcut for performance, used in InputWidget
+ state.checked = $input.prop( 'checked' );
+ return state;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
+ OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+ if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
+ this.setSelected( state.checked );
+ }
+};
+
+/**
* RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
* within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
* of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
@@ -13883,6 +14837,15 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
};
/**
+ * @inheritdoc
+ */
+OO.ui.RadioSelectInputWidget.prototype.gatherPreInfuseState = function ( node ) {
+ var state = OO.ui.RadioSelectInputWidget.parent.prototype.gatherPreInfuseState.call( this, node );
+ state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
+ return state;
+};
+
+/**
* TextInputWidgets, like HTML text inputs, can be configured with options that customize the
* size of the field as well as its presentation. In addition, these widgets can be configured
* with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
@@ -13937,7 +14900,7 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
* pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
* (the value must contain only numbers); when RegExp, a regular expression that must match the
* value for it to be considered valid; when Function, a function receiving the value as parameter
- * that must return true, or promise resolving to true, for it to be considered valid.
+ * that must return true, or promise that resolves, for it to be considered valid.
*/
OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
// Configuration initialization
@@ -14383,7 +15346,11 @@ OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
if ( isValid !== undefined ) {
setFlag( isValid );
} else {
- this.isValid().done( setFlag );
+ this.getValidity().then( function () {
+ setFlag( true );
+ }, function () {
+ setFlag( false );
+ } );
}
};
@@ -14393,6 +15360,7 @@ OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
* This method returns a promise that resolves with a boolean `true` if the current value is
* considered valid according to the supplied {@link #validate validation pattern}.
*
+ * @deprecated
* @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid.
*/
OO.ui.TextInputWidget.prototype.isValid = function () {
@@ -14409,6 +15377,50 @@ OO.ui.TextInputWidget.prototype.isValid = function () {
};
/**
+ * Get the validity of current value.
+ *
+ * This method returns a promise that resolves if the value is valid and rejects if
+ * it isn't. Uses the {@link #validate validation pattern} to check for validity.
+ *
+ * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
+ */
+OO.ui.TextInputWidget.prototype.getValidity = function () {
+ var result, promise;
+
+ function rejectOrResolve( valid ) {
+ if ( valid ) {
+ return $.Deferred().resolve().promise();
+ } else {
+ return $.Deferred().reject().promise();
+ }
+ }
+
+ if ( this.validate instanceof Function ) {
+ result = this.validate( this.getValue() );
+
+ if ( $.isFunction( result.promise ) ) {
+ promise = $.Deferred();
+
+ result.then( function ( valid ) {
+ if ( valid ) {
+ promise.resolve();
+ } else {
+ promise.reject();
+ }
+ }, function () {
+ promise.reject();
+ } );
+
+ return promise.promise();
+ } else {
+ return rejectOrResolve( result );
+ }
+ } else {
+ return rejectOrResolve( this.getValue().match( this.validate ) );
+ }
+};
+
+/**
* Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
*
* @param {string} labelPosition Label position, 'before' or 'after'
@@ -14443,9 +15455,7 @@ OO.ui.TextInputWidget.prototype.updatePosition = function () {
.toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
.toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
- if ( this.label ) {
- this.positionLabel();
- }
+ this.positionLabel();
return this;
};
@@ -14496,6 +15506,30 @@ OO.ui.TextInputWidget.prototype.positionLabel = function () {
};
/**
+ * @inheritdoc
+ */
+OO.ui.TextInputWidget.prototype.gatherPreInfuseState = function ( node ) {
+ var
+ state = OO.ui.TextInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
+ $input = $( node ).find( '.oo-ui-inputWidget-input' );
+ state.$input = $input; // shortcut for performance, used in InputWidget
+ if ( this.multiline ) {
+ state.scrollTop = $input.scrollTop();
+ }
+ return state;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
+ OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+ if ( state.scrollTop !== undefined ) {
+ this.$input.scrollTop( state.scrollTop );
+ }
+};
+
+/**
* ComboBoxWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
* can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
* a value can be chosen instead). Users can choose options from the combo box in one of two ways:
@@ -14886,7 +15920,7 @@ OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
* @return {boolean} Item is selectable
*/
OO.ui.OptionWidget.prototype.isSelectable = function () {
- return this.constructor.static.selectable && !this.isDisabled();
+ return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
};
/**
@@ -14897,7 +15931,7 @@ OO.ui.OptionWidget.prototype.isSelectable = function () {
* @return {boolean} Item is highlightable
*/
OO.ui.OptionWidget.prototype.isHighlightable = function () {
- return this.constructor.static.highlightable && !this.isDisabled();
+ return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
};
/**
@@ -14907,7 +15941,7 @@ OO.ui.OptionWidget.prototype.isHighlightable = function () {
* @return {boolean} Item is pressable
*/
OO.ui.OptionWidget.prototype.isPressable = function () {
- return this.constructor.static.pressable && !this.isDisabled();
+ return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
};
/**
@@ -15072,14 +16106,17 @@ OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
*/
OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
// Configuration initialization
- config = $.extend( { tabIndex: -1 }, config );
+ config = config || {};
// Parent constructor
OO.ui.ButtonOptionWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ButtonElement.call( this, config );
- OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
+ $tabIndexed: this.$button,
+ tabIndex: -1
+ } ) );
// Initialization
this.$element.addClass( 'oo-ui-buttonOptionWidget' );
@@ -15538,6 +16575,10 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
// Events
this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
+ this.$element.on( 'focusout', this.onFocusOut.bind( this ) );
+ if ( this.$autoCloseIgnore ) {
+ this.$autoCloseIgnore.on( 'focusout', this.onFocusOut.bind( this ) );
+ }
// Initialization
this.toggleAnchor( config.anchor === undefined || config.anchor );
@@ -15579,6 +16620,26 @@ OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
/* Methods */
/**
+ * Handles focus out events.
+ *
+ * @private
+ * @param {Event} e Focus out event
+ */
+OO.ui.PopupWidget.prototype.onFocusOut = function () {
+ var widget = this;
+
+ setTimeout( function () {
+ if (
+ widget.isVisible() &&
+ !OO.ui.contains( widget.$element, document.activeElement, true ) &&
+ ( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length )
+ ) {
+ widget.toggle( false );
+ }
+ } );
+};
+
+/**
* Handles mouse down events.
*
* @private
@@ -16468,7 +17529,7 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
this.keyPressBuffer += c;
}
- filter = this.getItemMatcher( this.keyPressBuffer );
+ filter = this.getItemMatcher( this.keyPressBuffer, false );
if ( !item || !filter( item ) ) {
item = this.getRelativeSelectableItem( item, 1, filter );
}
@@ -16489,15 +17550,21 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
*
* @protected
* @param {string} s String to match against items
+ * @param {boolean} [exact=false] Only accept exact matches
* @return {Function} function ( OO.ui.OptionItem ) => boolean
*/
-OO.ui.SelectWidget.prototype.getItemMatcher = function ( s ) {
+OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
var re;
if ( s.normalize ) {
s = s.normalize();
}
- re = new RegExp( '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' ), 'i' );
+ s = exact ? s.trim() : s.replace( /^\s+/, '' );
+ re = '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
+ if ( exact ) {
+ re += '\\s*$';
+ }
+ re = new RegExp( re, 'i' );
return function ( item ) {
var l = item.getLabel();
if ( typeof l !== 'string' ) {
@@ -16635,6 +17702,62 @@ OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
};
/**
+ * Fetch an item by its label.
+ *
+ * @param {string} label Label of the item to select.
+ * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
+ * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
+ */
+OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
+ var i, item, found,
+ len = this.items.length,
+ filter = this.getItemMatcher( label, true );
+
+ for ( i = 0; i < len; i++ ) {
+ item = this.items[i];
+ if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
+ return item;
+ }
+ }
+
+ if ( prefix ) {
+ found = null;
+ filter = this.getItemMatcher( label, false );
+ for ( i = 0; i < len; i++ ) {
+ item = this.items[i];
+ if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
+ if ( found ) {
+ return null;
+ }
+ found = item;
+ }
+ }
+ if ( found ) {
+ return found;
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Programmatically select an option by its label. If the item does not exist,
+ * all options will be deselected.
+ *
+ * @param {string} [label] Label of the item to select.
+ * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
+ * @fires select
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
+ var itemFromLabel = this.getItemFromLabel( label, !!prefix );
+ if ( label === undefined || !itemFromLabel ) {
+ return this.selectItem();
+ }
+ return this.selectItem( itemFromLabel );
+};
+
+/**
* Programmatically select an option by its data. If the `data` parameter is omitted,
* or if the item does not exist, all options will be deselected.
*
@@ -17011,11 +18134,14 @@ OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
* @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
* the text the user types. This config is used by {@link OO.ui.ComboBoxWidget ComboBoxWidget}
* and {@link OO.ui.mixin.LookupElement LookupElement}
+ * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
+ * the text the user types. This config is used by {@link OO.ui.CapsuleMultiSelectWidget CapsuleMultiSelectWidget}
* @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
* anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
* that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
* that button, unless the button (or its parent widget) is passed in here.
* @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
+ * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
*/
OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
// Configuration initialization
@@ -17030,9 +18156,11 @@ OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
// Properties
this.newItems = null;
this.autoHide = config.autoHide === undefined || !!config.autoHide;
- this.$input = config.input ? config.input.$input : null;
+ this.filterFromInput = !!config.filterFromInput;
+ this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
this.$widget = config.widget ? config.widget.$element : null;
this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
+ this.onInputKeyPressHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
// Initialization
this.$element
@@ -17103,6 +18231,27 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
};
/**
+ * Update menu item visibility after input key press
+ * @protected
+ */
+OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
+ var i, item,
+ len = this.items.length,
+ showAll = !this.isVisible(),
+ filter = showAll ? null : this.getItemMatcher( this.$input.val() );
+
+ for ( i = 0; i < len; i++ ) {
+ item = this.items[i];
+ if ( item instanceof OO.ui.OptionWidget ) {
+ item.toggle( showAll || filter( item ) );
+ }
+ }
+
+ // Reevaluate clipping
+ this.clip();
+};
+
+/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
@@ -17128,7 +18277,11 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
- if ( !this.$input ) {
+ if ( this.$input ) {
+ if ( this.filterFromInput ) {
+ this.$input.on( 'keypress', this.onInputKeyPressHandler );
+ }
+ } else {
OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
}
};
@@ -17138,7 +18291,10 @@ OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
*/
OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
if ( this.$input ) {
- this.clearKeyPressBuffer();
+ if ( this.filterFromInput ) {
+ this.$input.off( 'keypress', this.onInputKeyPressHandler );
+ this.updateItemVisibility();
+ }
} else {
OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
}