Merge "Handle missing namespace prefix in XML dumps more gracefully"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
index 872d81f..f10bdfa 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.3
+ * OOjs UI v0.19.5
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-04T00:22:40Z
+ * Date: 2017-03-07T22:57:01Z
  */
 ( function ( OO ) {
 
@@ -57,16 +57,17 @@ OO.ui.MouseButtons = {
 
 /**
  * @property {number}
+ * @private
  */
 OO.ui.elementId = 0;
 
 /**
  * Generate a unique ID for element
  *
- * @return {string} [id]
+ * @return {string} ID
  */
 OO.ui.generateElementId = function () {
-       OO.ui.elementId += 1;
+       OO.ui.elementId++;
        return 'oojsui-' + OO.ui.elementId;
 };
 
@@ -386,15 +387,49 @@ OO.ui.infuse = function ( idOrNode ) {
        /**
         * Get a localized message.
         *
-        * In environments that provide a localization system, this function should be overridden to
-        * return the message translated in the user's language. The default implementation always returns
-        * English messages.
-        *
         * After the message key, message parameters may optionally be passed. In the default implementation,
         * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
         * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
         * they support unnamed, ordered message parameters.
         *
+        * In environments that provide a localization system, this function should be overridden to
+        * return the message translated in the user's language. The default implementation always returns
+        * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
+        * follows.
+        *
+        *     @example
+        *     var i, iLen, button,
+        *         messagePath = 'oojs-ui/dist/i18n/',
+        *         languages = [ $.i18n().locale, 'ur', 'en' ],
+        *         languageMap = {};
+        *
+        *     for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
+        *         languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
+        *     }
+        *
+        *     $.i18n().load( languageMap ).done( function() {
+        *         // Replace the built-in `msg` only once we've loaded the internationalization.
+        *         // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
+        *         // you put off creating any widgets until this promise is complete, no English
+        *         // will be displayed.
+        *         OO.ui.msg = $.i18n;
+        *
+        *         // A button displaying "OK" in the default locale
+        *         button = new OO.ui.ButtonWidget( {
+        *             label: OO.ui.msg( 'ooui-dialog-message-accept' ),
+        *             icon: 'check'
+        *         } );
+        *         $( 'body' ).append( button.$element );
+        *
+        *         // A button displaying "OK" in Urdu
+        *         $.i18n().locale = 'ur';
+        *         button = new OO.ui.ButtonWidget( {
+        *             label: OO.ui.msg( 'ooui-dialog-message-accept' ),
+        *             icon: 'check'
+        *         } );
+        *         $( 'body' ).append( button.$element );
+        *     } );
+        *
         * @param {string} key Message key
         * @param {...Mixed} [params] Message parameters
         * @return {string} Translated message with parameters substituted
@@ -554,7 +589,6 @@ OO.ui.Element = function OoUiElement( config ) {
        this.$element = config.$element ||
                $( document.createElement( this.getTagName() ) );
        this.elementGroup = null;
-       this.debouncedUpdateThemeClassesHandler = OO.ui.debounce( this.debouncedUpdateThemeClasses );
 
        // Initialization
        if ( Array.isArray( config.classes ) ) {
@@ -792,7 +826,7 @@ OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
 };
 
 /**
- * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node
+ * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
  * (and its children) that represent an Element of the same class and the given configuration,
  * generated by the PHP implementation.
  *
@@ -1038,6 +1072,73 @@ OO.ui.Element.static.getDimensions = function ( el ) {
        }
 };
 
+/**
+ * Get the number of pixels that an element's content is scrolled to the left.
+ *
+ * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
+ * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
+ *
+ * This function smooths out browser inconsistencies (nicely described in the README at
+ * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
+ * with Firefox's 'scrollLeft', which seems the sanest.
+ *
+ * @static
+ * @method
+ * @param {HTMLElement|Window} el Element to measure
+ * @return {number} Scroll position from the left.
+ *  If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
+ *  and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
+ *  If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
+ *  and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
+ */
+OO.ui.Element.static.getScrollLeft = ( function () {
+       var rtlScrollType = null;
+
+       function test() {
+               var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
+                       definer = $definer[ 0 ];
+
+               $definer.appendTo( 'body' );
+               if ( definer.scrollLeft > 0 ) {
+                       // Safari, Chrome
+                       rtlScrollType = 'default';
+               } else {
+                       definer.scrollLeft = 1;
+                       if ( definer.scrollLeft === 0 ) {
+                               // Firefox, old Opera
+                               rtlScrollType = 'negative';
+                       } else {
+                               // Internet Explorer, Edge
+                               rtlScrollType = 'reverse';
+                       }
+               }
+               $definer.remove();
+       }
+
+       return function getScrollLeft( el ) {
+               var isRoot = el.window === el ||
+                               el === el.ownerDocument.body ||
+                               el === el.ownerDocument.documentElement,
+                       scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
+                       // All browsers use the correct scroll type ('negative') on the root, so don't
+                       // do any fixups when looking at the root element
+                       direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
+
+               if ( direction === 'rtl' ) {
+                       if ( rtlScrollType === null ) {
+                               test();
+                       }
+                       if ( rtlScrollType === 'reverse' ) {
+                               scrollLeft = -scrollLeft;
+                       } else if ( rtlScrollType === 'default' ) {
+                               scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
+                       }
+               }
+
+               return scrollLeft;
+       };
+}() );
+
 /**
  * Get scrollable object parent
  *
@@ -1083,7 +1184,8 @@ OO.ui.Element.static.getRootScrollableElement = function ( el ) {
  */
 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
        var i, val,
-               // props = [ 'overflow' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091
+               // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
+               // 'overflow-y' have different values, so we need to check the separate properties.
                props = [ 'overflow-x', 'overflow-y' ],
                $parent = $( el ).parent();
 
@@ -1098,6 +1200,11 @@ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension )
                i = props.length;
                while ( i-- ) {
                        val = $parent.css( props[ i ] );
+                       // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
+                       // scrolled in that direction, but they can actually be scrolled programatically. The user can
+                       // unintentionally perform a scroll in such case even if the application doesn't scroll
+                       // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
+                       // This could cause funny issues...
                        if ( val === 'auto' || val === 'scroll' ) {
                                return $parent[ 0 ];
                        }
@@ -1129,6 +1236,9 @@ OO.ui.Element.static.scrollIntoView = function ( el, config ) {
 
        animations = {};
        callback = typeof config.complete === 'function' && config.complete;
+       if ( callback ) {
+               OO.ui.warnDeprecation( 'Element#scrollIntoView: The `complete` callback config option is deprecated. Use the return promise instead.' );
+       }
        container = this.getClosestScrollableContainer( el, config.direction );
        $container = $( container );
        elementDimensions = this.getDimensions( el );
@@ -1297,16 +1407,7 @@ OO.ui.Element.prototype.supports = function ( methods ) {
  *   guaranteeing that theme updates do not occur within an element's constructor
  */
 OO.ui.Element.prototype.updateThemeClasses = function () {
-       this.debouncedUpdateThemeClassesHandler();
-};
-
-/**
- * @private
- * @localdoc This method is called directly from the QUnit tests instead of #updateThemeClasses, to
- *   make them synchronous.
- */
-OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () {
-       OO.ui.theme.updateElementClasses( this );
+       OO.ui.theme.queueUpdateElementClasses( this );
 };
 
 /**
@@ -1512,7 +1613,7 @@ OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
 /* Static Properties */
 
 /**
- * Whether this widget will behave reasonably when wrapped in a HTML `<label>`. If this is true,
+ * Whether this widget will behave reasonably when wrapped in an HTML `<label>`. If this is true,
  * wrappers such as OO.ui.FieldLayout may use a `<label>` instead of implementing own label click
  * handling.
  *
@@ -1595,7 +1696,10 @@ OO.ui.Widget.prototype.updateDisabled = function () {
  *
  * @constructor
  */
-OO.ui.Theme = function OoUiTheme() {};
+OO.ui.Theme = function OoUiTheme() {
+       this.elementClassesQueue = [];
+       this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
+};
 
 /* Setup */
 
@@ -1639,6 +1743,36 @@ OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
                .addClass( classes.on.join( ' ' ) );
 };
 
+/**
+ * @private
+ */
+OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
+       var i;
+       for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
+               this.updateElementClasses( this.elementClassesQueue[ i ] );
+       }
+       // Clear the queue
+       this.elementClassesQueue = [];
+};
+
+/**
+ * Queue #updateElementClasses to be called for this element.
+ *
+ * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
+ *   to make them synchronous.
+ *
+ * @param {OO.ui.Element} element Element for which to update classes
+ */
+OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
+       // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
+       // the most common case (this method is often called repeatedly for the same element).
+       if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
+               return;
+       }
+       this.elementClassesQueue.push( element );
+       this.debouncedUpdateQueuedElementClasses();
+};
+
 /**
  * Get the transition duration in milliseconds for dialogs opening/closing
  *
@@ -2861,16 +2995,6 @@ OO.ui.mixin.LabelElement.prototype.getLabel = function () {
        return this.label;
 };
 
-/**
- * Fit the label.
- *
- * @chainable
- * @deprecated since 0.16.0
- */
-OO.ui.mixin.LabelElement.prototype.fitLabel = function () {
-       return this;
-};
-
 /**
  * Set the content of the label.
  *
@@ -3412,6 +3536,7 @@ OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
 /* Static Properties */
 
 /**
+ * @static
  * @inheritdoc
  */
 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
@@ -3646,6 +3771,10 @@ OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.IconWidget.static.tagName = 'span';
 
 /**
@@ -3699,6 +3828,10 @@ OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.IndicatorWidget.static.tagName = 'span';
 
 /**
@@ -3754,12 +3887,17 @@ OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
        // Properties
        this.input = config.input;
 
-       // Events
+       // Initialization
        if ( this.input instanceof OO.ui.InputWidget ) {
-               this.$element.on( 'click', this.onClick.bind( this ) );
+               if ( this.input.getInputId() ) {
+                       this.$element.attr( 'for', this.input.getInputId() );
+               } else {
+                       this.$label.on( 'click', function () {
+                               this.fieldWidget.focus();
+                               return false;
+                       }.bind( this ) );
+               }
        }
-
-       // Initialization
        this.$element.addClass( 'oo-ui-labelWidget' );
 };
 
@@ -3771,20 +3909,11 @@ OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
 
 /* Static Properties */
 
-OO.ui.LabelWidget.static.tagName = 'span';
-
-/* Methods */
-
 /**
- * Handles label mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
+ * @static
+ * @inheritdoc
  */
-OO.ui.LabelWidget.prototype.onClick = function () {
-       this.input.simulateLabelClick();
-       return false;
-};
+OO.ui.LabelWidget.static.tagName = 'label';
 
 /**
  * PendingElement is a mixin that is used to create elements that notify users that something is happening
@@ -3802,6 +3931,7 @@ OO.ui.LabelWidget.prototype.onClick = function () {
  *     }
  *     OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
  *
+ *     MessageDialog.static.name = 'myMessageDialog';
  *     MessageDialog.static.actions = [
  *         { action: 'save', label: 'Done', flags: 'primary' },
  *         { label: 'Cancel', flags: 'safe' }
@@ -3919,188 +4049,592 @@ OO.ui.mixin.PendingElement.prototype.popPending = function () {
 };
 
 /**
- * Element that can be automatically clipped to visible boundaries.
+ * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
+ * in the document (for example, in an OO.ui.Window's $overlay).
  *
- * Whenever the element's natural height changes, you have to call
- * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
- * clipping correctly.
+ * The elements's position is automatically calculated and maintained when window is resized or the
+ * page is scrolled. If you reposition the container manually, you have to call #position to make
+ * sure the element is still placed correctly.
  *
- * The dimensions of #$clippableContainer will be compared to the boundaries of the
- * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
- * then #$clippable will be given a fixed reduced height and/or width and will be made
- * scrollable. By default, #$clippable and #$clippableContainer are the same element,
- * but you can build a static footer by setting #$clippableContainer to an element that contains
- * #$clippable and the footer.
+ * As positioning is only possible when both the element and the container are attached to the DOM
+ * and visible, it's only done after you call #togglePositioning. You might want to do this inside
+ * the #toggle method to display a floating popup, for example.
  *
  * @abstract
  * @class
  *
  * @constructor
  * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
- * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
- *   omit to use #$clippable
+ * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
+ * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
+ * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
+ *  'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
+ *  'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
+ *  'top': Align the top edge with $floatableContainer's top edge
+ *  'bottom': Align the bottom edge with $floatableContainer's bottom edge
+ *  'center': Vertically align the center with $floatableContainer's center
+ * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
+ *  'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
+ *  'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
+ *  'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
+ *  'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
+ *  'center': Horizontally align the center with $floatableContainer's center
+ * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
+ *  is out of view
  */
-OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
+OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
        // Configuration initialization
        config = config || {};
 
        // Properties
-       this.$clippable = null;
-       this.$clippableContainer = null;
-       this.clipping = false;
-       this.clippedHorizontally = false;
-       this.clippedVertically = false;
-       this.$clippableScrollableContainer = null;
-       this.$clippableScroller = null;
-       this.$clippableWindow = null;
-       this.idealWidth = null;
-       this.idealHeight = null;
-       this.onClippableScrollHandler = this.clip.bind( this );
-       this.onClippableWindowResizeHandler = this.clip.bind( this );
+       this.$floatable = null;
+       this.$floatableContainer = null;
+       this.$floatableWindow = null;
+       this.$floatableClosestScrollable = null;
+       this.onFloatableScrollHandler = this.position.bind( this );
+       this.onFloatableWindowResizeHandler = this.position.bind( this );
 
        // Initialization
-       if ( config.$clippableContainer ) {
-               this.setClippableContainer( config.$clippableContainer );
-       }
-       this.setClippableElement( config.$clippable || this.$element );
+       this.setFloatableContainer( config.$floatableContainer );
+       this.setFloatableElement( config.$floatable || this.$element );
+       this.setVerticalPosition( config.verticalPosition || 'below' );
+       this.setHorizontalPosition( config.horizontalPosition || 'start' );
+       this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
 };
 
 /* Methods */
 
 /**
- * Set clippable element.
+ * Set floatable element.
  *
  * If an element is already set, it will be cleaned up before setting up the new element.
  *
- * @param {jQuery} $clippable Element to make clippable
+ * @param {jQuery} $floatable Element to make floatable
  */
-OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
-       if ( this.$clippable ) {
-               this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
-               this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
-               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
+       if ( this.$floatable ) {
+               this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
+               this.$floatable.css( { left: '', top: '' } );
        }
 
-       this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
-       this.clip();
+       this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
+       this.position();
 };
 
 /**
- * Set clippable container.
- *
- * This is the container that will be measured when deciding whether to clip. When clipping,
- * #$clippable will be resized in order to keep the clippable container fully visible.
+ * Set floatable container.
  *
- * If the clippable container is unset, #$clippable will be used.
+ * The element will be positioned relative to the specified container.
  *
- * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
+ * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
  */
-OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
-       this.$clippableContainer = $clippableContainer;
-       if ( this.$clippable ) {
-               this.clip();
+OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
+       this.$floatableContainer = $floatableContainer;
+       if ( this.$floatable ) {
+               this.position();
        }
 };
 
 /**
- * Toggle clipping.
- *
- * Do not turn clipping on until after the element is attached to the DOM and visible.
+ * Change how the element is positioned vertically.
  *
- * @param {boolean} [clipping] Enable clipping, omit to toggle
- * @chainable
+ * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
  */
-OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
-       clipping = clipping === undefined ? !this.clipping : !!clipping;
-
-       if ( this.clipping !== clipping ) {
-               this.clipping = clipping;
-               if ( clipping ) {
-                       this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
-                       // If the clippable container is the root, we have to listen to scroll events and check
-                       // jQuery.scrollTop on the window because of browser inconsistencies
-                       this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
-                               $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
-                               this.$clippableScrollableContainer;
-                       this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
-                       this.$clippableWindow = $( this.getElementWindow() )
-                               .on( 'resize', this.onClippableWindowResizeHandler );
-                       // Initial clip after visible
-                       this.clip();
-               } else {
-                       this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
-                       OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
-
-                       this.$clippableScrollableContainer = null;
-                       this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
-                       this.$clippableScroller = null;
-                       this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
-                       this.$clippableWindow = null;
+OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
+       if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
+               throw new Error( 'Invalid value for vertical position: ' + position );
+       }
+       if ( this.verticalPosition !== position ) {
+               this.verticalPosition = position;
+               if ( this.$floatable ) {
+                       this.position();
                }
        }
-
-       return this;
 };
 
 /**
- * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
+ * Change how the element is positioned horizontally.
  *
- * @return {boolean} Element will be clipped to the visible area
+ * @param {string} position 'before', 'after', 'start', 'end' or 'center'
  */
-OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
-       return this.clipping;
+OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
+       if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
+               throw new Error( 'Invalid value for horizontal position: ' + position );
+       }
+       if ( this.horizontalPosition !== position ) {
+               this.horizontalPosition = position;
+               if ( this.$floatable ) {
+                       this.position();
+               }
+       }
 };
 
 /**
- * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
+ * Toggle positioning.
  *
- * @return {boolean} Part of the element is being clipped
- */
-OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
-       return this.clippedHorizontally || this.clippedVertically;
-};
-
-/**
- * Check if the right of the element is being clipped by the nearest scrollable container.
+ * Do not turn positioning on until after the element is attached to the DOM and visible.
  *
- * @return {boolean} Part of the element is being clipped
+ * @param {boolean} [positioning] Enable positioning, omit to toggle
+ * @chainable
  */
-OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
-       return this.clippedHorizontally;
-};
+OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
+       var closestScrollableOfContainer;
 
-/**
- * Check if the bottom of the element is being clipped by the nearest scrollable container.
- *
- * @return {boolean} Part of the element is being clipped
- */
-OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
-       return this.clippedVertically;
-};
+       if ( !this.$floatable || !this.$floatableContainer ) {
+               return this;
+       }
 
-/**
- * Set the ideal size. These are the dimensions the element will have when it's not being clipped.
- *
- * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
- * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
- */
-OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
-       this.idealWidth = width;
-       this.idealHeight = height;
+       positioning = positioning === undefined ? !this.positioning : !!positioning;
 
-       if ( !this.clipping ) {
-               // Update dimensions
-               this.$clippable.css( { width: width, height: height } );
+       if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
+               OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
+               this.warnedUnattached = true;
        }
-       // While clipping, idealWidth and idealHeight are not considered
-};
 
