Update OOjs UI to v0.12.3
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui.js
index b67289b..dd93fe3 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.11.8
+ * OOjs UI v0.12.3
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs Team and other contributors.
+ * Copyright 2011–2015 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-07-08T01:31:38Z
+ * 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, 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 );
@@ -1132,16 +1163,43 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) {
                // Special case: this is a raw Tag; wrap existing node, don't rebuild.
                return new OO.ui.Element( { $element: $elem } );
        }
-       cls = OO.ui[data._];
-       if ( !cls ) {
-               throw new Error( 'Unknown widget type: ' + id );
+       parts = data._.split( '.' );
+       cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
+       if ( cls === undefined ) {
+               // The PHP output might be old and not including the "OO.ui" prefix
+               // TODO: Remove this back-compat after next major release
+               cls = OO.getProp.apply( OO, [ OO.ui ].concat( parts ) );
+               if ( cls === undefined ) {
+                       throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
+               }
+       }
+
+       // Verify that we're creating an OO.ui.Element instance
+       parent = cls.parent;
+
+       while ( parent !== undefined ) {
+               if ( parent === OO.ui.Element ) {
+                       // Safe
+                       break;
+               }
+
+               parent = parent.parent;
+       }
+
+       if ( parent !== OO.ui.Element ) {
+               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 );
@@ -1150,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;
 };
 
@@ -1730,12 +1797,41 @@ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
        return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], 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
@@ -2067,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() ];
 };
 
 /**
@@ -2153,7 +2269,7 @@ OO.ui.Window.prototype.getBodyHeight = function () {
  * @return {string} Directionality: `'ltr'` or `'rtl'`
  */
 OO.ui.Window.prototype.getDir = function () {
-       return this.dir;
+       return OO.ui.Element.static.getDir( this.$content ) || 'ltr';
 };
 
 /**
@@ -2348,7 +2464,6 @@ OO.ui.Window.prototype.initialize = function () {
        this.$head = $( '<div>' );
        this.$body = $( '<div>' );
        this.$foot = $( '<div>' );
-       this.dir = OO.ui.Element.static.getDir( this.$content ) || 'ltr';
        this.$document = $( this.getElementDocument() );
 
        // Events
@@ -3401,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();
-
-       if ( !sizes[ size ] ) {
-               size = this.constructor.static.defaultSize;
-       }
-       if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
-               size = 'full';
-       }
+       var isFullscreen = win.getSize() === '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 );
 
@@ -5991,6 +6097,28 @@ OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = function () {
        return [];
 };
 
+/**
+ * Set the read-only state of the widget.
+ *
+ * This will also disable/enable the lookups functionality.
+ *
+ * @param {boolean} readOnly Make input read-only
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
+       // Parent method
+       // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
+       OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
+
+       this.setLookupsDisabled( readOnly );
+       // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
+       if ( readOnly && this.lookupMenu ) {
+               this.closeLookupMenu();
+       }
+
+       return this;
+};
+
 /**
  * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
  * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
@@ -6130,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 );
 };
 
 /**
@@ -6139,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 || {} );
 };
 
 /**
@@ -6516,8 +6646,9 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
                ccHeight = $container.innerHeight() - buffer,
                ccWidth = $container.innerWidth() - buffer,
                cWidth = this.$clippable.outerWidth() + buffer,
-               scrollTop = this.$clippableScroller[0] === this.$clippableWindow[0] ? this.$clippableScroller.scrollTop() : 0,
-               scrollLeft = this.$clippableScroller.scrollLeft(),
+               scrollerIsWindow = this.$clippableScroller[0] === this.$clippableWindow[0],
+               scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0,
+               scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0,
                desiredWidth = cOffset.left < 0 ?
                        cWidth + cOffset.left :
                        ( ccOffset.left + scrollLeft + ccWidth ) - cOffset.left,
@@ -6539,7 +6670,7 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
        }
 
        // If we stopped clipping in at least one of the dimensions
-       if ( !clipWidth || !clipHeight ) {
+       if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
                OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
        }
 
@@ -7786,7 +7917,7 @@ OO.ui.MessageDialog = function OoUiMessageDialog( config ) {
        this.$element.addClass( 'oo-ui-messageDialog' );
 };
 
-/* Inheritance */
+/* Setup */
 
 OO.inheritClass( OO.ui.MessageDialog, OO.ui.Dialog );
 
