Update OOjs UI to v0.16.3
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
index 5a43228..1b90db5 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.15.3
+ * OOjs UI v0.16.3
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2016-02-09T21:21:16Z
+ * Date: 2016-03-16T19:20:22Z
  */
 ( function ( OO ) {
 
@@ -56,14 +56,14 @@ OO.ui.MouseButtons = {
 };
 
 /**
- * @property {Number}
+ * @property {number}
  */
 OO.ui.elementId = 0;
 
 /**
  * Generate a unique ID for element
  *
- * @return {String} [id]
+ * @return {string} [id]
  */
 OO.ui.generateElementId = function () {
        OO.ui.elementId += 1;
@@ -74,7 +74,7 @@ OO.ui.generateElementId = function () {
  * Check if an element is focusable.
  * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14
  *
- * @param {jQuery} element Element to test
+ * @param {jQuery} $element Element to test
  * @return {boolean}
  */
 OO.ui.isFocusableElement = function ( $element ) {
@@ -253,18 +253,72 @@ OO.ui.debounce = function ( func, wait, immediate ) {
                if ( immediate && !timeout ) {
                        func.apply( context, args );
                }
-               clearTimeout( timeout );
-               timeout = setTimeout( later, wait );
+               if ( !timeout || wait ) {
+                       clearTimeout( timeout );
+                       timeout = setTimeout( later, wait );
+               }
+       };
+};
+
+/**
+ * Returns a function, that, when invoked, will only be triggered at most once
+ * during a given window of time. If called again during that window, it will
+ * wait until the window ends and then trigger itself again.
+ *
+ * As it's not knowable to the caller whether the function will actually run
+ * when the wrapper is called, return values from the function are entirely
+ * discarded.
+ *
+ * @param {Function} func
+ * @param {number} wait
+ * @return {Function}
+ */
+OO.ui.throttle = function ( func, wait ) {
+       var context, args, timeout,
+               previous = 0,
+               run = function () {
+                       timeout = null;
+                       previous = OO.ui.now();
+                       func.apply( context, args );
+               };
+       return function () {
+               // Check how long it's been since the last time the function was
+               // called, and whether it's more or less than the requested throttle
+               // period. If it's less, run the function immediately. If it's more,
+               // set a timeout for the remaining time -- but don't replace an
+               // existing timeout, since that'd indefinitely prolong the wait.
+               var remaining = wait - ( OO.ui.now() - previous );
+               context = this;
+               args = arguments;
+               if ( remaining <= 0 ) {
+                       // Note: unless wait was ridiculously large, this means we'll
+                       // automatically run the first time the function was called in a
+                       // given period. (If you provide a wait period larger than the
+                       // current Unix timestamp, you *deserve* unexpected behavior.)
+                       clearTimeout( timeout );
+                       run();
+               } else if ( !timeout ) {
+                       timeout = setTimeout( run, remaining );
+               }
        };
 };
 
+/**
+ * A (possibly faster) way to get the current timestamp as an integer
+ *
+ * @return {number} Current timestamp
+ */
+OO.ui.now = Date.now || function () {
+       return new Date().getTime();
+};
+
 /**
  * Proxy for `node.addEventListener( eventName, handler, true )`.
  *
  * @param {HTMLElement} node
  * @param {string} eventName
  * @param {Function} handler
- * @deprecated
+ * @deprecated since 0.15.0
  */
 OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
        node.addEventListener( eventName, handler, true );
@@ -276,7 +330,7 @@ OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
  * @param {HTMLElement} node
  * @param {string} eventName
  * @param {Function} handler
- * @deprecated
+ * @deprecated since 0.15.0
  */
 OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) {
        node.removeEventListener( eventName, handler, true );
@@ -354,7 +408,7 @@ OO.ui.infuse = function ( idOrNode ) {
         * they support unnamed, ordered message parameters.
         *
         * @param {string} key Message key
-        * @param {Mixed...} [params] Message parameters
+        * @param {...Mixed} [params] Message parameters
         * @return {string} Translated message with parameters substituted
         */
        OO.ui.msg = function ( key ) {
@@ -380,7 +434,7 @@ OO.ui.infuse = function ( idOrNode ) {
  * Use this when you are statically specifying a message and the message may not yet be present.
  *
  * @param {string} key Message key
- * @param {Mixed...} [params] Message parameters
+ * @param {...Mixed} [params] Message parameters
  * @return {Function} Function that returns the resolved message when executed
  */
 OO.ui.deferMsg = function () {
@@ -577,6 +631,7 @@ OO.ui.Element.static.infuse = function ( idOrNode ) {
 /**
  * Implementation helper for `infuse`; skips the type check and has an
  * extra property so that only the top-level invocation touches the DOM.
+ *
  * @private
  * @param {string|HTMLElement|jQuery} idOrNode
  * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
@@ -608,13 +663,13 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
                }
                if ( domPromise ) {
                        // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
-                       state = data.gatherPreInfuseState( $elem );
+                       state = data.constructor.static.gatherPreInfuseState( $elem, data );
                        // restore dynamic state after the new element is re-inserted into DOM under infused parent
                        domPromise.done( data.restorePreInfuseState.bind( data, state ) );
                        infusedChildren = $elem.data( 'ooui-infused-children' );
                        if ( infusedChildren && infusedChildren.length ) {
                                infusedChildren.forEach( function ( data ) {
-                                       var state = data.gatherPreInfuseState( $elem );
+                                       var state = data.constructor.static.gatherPreInfuseState( $elem, data );
                                        domPromise.done( data.restorePreInfuseState.bind( data, state ) );
                                } );
                        }
@@ -682,7 +737,7 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
                                infused.$element.removeData( 'ooui-infused-children' );
                                return infused;
                        }
-                       if ( value.html ) {
+                       if ( value.html !== undefined ) {
                                return new OO.ui.HtmlSnippet( value.html );
                        }
                }
@@ -1055,71 +1110,77 @@ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension )
  * @static
  * @param {HTMLElement} el Element to scroll into view
  * @param {Object} [config] Configuration options
- * @param {string} [config.duration] jQuery animation duration value
+ * @param {string} [config.duration='fast'] jQuery animation duration value
  * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
  *  to scroll in both directions
- * @param {Function} [config.complete] Function to call when scrolling completes
+ * @param {Function} [config.complete] Function to call when scrolling completes.
+ *  Deprecated since 0.15.4, use the return promise instead.
+ * @return {jQuery.Promise} Promise which resolves when the scroll is complete
  */
 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
-       var rel, anim, callback, sc, $sc, eld, scd, $win;
+       var position, animations, callback, container, $container, elementDimensions, containerDimensions, $window,
+               deferred = $.Deferred();
 
        // Configuration initialization
        config = config || {};
 
-       anim = {};
+       animations = {};
        callback = typeof config.complete === 'function' && config.complete;
-       sc = this.getClosestScrollableContainer( el, config.direction );
-       $sc = $( sc );
-       eld = this.getDimensions( el );
-       scd = this.getDimensions( sc );
-       $win = $( this.getWindow( el ) );
-
-       // Compute the distances between the edges of el and the edges of the scroll viewport
-       if ( $sc.is( 'html, body' ) ) {
+       container = this.getClosestScrollableContainer( el, config.direction );
+       $container = $( container );
+       elementDimensions = this.getDimensions( el );
+       containerDimensions = this.getDimensions( container );
+       $window = $( this.getWindow( el ) );
+
+       // Compute the element's position relative to the container
+       if ( $container.is( 'html, body' ) ) {
                // If the scrollable container is the root, this is easy
-               rel = {
-                       top: eld.rect.top,
-                       bottom: $win.innerHeight() - eld.rect.bottom,
-                       left: eld.rect.left,
-                       right: $win.innerWidth() - eld.rect.right
+               position = {
+                       top: elementDimensions.rect.top,
+                       bottom: $window.innerHeight() - elementDimensions.rect.bottom,
+                       left: elementDimensions.rect.left,
+                       right: $window.innerWidth() - elementDimensions.rect.right
                };
        } else {
-               // Otherwise, we have to subtract el's coordinates from sc's coordinates
-               rel = {
-                       top: eld.rect.top - ( scd.rect.top + scd.borders.top ),
-                       bottom: scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom,
-                       left: eld.rect.left - ( scd.rect.left + scd.borders.left ),
-                       right: scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right
+               // Otherwise, we have to subtract el's coordinates from container's coordinates
+               position = {
+                       top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
+                       bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
+                       left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
+                       right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
                };
        }
 
        if ( !config.direction || config.direction === 'y' ) {
-               if ( rel.top < 0 ) {
-                       anim.scrollTop = scd.scroll.top + rel.top;
-               } else if ( rel.top > 0 && rel.bottom < 0 ) {
-                       anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom );
+               if ( position.top < 0 ) {
+                       animations.scrollTop = containerDimensions.scroll.top + position.top;
+               } else if ( position.top > 0 && position.bottom < 0 ) {
+                       animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
                }
        }
        if ( !config.direction || config.direction === 'x' ) {
-               if ( rel.left < 0 ) {
-                       anim.scrollLeft = scd.scroll.left + rel.left;
-               } else if ( rel.left > 0 && rel.right < 0 ) {
-                       anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right );
+               if ( position.left < 0 ) {
+                       animations.scrollLeft = containerDimensions.scroll.left + position.left;
+               } else if ( position.left > 0 && position.right < 0 ) {
+                       animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
                }
        }
-       if ( !$.isEmptyObject( anim ) ) {
-               $sc.stop( true ).animate( anim, config.duration === undefined ? 'fast' : config.duration );
-               if ( callback ) {
-                       $sc.queue( function ( next ) {
+       if ( !$.isEmptyObject( animations ) ) {
+               $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
+               $container.queue( function ( next ) {
+                       if ( callback ) {
                                callback();
-                               next();
-                       } );
-               }
+                       }
+                       deferred.resolve();
+                       next();
+               } );
        } else {
                if ( callback ) {
                        callback();
                }
+               deferred.resolve();
        }
+       return deferred.promise();
 };
 
 /**
@@ -1197,7 +1258,7 @@ OO.ui.Element.prototype.getData = function () {
 /**
  * Set element data.
  *
- * @param {Mixed} Element data
+ * @param {Mixed} data Element data
  * @chainable
  */
 OO.ui.Element.prototype.setData = function ( data ) {
@@ -1258,6 +1319,7 @@ OO.ui.Element.prototype.getTagName = function () {
 
 /**
  * Check if the element is attached to the DOM
+ *
  * @return {boolean} The element is attached to the DOM
  */
 OO.ui.Element.prototype.isElementAttached = function () {
@@ -1285,6 +1347,8 @@ OO.ui.Element.prototype.getElementWindow = function () {
 
 /**
  * Get closest scrollable container.
+ *
+ * @return {HTMLElement} Closest scrollable container
  */
 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
        return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
@@ -1314,6 +1378,7 @@ OO.ui.Element.prototype.setElementGroup = function ( group ) {
  * Scroll element into view.
  *
  * @param {Object} [config] Configuration options
+ * @return {jQuery.Promise} Promise which resolves when the scroll is complete
  */
 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
        return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
@@ -1715,6 +1780,7 @@ OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
  * See the [OOjs UI documentation on MediaWiki] [1] for examples.
  *
  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons
+ *
  * @abstract
  * @class
  *
@@ -2566,7 +2632,7 @@ OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
  *
  * The title is displayed when a user moves the mouse over the indicator.
  *
- * @param {string|Function|null} indicator Indicator title text, a function that returns text, or
+ * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
  *   `null` for no indicator title
  * @chainable
  */
@@ -2627,8 +2693,6 @@ OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
  *  as a plaintext string, a jQuery selection of elements, or a function that will produce a string
  *  in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
  *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
- * @cfg {boolean} [autoFitLabel=true] Fit the label to the width of the parent element.
- *  The label will be truncated to fit if necessary.
  */
 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
        // Configuration initialization
@@ -2637,7 +2701,6 @@ OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
        // Properties
        this.$label = null;
        this.label = null;
-       this.autoFitLabel = config.autoFitLabel === undefined || !!config.autoFitLabel;
 
        // Initialization
        this.setLabel( config.label || this.constructor.static.label );
@@ -2668,6 +2731,33 @@ OO.initClass( OO.ui.mixin.LabelElement );
  */
 OO.ui.mixin.LabelElement.static.label = null;
 
+/* Static methods */
+
+/**
+ * Highlight the first occurrence of the query in the given text
+ *
+ * @param {string} text Text
+ * @param {string} query Query to find
+ * @return {jQuery} Text with the first match of the query
+ *  sub-string wrapped in highlighted span
+ */
+OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query ) {
+       var $result = $( '<span>' ),
+               offset = text.toLowerCase().indexOf( query.toLowerCase() );
+
+       if ( !query.length || offset === -1 ) {
+               return $result.text( text );
+       }
+       $result.append(
+               document.createTextNode( text.slice( 0, offset ) ),
+               $( '<span>' )
+                       .addClass( 'oo-ui-labelElement-label-highlight' )
+                       .text( text.slice( offset, offset + query.length ) ),
+               document.createTextNode( text.slice( offset + query.length ) )
+       );
+       return $result.contents();
+};
+
 /* Methods */
 
 /**
@@ -2713,6 +2803,17 @@ OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
        return this;
 };
 
+/**
+ * Set the label as plain text with a highlighted query
+ *
+ * @param {string} text Text label to set
+ * @param {string} query Substring of text to highlight
+ * @chainable
+ */
+OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query ) {
+       return this.setLabel( this.constructor.static.highlightQuery( text, query ) );
+};
+
 /**
  * Get the label.
  *
@@ -2727,12 +2828,9 @@ OO.ui.mixin.LabelElement.prototype.getLabel = function () {
  * Fit the label.
  *
  * @chainable
+ * @deprecated since 0.16.0
  */
 OO.ui.mixin.LabelElement.prototype.fitLabel = function () {
-       if ( this.$label && this.$label.autoEllipsis && this.autoFitLabel ) {
-               this.$label.autoEllipsis( { hasSpan: false, tooltip: true } );
-       }
-
        return this;
 };
 
@@ -3008,7 +3106,7 @@ OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
        this.title = null;
 
        // Initialization
-       this.setTitle( config.title || this.constructor.static.title );
+       this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
        this.setTitledElement( config.$titled || this.$element );
 };
 
@@ -3160,7 +3258,7 @@ OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $acc
 /**
  * Set accesskey.
  *
- * @param {string|Function|null} accesskey Key, a function that returns a key, or `null` for no accesskey
+ * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
  * @chainable
  */
 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
@@ -4006,6 +4104,9 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
                ccWidth + ccOffset.left :
                ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
        desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
+       // It should never be desirable to exceed the dimensions of the browser viewport... right?
+       desiredWidth = Math.min( desiredWidth, document.documentElement.clientWidth );
+       desiredHeight = Math.min( desiredHeight, document.documentElement.clientHeight );
        allotedWidth = Math.ceil( desiredWidth - extraWidth );
        allotedHeight = Math.ceil( desiredHeight - extraHeight );
        naturalWidth = this.$clippable.prop( 'scrollWidth' );
@@ -4082,9 +4183,9 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
  *  This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
  *  for an example.
  *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
- * @cfg {boolean} [head] Show a popup header that contains a #label (if specified) and close
+ * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
  *  button.
- * @cfg {boolean} [padded] Add padding to the popup's body
+ * @cfg {boolean} [padded=false] Add padding to the popup's body
  */
 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
        // Configuration initialization
@@ -4105,8 +4206,6 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
        } ) );
 
        // Properties
-       this.$head = $( '<div>' );
-       this.$footer = $( '<div>' );
        this.$anchor = $( '<div>' );
        // If undefined, will be computed lazily in updateDimensions()
        this.$container = config.$container;
@@ -4118,44 +4217,45 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
        this.width = config.width !== undefined ? config.width : 320;
        this.height = config.height !== undefined ? config.height : null;
        this.setAlignment( config.align );
-       this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
        this.onMouseDownHandler = this.onMouseDown.bind( this );
        this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
 
-       // Events
-       this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
-
        // Initialization
        this.toggleAnchor( config.anchor === undefined || config.anchor );
        this.$body.addClass( 'oo-ui-popupWidget-body' );
        this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
-       this.$head
-               .addClass( 'oo-ui-popupWidget-head' )
-               .append( this.$label, this.closeButton.$element );
-       this.$footer.addClass( 'oo-ui-popupWidget-footer' );
-       if ( !config.head ) {
-               this.$head.addClass( 'oo-ui-element-hidden' );
-       }
-       if ( !config.$footer ) {
-               this.$footer.addClass( 'oo-ui-element-hidden' );
-       }
        this.$popup
                .addClass( 'oo-ui-popupWidget-popup' )
-               .append( this.$head, this.$body, this.$footer );
+               .append( this.$body );
        this.$element
                .addClass( 'oo-ui-popupWidget' )
                .append( this.$popup, this.$anchor );
        // Move content, which was added to #$element by OO.ui.Widget, to the body
+       // FIXME This is gross, we should use '$body' or something for the config
        if ( config.$content instanceof jQuery ) {
                this.$body.append( config.$content );
        }
-       if ( config.$footer instanceof jQuery ) {
-               this.$footer.append( config.$footer );
-       }
+
        if ( config.padded ) {
                this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
        }
 
+       if ( config.head ) {
+               this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
+               this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
+               this.$head = $( '<div>' )
+                       .addClass( 'oo-ui-popupWidget-head' )
+                       .append( this.$label, this.closeButton.$element );
+               this.$popup.prepend( this.$head );
+       }
+
+       if ( config.$footer ) {
+               this.$footer = $( '<div>' )
+                       .addClass( 'oo-ui-popupWidget-footer' )
+                       .append( config.$footer );
+               this.$popup.append( this.$footer );
+       }
+
        // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
        // that reference properties not initialized at that time of parent class construction
        // TODO: Find a better way to handle post-constructor setup
@@ -4424,6 +4524,7 @@ OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
 
 /**
  * Set popup alignment
+ *
  * @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
  *  `backwards` or `forwards`.
  */
@@ -4438,6 +4539,7 @@ OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
 
 /**
  * Get popup alignment
+ *
  * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
  *  `backwards` or `forwards`.
  */
@@ -4879,6 +4981,7 @@ OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
        this.onKeyPressHandler = this.onKeyPress.bind( this );
        this.keyPressBuffer = '';
        this.keyPressBufferTimer = null;
+       this.blockMouseOverEvents = 0;
 
        // Events
        this.connect( this, {
@@ -5041,7 +5144,9 @@ OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
  */
 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
        var item;
-
+       if ( this.blockMouseOverEvents ) {
+               return;
+       }
        if ( !this.isDisabled() ) {
                item = this.getTargetItem( e );
                this.highlightItem( item && item.isHighlightable() ? item : null );
@@ -5112,7 +5217,7 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
                        } else {
                                this.chooseItem( nextItem );
                        }
-                       nextItem.scrollElementIntoView();
+                       this.scrollItemIntoView( nextItem );
                }
 
                if ( handled ) {
@@ -5140,6 +5245,23 @@ OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
        this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
 };
 
+/**
+ * Scroll item into view, preventing spurious mouse highlight actions from happening.
+ *
+ * @param {OO.ui.OptionWidget} item Item to scroll into view
+ */
+OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
+       var widget = this;
+       // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
+       // and around 100-150 ms after it is finished.
+       this.blockMouseOverEvents++;
+       item.scrollElementIntoView().done( function () {
+               setTimeout( function () {
+                       widget.blockMouseOverEvents--;
+               }, 200 );
+       } );
+};
+
 /**
  * Clear the key-press buffer
  *
@@ -5202,7 +5324,7 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
                } else {
                        this.chooseItem( item );
                }
-               item.scrollElementIntoView();
+               this.scrollItemIntoView( item );
        }
 
        e.preventDefault();
@@ -5838,7 +5960,6 @@ OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
        OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
 
        // Properties
-       this.newItems = null;
        this.autoHide = config.autoHide === undefined || !!config.autoHide;
        this.filterFromInput = !!config.filterFromInput;
        this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
@@ -5916,6 +6037,7 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
 
 /**
  * Update menu item visibility after input changes.
+ *
  * @protected
  */
 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
@@ -5991,6 +6113,7 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
  *
  * 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.
+ *
  * @param {OO.ui.OptionWidget} item Item to choose
  * @chainable
  */
@@ -6004,26 +6127,9 @@ OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
  * @inheritdoc
  */
 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
-       var i, len, item;
-
        // Parent method
        OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
 
-       // Auto-initialize
-       if ( !this.newItems ) {
-               this.newItems = [];
-       }
-
-       for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[ i ];
-               if ( this.isVisible() ) {
-                       // Defer fitting label until item has been attached
-                       item.fitLabel();
-               } else {
-                       this.newItems.push( item );
-               }
-       }
-
        // Reevaluate clipping
        this.clip();
 
@@ -6060,7 +6166,7 @@ OO.ui.MenuSelectWidget.prototype.clearItems = function () {
  * @inheritdoc
  */
 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
-       var i, len, change;
+       var change;
 
        visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
        change = visible !== this.isVisible();
@@ -6073,12 +6179,6 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
                        this.bindKeyDownListener();
                        this.bindKeyPressListener();
 
-                       if ( this.newItems && this.newItems.length ) {
-                               for ( i = 0, len = this.newItems.length; i < len; i++ ) {
-                                       this.newItems[ i ].fitLabel();
-                               }
-                               this.newItems = null;
-                       }
                        this.toggleClipping( true );
 
                        if ( this.getSelectedItem() ) {
@@ -6518,22 +6618,19 @@ OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positionin
 
                closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
                closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] );
-               if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
-                       // If the scrollable is the root, we have to listen to scroll events
-                       // on the window because of browser inconsistencies (or do we? someone should verify this)
-                       if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
-                               closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
-                       }
+               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 );
                }
 
                if ( positioning ) {
                        this.$floatableWindow = $( this.getElementWindow() );
                        this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
 
-                       if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
-                               this.$floatableClosestScrollable = $( closestScrollableOfContainer );
-                               this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
-                       }
+                       this.$floatableClosestScrollable = $( closestScrollableOfContainer );
+                       this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
 
                        // Initial position after visible
                        this.position();
@@ -6555,6 +6652,50 @@ OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positionin
        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 = false,
+               leftEdgeInBounds = false,
+               bottomEdgeInBounds = false,
+               rightEdgeInBounds = false;
+
+       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();
+       }
+
+       if ( elemRect.top >= contRect.top && elemRect.top <= contRect.bottom ) {
+               topEdgeInBounds = true;
+       }
+       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;
+       }
+
+       // We only care that any part of the bottom edge is visible
+       return bottomEdgeInBounds && ( leftEdgeInBounds || rightEdgeInBounds );
+};
+
 /**
  * Position the floatable below its container.
  *
@@ -6569,6 +6710,17 @@ OO.ui.mixin.FloatableElement.prototype.position = function () {
                return this;
        }
 
+       if ( !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
+               this.$floatable.addClass( 'oo-ui-floatableElement-hidden' );
+               return;
+       } else {
+               this.$floatable.removeClass( 'oo-ui-floatableElement-hidden' );
+       }
+
+       if ( !this.needsCustomPosition ) {
+               return;
+       }
+
        pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
 
        // Position under container
@@ -6700,7 +6852,8 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) {
        OO.ui.InputWidget.parent.call( this, config );
 
        // Properties
-       this.$input = this.getInputElement( config );
+       // See #reusePreInfuseDOM about config.$input
+       this.$input = config.$input || this.getInputElement( config );
        this.value = '';
        this.inputFilter = config.inputFilter;
 
@@ -6784,9 +6937,8 @@ OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
  * @param {Object} config Configuration options
  * @return {jQuery} Input element
  */
-OO.ui.InputWidget.prototype.getInputElement = function ( config ) {
-       // See #reusePreInfuseDOM about config.$input
-       return config.$input || $( '<input>' );
+OO.ui.InputWidget.prototype.getInputElement = function () {
+       return $( '<input>' );
 };
 
 /**
@@ -6823,7 +6975,7 @@ OO.ui.InputWidget.prototype.getValue = function () {
 /**
  * Set the directionality of the input, either RTL (right-to-left) or LTR (left-to-right).
  *
- * @deprecated since v0.13.1, use #setDir directly
+ * @deprecated since v0.13.1; use #setDir directly
  * @param {boolean} isRTL Directionality is right-to-left
  * @chainable
  */
@@ -6975,12 +7127,17 @@ OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
  * @cfg {boolean} [useInputTag=false] Use an `<input/>` tag instead of a `<button/>` tag, the default.
  *  Widgets configured to be an `<input/>` do not support {@link #icon icons} and {@link #indicator indicators},
  *  non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
- *  be set to `true` when there’s need to support IE6 in a form with multiple buttons.
+ *  be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
  */
 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
        // Configuration initialization
        config = $.extend( { type: 'button', useInputTag: false }, config );
 
+       // See InputWidget#reusePreInfuseDOM about config.$input
+       if ( config.$input ) {
+               config.$input.empty();
+       }
+
        // Properties (must be set before parent constructor, which calls #setValue)
        this.useInputTag = config.useInputTag;
 
@@ -7026,10 +7183,6 @@ OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
  */
 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
        var type;
-       // See InputWidget#reusePreInfuseDOM about config.$input
-       if ( config.$input ) {
-               return config.$input.empty();
-       }
        type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
        return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
 };
@@ -7044,22 +7197,20 @@ OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
  * @chainable
  */
 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
-       OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
+       if ( typeof label === 'function' ) {
+               label = OO.ui.resolveMsg( label );
+       }
 
        if ( this.useInputTag ) {
-               if ( typeof label === 'function' ) {
-                       label = OO.ui.resolveMsg( label );
-               }
-               if ( label instanceof jQuery ) {
-                       label = label.text();
-               }
-               if ( !label ) {
+               // Discard non-plaintext labels
+               if ( typeof label !== 'string' ) {
                        label = '';
                }
+
                this.$input.val( label );
        }
 
-       return this;
+       return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
 };
 
 /**
@@ -7156,7 +7307,7 @@ OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config
  * @protected
  */
 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="checkbox" />' );
+       return $( '<input>' ).attr( 'type', 'checkbox' );
 };
 
 /**
@@ -7251,6 +7402,11 @@ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
        // Configuration initialization
        config = config || {};
 
+       // See InputWidget#reusePreInfuseDOM about config.$input
+       if ( config.$input ) {
+               config.$input.addClass( 'oo-ui-element-hidden' );
+       }
+
        // Properties (must be done before parent constructor which calls #setDisabled)
        this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
 
@@ -7281,12 +7437,8 @@ OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
  * @inheritdoc
  * @protected
  */
-OO.ui.DropdownInputWidget.prototype.getInputElement = function ( config ) {
-       // See InputWidget#reusePreInfuseDOM about config.$input
-       if ( config.$input ) {
-               return config.$input.addClass( 'oo-ui-element-hidden' );
-       }
-       return $( '<input type="hidden">' );
+OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
+       return $( '<input>' ).attr( 'type', 'hidden' );
 };
 
 /**
@@ -7448,7 +7600,7 @@ OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
  * @protected
  */
 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="radio" />' );
+       return $( '<input>' ).attr( 'type', 'radio' );
 };
 
 /**
@@ -7563,7 +7715,7 @@ OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, conf
  * @protected
  */
 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="hidden">' );
+       return $( '<input>' ).attr( 'type', 'hidden' );
 };
 
 /**
@@ -7660,7 +7812,7 @@ OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
  * @constructor
  * @param {Object} [config] Configuration options
  * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
- *  'email' or 'url'. Ignored if `multiline` is true.
+ *  'email', 'url' or 'date'. Ignored if `multiline` is true.
  *
  *  Some values of `type` result in additional behaviors:
  *
@@ -7760,7 +7912,7 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
                .append( this.$icon, this.$indicator );
        this.setReadOnly( !!config.readOnly );
        this.updateSearchIndicator();
-       if ( config.placeholder ) {
+       if ( config.placeholder !== undefined ) {
                this.$input.attr( 'placeholder', config.placeholder );
        }
        if ( config.maxLength !== undefined ) {
@@ -8061,8 +8213,8 @@ OO.ui.TextInputWidget.prototype.adjustSize = function () {
                                .val( '' );
                        maxInnerHeight = this.$clone.innerHeight();
 
-                       // Difference between reported innerHeight and scrollHeight with no scrollbars present
-                       // Equals 1 on Blink-based browsers and 0 everywhere else
+                       // Difference between reported innerHeight and scrollHeight with no scrollbars present.
+                       // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
                        measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
                        idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
 
@@ -8105,7 +8257,7 @@ OO.ui.TextInputWidget.prototype.adjustSize = function () {
 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
        return config.multiline ?
                $( '<textarea>' ) :
-               $( '<input type="' + this.getSaneType( config ) + '" />' );
+               $( '<input>' ).attr( 'type', this.getSaneType( config ) );
 };
 
 /**
@@ -8116,7 +8268,7 @@ OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
  * @private
  */
 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
-       var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ?
+       var type = [ 'text', 'password', 'search', 'email', 'url', 'date' ].indexOf( config.type ) !== -1 ?
                config.type :
                'text';
        return config.multiline ? 'multiline' : type;
@@ -8159,7 +8311,16 @@ OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
 
        this.focus();
 
-       input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
+       try {
+               input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
+       } catch ( e ) {
+               // IE throws an exception if you call setSelectionRange on a unattached DOM node.
+               // Rather than expensively check if the input is attached every time, just check
+               // if it was the cause of an error being thrown. If not, rethrow the error.
+               if ( this.getElementDocument().body.contains( input ) ) {
+                       throw e;
+               }
+       }
        return this;
 };
 
@@ -8311,7 +8472,7 @@ OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
  * This method returns a promise that resolves with a boolean `true` if the current value is
  * considered valid according to the supplied {@link #validate validation pattern}.
  *
- * @deprecated
+ * @deprecated since v0.12.3
  * @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid.
  */
 OO.ui.TextInputWidget.prototype.isValid = function () {
@@ -8370,7 +8531,11 @@ OO.ui.TextInputWidget.prototype.getValidity = function () {
  */
 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
        this.labelPosition = labelPosition;
-       this.updatePosition();
+       if ( this.label ) {
+               // If there is no label and we only change the position, #updatePosition is a no-op,
+               // but it takes really a lot of work to do nothing.
+               this.updatePosition();
+       }
        return this;
 };
 
@@ -8575,6 +8740,7 @@ OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
 
 /**
  * Get the combobox's menu.
+ *
  * @return {OO.ui.FloatingMenuSelectWidget} Menu widget
  */
 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
@@ -8583,6 +8749,7 @@ OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
 
 /**
  * Get the combobox's text input widget.
+ *
  * @return {OO.ui.TextInputWidget} Text input widget
  */
 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
@@ -8706,7 +8873,7 @@ OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
 
 /**
  * @class
- * @deprecated Use OO.ui.ComboBoxInputWidget instead.
+ * @deprecated since 0.13.2; use OO.ui.ComboBoxInputWidget instead
  */
 OO.ui.ComboBoxWidget = OO.ui.ComboBoxInputWidget;
 
@@ -8730,6 +8897,7 @@ OO.ui.ComboBoxWidget = OO.ui.ComboBoxInputWidget;
  * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
  *
  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
+ *
  * @class
  * @extends OO.ui.Layout
  * @mixins OO.ui.mixin.LabelElement