-/**
- * Clip element to visible boundaries and allow scrolling when needed. You should call this method
- * when the element's natural height changes.
- *
- * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
+       if ( this.positioning !== positioning ) {
+               this.positioning = positioning;
+
+               this.needsCustomPosition =
+                       this.verticalPostion !== 'below' ||
+                       this.horizontalPosition !== 'start' ||
+                       !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
+
+               closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
+               // If the scrollable is the root, we have to listen to scroll events
+               // on the window because of browser inconsistencies.
+               if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
+                       closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
+               }
+
+               if ( positioning ) {
+                       this.$floatableWindow = $( this.getElementWindow() );
+                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
+
+                       this.$floatableClosestScrollable = $( closestScrollableOfContainer );
+                       this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
+
+                       // Initial position after visible
+                       this.position();
+               } else {
+                       if ( this.$floatableWindow ) {
+                               this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
+                               this.$floatableWindow = null;
+                       }
+
+                       if ( this.$floatableClosestScrollable ) {
+                               this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
+                               this.$floatableClosestScrollable = null;
+                       }
+
+                       this.$floatable.css( { left: '', right: '', top: '' } );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Check whether the bottom edge of the given element is within the viewport of the given container.
+ *
+ * @private
+ * @param {jQuery} $element
+ * @param {jQuery} $container
+ * @return {boolean}
+ */
+OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
+       var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
+               startEdgeInBounds, endEdgeInBounds,
+               direction = $element.css( 'direction' );
+
+       elemRect = $element[ 0 ].getBoundingClientRect();
+       if ( $container[ 0 ] === window ) {
+               contRect = {
+                       top: 0,
+                       left: 0,
+                       right: document.documentElement.clientWidth,
+                       bottom: document.documentElement.clientHeight
+               };
+       } else {
+               contRect = $container[ 0 ].getBoundingClientRect();
+       }
+
+       topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
+       bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
+       leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
+       rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
+       if ( direction === 'rtl' ) {
+               startEdgeInBounds = rightEdgeInBounds;
+               endEdgeInBounds = leftEdgeInBounds;
+       } else {
+               startEdgeInBounds = leftEdgeInBounds;
+               endEdgeInBounds = rightEdgeInBounds;
+       }
+
+       if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
+               return false;
+       }
+       if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
+               return false;
+       }
+       if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
+               return false;
+       }
+       if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
+               return false;
+       }
+
+       // The other positioning values are all about being inside the container,
+       // so in those cases all we care about is that any part of the container is visible.
+       return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
+               elemRect.left <= contRect.right && elemRect.right >= contRect.left;
+};
+
+/**
+ * Position the floatable below its container.
+ *
+ * This should only be done when both of them are attached to the DOM and visible.
+ *
+ * @chainable
+ */
+OO.ui.mixin.FloatableElement.prototype.position = function () {
+       if ( !this.positioning ) {
+               return this;
+       }
+
+       if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
+               this.$floatable.addClass( 'oo-ui-element-hidden' );
+               return this;
+       } else {
+               this.$floatable.removeClass( 'oo-ui-element-hidden' );
+       }
+
+       if ( !this.needsCustomPosition ) {
+               return this;
+       }
+
+       this.$floatable.css( this.computePosition() );
+
+       // We updated the position, so re-evaluate the clipping state.
+       // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
+       // will not notice the need to update itself.)
+       // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
+       // it not listen to the right events in the right places?
+       if ( this.clip ) {
+               this.clip();
+       }
+
+       return this;
+};
+
+/**
+ * Compute how #$floatable should be positioned based on the position of #$floatableContainer
+ * and the positioning settings. This is a helper for #position that shouldn't be called directly,
+ * but may be overridden by subclasses if they want to change or add to the positioning logic.
+ *
+ * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
+ */
+OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
+       var isBody, scrollableX, scrollableY, containerPos,
+               horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
+               newPos = { top: '', left: '', bottom: '', right: '' },
+               direction = this.$floatableContainer.css( 'direction' ),
+               $offsetParent = this.$floatable.offsetParent();
+
+       if ( $offsetParent.is( 'html' ) ) {
+               // The innerHeight/Width and clientHeight/Width calculations don't work well on the
+               // <html> element, but they do work on the <body>
+               $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
+       }
+       isBody = $offsetParent.is( 'body' );
+       scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
+       scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
+
+       vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
+       horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
+       // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
+       // or if it isn't scrollable
+       scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
+       scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
+
+       // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
+       // if the <body> has a margin
+       containerPos = isBody ?
+               this.$floatableContainer.offset() :
+               OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
+       containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
+       containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
+       containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
+       containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
+
+       if ( this.verticalPosition === 'below' ) {
+               newPos.top = containerPos.bottom;
+       } else if ( this.verticalPosition === 'above' ) {
+               newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
+       } else if ( this.verticalPosition === 'top' ) {
+               newPos.top = containerPos.top;
+       } else if ( this.verticalPosition === 'bottom' ) {
+               newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
+       } else if ( this.verticalPosition === 'center' ) {
+               newPos.top = containerPos.top +
+                       ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
+       }
+
+       if ( this.horizontalPosition === 'before' ) {
+               newPos.end = containerPos.start;
+       } else if ( this.horizontalPosition === 'after' ) {
+               newPos.start = containerPos.end;
+       } else if ( this.horizontalPosition === 'start' ) {
+               newPos.start = containerPos.start;
+       } else if ( this.horizontalPosition === 'end' ) {
+               newPos.end = containerPos.end;
+       } else if ( this.horizontalPosition === 'center' ) {
+               newPos.left = containerPos.left +
+                       ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
+       }
+
+       if ( newPos.start !== undefined ) {
+               if ( direction === 'rtl' ) {
+                       newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
+               } else {
+                       newPos.left = newPos.start;
+               }
+               delete newPos.start;
+       }
+       if ( newPos.end !== undefined ) {
+               if ( direction === 'rtl' ) {
+                       newPos.left = newPos.end;
+               } else {
+                       newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
+               }
+               delete newPos.end;
+       }
+
+       // Account for scroll position
+       if ( newPos.top !== '' ) {
+               newPos.top += scrollTop;
+       }
+       if ( newPos.bottom !== '' ) {
+               newPos.bottom -= scrollTop;
+       }
+       if ( newPos.left !== '' ) {
+               newPos.left += scrollLeft;
+       }
+       if ( newPos.right !== '' ) {
+               newPos.right -= scrollLeft;
+       }
+
+       // Account for scrollbar gutter
+       if ( newPos.bottom !== '' ) {
+               newPos.bottom -= horizScrollbarHeight;
+       }
+       if ( direction === 'rtl' ) {
+               if ( newPos.left !== '' ) {
+                       newPos.left -= vertScrollbarWidth;
+               }
+       } else {
+               if ( newPos.right !== '' ) {
+                       newPos.right -= vertScrollbarWidth;
+               }
+       }
+
+       return newPos;
+};
+
+/**
+ * Element that can be automatically clipped to visible boundaries.
+ *
+ * Whenever the element's natural height changes, you have to call
+ * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
+ * clipping correctly.
+ *
+ * The dimensions of #$clippableContainer will be compared to the boundaries of the
+ * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
+ * then #$clippable will be given a fixed reduced height and/or width and will be made
+ * scrollable. By default, #$clippable and #$clippableContainer are the same element,
+ * but you can build a static footer by setting #$clippableContainer to an element that contains
+ * #$clippable and the footer.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
+ * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
+ *   omit to use #$clippable
+ */
+OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$clippable = null;
+       this.$clippableContainer = null;
+       this.clipping = false;
+       this.clippedHorizontally = false;
+       this.clippedVertically = false;
+       this.$clippableScrollableContainer = null;
+       this.$clippableScroller = null;
+       this.$clippableWindow = null;
+       this.idealWidth = null;
+       this.idealHeight = null;
+       this.onClippableScrollHandler = this.clip.bind( this );
+       this.onClippableWindowResizeHandler = this.clip.bind( this );
+
+       // Initialization
+       if ( config.$clippableContainer ) {
+               this.setClippableContainer( config.$clippableContainer );
+       }
+       this.setClippableElement( config.$clippable || this.$element );
+};
+
+/* Methods */
+
+/**
+ * Set clippable element.
+ *
+ * If an element is already set, it will be cleaned up before setting up the new element.
+ *
+ * @param {jQuery} $clippable Element to make clippable
+ */
+OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
+       if ( this.$clippable ) {
+               this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
+               this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
+               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+       }
+
+       this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
+       this.clip();
+};
+
+/**
+ * Set clippable container.
+ *
+ * This is the container that will be measured when deciding whether to clip. When clipping,
+ * #$clippable will be resized in order to keep the clippable container fully visible.
+ *
+ * If the clippable container is unset, #$clippable will be used.
+ *
+ * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
+ */
+OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
+       this.$clippableContainer = $clippableContainer;
+       if ( this.$clippable ) {
+               this.clip();
+       }
+};
+
+/**
+ * Toggle clipping.
+ *
+ * Do not turn clipping on until after the element is attached to the DOM and visible.
+ *
+ * @param {boolean} [clipping] Enable clipping, omit to toggle
+ * @chainable
+ */
+OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
+       clipping = clipping === undefined ? !this.clipping : !!clipping;
+
+       if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
+               OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
+               this.warnedUnattached = true;
+       }
+
+       if ( this.clipping !== clipping ) {
+               this.clipping = clipping;
+               if ( clipping ) {
+                       this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
+                       // If the clippable container is the root, we have to listen to scroll events and check
+                       // jQuery.scrollTop on the window because of browser inconsistencies
+                       this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
+                               $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
+                               this.$clippableScrollableContainer;
+                       this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
+                       this.$clippableWindow = $( this.getElementWindow() )
+                               .on( 'resize', this.onClippableWindowResizeHandler );
+                       // Initial clip after visible
+                       this.clip();
+               } else {
+                       this.$clippable.css( {
+                               width: '',
+                               height: '',
+                               maxWidth: '',
+                               maxHeight: '',
+                               overflowX: '',
+                               overflowY: ''
+                       } );
+                       OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+
+                       this.$clippableScrollableContainer = null;
+                       this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
+                       this.$clippableScroller = null;
+                       this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
+                       this.$clippableWindow = null;
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
+ *
+ * @return {boolean} Element will be clipped to the visible area
+ */
+OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
+       return this.clipping;
+};
+
+/**
+ * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
+       return this.clippedHorizontally || this.clippedVertically;
+};
+
+/**
+ * Check if the right of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
+       return this.clippedHorizontally;
+};
+
+/**
+ * Check if the bottom of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
+       return this.clippedVertically;
+};
+
+/**
+ * Set the ideal size. These are the dimensions the element will have when it's not being clipped.
+ *
+ * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
+ * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
+ */
+OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
+       this.idealWidth = width;
+       this.idealHeight = height;
+
+       if ( !this.clipping ) {
+               // Update dimensions
+               this.$clippable.css( { width: width, height: height } );
+       }
+       // While clipping, idealWidth and idealHeight are not considered
+};
+
+/**
+ * Clip element to visible boundaries and allow scrolling when needed. You should call this method
+ * when the element's natural height changes.
+ *
+ * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
  * overlapped by, the visible area of the nearest scrollable container.
  *
  * Because calling clip() when the natural height changes isn't always possible, we also set
@@ -4154,8 +4688,11 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
        clipHeight = allotedHeight < naturalHeight;
 
        if ( clipWidth ) {
+               // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. (T157672)
+               // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
+               this.$clippable.css( 'overflowX', 'scroll' );
+               void this.$clippable[ 0 ].offsetHeight; // Force reflow
                this.$clippable.css( {
-                       overflowX: 'scroll',
                        width: Math.max( 0, allotedWidth ),
                        maxWidth: ''
                } );
@@ -4167,8 +4704,11 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
                } );
        }
        if ( clipHeight ) {
+               // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. (T157672)
+               // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
+               this.$clippable.css( 'overflowY', 'scroll' );
+               void this.$clippable[ 0 ].offsetHeight; // Force reflow
                this.$clippable.css( {
-                       overflowY: 'scroll',
                        height: Math.max( 0, allotedHeight ),
                        maxHeight: ''
                } );
@@ -4196,6 +4736,8 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
  * By default, each popup has an anchor that points toward its origin.
  * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
  *
+ * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
+ *
  *     @example
  *     // A popup widget.
  *     var popup = new OO.ui.PopupWidget( {
@@ -4214,19 +4756,31 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
  * @extends OO.ui.Widget
  * @mixins OO.ui.mixin.LabelElement
  * @mixins OO.ui.mixin.ClippableElement
+ * @mixins OO.ui.mixin.FloatableElement
  *
  * @constructor
  * @param {Object} [config] Configuration options
  * @cfg {number} [width=320] Width of popup in pixels
  * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
  * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
- * @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`.
- *  If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the
- *  popup is leaning towards the right of the screen.
- *  Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence
- *  in the given language, which means it will flip to the correct positioning in right-to-left languages.
- *  Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the
- *  sentence in the given language.
+ * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
+ *  'above': Put popup above $floatableContainer; anchor points down to the start edge of $floatableContainer
+ *  'below': Put popup below $floatableContainer; anchor points up to the start edge of $floatableContainer
+ *  'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
+ *            endwards (right/left) to the vertical center of $floatableContainer
+ *  'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
+ *            startwards (left/right) to the vertical center of $floatableContainer
+ * @cfg {string} [align='center'] How to align the popup to $floatableContainer
+ *  'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
+ *              as possible while still keeping the anchor within the popup;
+ *              if position is before/after, move the popup as far downwards as possible.
+ *  'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
+ *               as possible while still keeping the anchor within the popup;
+ *               if position in before/after, move the popup as far upwards as possible.
+ *  'center': Horizontally (if position is above/below) or vertically (before/after) align the center
+ *            of the popup with the center of $floatableContainer.
+ * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
+ * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
  * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
  *  See the [OOjs UI docs on MediaWiki][3] for an example.
  *  [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
@@ -4259,6 +4813,7 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
                $clippable: this.$body,
                $clippableContainer: this.$popup
        } ) );
+       OO.ui.mixin.FloatableElement.call( this, config );
 
        // Properties
        this.$anchor = $( '<div>' );
@@ -4268,15 +4823,16 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
        this.autoClose = !!config.autoClose;
        this.$autoCloseIgnore = config.$autoCloseIgnore;
        this.transitionTimeout = null;
-       this.anchor = null;
+       this.anchored = false;
        this.width = config.width !== undefined ? config.width : 320;
        this.height = config.height !== undefined ? config.height : null;
-       this.setAlignment( config.align );
        this.onMouseDownHandler = this.onMouseDown.bind( this );
        this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
 
        // Initialization
        this.toggleAnchor( config.anchor === undefined || config.anchor );
+       this.setAlignment( config.align || 'center' );
+       this.setPosition( config.position || 'below' );
        this.$body.addClass( 'oo-ui-popupWidget-body' );
        this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
        this.$popup
@@ -4323,6 +4879,7 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
+OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
 
 /* Methods */
 