@@ -8124,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' );
 };
@@ -8265,6 +8399,16 @@ 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.
  *
@@ -8272,15 +8416,31 @@ OO.ui.ProcessDialog.prototype.executeAction = function ( action ) {
  * @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
@@ -8374,6 +8534,7 @@ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) {
                .first( function () {
                        // Make sure to hide errors
                        this.hideErrors();
+                       this.fitOnOpen = false;
                }, this );
 };
 
@@ -8400,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} [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
@@ -8415,7 +8582,8 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
                fieldWidget = config.fieldWidget;
        }
 
-       var hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel;
+       var hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel,
+               div, i;
 
        // Configuration initialization
        config = $.extend( { align: 'left' }, config );
@@ -8425,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 ) {
@@ -8438,10 +8610,14 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
                        icon: 'info'
                } );
 
+               div = $( '<div>' );
+               if ( config.help instanceof OO.ui.HtmlSnippet ) {
+                       div.html( config.help.toString() );
+               } else {
+                       div.text( config.help );
+               }
                this.popupButtonWidget.getPopup().$body.append(
-                       $( '<div>' )
-                               .text( config.help )
-                               .addClass( 'oo-ui-fieldLayout-help-content' )
+                       div.addClass( 'oo-ui-fieldLayout-help-content' )
                );
                this.$help = this.popupButtonWidget.$element;
        } else {
@@ -8458,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 );
 };
 
@@ -8471,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 */
 
@@ -8504,6 +8692,28 @@ OO.ui.FieldLayout.prototype.getField = function () {
        return this.fieldWidget;
 };
 
+/**
+ * @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.
  *
@@ -8779,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' )
@@ -10526,6 +10741,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}
@@ -10814,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;
@@ -10821,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 );
@@ -10832,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 );
@@ -11928,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;
@@ -12349,106 +12632,751 @@ OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
 };
 
 /**
- * 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.
+ * CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxWidget combo box widget}
+ * that allows for selecting multiple values.
  *
- * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
- * OO.ui.DropdownInputWidget instead.
+ * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
  *
  *     @example
- *     // Example: A DropdownWidget with a menu that contains three options
- *     var dropDown = new OO.ui.DropdownWidget( {
- *         label: 'Dropdown menu: Select a menu option',
+ *     // Example: A CapsuleMultiSelectWidget.
+ *     var capsule = new OO.ui.CapsuleMultiSelectWidget( {
+ *         label: 'CapsuleMultiSelectWidget',
+ *         selected: [ 'Option 1', 'Option 3' ],
  *         menu: {
  *             items: [
  *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'a',
- *                     label: 'First'
+ *                     data: 'Option 1',
+ *                     label: 'Option One'
  *                 } ),
  *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'b',
- *                     label: 'Second'
+ *                     data: 'Option 2',
+ *                     label: 'Option Two'
  *                 } ),
  *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'c',
- *                     label: 'Third'
+ *                     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( dropDown.$element );
- *
- * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
+ *     $( '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.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
  * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.GroupElement
  *
  * @constructor
  * @param {Object} [config] Configuration options
- * @cfg {Object} [menu] Configuration options to pass to menu widget
- */
-OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
+ * @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 = $.extend( { indicator: 'down' }, config );
+       config = config || {};
 
        // Parent constructor
-       OO.ui.DropdownWidget.parent.call( this, config );
+       OO.ui.CapsuleMultiSelectWidget.parent.call( this, config );
 
-       // Properties (must be set before TabIndexedElement constructor call)
-       this.$handle = this.$( '<span>' );
+       // Properties (must be set before mixin constructor calls)
+       this.$input = config.popup ? null : $( '<input>' );
+       this.$handle = $( '<div>' );
 
        // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
