/*!
- * OOjs UI v0.16.2
+ * OOjs UI v0.16.6
* 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-03-08T21:46:49Z
+ * Date: 2016-04-19T21:57:49Z
*/
( function ( OO ) {
};
};
+/**
+ * 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 )`.
*
infused.$element.removeData( 'ooui-infused-children' );
return infused;
}
- if ( value.html ) {
+ if ( value.html !== undefined ) {
return new OO.ui.HtmlSnippet( value.html );
}
}
this.setGroupElement( config.$group || $( '<div>' ) );
};
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the set of selected items changes.
+ *
+ * @param {OO.ui.Element[]} items Items currently in the group
+ */
+
/* Methods */
/**
this.items.splice.apply( this.items, [ index, 0 ].concat( items ) );
}
+ this.emit( 'change', this.getItems() );
return this;
};
}
}
+ this.emit( 'change', this.getItems() );
return this;
};
item.$element.detach();
}
+ this.emit( 'change', this.getItems() );
this.items = [];
return this;
};
*/
OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
- label = ( ( typeof label === 'string' && label.length ) || label instanceof jQuery || label instanceof OO.ui.HtmlSnippet ) ? label : null;
-
- this.$element.toggleClass( 'oo-ui-labelElement', !!label );
+ label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
if ( this.label !== label ) {
if ( this.$label ) {
this.emit( 'labelChange' );
}
+ this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
+
return this;
};
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 );
};
};
/**
- * Clip element to visible boundaries and allow scrolling when needed. Call this method when
- * the element's natural height changes.
+ * 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
+ * max-height when the element isn't being clipped. This means that if the element tries to grow
+ * beyond the edge, something reasonable will happen before clip() is called.
+ *
* @chainable
*/
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' );
clipHeight = allotedHeight < naturalHeight;
if ( clipWidth ) {
- this.$clippable.css( { overflowX: 'scroll', width: Math.max( 0, allotedWidth ) } );
+ this.$clippable.css( {
+ overflowX: 'scroll',
+ width: Math.max( 0, allotedWidth ),
+ maxWidth: ''
+ } );
} else {
- this.$clippable.css( { width: this.idealWidth ? this.idealWidth - extraWidth : '', overflowX: '' } );
+ this.$clippable.css( {
+ overflowX: '',
+ width: this.idealWidth ? this.idealWidth - extraWidth : '',
+ maxWidth: Math.max( 0, allotedWidth )
+ } );
}
if ( clipHeight ) {
- this.$clippable.css( { overflowY: 'scroll', height: Math.max( 0, allotedHeight ) } );
+ this.$clippable.css( {
+ overflowY: 'scroll',
+ height: Math.max( 0, allotedHeight ),
+ maxHeight: ''
+ } );
} else {
- this.$clippable.css( { height: this.idealHeight ? this.idealHeight - extraHeight : '', overflowY: '' } );
+ this.$clippable.css( {
+ overflowY: '',
+ height: this.idealHeight ? this.idealHeight - extraHeight : '',
+ maxHeight: Math.max( 0, allotedHeight )
+ } );
}
// If we stopped clipping in at least one of the dimensions
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();
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.
*
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
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;
*/
OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
- state.value = config.$input.val();
- // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
- state.focus = config.$input.is( ':focus' );
+ if ( config.$input && config.$input.length ) {
+ state.value = config.$input.val();
+ // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
+ state.focus = config.$input.is( ':focus' );
+ }
return state;
};
* @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>' );
};
/**
// 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;
*/
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 + '">' );
};
* @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 );
};
/**
// 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 );
* @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' );
- }
+OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
return $( '<input>' ).attr( 'type', 'hidden' );
};
return state;
};
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
+ config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
+ // Cannot reuse the `<input type=radio>` set
+ delete config.$input;
+ return config;
+};
+
/* Methods */
/**
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
- * 'email', 'url' or 'date'. Ignored if `multiline` is true.
+ * 'email', 'url', 'date' or 'number'. Ignored if `multiline` is true.
*
* Some values of `type` result in additional behaviors:
*
.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 ) {
*
* @private
* @param {jQuery.Event} e Mouse down event
- * @fires icon
*/
OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
if ( e.which === OO.ui.MouseButtons.LEFT ) {
*
* @private
* @param {jQuery.Event} e Mouse down event
- * @fires indicator
*/
OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
if ( e.which === OO.ui.MouseButtons.LEFT ) {
* @protected
*/
OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
- return config.multiline ?
- $( '<textarea>' ) :
- $( '<input>' ).attr( 'type', this.getSaneType( config ) );
+ if ( config.multiline ) {
+ return $( '<textarea>' );
+ } else if ( this.getSaneType( config ) === 'number' ) {
+ return $( '<input>' )
+ .attr( 'step', 'any' )
+ .attr( 'type', 'number' );
+ } else {
+ return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
+ }
};
/**
* @private
*/
OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
- var type = [ 'text', 'password', 'search', 'email', 'url', 'date' ].indexOf( config.type ) !== -1 ?
- config.type :
- 'text';
+ var allowedTypes = [
+ 'text',
+ 'password',
+ 'search',
+ 'email',
+ 'url',
+ 'date',
+ 'number'
+ ],
+ type = allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
return config.multiline ? 'multiline' : type;
};
OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
// Configuration initialization
config = $.extend( {
- indicator: 'down'
+ indicator: 'down',
+ autocomplete: false
}, config );
// For backwards-compatibility with ComboBoxWidget config
$.extend( config, config.input );