@@ -4423,6 +4980,21 @@ OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
                this.anchored = show;
        }
 };
+/**
+ * Change which edge the anchor appears on.
+ *
+ * @param {string} edge 'top', 'bottom', 'start' or 'end'
+ */
+OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
+       if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
+               throw new Error( 'Invalid value for edge: ' + edge );
+       }
+       if ( this.anchorEdge !== null ) {
+               this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
+       }
+       this.anchorEdge = edge;
+       this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
+};
 
 /**
  * Check if the anchor is visible.
@@ -4430,10 +5002,18 @@ OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
  * @return {boolean} Anchor is visible
  */
 OO.ui.PopupWidget.prototype.hasAnchor = function () {
-       return this.anchor;
+       return this.anchored;
 };
 
 /**
+ * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
+ * `.toggle( true )` after its #$element is attached to the DOM.
+ *
+ * Do not show the popup while it is not attached to the DOM. The calculations required to display
+ * it in the right place and with the right dimensions only work correctly while it is attached.
+ * Side-effects may include broken interface and exceptions being thrown. This wasn't always
+ * strictly enforced, so currently it only generates a warning in the browser console.
+ *
  * @inheritdoc
  */
 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
@@ -4442,10 +5022,21 @@ OO.ui.PopupWidget.prototype.toggle = function ( show ) {
 
        change = show !== this.isVisible();
 
+       if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
+               OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
+               this.warnedUnattached = true;
+       }
+       if ( show && !this.$floatableContainer && this.isElementAttached() ) {
+               // Fall back to the parent node if the floatableContainer is not set
+               this.setFloatableContainer( this.$element.parent() );
+       }
+
        // Parent method
        OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
 
        if ( change ) {
+               this.togglePositioning( show && !!this.$floatableContainer );
+
                if ( show ) {
                        if ( this.autoClose ) {
                                this.bindMouseDownListener();
@@ -4493,62 +5084,7 @@ OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
  * @chainable
  */
 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
-       var popupOffset, originOffset, containerLeft, containerWidth, containerRight,
-               popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth,
-               align = this.align,
-               widget = this;
-
-       if ( !this.$container ) {
-               // Lazy-initialize $container if not specified in constructor
-               this.$container = $( this.getClosestScrollableElementContainer() );
-       }
-
-       // Set height and width before measuring things, since it might cause our measurements
-       // to change (e.g. due to scrollbars appearing or disappearing)
-       this.$popup.css( {
-               width: this.width,
-               height: this.height !== null ? this.height : 'auto'
-       } );
-
-       // If we are in RTL, we need to flip the alignment, unless it is center
-       if ( align === 'forwards' || align === 'backwards' ) {
-               if ( this.$container.css( 'direction' ) === 'rtl' ) {
-                       align = ( { forwards: 'force-left', backwards: 'force-right' } )[ this.align ];
-               } else {
-                       align = ( { forwards: 'force-right', backwards: 'force-left' } )[ this.align ];
-               }
-
-       }
-
-       // Compute initial popupOffset based on alignment
-       popupOffset = this.width * ( { 'force-left': -1, center: -0.5, 'force-right': 0 } )[ align ];
-
-       // Figure out if this will cause the popup to go beyond the edge of the container
-       originOffset = this.$element.offset().left;
-       containerLeft = this.$container.offset().left;
-       containerWidth = this.$container.innerWidth();
-       containerRight = containerLeft + containerWidth;
-       popupLeft = popupOffset - this.containerPadding;
-       popupRight = popupOffset + this.containerPadding + this.width + this.containerPadding;
-       overlapLeft = ( originOffset + popupLeft ) - containerLeft;
-       overlapRight = containerRight - ( originOffset + popupRight );
-
-       // Adjust offset to make the popup not go beyond the edge, if needed
-       if ( overlapRight < 0 ) {
-               popupOffset += overlapRight;
-       } else if ( overlapLeft < 0 ) {
-               popupOffset -= overlapLeft;
-       }
-
-       // Adjust offset to avoid anchor being rendered too close to the edge
-       // $anchor.width() doesn't work with the pure CSS anchor (returns 0)
-       // TODO: Find a measurement that works for CSS anchors and image anchors
-       anchorWidth = this.$anchor[ 0 ].scrollWidth * 2;
-       if ( popupOffset + this.width < anchorWidth ) {
-               popupOffset = anchorWidth - this.width;
-       } else if ( -popupOffset < anchorWidth ) {
-               popupOffset = -anchorWidth;
-       }
+       var widget = this;
 
        // Prevent transition from being interrupted
        clearTimeout( this.transitionTimeout );
@@ -4557,8 +5093,7 @@ OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
                this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
        }
 
-       // Position body relative to anchor
-       this.$popup.css( 'margin-left', popupOffset );
+       this.position();
 
        if ( transition ) {
                // Prevent transitioning after transition is complete
@@ -4569,38 +5104,196 @@ OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
                // Prevent transitioning immediately
                this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
        }
+};
 
-       // Reevaluate clipping state since we've relocated and resized the popup
-       this.clip();
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupWidget.prototype.computePosition = function () {
+       var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
+               anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
+               offsetParentPos, containerPos,
+               popupPos = {},
+               anchorCss = { left: '', right: '', top: '', bottom: '' },
+               alignMap = {
+                       ltr: {
+                               'force-left': 'backwards',
+                               'force-right': 'forwards'
+                       },
+                       rtl: {
+                               'force-left': 'forwards',
+                               'force-right': 'backwards'
+                       }
+               },
+               anchorEdgeMap = {
+                       above: 'bottom',
+                       below: 'top',
+                       before: 'end',
+                       after: 'start'
+               },
+               hPosMap = {
+                       forwards: 'start',
+                       center: 'center',
+                       backwards: 'before'
+               },
+               vPosMap = {
+                       forwards: 'top',
+                       center: 'center',
+                       backwards: 'bottom'
+               };
 
-       return this;
+       if ( !this.$container ) {
+               // Lazy-initialize $container if not specified in constructor
+               this.$container = $( this.getClosestScrollableElementContainer() );
+       }
+       direction = this.$container.css( 'direction' );
+
+       // Set height and width before we do anything else, since it might cause our measurements
+       // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
+       this.$popup.css( {
+               width: this.width,
+               height: this.height !== null ? this.height : 'auto'
+       } );
+
+       align = alignMap[ direction ][ this.align ] || this.align;
+       // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
+       vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
+       start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
+       end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
+       near = vertical ? 'top' : 'left';
+       far = vertical ? 'bottom' : 'right';
+       sizeProp = vertical ? 'Height' : 'Width';
+       popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
+
+       this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
+       this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
+       this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
+
+       // Parent method
+       parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
+       // Find out which property FloatableElement used for positioning, and adjust that value
+       positionProp = vertical ?
+               ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
+               ( parentPosition.left !== '' ? 'left' : 'right' );
+
+       // Figure out where the near and far edges of the popup and $floatableContainer are
+       floatablePos = this.$floatableContainer.offset();
+       floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
+       // Measure where the offsetParent is and compute our position based on that and parentPosition
+       offsetParentPos = this.$element.offsetParent().offset();
+
+       if ( positionProp === near ) {
+               popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
+               popupPos[ far ] = popupPos[ near ] + popupSize;
+       } else {
+               popupPos[ far ] = offsetParentPos[ near ] +
+                       this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
+               popupPos[ near ] = popupPos[ far ] - popupSize;
+       }
+
+       // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
+       // For popups above/below, we point to the start edge; for popups before/after, we point to the center
+       anchorPos = vertical ? ( floatablePos[ start ] + floatablePos[ end ] ) / 2 : floatablePos[ start ];
+       anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
+
+       // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
+       // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
+       anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
+       anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
+       if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
+               // Not enough space for the anchor on the start side; pull the popup startwards
+               positionAdjustment = ( positionProp === start ? -1 : 1 ) *
+                       ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
+       } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
+               // Not enough space for the anchor on the end side; pull the popup endwards
+               positionAdjustment = ( positionProp === end ? -1 : 1 ) *
+                       ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
+       } else {
+               positionAdjustment = 0;
+       }
+
+       // Check if the popup will go beyond the edge of this.$container
+       containerPos = this.$container.offset();
+       containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
+       // Take into account how much the popup will move because of the adjustments we're going to make
+       popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
+       popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
+       if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
+               // Popup goes beyond the near (left/top) edge, move it to the right/bottom
+               positionAdjustment += ( positionProp === near ? 1 : -1 ) *
+                       ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
+       } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
+               // Popup goes beyond the far (right/bottom) edge, move it to the left/top
+               positionAdjustment += ( positionProp === far ? 1 : -1 ) *
+                       ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
+       }
+
+       // Adjust anchorOffset for positionAdjustment
+       anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
+
+       // Position the anchor
+       anchorCss[ start ] = anchorOffset;
+       this.$anchor.css( anchorCss );
+       // Move the popup if needed
+       parentPosition[ positionProp ] += positionAdjustment;
+
+       return parentPosition;
 };
 
 /**
  * Set popup alignment
  *
- * @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
+ * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
  *  `backwards` or `forwards`.
  */
 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