+       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.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
+       OO.ui.mixin.IconElement.call( this, config );
 
        // Properties
-       this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) );
+       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 ),
-               keypress: this.onKeyPress.bind( this )
+               click: this.onClick.bind( this )
        } );
-       this.menu.connect( this, { select: 'onMenuSelect' } );
 
        // Initialization
-       this.$handle
-               .addClass( 'oo-ui-dropdownWidget-handle' )
-               .append( this.$icon, this.$label, this.$indicator );
-       this.$element
-               .addClass( 'oo-ui-dropdownWidget' )
-               .append( this.$handle, this.menu.$element );
+       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.DropdownWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
+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 );
 
-/* Methods */
+/* Events */
 
 /**
- * Get the menu.
+ * @event change
  *
- * @return {OO.ui.MenuSelectWidget} Menu of widget
+ * A change event is emitted when the set of selected items changes.
+ *
+ * @param {Mixed[]} datas Data of the now-selected items
  */
-OO.ui.DropdownWidget.prototype.getMenu = function () {
+
+/* 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.
+ *
+ * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * OO.ui.DropdownInputWidget instead.
+ *
+ *     @example
+ *     // Example: A DropdownWidget with a menu that contains three options
+ *     var dropDown = new OO.ui.DropdownWidget( {
+ *         label: 'Dropdown menu: Select a menu option',
+ *         menu: {
+ *             items: [
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'a',
+ *                     label: 'First'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'b',
+ *                     label: 'Second'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'c',
+ *                     label: 'Third'
+ *                 } )
+ *             ]
+ *         }
+ *     } );
+ *
+ *     $( 'body' ).append( dropDown.$element );
+ *
+ * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
+ *
+ * [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.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [menu] Configuration options to pass to menu widget
+ */
+OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
+       // Configuration initialization
+       config = $.extend( { indicator: 'down' }, config );
+
+       // Parent constructor
+       OO.ui.DropdownWidget.parent.call( this, config );
+
+       // Properties (must be set before TabIndexedElement constructor call)
+       this.$handle = this.$( '<span>' );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
+
+       // Properties
+       this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) );
+
+       // Events
+       this.$handle.on( {
+               click: this.onClick.bind( this ),
+               keypress: this.onKeyPress.bind( this )
+       } );
+       this.menu.connect( this, { select: 'onMenuSelect' } );
+
+       // Initialization
+       this.$handle
+               .addClass( 'oo-ui-dropdownWidget-handle' )
+               .append( this.$icon, this.$label, this.$indicator );
+       this.$element
+               .addClass( 'oo-ui-dropdownWidget' )
+               .append( this.$handle, this.menu.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Get the menu.
+ *
+ * @return {OO.ui.MenuSelectWidget} Menu of widget
+ */
+OO.ui.DropdownWidget.prototype.getMenu = function () {
        return this.menu;
 };
 
@@ -12552,8 +13480,8 @@ OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
        // Mixin constructors
        OO.ui.mixin.IconElement.call( this, config );
        OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.PendingElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, $.extend( config, { autoFitLabel: true } ) );
+       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
+       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { autoFitLabel: true } ) );
        OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
 
        // Properties
@@ -12616,7 +13544,7 @@ OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.TabIndexedElement );
 
