/*!
- * OOjs UI v0.15.2
+ * 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-02T22:07:00Z
+ * Date: 2016-03-16T19:20:22Z
*/
( function ( OO ) {
};
/**
- * @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;
* 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 ) {
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 );
* @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 );
* 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 ) {
* 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 () {
/**
* 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
*/
OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
// look for a cached result of a previous infusion.
- var id, $elem, data, cls, parts, parent, obj, top, state;
+ var id, $elem, data, cls, parts, parent, obj, top, state, infusedChildren;
if ( typeof idOrNode === 'string' ) {
id = idOrNode;
$elem = $( document.getElementById( id ) );
if ( !$elem.length ) {
throw new Error( 'Widget not found: ' + id );
}
- data = $elem.data( 'ooui-infused' ) || $elem[ 0 ].oouiInfused;
+ if ( $elem[ 0 ].oouiInfused ) {
+ $elem = $elem[ 0 ].oouiInfused;
+ }
+ data = $elem.data( 'ooui-infused' );
if ( data ) {
// cached!
if ( data === true ) {
throw new Error( 'Circular dependency! ' + id );
}
+ if ( domPromise ) {
+ // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
+ 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.constructor.static.gatherPreInfuseState( $elem, data );
+ domPromise.done( data.restorePreInfuseState.bind( data, state ) );
+ } );
+ }
+ }
return data;
}
data = $elem.attr( 'data-ooui' );
}
$elem.data( 'ooui-infused', true ); // prevent loops
data.id = id; // implicit
+ infusedChildren = [];
data = OO.copy( data, null, function deserialize( value ) {
+ var infused;
if ( OO.isPlainObject( value ) ) {
if ( value.tag ) {
- return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
+ infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
+ infusedChildren.push( infused );
+ // Flatten the structure
+ infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
+ infused.$element.removeData( 'ooui-infused-children' );
+ return infused;
}
- if ( value.html ) {
+ if ( value.html !== undefined ) {
return new OO.ui.HtmlSnippet( value.html );
}
}
// This element is now gone from the DOM, but if anyone is holding a reference to it,
// let's allow them to OO.ui.infuse() it and do what they expect (T105828).
// Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
- $elem[ 0 ].oouiInfused = obj;
+ $elem[ 0 ].oouiInfused = obj.$element;
}
top.resolve();
}
obj.$element.data( 'ooui-infused', obj );
+ obj.$element.data( 'ooui-infused-children', infusedChildren );
// set the 'data-ooui' attribute so we can identify infused widgets
obj.$element.attr( 'data-ooui', '' );
// restore dynamic state after the new element is inserted into DOM
* @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 || 'fast' );
- 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();
};
/**
/**
* Set element data.
*
- * @param {Mixed} Element data
+ * @param {Mixed} data Element data
* @chainable
*/
OO.ui.Element.prototype.setData = function ( data ) {
/**
* Check if the element is attached to the DOM
+ *
* @return {boolean} The element is attached to the DOM
*/
OO.ui.Element.prototype.isElementAttached = 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 ] );
* 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 );
* 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
*
* Handles mouse up events.
*
* @protected
- * @param {jQuery.Event} e Mouse up event
+ * @param {MouseEvent} e Mouse up event
*/
OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
* Handles key up events.
*
* @protected
- * @param {jQuery.Event} e Key up event
+ * @param {KeyboardEvent} e Key up event
*/
OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
*
* 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
*/
* 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
// Properties
this.$label = null;
this.label = null;
- this.autoFitLabel = config.autoFitLabel === undefined || !!config.autoFitLabel;
// Initialization
this.setLabel( config.label || this.constructor.static.label );
*/
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 */
/**
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.
*
* 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;
};
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 );
};
/**
* 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 ) {
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' );
* 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
} ) );
// Properties
- this.$head = $( '<div>' );
- this.$footer = $( '<div>' );
this.$anchor = $( '<div>' );
// If undefined, will be computed lazily in updateDimensions()
this.$container = config.$container;
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
/**
* Set popup alignment
+ *
* @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
* `backwards` or `forwards`.
*/
/**
* Get popup alignment
+ *
* @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
* `backwards` or `forwards`.
*/
this.onKeyPressHandler = this.onKeyPress.bind( this );
this.keyPressBuffer = '';
this.keyPressBufferTimer = null;
+ this.blockMouseOverEvents = 0;
// Events
this.connect( this, {
* Handle mouse up events.
*
* @private
- * @param {jQuery.Event} e Mouse up event
+ * @param {MouseEvent} e Mouse up event
*/
OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
var item;
* Handle mouse move events.
*
* @private
- * @param {jQuery.Event} e Mouse move event
+ * @param {MouseEvent} e Mouse move event
*/
OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
var item;
this.selecting = item;
}
}
- return false;
};
/**
*/
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 );
* Handle key down events.
*
* @protected
- * @param {jQuery.Event} e Key down event
+ * @param {KeyboardEvent} e Key down event
*/
OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
var nextItem,
} else {
this.chooseItem( nextItem );
}
- nextItem.scrollElementIntoView();
+ this.scrollItemIntoView( nextItem );
}
if ( handled ) {
- // Can't just return false, because e is not always a jQuery event
e.preventDefault();
e.stopPropagation();
}
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
*
* Handle key press events.
*
* @protected
- * @param {jQuery.Event} e Key press event
+ * @param {KeyboardEvent} e Key press event
*/
OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
var c, filter, item;
} else {
this.chooseItem( item );
}
- item.scrollElementIntoView();
+ this.scrollItemIntoView( item );
}
- return false;
+ e.preventDefault();
+ e.stopPropagation();
};
/**
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;
* Handles document mouse down events.
*
* @protected
- * @param {jQuery.Event} e Key down event
+ * @param {MouseEvent} e Mouse down event
*/
OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
if (
/**
* Update menu item visibility after input changes.
+ *
* @protected
*/
OO.ui.MenuSelectWidget.prototype.updateItemVisibility = 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
*/
* @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();
* @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();
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() ) {
+ this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
+ }
+
// Auto-hide
if ( this.autoHide ) {
this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
// Events
this.$handle.on( {
click: this.onClick.bind( this ),
- keypress: this.onKeyPress.bind( this )
+ keydown: this.onKeyDown.bind( this )
} );
this.menu.connect( this, { select: 'onMenuSelect' } );
};
/**
- * Handle key press events.
+ * Handle key down events.
*
* @private
- * @param {jQuery.Event} e Key press event
+ * @param {jQuery.Event} e Key down event
*/
-OO.ui.DropdownWidget.prototype.onKeyPress = function ( e ) {
- if ( !this.isDisabled() &&
- ( ( e.which === OO.ui.Keys.SPACE && !this.menu.isVisible() ) || e.which === OO.ui.Keys.ENTER )
+OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
+ if (
+ !this.isDisabled() &&
+ (
+ e.which === OO.ui.Keys.ENTER ||
+ (
+ !this.menu.isVisible() &&
+ (
+ e.which === OO.ui.Keys.SPACE ||
+ e.which === OO.ui.Keys.UP ||
+ e.which === OO.ui.Keys.DOWN
+ )
+ )
+ )
) {
this.menu.toggle();
return false;
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;
.addClass( 'oo-ui-inputWidget' )
.append( this.$input );
this.setValue( config.value );
- this.setAccessKey( config.accessKey );
if ( config.dir ) {
this.setDir( config.dir );
}
* @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>' );
};
/**
/**
* 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
*/
return this;
};
-/**
- * Set the input's access key.
- * FIXME: This is the same code as in OO.ui.mixin.ButtonElement, maybe find a better place for it?
- *
- * @param {string} accessKey Input's access key, use empty string to remove
- * @chainable
- */
-OO.ui.InputWidget.prototype.setAccessKey = function ( accessKey ) {
- accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null;
-
- if ( this.accessKey !== accessKey ) {
- if ( this.$input ) {
- if ( accessKey !== null ) {
- this.$input.attr( 'accesskey', accessKey );
- } else {
- this.$input.removeAttr( 'accesskey' );
- }
- }
- this.accessKey = accessKey;
- }
-
- return this;
-};
-
/**
* Clean up incoming value.
*
* @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;
*/
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 );
};
/**
* @protected
*/
OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
- return $( '<input type="checkbox" />' );
+ return $( '<input>' ).attr( 'type', 'checkbox' );
};
/**
// 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' );
- }
- return $( '<input type="hidden">' );
+OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
+ return $( '<input>' ).attr( 'type', 'hidden' );
};
/**
* @protected
*/
OO.ui.RadioInputWidget.prototype.getInputElement = function () {
- return $( '<input type="radio" />' );
+ return $( '<input>' ).attr( 'type', 'radio' );
};
/**
* @protected
*/
OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
- return $( '<input type="hidden">' );
+ return $( '<input>' ).attr( 'type', 'hidden' );
};
/**
* @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:
*
.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 ) {
.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 );
OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
return config.multiline ?
$( '<textarea>' ) :
- $( '<input type="' + this.getSaneType( config ) + '" />' );
+ $( '<input>' ).attr( 'type', this.getSaneType( 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;
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;
};
* 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 () {
*/
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;
};
/**
* Get the combobox's menu.
+ *
* @return {OO.ui.FloatingMenuSelectWidget} Menu widget
*/
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 () {
/**
* @class
- * @deprecated Use OO.ui.ComboBoxInputWidget instead.
+ * @deprecated since 0.13.2; use OO.ui.ComboBoxInputWidget instead
*/
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