-       // Validate alignment and transform deprecated values
-       if ( [ 'left', 'right', 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
-               this.align = { left: 'force-right', right: 'force-left' }[ align ] || align;
+       // Transform values deprecated since v0.11.0
+       if ( align === 'left' || align === 'right' ) {
+               OO.ui.warnDeprecation( 'PopupWidget#setAlignment parameter value `' + align + '` is deprecated. Use `force-right` or `force-left` instead.' );
+               align = { left: 'force-right', right: 'force-left' }[ align ];
+       }
+
+       // Validate alignment
+       if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
+               this.align = align;
        } else {
                this.align = 'center';
        }
+       this.position();
 };
 
 /**
  * Get popup alignment
  *
- * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
+ * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
  *  `backwards` or `forwards`.
  */
 OO.ui.PopupWidget.prototype.getAlignment = function () {
        return this.align;
 };
 
+/**
+ * Change the positioning of the popup.
+ *
+ * @param {string} position 'above', 'below', 'before' or 'after'
+ */
+OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
+       if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
+               position = 'below';
+       }
+       this.popupPosition = position;
+       this.position();
+};
+
+/**
+ * Get popup positioning.
+ *
+ * @return {string} 'above', 'below', 'before' or 'after'
+ */
+OO.ui.PopupWidget.prototype.getPosition = function () {
+       return this.popupPosition;
+};
+
 /**
  * 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
@@ -4621,9 +5314,14 @@ OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
 
        // Properties
        this.popup = new OO.ui.PopupWidget( $.extend(
-               { autoClose: true },
+               {
+                       autoClose: true,
+                       $floatableContainer: this.$element
+               },
                config.popup,
-               { $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore ) }
+               {
+                       $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
+               }
        ) );
 };
 
@@ -4662,6 +5360,9 @@ OO.ui.mixin.PopupElement.prototype.getPopup = function () {
  *
  * @constructor
  * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
+ *  the expanded popup 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 popup uses relative positioning.
  */
 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
        // Parent constructor
@@ -4670,14 +5371,21 @@ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
        // Mixin constructors
        OO.ui.mixin.PopupElement.call( this, config );
 
+       // Properties
+       this.$overlay = config.$overlay || this.$element;
+
        // Events
        this.connect( this, { click: 'onAction' } );
 
        // Initialization
        this.$element
                .addClass( 'oo-ui-popupButtonWidget' )
-               .attr( 'aria-haspopup', 'true' )
-               .append( this.popup.$element );
+               .attr( 'aria-haspopup', 'true' );
+       this.popup.$element
+               .addClass( 'oo-ui-popupButtonWidget-popup' )
+               .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
+               .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
+       this.$overlay.append( this.popup.$element );
 };
 
 /* Setup */
@@ -4851,12 +5559,40 @@ OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
 
 /* Static Properties */
 
+/**
+ * Whether this option can be selected. See #setSelected.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
 OO.ui.OptionWidget.static.selectable = true;
 
+/**
+ * Whether this option can be highlighted. See #setHighlighted.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
 OO.ui.OptionWidget.static.highlightable = true;
 
+/**
+ * Whether this option can be pressed. See #setPressed.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
 OO.ui.OptionWidget.static.pressable = true;
 
+/**
+ * Whether this option will be scrolled into view when it is selected.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
 
 /* Methods */
@@ -4979,6 +5715,19 @@ OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
        return this;
 };
 
+/**
+ * Get text to match search strings against.
+ *
+ * The default implementation returns the label text, but subclasses
+ * can override this to provide more complex behavior.
+ *
+ * @return {string|boolean} String to match search string against
+ */
+OO.ui.OptionWidget.prototype.getMatchText = function () {
+       var label = this.getLabel();
+       return typeof label === 'string' ? label : this.$label.text();
+};
+
 /**
  * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
  * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
@@ -5423,7 +6172,7 @@ 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
+ * @return {Function} function ( OO.ui.OptionWidget ) => boolean
  */
 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
        var re;
@@ -5438,14 +6187,11 @@ OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
        }
        re = new RegExp( re, 'i' );
        return function ( item ) {
-               var l = item.getLabel();
-               if ( typeof l !== 'string' ) {
-                       l = item.$label.text();
+               var matchText = item.getMatchText();
+               if ( matchText.normalize ) {
+                       matchText = matchText.normalize();
                }
-               if ( l.normalize ) {
-                       l = l.normalize();
-               }
-               return re.test( l );
+               return re.test( matchText );
        };
 };
 