-/* Static properties */
+/* Static Properties */
 
 /**
  * Check if this widget is supported
@@ -13038,9 +13966,12 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) {
 
        // Initialization
        this.$input
+               .addClass( 'oo-ui-inputWidget-input' )
                .attr( 'name', config.name )
                .prop( 'disabled', this.isDisabled() );
-       this.$element.addClass( 'oo-ui-inputWidget' ).append( this.$input, $( '<span>' ) );
+       this.$element
+               .addClass( 'oo-ui-inputWidget' )
+               .append( this.$input );
        this.setValue( config.value );
 };
 
@@ -13208,6 +14139,32 @@ OO.ui.InputWidget.prototype.blur = function () {
        return this;
 };
 
+/**
+ * @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
@@ -13275,6 +14232,14 @@ OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
 
+/* Static Properties */
+
+/**
+ * Disable generating `<label>` elements for buttons. One would very rarely need additional label
+ * for a button, and it's already a big clickable target, and it causes unexpected rendering.
+ */
+OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
+
 /* Methods */
 
 /**
@@ -13381,7 +14346,10 @@ OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
        OO.ui.CheckboxInputWidget.parent.call( this, config );
 
        // Initialization
-       this.$element.addClass( 'oo-ui-checkboxInputWidget' );
+       this.$element
+               .addClass( 'oo-ui-checkboxInputWidget' )
+               // Required for pretty styling in MediaWiki theme
+               .append( $( '<span>' ) );
        this.setSelected( config.selected !== undefined ? config.selected : false );
 };
 
@@ -13443,6 +14411,28 @@ OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
        return this.selected;
 };
 
+/**
+ * @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
@@ -13470,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
@@ -13485,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' } );
 
@@ -13498,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 */
 
@@ -13639,7 +14634,10 @@ OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
        OO.ui.RadioInputWidget.parent.call( this, config );
 
        // Initialization
-       this.$element.addClass( 'oo-ui-radioInputWidget' );
+       this.$element
+               .addClass( 'oo-ui-radioInputWidget' )
+               // Required for pretty styling in MediaWiki theme
+               .append( $( '<span>' ) );
        this.setSelected( config.selected !== undefined ? config.selected : false );
 };
 
@@ -13685,6 +14683,28 @@ OO.ui.RadioInputWidget.prototype.isSelected = function () {
        return this.$input.prop( 'checked' );
 };
 