@@ -5929,6 +6675,10 @@ OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
 
 /**
@@ -5982,8 +6732,16 @@ OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.MenuSectionOptionWidget.static.selectable = false;
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
 
 /**
@@ -6004,6 +6762,8 @@ OO.ui.MenuSectionOptionWidget.static.highlightable = false;
  * - Down-arrow key: highlight the next menu option
  * - Esc key: hide the menu
  *
+ * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
+ *
  * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
  *
@@ -6023,6 +6783,7 @@ OO.ui.MenuSectionOptionWidget.static.highlightable = false;
  *  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} [hideOnChoose=true] Hide the menu when the user chooses an option.
  * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
  */
 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
@@ -6037,6 +6798,7 @@ OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
 
        // Properties
        this.autoHide = config.autoHide === undefined || !!config.autoHide;
+       this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
        this.filterFromInput = !!config.filterFromInput;
        this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
        this.$widget = config.widget ? config.widget.$element : null;
@@ -6117,17 +6879,36 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
  * @protected
  */
 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
-       var i, item,
+       var i, item, visible, section, sectionEmpty,
+               anyVisible = false,
                len = this.items.length,
                showAll = !this.isVisible(),
                filter = showAll ? null : this.getItemMatcher( this.$input.val() );
 
+       // Hide non-matching options, and also hide section headers if all options
+       // in their section are hidden.
        for ( i = 0; i < len; i++ ) {
                item = this.items[ i ];
-               if ( item instanceof OO.ui.OptionWidget ) {
-                       item.toggle( showAll || filter( item ) );
+               if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
+                       if ( section ) {
+                               // If the previous section was empty, hide its header
+                               section.toggle( showAll || !sectionEmpty );
+                       }
+                       section = item;
+                       sectionEmpty = true;
+               } else if ( item instanceof OO.ui.OptionWidget ) {
+                       visible = showAll || filter( item );
+                       anyVisible = anyVisible || visible;
+                       sectionEmpty = sectionEmpty && !visible;
+                       item.toggle( visible );
                }
        }
+       // Process the final section
+       if ( section ) {
+               section.toggle( showAll || !sectionEmpty );
+       }
+
+       this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
 
        // Reevaluate clipping
        this.clip();
@@ -6185,7 +6966,7 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
 /**
  * Choose an item.
  *
- * When a user chooses an item, the menu is closed.
+ * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
  *
  * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
  * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
@@ -6195,7 +6976,9 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
  */
 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
        OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
-       this.toggle( false );
+       if ( this.hideOnChoose ) {
+               this.toggle( false );
+       }
        return this;
 };
 
@@ -6239,6 +7022,14 @@ OO.ui.MenuSelectWidget.prototype.clearItems = function () {
 };
 
 /**
+ * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
+ * `.toggle( true )` after its #$element is attached to the DOM.
+ *
+ * Do not show the menu while it is not attached to the DOM. The calculations required to display
+ * it in the right place and with the right dimensions only work correctly while it is attached.
+ * Side-effects may include broken interface and exceptions being thrown. This wasn't always
+ * strictly enforced, so currently it only generates a warning in the browser console.
+ *
  * @inheritdoc
  */
 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
@@ -6247,6 +7038,11 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
        visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
        change = visible !== this.isVisible();
 
+       if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
+               OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
+               this.warnedUnattached = true;
+       }
+
        // Parent method
        OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
 
@@ -6281,7 +7077,7 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
  * 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
+ * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
  * OO.ui.DropdownInputWidget instead.
  *
  *     @example
@@ -6511,12 +7307,28 @@ OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.RadioOptionWidget.static.highlightable = false;
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.RadioOptionWidget.static.pressable = false;
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.RadioOptionWidget.static.tagName = 'label';
 
 /* Methods */
@@ -6552,7 +7364,7 @@ OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
  * an interface for adding, removing and selecting options.
  * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
  *
- * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
  * OO.ui.RadioSelectInputWidget instead.
  *
  *     @example
@@ -6846,6 +7658,10 @@ OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
 
 /* Methods */
@@ -6913,7 +7729,7 @@ OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
  * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
  * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
  *
- * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
  * OO.ui.CheckboxMultiselectInputWidget instead.
  *
  *     @example
@@ -6941,315 +7757,108 @@ OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
  * @extends OO.ui.MultiselectWidget
  *
  * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
-       // Parent constructor
-       OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
-
-       // Properties
-       this.$lastClicked = null;
-
-       // Events
-       this.$group.on( 'click', this.onClick.bind( this ) );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-checkboxMultiselectWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
-
-/* Methods */
-
-/**
- * Get an option by its position relative to the specified item (or to the start of the option array,
- * if item is `null`). The direction in which to search through the option array is specified with a
- * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
- * `null` if there are no options in the array.
- *
- * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
- * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
- * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
- */
-OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
-       var currentIndex, nextIndex, i,
-               increase = direction > 0 ? 1 : -1,
-               len = this.items.length;
-
-       if ( item ) {
-               currentIndex = this.items.indexOf( item );
-               nextIndex = ( currentIndex + increase + len ) % len;
-       } else {
-               // If no item is selected and moving forward, start at the beginning.
-               // If moving backward, start at the end.
-               nextIndex = direction > 0 ? 0 : len - 1;
-       }
-
-       for ( i = 0; i < len; i++ ) {
-               item = this.items[ nextIndex ];
-               if ( item && !item.isDisabled() ) {
-                       return item;
-               }
-               nextIndex = ( nextIndex + increase + len ) % len;
-       }
-       return null;
-};
-
-/**
- * Handle click events on checkboxes.
- *
- * @param {jQuery.Event} e
- */
-OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
-       var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
-               $lastClicked = this.$lastClicked,
-               $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
-                       .not( '.oo-ui-widget-disabled' );
-
-       // Allow selecting multiple options at once by Shift-clicking them
-       if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
-               $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
-               lastClickedIndex = $options.index( $lastClicked );
-               nowClickedIndex = $options.index( $nowClicked );
-               // If it's the same item, either the user is being silly, or it's a fake event generated by the
-               // browser. In either case we don't need custom handling.
-               if ( nowClickedIndex !== lastClickedIndex ) {
-                       items = this.items;
-                       wasSelected = items[ nowClickedIndex ].isSelected();
-                       direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
-
-                       // This depends on the DOM order of the items and the order of the .items array being the same.
-                       for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
-                               if ( !items[ i ].isDisabled() ) {
-                                       items[ i ].setSelected( !wasSelected );
-                               }
-                       }
-                       // For the now-clicked element, use immediate timeout to allow the browser to do its own
-                       // handling first, then set our value. The order in which events happen is different for
-                       // clicks on the <input> and on the <label> and there are additional fake clicks fired for
-                       // non-click actions that change the checkboxes.
-                       e.preventDefault();
-                       setTimeout( function () {
-                               if ( !items[ nowClickedIndex ].isDisabled() ) {
-                                       items[ nowClickedIndex ].setSelected( !wasSelected );
-                               }
-                       } );
-               }
-       }
-
-       if ( $nowClicked.length ) {
-               this.$lastClicked = $nowClicked;
-       }
-};
-
-/**
- * Element that will stick under a specified container, even when it is inserted elsewhere in the
- * document (for example, in a OO.ui.Window's $overlay).
- *
- * The elements's position is automatically calculated and maintained when window is resized or the
- * page is scrolled. If you reposition the container manually, you have to call #position to make
- * sure the element is still placed correctly.
- *
- * As positioning is only possible when both the element and the container are attached to the DOM
- * and visible, it's only done after you call #togglePositioning. You might want to do this inside
- * the #toggle method to display a floating popup, for example.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
- * @cfg {jQuery} [$floatableContainer] Node to position below
- */
-OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$floatable = null;
-       this.$floatableContainer = null;
-       this.$floatableWindow = null;
-       this.$floatableClosestScrollable = null;
-       this.onFloatableScrollHandler = this.position.bind( this );
-       this.onFloatableWindowResizeHandler = this.position.bind( this );
-
-       // Initialization
-       this.setFloatableContainer( config.$floatableContainer );
-       this.setFloatableElement( config.$floatable || this.$element );
-};
-
-/* Methods */
-
-/**
- * Set floatable element.
- *
- * If an element is already set, it will be cleaned up before setting up the new element.
- *
- * @param {jQuery} $floatable Element to make floatable
- */
-OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
-       if ( this.$floatable ) {
-               this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
-               this.$floatable.css( { left: '', top: '' } );
-       }
-
-       this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
-       this.position();
-};
-
-/**
- * Set floatable container.
- *
- * The element will be always positioned under the specified container.
- *
- * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
- */
-OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
-       this.$floatableContainer = $floatableContainer;
-       if ( this.$floatable ) {
-               this.position();
-       }
-};
-
-/**
- * Toggle positioning.
- *
- * Do not turn positioning on until after the element is attached to the DOM and visible.
- *
- * @param {boolean} [positioning] Enable positioning, omit to toggle
- * @chainable
+ * @param {Object} [config] Configuration options
  */
-OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
-       var closestScrollableOfContainer, closestScrollableOfFloatable;
-
-       positioning = positioning === undefined ? !this.positioning : !!positioning;
-
-       if ( this.positioning !== positioning ) {
-               this.positioning = positioning;
-
-               closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
-               closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] );
-               this.needsCustomPosition = closestScrollableOfContainer !== closestScrollableOfFloatable;
-               // If the scrollable is the root, we have to listen to scroll events
-               // on the window because of browser inconsistencies.
-               if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
-                       closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
-               }
+OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
+       // Parent constructor
+       OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
 
-               if ( positioning ) {
-                       this.$floatableWindow = $( this.getElementWindow() );
-                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
+       // Properties
+       this.$lastClicked = null;
 
-                       this.$floatableClosestScrollable = $( closestScrollableOfContainer );
-                       this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
+       // Events
+       this.$group.on( 'click', this.onClick.bind( this ) );
 
-                       // Initial position after visible
-                       this.position();
-               } else {
-                       if ( this.$floatableWindow ) {
-                               this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
-                               this.$floatableWindow = null;
-                       }
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-checkboxMultiselectWidget' );
+};
 
-                       if ( this.$floatableClosestScrollable ) {
-                               this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
-                               this.$floatableClosestScrollable = null;
-                       }
+/* Setup */
 
-                       this.$floatable.css( { left: '', top: '' } );
-               }
-       }
+OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
 
-       return this;
-};
+/* Methods */
 
 /**
- * Check whether the bottom edge of the given element is within the viewport of the given container.
+ * Get an option by its position relative to the specified item (or to the start of the option array,
+ * if item is `null`). The direction in which to search through the option array is specified with a
+ * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
+ * `null` if there are no options in the array.
  *
- * @private
- * @param {jQuery} $element
- * @param {jQuery} $container
- * @return {boolean}
+ * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
+ * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
+ * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
  */
-OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
-       var elemRect, contRect,
-               leftEdgeInBounds = false,
-               bottomEdgeInBounds = false,
-               rightEdgeInBounds = false;
+OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
+       var currentIndex, nextIndex, i,
+               increase = direction > 0 ? 1 : -1,
+               len = this.items.length;
 
-       elemRect = $element[ 0 ].getBoundingClientRect();
-       if ( $container[ 0 ] === window ) {
-               contRect = {
-                       top: 0,
-                       left: 0,
-                       right: document.documentElement.clientWidth,
-                       bottom: document.documentElement.clientHeight
-               };
+       if ( item ) {
+               currentIndex = this.items.indexOf( item );
+               nextIndex = ( currentIndex + increase + len ) % len;
        } else {
-               contRect = $container[ 0 ].getBoundingClientRect();
+               // If no item is selected and moving forward, start at the beginning.
+               // If moving backward, start at the end.
+               nextIndex = direction > 0 ? 0 : len - 1;
        }
 
-       // For completeness, if we still cared about topEdgeInBounds, that'd be:
-       // elemRect.top >= contRect.top && elemRect.top <= contRect.bottom
-       if ( elemRect.left >= contRect.left && elemRect.left <= contRect.right ) {
-               leftEdgeInBounds = true;
-       }
-       if ( elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom ) {
-               bottomEdgeInBounds = true;
-       }
-       if ( elemRect.right >= contRect.left && elemRect.right <= contRect.right ) {
-               rightEdgeInBounds = true;
+       for ( i = 0; i < len; i++ ) {
+               item = this.items[ nextIndex ];
+               if ( item && !item.isDisabled() ) {
+                       return item;
+               }
+               nextIndex = ( nextIndex + increase + len ) % len;
        }
-
-       // We only care that any part of the bottom edge is visible
-       return bottomEdgeInBounds && ( leftEdgeInBounds || rightEdgeInBounds );
+       return null;
 };
 
 /**
- * Position the floatable below its container.
- *
- * This should only be done when both of them are attached to the DOM and visible.
+ * Handle click events on checkboxes.
  *
- * @chainable
+ * @param {jQuery.Event} e
  */
-OO.ui.mixin.FloatableElement.prototype.position = function () {
-       var pos;
-
-       if ( !this.positioning ) {
-               return this;
-       }
+OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
+       var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
+               $lastClicked = this.$lastClicked,
+               $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
+                       .not( '.oo-ui-widget-disabled' );
 
-       if ( !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
-               this.$floatable.addClass( 'oo-ui-element-hidden' );
-               return;
-       } else {
-               this.$floatable.removeClass( 'oo-ui-element-hidden' );
-       }
+       // Allow selecting multiple options at once by Shift-clicking them
+       if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
+               $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
+               lastClickedIndex = $options.index( $lastClicked );
+               nowClickedIndex = $options.index( $nowClicked );
+               // If it's the same item, either the user is being silly, or it's a fake event generated by the
+               // browser. In either case we don't need custom handling.
+               if ( nowClickedIndex !== lastClickedIndex ) {
+                       items = this.items;
+                       wasSelected = items[ nowClickedIndex ].isSelected();
+                       direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
 
-       if ( !this.needsCustomPosition ) {
-               return;
+                       // This depends on the DOM order of the items and the order of the .items array being the same.
+                       for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
+                               if ( !items[ i ].isDisabled() ) {
+                                       items[ i ].setSelected( !wasSelected );
+                               }
+                       }
+                       // For the now-clicked element, use immediate timeout to allow the browser to do its own
+                       // handling first, then set our value. The order in which events happen is different for
+                       // clicks on the <input> and on the <label> and there are additional fake clicks fired for
+                       // non-click actions that change the checkboxes.
+                       e.preventDefault();
+                       setTimeout( function () {
+                               if ( !items[ nowClickedIndex ].isDisabled() ) {
+                                       items[ nowClickedIndex ].setSelected( !wasSelected );
+                               }
+                       } );
+               }
        }
 
-       pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
-
-       // Position under container
-       pos.top += this.$floatableContainer.height();
-       this.$floatable.css( pos );
-
-       // We updated the position, so re-evaluate the clipping state.
-       // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
-       // will not notice the need to update itself.)
-       // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
-       // it not listen to the right events in the right places?
-       if ( this.clip ) {
-               this.clip();
+       if ( $nowClicked.length ) {
+               this.$lastClicked = $nowClicked;
        }
-
-       return this;
 };
 
 /**
@@ -7304,9 +7913,6 @@ OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWid
 OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget );
 OO.mixinClass( OO.ui.FloatingMenuSelectWidget, OO.ui.mixin.FloatableElement );
 
-// For backwards compatibility
-OO.ui.TextInputMenuSelectWidget = OO.ui.FloatingMenuSelectWidget;
-
 /* Methods */
 
 /**
@@ -7333,6 +7939,23 @@ OO.ui.FloatingMenuSelectWidget.prototype.toggle = function ( visible ) {
        return this;
 };
 
+/*
+ * The old name for the FloatingMenuSelectWidget widget, provided for backwards-compatibility.
+ *
+ * @class
+ * @extends OO.ui.FloatingMenuSelectWidget
+ *
+ * @constructor
+ * @deprecated since v0.12.5.
+ */
+OO.ui.TextInputMenuSelectWidget = function OoUiTextInputMenuSelectWidget() {
+       OO.ui.warnDeprecation( 'TextInputMenuSelectWidget is deprecated. Use the FloatingMenuSelectWidget instead.' );
+       // Parent constructor
+       OO.ui.TextInputMenuSelectWidget.parent.apply( this, arguments );
+};
+
+OO.inheritClass( OO.ui.TextInputMenuSelectWidget, OO.ui.FloatingMenuSelectWidget );
+
 /**
  * Progress bars visually display the status of an operation, such as a download,
  * and can be either determinate or indeterminate:
@@ -7399,6 +8022,10 @@ OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.ProgressBarWidget.static.tagName = 'div';
 
 /* Methods */
@@ -7500,6 +8127,10 @@ OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.InputWidget.static.supportsSimpleLabel = true;
 
 /* Static Methods */
@@ -7553,6 +8184,25 @@ OO.ui.InputWidget.prototype.getInputElement = function () {
        return $( '<input>' );
 };
 
+/**
+ * Get input element's ID.
+ *
+ * If the element already has an ID then that is returned, otherwise unique ID is
+ * generated, set on the element, and returned.
+ *
+ * @return {string} The ID of the element
+ */
+OO.ui.InputWidget.prototype.getInputId = function () {
+       var id = this.$input.attr( 'id' );
+
+       if ( id === undefined ) {
+               id = OO.ui.generateElementId();
+               this.$input.attr( 'id', id );
+       }
+
+       return id;
+};
+
 /**
  * Handle potentially value-changing events.
  *
@@ -7641,6 +8291,7 @@ OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
  * called directly.
  */
 OO.ui.InputWidget.prototype.simulateLabelClick = function () {
+       OO.ui.warnDeprecation( 'InputWidget: simulateLabelClick() is deprecated.' );
        if ( !this.isDisabled() ) {
                if ( this.$input.is( ':checkbox, :radio' ) ) {
                        this.$input.click();
@@ -7772,6 +8423,9 @@ OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
 /**
  * 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.
+ *
+ * @static
+ * @inheritdoc
  */
 OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
 
@@ -7835,7 +8489,7 @@ OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
  * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
  * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
  *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
  *
  *     @example
  *     // An example of selected, unselected, and disabled checkbox inputs
@@ -7966,7 +8620,7 @@ OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
 
 /**
  * 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
+ * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
  * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
  * more information about input widgets.
  *
@@ -8128,7 +8782,7 @@ OO.ui.DropdownInputWidget.prototype.blur = function () {
  * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
  * please see the [OOjs UI documentation on MediaWiki][1].
  *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
  *
  *     @example
  *     // An example of selected, unselected, and disabled radio inputs
@@ -8243,7 +8897,7 @@ OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
 
 /**
  * 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
+ * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
  * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
  * more information about input widgets.
  *
@@ -8295,6 +8949,10 @@ OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.RadioSelectInputWidget.static.supportsSimpleLabel = false;
 
 /* Static Methods */
@@ -8418,7 +9076,7 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
  *
  * @constructor
  * @param {Object} [config] Configuration options
- * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
+ * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
  */
 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
        // Configuration initialization
@@ -8450,6 +9108,10 @@ OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.CheckboxMultiselectInputWidget.static.supportsSimpleLabel = false;
 
 /* Static Methods */
@@ -8543,7 +9205,7 @@ OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state )
 /**
  * Set the options available for this input.
  *
- * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
+ * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
  * @chainable
  */
 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
@@ -8553,12 +9215,14 @@ OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options )
        this.checkboxMultiselectWidget
                .clearItems()
                .addItems( options.map( function ( opt ) {
-                       var optValue, item;
+                       var optValue, item, optDisabled;
                        optValue =
                                OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
+                       optDisabled = opt.disabled !== undefined ? opt.disabled : false;
                        item = new OO.ui.CheckboxMultioptionWidget( {
                                data: optValue,
-                               label: opt.label !== undefined ? opt.label : optValue
+                               label: opt.label !== undefined ? opt.label : optValue,
+                               disabled: optDisabled
                        } );
                        // Set the 'name' and 'value' for form submission
                        item.checkbox.$input.attr( 'name', widget.inputName );
@@ -8581,7 +9245,7 @@ OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options )
  * which modifies incoming values rather than validating them.
  * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
  *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
  *
  *     @example
  *     // Example of a text input widget
@@ -8618,7 +9282,7 @@ OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options )
  *  specifies minimum number of rows to display.
  * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
  *  Use the #maxRows config to specify a maximum number of displayed rows.
- * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
+ * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
  *  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'`
@@ -9347,6 +10011,15 @@ OO.ui.TextInputWidget.prototype.getValidity = function () {
                }
        }
 
+       // Check browser validity and reject if it is invalid
+       if (
+               this.$input[ 0 ].checkValidity !== undefined &&
+               this.$input[ 0 ].checkValidity() === false
+       ) {
+               return rejectOrResolve( false );
+       }
+
+       // Run our checks if the browser thinks the field is valid
        if ( this.validate instanceof Function ) {
                result = this.validate( this.getValue() );
                if ( result && $.isFunction( result.promise ) ) {
@@ -9565,39 +10238,44 @@ OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
  * - by choosing a value from the menu. The value of the chosen option will then appear in the text
  *   input field.
  *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ * After the user chooses an option, its `data` will be used as a new value for the widget.
+ * A `label` also can be specified for each option: if given, it will be shown instead of the
+ * `data` in the dropdown menu.
+ *
+ * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
  *
  * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
  *
  *     @example
  *     // Example: A ComboBoxInputWidget.
  *     var comboBox = new OO.ui.ComboBoxInputWidget( {
- *         label: 'ComboBoxInputWidget',
  *         value: 'Option 1',
- *         menu: {
- *             items: [
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 1',
- *                     label: 'Option One'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 2',
- *                     label: 'Option Two'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 3',
- *                     label: 'Option Three'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 4',
- *                     label: 'Option Four'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 5',
- *                     label: 'Option Five'
- *                 } )
- *             ]
- *         }
+ *         options: [
+ *             { data: 'Option 1' },
+ *             { data: 'Option 2' },
+ *             { data: 'Option 3' }
+ *         ]
+ *     } );
+ *     $( 'body' ).append( comboBox.$element );
+ *
+ *     @example
+ *     // Example: A ComboBoxInputWidget with additional option labels.
+ *     var comboBox = new OO.ui.ComboBoxInputWidget( {
+ *         value: 'Option 1',
+ *         options: [
+ *             {
+ *                 data: 'Option 1',
+ *                 label: 'Option One'
+ *             },
+ *             {
+ *                 data: 'Option 2',
+ *                 label: 'Option Two'
+ *             },
+ *             {
+ *                 data: 'Option 3',
+ *                 label: 'Option Three'
+ *             }
+ *         ]
  *     } );
  *     $( 'body' ).append( comboBox.$element );
  *
@@ -9837,12 +10515,11 @@ OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
  * @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.
+ * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
  *
  * @throws {Error} An error is thrown if no widget is specified
  */
 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
-       var hasInputWidget, $div;
-
        // Allow passing positional parameters inside the config object
        if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
                config = fieldWidget;
@@ -9854,8 +10531,6 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
                throw new Error( 'Widget not found' );
        }
 
-       hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel;
-
        // Configuration initialization
        config = $.extend( { align: 'left' }, config );
 
@@ -9863,7 +10538,9 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
        OO.ui.FieldLayout.parent.call( this, config );
 
        // Mixin constructors
-       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+               $label: $( '<label>' )
+       } ) );
        OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
 
        // Properties