+/**
+ * @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
@@ -13816,6 +14836,15 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
        return this;
 };
 
+/**
+ * @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
@@ -13846,6 +14875,11 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
  * @param {Object} [config] Configuration options
  * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
  *  'email' or 'url'. Ignored if `multiline` is true.
+ *
+ *  Some values of `type` result in additional behaviors:
+ *
+ *  - `search`: implies `icon: 'search'` and `indicator: 'clear'`; when clicked, the indicator
+ *    empties the text field
  * @cfg {string} [placeholder] Placeholder text
  * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
  *  instruct the browser to focus this widget.
@@ -13860,13 +14894,13 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
  *  Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
  * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
  *  the value or placeholder text: `'before'` or `'after'`
- * @cfg {boolean} [required=false] Mark the field as required
+ * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
  * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
  * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
  *  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
@@ -13874,6 +14908,17 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
                type: 'text',
                labelPosition: 'after'
        }, config );
+       if ( config.type === 'search' ) {
+               if ( config.icon === undefined ) {
+                       config.icon = 'search';
+               }
+               // indicator: 'clear' is set dynamically later, depending on value
+       }
+       if ( config.required ) {
+               if ( config.indicator === undefined ) {
+                       config.indicator = 'required';
+               }
+       }
 
        // Parent constructor
        OO.ui.TextInputWidget.parent.call( this, config );
@@ -13881,10 +14926,11 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
        // Mixin constructors
        OO.ui.mixin.IconElement.call( this, config );
        OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.PendingElement.call( this, config );
+       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
        OO.ui.mixin.LabelElement.call( this, config );
 
        // Properties
+       this.type = this.getSaneType( config );
        this.readOnly = false;
        this.multiline = !!config.multiline;
        this.autosize = !!config.autosize;
@@ -13915,13 +14961,17 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
        this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
        this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
        this.on( 'labelChange', this.updatePosition.bind( this ) );
-       this.connect( this, { change: 'onChange' } );
+       this.connect( this, {
+               change: 'onChange',
+               disable: 'onDisable'
+       } );
 
        // Initialization
        this.$element
-               .addClass( 'oo-ui-textInputWidget' )
+               .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
                .append( this.$icon, this.$indicator );
        this.setReadOnly( !!config.readOnly );
+       this.updateSearchIndicator();
        if ( config.placeholder ) {
                this.$input.attr( 'placeholder', config.placeholder );
        }
@@ -13954,7 +15004,7 @@ OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
 
-/* Static properties */
+/* Static Properties */
 
 OO.ui.TextInputWidget.static.validationPatterns = {
        'non-empty': /.+/,
@@ -13996,6 +15046,10 @@ OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
  */
 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
        if ( e.which === 1 ) {
+               if ( this.type === 'search' ) {
+                       // Clear the text field
+                       this.setValue( '' );
+               }
                this.$input[ 0 ].focus();
                return false;
        }
@@ -14044,10 +15098,21 @@ OO.ui.TextInputWidget.prototype.onElementAttach = function () {
  * @private
  */
 OO.ui.TextInputWidget.prototype.onChange = function () {
+       this.updateSearchIndicator();
        this.setValidityFlag();
        this.adjustSize();
 };
 
+/**
+ * Handle disable events.
+ *
+ * @param {boolean} disabled Element is disabled
+ * @private
+ */
+OO.ui.TextInputWidget.prototype.onDisable = function () {
+       this.updateSearchIndicator();
+};
+
 /**
  * Check if the input is {@link #readOnly read-only}.
  *
@@ -14066,6 +15131,7 @@ OO.ui.TextInputWidget.prototype.isReadOnly = function () {
 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
        this.readOnly = !!state;
        this.$input.prop( 'readOnly', this.readOnly );
+       this.updateSearchIndicator();
        return this;
 };
 
@@ -14129,7 +15195,7 @@ OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
                };
 
                // Create a fake parent and observe it
-               fakeParentNode = $( '<div>' ).append( this.$element )[0];
+               fakeParentNode = $( '<div>' ).append( topmostNode )[0];
                mutationObserver.observe( fakeParentNode, { childList: true } );
        } else {
                // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
@@ -14196,10 +15262,23 @@ OO.ui.TextInputWidget.prototype.adjustSize = function () {
  * @protected
  */
 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
+       return config.multiline ?
+               $( '<textarea>' ) :
+               $( '<input type="' + this.getSaneType( config ) + '" />' );
+};
+
+/**
+ * Get sanitized value for 'type' for given config.
+ *
+ * @param {Object} config Configuration options
+ * @return {string|null}
+ * @private
+ */
+OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
        var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ?
                config.type :
                'text';
-       return config.multiline ? $( '<textarea>' ) : $( '<input type="' + type + '" />' );
+       return config.multiline ? 'multiline' : type;
 };
 
 /**
@@ -14267,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 );
+               } );
        }
 };
 
@@ -14277,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 () {
@@ -14292,6 +15376,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’`.
  *
@@ -14318,7 +15446,6 @@ OO.ui.TextInputWidget.prototype.setPosition =
  * This method is called by #setLabelPosition, and can also be called on its own if
  * something causes the label to be mispositioned.
  *
- *
  * @chainable
  */
 OO.ui.TextInputWidget.prototype.updatePosition = function () {
@@ -14328,13 +15455,25 @@ 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;
 };
 