@@ -9872,41 +10549,49 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
        this.notices = [];
        this.$field = $( '<div>' );
        this.$messages = $( '<ul>' );
-       this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' );
+       this.$header = $( '<div>' );
+       this.$body = $( '<div>' );
        this.align = null;
        if ( config.help ) {
                this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+                       $overlay: config.$overlay,
+                       popup: {
+                               padded: true
+                       },
                        classes: [ 'oo-ui-fieldLayout-help' ],
                        framed: false,
                        icon: 'info'
                } );
-
-               $div = $( '<div>' );
                if ( config.help instanceof OO.ui.HtmlSnippet ) {
-                       $div.html( config.help.toString() );
+                       this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
                } else {
-                       $div.text( config.help );
+                       this.popupButtonWidget.getPopup().$body.text( config.help );
                }
-               this.popupButtonWidget.getPopup().$body.append(
-                       $div.addClass( 'oo-ui-fieldLayout-help-content' )
-               );
                this.$help = this.popupButtonWidget.$element;
        } else {
                this.$help = $( [] );
        }
 
        // Events
-       if ( hasInputWidget ) {
-               this.$label.on( 'click', this.onLabelClick.bind( this ) );
-       }
        this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
 
        // Initialization
+       if ( fieldWidget.constructor.static.supportsSimpleLabel ) {
+               if ( this.fieldWidget.getInputId() ) {
+                       this.$label.attr( 'for', this.fieldWidget.getInputId() );
+               } else {
+                       this.$label.on( 'click', function () {
+                               this.fieldWidget.focus();
+                               return false;
+                       }.bind( this ) );
+               }
+       }
        this.$element
                .addClass( 'oo-ui-fieldLayout' )
                .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
-               .append( this.$help, this.$body );
+               .append( this.$body );
        this.$body.addClass( 'oo-ui-fieldLayout-body' );
+       this.$header.addClass( 'oo-ui-fieldLayout-header' );
        this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
        this.$field
                .addClass( 'oo-ui-fieldLayout-field' )
@@ -9935,17 +10620,6 @@ OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
        this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
 };
 
-/**
- * Handle label mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.FieldLayout.prototype.onLabelClick = function () {
-       this.fieldWidget.simulateLabelClick();
-       return false;
-};
-
 /**
  * Get the widget contained by the field.
  *
@@ -9992,10 +10666,15 @@ OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
                        value = 'left';
                }
                // Reorder elements
-               if ( value === 'inline' ) {
-                       this.$body.append( this.$field, this.$label );
+               if ( value === 'top' ) {
+                       this.$header.append( this.$label, this.$help );
+                       this.$body.append( this.$header, this.$field );
+               } else if ( value === 'inline' ) {
+                       this.$header.append( this.$label, this.$help );
+                       this.$body.append( this.$field, this.$header );
                } else {
-                       this.$body.append( this.$label, this.$field );
+                       this.$header.append( this.$label );
+                       this.$body.append( this.$header, this.$help, this.$field );
                }
                // Set classes. The following classes can be used here:
                // * oo-ui-fieldLayout-align-left
@@ -10185,10 +10864,9 @@ OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
  * @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.
+ * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
  */
 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
-       var $div;
-
        // Configuration initialization
        config = config || {};
 
@@ -10200,32 +10878,36 @@ OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
        OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: $( '<div>' ) } ) );
        OO.ui.mixin.GroupElement.call( this, config );
 
+       // Properties
+       this.$header = $( '<div>' );
        if ( config.help ) {
                this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+                       $overlay: config.$overlay,
+                       popup: {
+                               padded: true
+                       },
                        classes: [ 'oo-ui-fieldsetLayout-help' ],
                        framed: false,
                        icon: 'info'
                } );
-
-               $div = $( '<div>' );
                if ( config.help instanceof OO.ui.HtmlSnippet ) {
-                       $div.html( config.help.toString() );
+                       this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
                } else {
-                       $div.text( config.help );
+                       this.popupButtonWidget.getPopup().$body.text( config.help );
                }
-               this.popupButtonWidget.getPopup().$body.append(
-                       $div.addClass( 'oo-ui-fieldsetLayout-help-content' )
-               );
                this.$help = this.popupButtonWidget.$element;
        } else {
                this.$help = $( [] );
        }
 
        // Initialization
+       this.$header
+               .addClass( 'oo-ui-fieldsetLayout-header' )
+               .append( this.$icon, this.$label, this.$help );
        this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
        this.$element
                .addClass( 'oo-ui-fieldsetLayout' )
-               .prepend( this.$label, this.$help, this.$icon, this.$group );
+               .prepend( this.$header, this.$group );
        if ( Array.isArray( config.items ) ) {
                this.addItems( config.items );
        }
@@ -10240,6 +10922,10 @@ OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
 
 /**
@@ -10353,6 +11039,10 @@ OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
 
 /* Static Properties */
 
+/**
+ * @static
+ * @inheritdoc
+ */
 OO.ui.FormLayout.static.tagName = 'form';
 
 /* Methods */