+/**
+ * Update the 'clear' indicator displayed on type: 'search' text fields, hiding it when the field is
+ * already empty or when it's not editable.
+ */
+OO.ui.TextInputWidget.prototype.updateSearchIndicator = function () {
+       if ( this.type === 'search' ) {
+               if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
+                       this.setIndicator( null );
+               } else {
+                       this.setIndicator( 'clear' );
+               }
+       }
+};
+
 /**
  * Position the label by setting the correct padding on the input.
  *
@@ -14366,6 +15505,30 @@ OO.ui.TextInputWidget.prototype.positionLabel = function () {
        return this;
 };
 
+/**
+ * @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
@@ -14757,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();
 };
 
 /**
@@ -14768,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();
 };
 
 /**
@@ -14778,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();
 };
 
 /**
@@ -14943,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' );
@@ -15013,8 +16179,13 @@ OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
        this.radio.$input.on( 'focus', this.onInputFocus.bind( this ) );
 
        // Initialization
+       // Remove implicit role, we're handling it ourselves
+       this.radio.$input.attr( 'role', 'presentation' );
        this.$element
                .addClass( 'oo-ui-radioOptionWidget' )
+               .attr( 'role', 'radio' )
+               .attr( 'aria-checked', 'false' )
+               .removeAttr( 'aria-selected' )
                .prepend( this.radio.$element );
 };
 
@@ -15050,6 +16221,9 @@ OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
        OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
 
        this.radio.setSelected( state );
+       this.$element
+               .attr( 'aria-checked', state.toString() )
+               .removeAttr( 'aria-selected' );
 
        return this;
 };
@@ -15401,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 );
@@ -15441,6 +16619,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.
  *
@@ -15853,10 +17051,6 @@ OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
                change: 'onQueryChange',
                enter: 'onQueryEnter'
        } );
-       this.results.connect( this, {
-               highlight: 'onResultsHighlight',
-               select: 'onResultsSelect'
-       } );
        this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
 
        // Initialization
@@ -15875,28 +17069,6 @@ OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
 
 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
 
-/* Events */
-
-/**
- * A 'highlight' event is emitted when an item is highlighted. The highlight indicates which
- * item will be selected. When a user mouses over a menu item, it is highlighted. If a search
- * string is typed into the query field instead, the first menu item that matches the query
- * will be highlighted.
-
- * @event highlight
- * @deprecated Connect straight to getResults() events instead
- * @param {Object|null} item Item data or null if no item is highlighted
- */
-
-/**
- * A 'select' event is emitted when an item is selected. A menu item is selected when it is clicked,
- * or when a user types a search query, a menu result is highlighted, and the user presses enter.
- *
- * @event select
- * @deprecated Connect straight to getResults() events instead
- * @param {Object|null} item Item data or null if no item is selected
- */
-
 /* Methods */
 
 /**
@@ -15936,38 +17108,14 @@ OO.ui.SearchWidget.prototype.onQueryChange = function () {
 /**
  * Handle select widget enter key events.
  *
- * Selects highlighted item.
+ * Chooses highlighted item.
  *
  * @private
  * @param {string} value New value
  */
 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
        // Reset
-       this.results.selectItem( this.results.getHighlightedItem() );
-};
-
-/**
- * Handle select widget highlight events.
- *
- * @private
- * @deprecated Connect straight to getResults() events instead
- * @param {OO.ui.OptionWidget} item Highlighted item
- * @fires highlight
- */
-OO.ui.SearchWidget.prototype.onResultsHighlight = function ( item ) {
-       this.emit( 'highlight', item ? item.getData() : null );
-};
-
-/**
- * Handle select widget select events.
- *
- * @private
- * @deprecated Connect straight to getResults() events instead
- * @param {OO.ui.OptionWidget} item Selected item
- * @fires select
- */
-OO.ui.SearchWidget.prototype.onResultsSelect = function ( item ) {
-       this.emit( 'select', item ? item.getData() : null );
+       this.results.chooseItem( this.results.getHighlightedItem() );
 };
 
 /**
@@ -16022,7 +17170,7 @@ OO.ui.SearchWidget.prototype.getResults = function () {
  * @abstract
  * @class
  * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.GroupElement
+ * @mixins OO.ui.mixin.GroupWidget
  *
  * @constructor
  * @param {Object} [config] Configuration options
@@ -16381,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 );
        }
@@ -16402,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' ) {
@@ -16547,6 +17701,62 @@ OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
        return this;
 };
 
+/**
+ * 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.
@@ -16884,7 +18094,9 @@ OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
        } );
 
        // Initialization
-       this.$element.addClass( 'oo-ui-radioSelectWidget' );
+       this.$element
+               .addClass( 'oo-ui-radioSelectWidget' )
+               .attr( 'role', 'radiogroup' );
 };
 
 /* Setup */
@@ -16922,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
@@ -16941,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
@@ -17013,6 +18230,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
  */
@@ -17039,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 );
        }
 };
@@ -17049,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 );
        }