/*!
- * OOjs UI v0.19.2
+ * OOjs UI v0.21.0
* https://www.mediawiki.org/wiki/OOjs_UI
*
* Copyright 2011–2017 OOjs UI Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
- * Date: 2017-02-14T22:47:20Z
+ * Date: 2017-04-11T22:51:05Z
*/
( function ( OO ) {
/**
* Check if an element is focusable.
- * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14
+ * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
*
* @param {jQuery} $element Element to test
- * @return {boolean}
+ * @return {boolean} Element is focusable
*/
OO.ui.isFocusableElement = function ( $element ) {
var nodeName,
// Check if the element is visible
if ( !(
// This is quicker than calling $element.is( ':visible' )
- $.expr.filters.visible( element ) &&
+ $.expr.pseudos.visible( element ) &&
// Check that all parents are visible
!$element.parents().addBack().filter( function () {
return $.css( this, 'visibility' ) === 'hidden';
*
* @param {jQuery} $container Container to search in
* @param {boolean} [backwards] Search backwards
- * @return {jQuery} Focusable child, an empty jQuery object if none found
+ * @return {jQuery} Focusable child, or an empty jQuery object if none found
*/
OO.ui.findFocusable = function ( $container, backwards ) {
var $focusable = $( [] ),
*
* Ported from: http://underscorejs.org/underscore.js
*
- * @param {Function} func
- * @param {number} wait
- * @param {boolean} immediate
- * @return {Function}
+ * @param {Function} func Function to debounce
+ * @param {number} [wait=0] Wait period in milliseconds
+ * @param {boolean} [immediate] Trigger on leading edge
+ * @return {Function} Debounced function
*/
OO.ui.debounce = function ( func, wait, immediate ) {
var timeout;
/**
* Puts a console warning with provided message.
*
- * @param {string} message
+ * @param {string} message Message
*/
OO.ui.warnDeprecation = function ( message ) {
if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
* when the wrapper is called, return values from the function are entirely
* discarded.
*
- * @param {Function} func
- * @param {number} wait
- * @return {Function}
+ * @param {Function} func Function to throttle
+ * @param {number} wait Throttle window length, in milliseconds
+ * @return {Function} Throttled function
*/
OO.ui.throttle = function ( func, wait ) {
var context, args, timeout,
/**
* A (possibly faster) way to get the current timestamp as an integer
*
- * @return {number} Current timestamp
+ * @return {number} Current timestamp, in milliseconds since the Unix epoch
*/
OO.ui.now = Date.now || function () {
return new Date().getTime();
// Window
obj.document ||
// HTMLDocument
- ( obj.nodeType === 9 && obj ) ||
+ ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
null;
};
if ( obj instanceof jQuery ) {
obj = obj[ 0 ];
}
- isDoc = obj.nodeType === 9;
+ isDoc = obj.nodeType === Node.DOCUMENT_NODE;
isWin = obj.document !== undefined;
if ( isDoc || isWin ) {
if ( isWin ) {
};
/**
- * Get scrollable object parent
+ * Get the number of pixels that an element's content is scrolled to the left.
+ *
+ * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
+ * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
*
- * documentElement can't be used to get or set the scrollTop
- * property on Blink. Changing and testing its value lets us
- * use 'body' or 'documentElement' based on what is working.
+ * This function smooths out browser inconsistencies (nicely described in the README at
+ * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
+ * with Firefox's 'scrollLeft', which seems the sanest.
+ *
+ * @static
+ * @method
+ * @param {HTMLElement|Window} el Element to measure
+ * @return {number} Scroll position from the left.
+ * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
+ * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
+ * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
+ * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
+ */
+OO.ui.Element.static.getScrollLeft = ( function () {
+ var rtlScrollType = null;
+
+ function test() {
+ var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
+ definer = $definer[ 0 ];
+
+ $definer.appendTo( 'body' );
+ if ( definer.scrollLeft > 0 ) {
+ // Safari, Chrome
+ rtlScrollType = 'default';
+ } else {
+ definer.scrollLeft = 1;
+ if ( definer.scrollLeft === 0 ) {
+ // Firefox, old Opera
+ rtlScrollType = 'negative';
+ } else {
+ // Internet Explorer, Edge
+ rtlScrollType = 'reverse';
+ }
+ }
+ $definer.remove();
+ }
+
+ return function getScrollLeft( el ) {
+ var isRoot = el.window === el ||
+ el === el.ownerDocument.body ||
+ el === el.ownerDocument.documentElement,
+ scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
+ // All browsers use the correct scroll type ('negative') on the root, so don't
+ // do any fixups when looking at the root element
+ direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
+
+ if ( direction === 'rtl' ) {
+ if ( rtlScrollType === null ) {
+ test();
+ }
+ if ( rtlScrollType === 'reverse' ) {
+ scrollLeft = -scrollLeft;
+ } else if ( rtlScrollType === 'default' ) {
+ scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
+ }
+ }
+
+ return scrollLeft;
+ };
+}() );
+
+/**
+ * Get the root scrollable element of given element's document.
+ *
+ * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
+ * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
+ * lets us use 'body' or 'documentElement' based on what is working.
*
* https://code.google.com/p/chromium/issues/detail?id=303131
*
* @static
- * @param {HTMLElement} el Element to find scrollable parent for
- * @return {HTMLElement} Scrollable parent
+ * @param {HTMLElement} el Element to find root scrollable parent for
+ * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
+ * depending on browser
*/
OO.ui.Element.static.getRootScrollableElement = function ( el ) {
var scrollTop, body;
/**
* Get closest scrollable container.
*
- * Traverses up until either a scrollable element or the root is reached, in which case the window
- * will be returned.
+ * Traverses up until either a scrollable element or the root is reached, in which case the root
+ * scrollable element will be returned (see #getRootScrollableElement).
*
* @static
* @param {HTMLElement} el Element to find scrollable container for
*/
OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
var i, val,
- // props = [ 'overflow' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091
+ // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
+ // 'overflow-y' have different values, so we need to check the separate properties.
props = [ 'overflow-x', 'overflow-y' ],
$parent = $( el ).parent();
props = [ 'overflow-' + dimension ];
}
+ // Special case for the document root (which doesn't really have any scrollable container, since
+ // it is the ultimate scrollable container, but this is probably saner than null or exception)
+ if ( $( el ).is( 'html, body' ) ) {
+ return this.getRootScrollableElement( el );
+ }
+
while ( $parent.length ) {
if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
return $parent[ 0 ];
i = props.length;
while ( i-- ) {
val = $parent.css( props[ i ] );
+ // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
+ // scrolled in that direction, but they can actually be scrolled programatically. The user can
+ // unintentionally perform a scroll in such case even if the application doesn't scroll
+ // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
+ // This could cause funny issues...
if ( val === 'auto' || val === 'scroll' ) {
return $parent[ 0 ];
}
}
$parent = $parent.parent();
}
- return this.getDocument( el ).body;
+ // The element is unattached... return something mostly sane
+ return this.getRootScrollableElement( el );
};
/**
* @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.
- * 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 position, animations, callback, container, $container, elementDimensions, containerDimensions, $window,
+ var position, animations, container, $container, elementDimensions, containerDimensions, $window,
deferred = $.Deferred();
// Configuration initialization
config = config || {};
animations = {};
- callback = typeof config.complete === 'function' && config.complete;
- if ( callback ) {
- OO.ui.warnDeprecation( 'Element#scrollIntoView: The `complete` callback config option is deprecated. Use the return promise instead.' );
- }
container = this.getClosestScrollableContainer( el, config.direction );
$container = $( container );
elementDimensions = this.getDimensions( el );
if ( !$.isEmptyObject( animations ) ) {
$container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
$container.queue( function ( next ) {
- if ( callback ) {
- callback();
- }
deferred.resolve();
next();
} );
} else {
- if ( callback ) {
- callback();
- }
deferred.resolve();
}
return deferred.promise();
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups
*
* @abstract
+ * @mixins OO.EmitterList
* @class
*
* @constructor
// Configuration initialization
config = config || {};
+ // Mixin constructors
+ OO.EmitterList.call( this, config );
+
// Properties
this.$group = null;
- this.items = [];
- this.aggregateItemEvents = {};
// Initialization
this.setGroupElement( config.$group || $( '<div>' ) );
};
+/* Setup */
+
+OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
+
/* Events */
/**
}
};
-/**
- * Check if a group contains no items.
- *
- * @return {boolean} Group is empty
- */
-OO.ui.mixin.GroupElement.prototype.isEmpty = function () {
- return !this.items.length;
-};
-
-/**
- * Get all items in the group.
- *
- * The method returns an array of item references (e.g., [button1, button2, button3]) and is useful
- * when synchronizing groups of items, or whenever the references are required (e.g., when removing items
- * from a group).
- *
- * @return {OO.ui.Element[]} An array of items.
- */
-OO.ui.mixin.GroupElement.prototype.getItems = function () {
- return this.items.slice( 0 );
-};
-
/**
* Get an item by its data.
*
return items;
};
-/**
- * Aggregate the events emitted by the group.
- *
- * When events are aggregated, the group will listen to all contained items for the event,
- * and then emit the event under a new name. The new event will contain an additional leading
- * parameter containing the item that emitted the original event. Other arguments emitted from
- * the original event are passed through.
- *
- * @param {Object.<string,string|null>} events An object keyed by the name of the event that should be
- * aggregated (e.g., ‘click’) and the value of the new name to use (e.g., ‘groupClick’).
- * A `null` value will remove aggregated events.
-
- * @throws {Error} An error is thrown if aggregation already exists.
- */
-OO.ui.mixin.GroupElement.prototype.aggregate = function ( events ) {
- var i, len, item, add, remove, itemEvent, groupEvent;
-
- for ( itemEvent in events ) {
- groupEvent = events[ itemEvent ];
-
- // Remove existing aggregated event
- if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
- // Don't allow duplicate aggregations
- if ( groupEvent ) {
- throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
- }
- // Remove event aggregation from existing items
- for ( i = 0, len = this.items.length; i < len; i++ ) {
- item = this.items[ i ];
- if ( item.connect && item.disconnect ) {
- remove = {};
- remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
- item.disconnect( this, remove );
- }
- }
- // Prevent future items from aggregating event
- delete this.aggregateItemEvents[ itemEvent ];
- }
-
- // Add new aggregate event
- if ( groupEvent ) {
- // Make future items aggregate event
- this.aggregateItemEvents[ itemEvent ] = groupEvent;
- // Add event aggregation to existing items
- for ( i = 0, len = this.items.length; i < len; i++ ) {
- item = this.items[ i ];
- if ( item.connect && item.disconnect ) {
- add = {};
- add[ itemEvent ] = [ 'emit', groupEvent, item ];
- item.connect( this, add );
- }
- }
- }
- }
-};
-
/**
* Add items to the group.
*
* @chainable
*/
OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
- var i, len, item, itemEvent, events, currentIndex,
- itemElements = [];
+ // Mixin method
+ OO.EmitterList.prototype.addItems.call( this, items, index );
- for ( i = 0, len = items.length; i < len; i++ ) {
- item = items[ i ];
+ this.emit( 'change', this.getItems() );
+ return this;
+};
- // Check if item exists then remove it first, effectively "moving" it
- currentIndex = this.items.indexOf( item );
- if ( currentIndex >= 0 ) {
- this.removeItems( [ item ] );
- // Adjust index to compensate for removal
- if ( currentIndex < index ) {
- index--;
- }
- }
- // Add the item
- if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
- events = {};
- for ( itemEvent in this.aggregateItemEvents ) {
- events[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
- }
- item.connect( this, events );
- }
- item.setElementGroup( this );
- itemElements.push( item.$element.get( 0 ) );
- }
+/**
+ * @inheritdoc
+ */
+OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
+ // insertItemElements expects this.items to not have been modified yet, so call before the mixin
+ this.insertItemElements( items, newIndex );
+ // Mixin method
+ newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
+
+ return newIndex;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
+ item.setElementGroup( this );
+ this.insertItemElements( item, index );
+
+ // Mixin method
+ index = OO.EmitterList.prototype.insertItem.call( this, item, index );
+
+ return index;
+};
+
+/**
+ * Insert elements into the group
+ *
+ * @private
+ * @param {OO.ui.Element} itemWidget Item to insert
+ * @param {number} index Insertion index
+ */
+OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
if ( index === undefined || index < 0 || index >= this.items.length ) {
- this.$group.append( itemElements );
- this.items.push.apply( this.items, items );
+ this.$group.append( itemWidget.$element );
} else if ( index === 0 ) {
- this.$group.prepend( itemElements );
- this.items.unshift.apply( this.items, items );
+ this.$group.prepend( itemWidget.$element );
} else {
- this.items[ index ].$element.before( itemElements );
- this.items.splice.apply( this.items, [ index, 0 ].concat( items ) );
+ this.items[ index ].$element.before( itemWidget.$element );
}
-
- this.emit( 'change', this.getItems() );
- return this;
};
/**
* @chainable
*/
OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
- var i, len, item, index, events, itemEvent;
+ var i, len, item, index;
- // Remove specific items
+ // Remove specific items elements
for ( i = 0, len = items.length; i < len; i++ ) {
item = items[ i ];
index = this.items.indexOf( item );
if ( index !== -1 ) {
- if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
- events = {};
- for ( itemEvent in this.aggregateItemEvents ) {
- events[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
- }
- item.disconnect( this, events );
- }
item.setElementGroup( null );
- this.items.splice( index, 1 );
item.$element.detach();
}
}
+ // Mixin method
+ OO.EmitterList.prototype.removeItems.call( this, items );
+
this.emit( 'change', this.getItems() );
return this;
};
* @chainable
*/
OO.ui.mixin.GroupElement.prototype.clearItems = function () {
- var i, len, item, remove, itemEvent;
+ var i, len;
- // Remove all items
+ // Remove all item elements
for ( i = 0, len = this.items.length; i < len; i++ ) {
- item = this.items[ i ];
- if (
- item.connect && item.disconnect &&
- !$.isEmptyObject( this.aggregateItemEvents )
- ) {
- remove = {};
- if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
- remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
- }
- item.disconnect( this, remove );
- }
- item.setElementGroup( null );
- item.$element.detach();
+ this.items[ i ].setElementGroup( null );
+ this.items[ i ].$element.detach();
}
+ // Mixin method
+ OO.EmitterList.prototype.clearItems.call( this );
+
this.emit( 'change', this.getItems() );
- this.items = [];
return this;
};
*/
OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.ButtonWidget.static.tagName = 'span';
+
/* Methods */
/**
// Override method visibility hints from ButtonElement
/**
* @method setActive
+ * @inheritdoc
*/
/**
* @method isActive
+ * @inheritdoc
*/
/**
OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
+/* Static Properties */
+
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.ButtonGroupWidget.static.tagName = 'span';
+
/**
* IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
* which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
if ( this.input instanceof OO.ui.InputWidget ) {
if ( this.input.getInputId() ) {
this.$element.attr( 'for', this.input.getInputId() );
+ } else {
+ this.$label.on( 'click', function () {
+ this.fieldWidget.focus();
+ return false;
+ }.bind( this ) );
}
}
this.$element.addClass( 'oo-ui-labelWidget' );
};
/**
- * Element that will stick under a specified container, even when it is inserted elsewhere in the
- * document (for example, in a OO.ui.Window's $overlay).
+ * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
+ * in the document (for example, in an OO.ui.Window's $overlay).
*
* The elements's position is automatically calculated and maintained when window is resized or the
* page is scrolled. If you reposition the container manually, you have to call #position to make
* @constructor
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
- * @cfg {jQuery} [$floatableContainer] Node to position below
+ * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
+ * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
+ * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
+ * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
+ * 'top': Align the top edge with $floatableContainer's top edge
+ * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
+ * 'center': Vertically align the center with $floatableContainer's center
+ * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
+ * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
+ * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
+ * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
+ * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
+ * 'center': Horizontally align the center with $floatableContainer's center
+ * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
+ * is out of view
*/
OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
// Configuration initialization
// Initialization
this.setFloatableContainer( config.$floatableContainer );
this.setFloatableElement( config.$floatable || this.$element );
+ this.setVerticalPosition( config.verticalPosition || 'below' );
+ this.setHorizontalPosition( config.horizontalPosition || 'start' );
+ this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
};
/* Methods */
/**
* Set floatable container.
*
- * The element will be always positioned under the specified container.
+ * The element will be positioned relative to the specified container.
*
* @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
*/
}
};
+/**
+ * Change how the element is positioned vertically.
+ *
+ * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
+ */
+OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
+ if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
+ throw new Error( 'Invalid value for vertical position: ' + position );
+ }
+ if ( this.verticalPosition !== position ) {
+ this.verticalPosition = position;
+ if ( this.$floatable ) {
+ this.position();
+ }
+ }
+};
+
+/**
+ * Change how the element is positioned horizontally.
+ *
+ * @param {string} position 'before', 'after', 'start', 'end' or 'center'
+ */
+OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
+ if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
+ throw new Error( 'Invalid value for horizontal position: ' + position );
+ }
+ if ( this.horizontalPosition !== position ) {
+ this.horizontalPosition = position;
+ if ( this.$floatable ) {
+ this.position();
+ }
+ }
+};
+
/**
* Toggle positioning.
*
positioning = positioning === undefined ? !this.positioning : !!positioning;
+ if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
+ OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
+ this.warnedUnattached = true;
+ }
+
if ( this.positioning !== positioning ) {
this.positioning = positioning;
+ this.needsCustomPosition =
+ this.verticalPostion !== 'below' ||
+ this.horizontalPosition !== 'start' ||
+ !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
+
closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
- this.needsCustomPosition = !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
// If the scrollable is the root, we have to listen to scroll events
// on the window because of browser inconsistencies.
if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
* @return {boolean}
*/
OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
- var elemRect, contRect,
- leftEdgeInBounds = false,
- bottomEdgeInBounds = false,
- rightEdgeInBounds = false;
+ var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
+ startEdgeInBounds, endEdgeInBounds,
+ direction = $element.css( 'direction' );
elemRect = $element[ 0 ].getBoundingClientRect();
if ( $container[ 0 ] === window ) {
contRect = $container[ 0 ].getBoundingClientRect();
}
- // For completeness, if we still cared about topEdgeInBounds, that'd be:
- // elemRect.top >= contRect.top && elemRect.top <= contRect.bottom
- if ( elemRect.left >= contRect.left && elemRect.left <= contRect.right ) {
- leftEdgeInBounds = true;
+ topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
+ bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
+ leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
+ rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
+ if ( direction === 'rtl' ) {
+ startEdgeInBounds = rightEdgeInBounds;
+ endEdgeInBounds = leftEdgeInBounds;
+ } else {
+ startEdgeInBounds = leftEdgeInBounds;
+ endEdgeInBounds = rightEdgeInBounds;
+ }
+
+ if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
+ return false;
+ }
+ if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
+ return false;
}
- if ( elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom ) {
- bottomEdgeInBounds = true;
+ if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
+ return false;
}
- if ( elemRect.right >= contRect.left && elemRect.right <= contRect.right ) {
- rightEdgeInBounds = true;
+ if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
+ return false;
}
- // We only care that any part of the bottom edge is visible
- return bottomEdgeInBounds && ( leftEdgeInBounds || rightEdgeInBounds );
+ // The other positioning values are all about being inside the container,
+ // so in those cases all we care about is that any part of the container is visible.
+ return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
+ elemRect.left <= contRect.right && elemRect.right >= contRect.left;
};
/**
* @chainable
*/
OO.ui.mixin.FloatableElement.prototype.position = function () {
- var pos;
-
if ( !this.positioning ) {
return this;
}
- if ( !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
+ if ( !(
+ // To continue, some things need to be true:
+ // The element must actually be in the DOM
+ this.isElementAttached() && (
+ // The closest scrollable is the current window
+ this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
+ // OR is an element in the element's DOM
+ $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
+ )
+ ) ) {
+ // Abort early if important parts of the widget are no longer attached to the DOM
+ return this;
+ }
+
+ if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
this.$floatable.addClass( 'oo-ui-element-hidden' );
- return;
+ return this;
} else {
this.$floatable.removeClass( 'oo-ui-element-hidden' );
}
if ( !this.needsCustomPosition ) {
- return;
+ return this;
}
- pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
- // Position under container
- pos.top += this.$floatableContainer.height();
- // In LTR, we position from the left, and pos.left is already set
- // In RTL, we position from the right instead.
- if ( this.$floatableContainer.css( 'direction' ) === 'rtl' ) {
- pos.right = this.$floatable.offsetParent().width() - pos.left - this.$floatableContainer.outerWidth();
- delete pos.left;
- }
- this.$floatable.css( pos );
+ this.$floatable.css( this.computePosition() );
// We updated the position, so re-evaluate the clipping state.
// (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
return this;
};
+/**
+ * Compute how #$floatable should be positioned based on the position of #$floatableContainer
+ * and the positioning settings. This is a helper for #position that shouldn't be called directly,
+ * but may be overridden by subclasses if they want to change or add to the positioning logic.
+ *
+ * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
+ */
+OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
+ var isBody, scrollableX, scrollableY, containerPos,
+ horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
+ newPos = { top: '', left: '', bottom: '', right: '' },
+ direction = this.$floatableContainer.css( 'direction' ),
+ $offsetParent = this.$floatable.offsetParent();
+
+ if ( $offsetParent.is( 'html' ) ) {
+ // The innerHeight/Width and clientHeight/Width calculations don't work well on the
+ // <html> element, but they do work on the <body>
+ $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
+ }
+ isBody = $offsetParent.is( 'body' );
+ scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
+ scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
+
+ vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
+ horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
+ // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
+ // or if it isn't scrollable
+ scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
+ scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
+
+ // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
+ // if the <body> has a margin
+ containerPos = isBody ?
+ this.$floatableContainer.offset() :
+ OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
+ containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
+ containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
+ containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
+ containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
+
+ if ( this.verticalPosition === 'below' ) {
+ newPos.top = containerPos.bottom;
+ } else if ( this.verticalPosition === 'above' ) {
+ newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
+ } else if ( this.verticalPosition === 'top' ) {
+ newPos.top = containerPos.top;
+ } else if ( this.verticalPosition === 'bottom' ) {
+ newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
+ } else if ( this.verticalPosition === 'center' ) {
+ newPos.top = containerPos.top +
+ ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
+ }
+
+ if ( this.horizontalPosition === 'before' ) {
+ newPos.end = containerPos.start;
+ } else if ( this.horizontalPosition === 'after' ) {
+ newPos.start = containerPos.end;
+ } else if ( this.horizontalPosition === 'start' ) {
+ newPos.start = containerPos.start;
+ } else if ( this.horizontalPosition === 'end' ) {
+ newPos.end = containerPos.end;
+ } else if ( this.horizontalPosition === 'center' ) {
+ newPos.left = containerPos.left +
+ ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
+ }
+
+ if ( newPos.start !== undefined ) {
+ if ( direction === 'rtl' ) {
+ newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
+ } else {
+ newPos.left = newPos.start;
+ }
+ delete newPos.start;
+ }
+ if ( newPos.end !== undefined ) {
+ if ( direction === 'rtl' ) {
+ newPos.left = newPos.end;
+ } else {
+ newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
+ }
+ delete newPos.end;
+ }
+
+ // Account for scroll position
+ if ( newPos.top !== '' ) {
+ newPos.top += scrollTop;
+ }
+ if ( newPos.bottom !== '' ) {
+ newPos.bottom -= scrollTop;
+ }
+ if ( newPos.left !== '' ) {
+ newPos.left += scrollLeft;
+ }
+ if ( newPos.right !== '' ) {
+ newPos.right -= scrollLeft;
+ }
+
+ // Account for scrollbar gutter
+ if ( newPos.bottom !== '' ) {
+ newPos.bottom -= horizScrollbarHeight;
+ }
+ if ( direction === 'rtl' ) {
+ if ( newPos.left !== '' ) {
+ newPos.left -= vertScrollbarWidth;
+ }
+ } else {
+ if ( newPos.right !== '' ) {
+ newPos.right -= vertScrollbarWidth;
+ }
+ }
+
+ return newPos;
+};
+
/**
* Element that can be automatically clipped to visible boundaries.
*
OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
clipping = clipping === undefined ? !this.clipping : !!clipping;
+ if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
+ OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
+ this.warnedUnattached = true;
+ }
+
if ( this.clipping !== clipping ) {
this.clipping = clipping;
if ( clipping ) {
};
/**
- * Set the ideal size. These are the dimensions the element will have when it's not being clipped.
+ * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
*
* @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
* @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
} else {
this.$clippable.css( {
overflowX: '',
- width: this.idealWidth ? this.idealWidth - extraWidth : '',
+ width: this.idealWidth || '',
maxWidth: Math.max( 0, allotedWidth )
} );
}
} else {
this.$clippable.css( {
overflowY: '',
- height: this.idealHeight ? this.idealHeight - extraHeight : '',
+ height: this.idealHeight || '',
maxHeight: Math.max( 0, allotedHeight )
} );
}
* By default, each popup has an anchor that points toward its origin.
* Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
*
+ * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
+ *
* @example
* // A popup widget.
* var popup = new OO.ui.PopupWidget( {
* @cfg {number} [width=320] Width of popup in pixels
* @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
* @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
- * @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`.
- * If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the
- * popup is leaning towards the right of the screen.
- * Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence
- * in the given language, which means it will flip to the correct positioning in right-to-left languages.
- * Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the
- * sentence in the given language.
+ * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
+ * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
+ * of $floatableContainer
+ * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
+ * of $floatableContainer
+ * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
+ * endwards (right/left) to the vertical center of $floatableContainer
+ * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
+ * startwards (left/right) to the vertical center of $floatableContainer
+ * @cfg {string} [align='center'] How to align the popup to $floatableContainer
+ * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
+ * as possible while still keeping the anchor within the popup;
+ * if position is before/after, move the popup as far downwards as possible.
+ * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
+ * as possible while still keeping the anchor within the popup;
+ * if position in before/after, move the popup as far upwards as possible.
+ * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
+ * of the popup with the center of $floatableContainer.
+ * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
+ * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
* @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
* See the [OOjs UI docs on MediaWiki][3] for an example.
* [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
this.autoClose = !!config.autoClose;
this.$autoCloseIgnore = config.$autoCloseIgnore;
this.transitionTimeout = null;
- this.anchor = null;
+ this.anchored = false;
this.width = config.width !== undefined ? config.width : 320;
this.height = config.height !== undefined ? config.height : null;
- this.setAlignment( config.align );
this.onMouseDownHandler = this.onMouseDown.bind( this );
this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
// Initialization
this.toggleAnchor( config.anchor === undefined || config.anchor );
+ this.setAlignment( config.align || 'center' );
+ this.setPosition( config.position || 'below' );
this.$body.addClass( 'oo-ui-popupWidget-body' );
this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
this.$popup
OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
+/* Events */
+
+/**
+ * @event ready
+ *
+ * The popup is ready: it is visible and has been positioned and clipped.
+ */
+
/* Methods */
/**
this.anchored = show;
}
};
+/**
+ * Change which edge the anchor appears on.
+ *
+ * @param {string} edge 'top', 'bottom', 'start' or 'end'
+ */
+OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
+ if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
+ throw new Error( 'Invalid value for edge: ' + edge );
+ }
+ if ( this.anchorEdge !== null ) {
+ this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
+ }
+ this.anchorEdge = edge;
+ this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
+};
/**
* Check if the anchor is visible.
* @return {boolean} Anchor is visible
*/
OO.ui.PopupWidget.prototype.hasAnchor = function () {
- return this.anchor;
+ return this.anchored;
};
/**
+ * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
+ * `.toggle( true )` after its #$element is attached to the DOM.
+ *
+ * Do not show the popup while it is not attached to the DOM. The calculations required to display
+ * it in the right place and with the right dimensions only work correctly while it is attached.
+ * Side-effects may include broken interface and exceptions being thrown. This wasn't always
+ * strictly enforced, so currently it only generates a warning in the browser console.
+ *
+ * @fires ready
* @inheritdoc
*/
OO.ui.PopupWidget.prototype.toggle = function ( show ) {
change = show !== this.isVisible();
+ if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
+ OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
+ this.warnedUnattached = true;
+ }
+ if ( show && !this.$floatableContainer && this.isElementAttached() ) {
+ // Fall back to the parent node if the floatableContainer is not set
+ this.setFloatableContainer( this.$element.parent() );
+ }
+
// Parent method
OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
}
this.updateDimensions();
this.toggleClipping( true );
+ this.emit( 'ready' );
} else {
this.toggleClipping( false );
if ( this.autoClose ) {
* @chainable
*/
OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
- var popupOffset, originOffset, containerLeft, containerWidth, containerRight,
- popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth, direction,
- dirFactor, align,
+ var widget = this;
+
+ // Prevent transition from being interrupted
+ clearTimeout( this.transitionTimeout );
+ if ( transition ) {
+ // Enable transition
+ this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
+ }
+
+ this.position();
+
+ if ( transition ) {
+ // Prevent transitioning after transition is complete
+ this.transitionTimeout = setTimeout( function () {
+ widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
+ }, 200 );
+ } else {
+ // Prevent transitioning immediately
+ this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
+ }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupWidget.prototype.computePosition = function () {
+ var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
+ anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
+ offsetParentPos, containerPos,
+ popupPos = {},
+ anchorCss = { left: '', right: '', top: '', bottom: '' },
alignMap = {
ltr: {
'force-left': 'backwards',
'force-right': 'backwards'
}
},
- widget = this;
+ anchorEdgeMap = {
+ above: 'bottom',
+ below: 'top',
+ before: 'end',
+ after: 'start'
+ },
+ hPosMap = {
+ forwards: 'start',
+ center: 'center',
+ backwards: 'before'
+ },
+ vPosMap = {
+ forwards: 'top',
+ center: 'center',
+ backwards: 'bottom'
+ };
if ( !this.$container ) {
// Lazy-initialize $container if not specified in constructor
this.$container = $( this.getClosestScrollableElementContainer() );
}
direction = this.$container.css( 'direction' );
- dirFactor = direction === 'rtl' ? -1 : 1;
- align = alignMap[ direction ][ this.align ] || this.align;
- // Set height and width before measuring things, since it might cause our measurements
- // to change (e.g. due to scrollbars appearing or disappearing)
+ // Set height and width before we do anything else, since it might cause our measurements
+ // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
this.$popup.css( {
width: this.width,
height: this.height !== null ? this.height : 'auto'
} );
- // Compute initial popupOffset based on alignment
- popupOffset = this.width * ( { backwards: -1, center: -0.5, forwards: 0 } )[ align ];
-
- // Figure out if this will cause the popup to go beyond the edge of the container
- originOffset = this.$element.offset().left;
- containerLeft = this.$container.offset().left;
- containerWidth = this.$container.innerWidth();
- containerRight = containerLeft + containerWidth;
- popupLeft = dirFactor * popupOffset - this.containerPadding;
- popupRight = dirFactor * popupOffset + this.containerPadding + this.width + this.containerPadding;
- overlapLeft = ( originOffset + popupLeft ) - containerLeft;
- overlapRight = containerRight - ( originOffset + popupRight );
+ align = alignMap[ direction ][ this.align ] || this.align;
+ // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
+ vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
+ start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
+ end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
+ near = vertical ? 'top' : 'left';
+ far = vertical ? 'bottom' : 'right';
+ sizeProp = vertical ? 'Height' : 'Width';
+ popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
+
+ this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
+ this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
+ this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
- // Adjust offset to make the popup not go beyond the edge, if needed
- if ( overlapRight < 0 ) {
- popupOffset += dirFactor * overlapRight;
- } else if ( overlapLeft < 0 ) {
- popupOffset -= dirFactor * overlapLeft;
+ // Parent method
+ parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
+ // Find out which property FloatableElement used for positioning, and adjust that value
+ positionProp = vertical ?
+ ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
+ ( parentPosition.left !== '' ? 'left' : 'right' );
+
+ // Figure out where the near and far edges of the popup and $floatableContainer are
+ floatablePos = this.$floatableContainer.offset();
+ floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
+ // Measure where the offsetParent is and compute our position based on that and parentPosition
+ offsetParentPos = this.$element.offsetParent().offset();
+
+ if ( positionProp === near ) {
+ popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
+ popupPos[ far ] = popupPos[ near ] + popupSize;
+ } else {
+ popupPos[ far ] = offsetParentPos[ near ] +
+ this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
+ popupPos[ near ] = popupPos[ far ] - popupSize;
+ }
+
+ // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
+ anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
+ anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
+
+ // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
+ // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
+ anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
+ anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
+ if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
+ // Not enough space for the anchor on the start side; pull the popup startwards
+ positionAdjustment = ( positionProp === start ? -1 : 1 ) *
+ ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
+ } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
+ // Not enough space for the anchor on the end side; pull the popup endwards
+ positionAdjustment = ( positionProp === end ? -1 : 1 ) *
+ ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
+ } else {
+ positionAdjustment = 0;
}
- // Adjust offset to avoid anchor being rendered too close to the edge
- // $anchor.width() doesn't work with the pure CSS anchor (returns 0)
- // TODO: Find a measurement that works for CSS anchors and image anchors
- anchorWidth = this.$anchor[ 0 ].scrollWidth * 2;
- if ( popupOffset + this.width < anchorWidth ) {
- popupOffset = anchorWidth - this.width;
- } else if ( -popupOffset < anchorWidth ) {
- popupOffset = -anchorWidth;
+ // Check if the popup will go beyond the edge of this.$container
+ containerPos = this.$container.offset();
+ containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
+ // Take into account how much the popup will move because of the adjustments we're going to make
+ popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
+ popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
+ if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
+ // Popup goes beyond the near (left/top) edge, move it to the right/bottom
+ positionAdjustment += ( positionProp === near ? 1 : -1 ) *
+ ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
+ } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
+ // Popup goes beyond the far (right/bottom) edge, move it to the left/top
+ positionAdjustment += ( positionProp === far ? 1 : -1 ) *
+ ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
}
- // Prevent transition from being interrupted
- clearTimeout( this.transitionTimeout );
- if ( transition ) {
- // Enable transition
- this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
- }
-
- // Position body relative to anchor
- this.$popup.css( direction === 'rtl' ? 'margin-right' : 'margin-left', popupOffset );
-
- if ( transition ) {
- // Prevent transitioning after transition is complete
- this.transitionTimeout = setTimeout( function () {
- widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
- }, 200 );
- } else {
- // Prevent transitioning immediately
- this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
- }
+ // Adjust anchorOffset for positionAdjustment
+ anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
- // Reevaluate clipping state since we've relocated and resized the popup
- this.clip();
+ // Position the anchor
+ anchorCss[ start ] = anchorOffset;
+ this.$anchor.css( anchorCss );
+ // Move the popup if needed
+ parentPosition[ positionProp ] += positionAdjustment;
- return this;
+ return parentPosition;
};
/**
* `backwards` or `forwards`.
*/
OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
- // Transform values deprecated since v0.11.0
- if ( align === 'left' || align === 'right' ) {
- OO.ui.warnDeprecation( 'PopupWidget#setAlignment parameter value `' + align + '` is deprecated. Use `force-right` or `force-left` instead.' );
- align = { left: 'force-right', right: 'force-left' }[ align ];
- }
-
// Validate alignment
if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
this.align = align;
} else {
this.align = 'center';
}
+ this.position();
};
/**
* Get popup alignment
*
- * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
+ * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
* `backwards` or `forwards`.
*/
OO.ui.PopupWidget.prototype.getAlignment = function () {
return this.align;
};
+/**
+ * Change the positioning of the popup.
+ *
+ * @param {string} position 'above', 'below', 'before' or 'after'
+ */
+OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
+ if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
+ position = 'below';
+ }
+ this.popupPosition = position;
+ this.position();
+};
+
+/**
+ * Get popup positioning.
+ *
+ * @return {string} 'above', 'below', 'before' or 'after'
+ */
+OO.ui.PopupWidget.prototype.getPosition = function () {
+ return this.popupPosition;
+};
+
/**
* PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
* A popup is a container for content. It is overlaid and positioned absolutely. By default, each
// Properties
this.popup = new OO.ui.PopupWidget( $.extend(
- { autoClose: true },
+ {
+ autoClose: true,
+ $floatableContainer: this.$element
+ },
config.popup,
- { $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore ) }
+ {
+ $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
+ }
) );
};
OO.ui.PopupButtonWidget.parent.call( this, config );
// Mixin constructors
- OO.ui.mixin.PopupElement.call( this, $.extend( true, {}, config, {
- popup: {
- $floatableContainer: this.$element
- }
- } ) );
+ OO.ui.mixin.PopupElement.call( this, config );
// Properties
this.$overlay = config.$overlay || this.$element;
OO.ui.MenuSectionOptionWidget.parent.call( this, config );
// Initialization
- this.$element.addClass( 'oo-ui-menuSectionOptionWidget' );
+ this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
+ .attr( 'role', '' );
};
/* Setup */
* - Down-arrow key: highlight the next menu option
* - Esc key: hide the menu
*
+ * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
+ *
* Please see the [OOjs UI documentation on MediaWiki][1] for more information.
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
* that button, unless the button (or its parent widget) is passed in here.
* @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
+ * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
+ * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
* @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
+ * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
*/
OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
// Configuration initialization
// Properties
this.autoHide = config.autoHide === undefined || !!config.autoHide;
+ this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
this.filterFromInput = !!config.filterFromInput;
this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
this.$widget = config.widget ? config.widget.$element : null;
+ this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
+ this.highlightOnFilter = !!config.highlightOnFilter;
// Initialization
this.$element
*/
OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
if (
- !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
- ( !this.$widget || !OO.ui.contains( this.$widget[ 0 ], e.target, true ) )
+ this.isVisible() &&
+ !OO.ui.contains(
+ this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
+ e.target,
+ true
+ )
) {
this.toggle( false );
}
* @protected
*/
OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
- var i, item, visible,
+ var i, item, visible, section, sectionEmpty,
+ firstItemFound = false,
anyVisible = false,
len = this.items.length,
showAll = !this.isVisible(),
filter = showAll ? null : this.getItemMatcher( this.$input.val() );
+ // Hide non-matching options, and also hide section headers if all options
+ // in their section are hidden.
for ( i = 0; i < len; i++ ) {
item = this.items[ i ];
- if ( item instanceof OO.ui.OptionWidget ) {
+ if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
+ if ( section ) {
+ // If the previous section was empty, hide its header
+ section.toggle( showAll || !sectionEmpty );
+ }
+ section = item;
+ sectionEmpty = true;
+ } else if ( item instanceof OO.ui.OptionWidget ) {
visible = showAll || filter( item );
anyVisible = anyVisible || visible;
+ sectionEmpty = sectionEmpty && !visible;
item.toggle( visible );
+ if ( this.highlightOnFilter && visible && !firstItemFound ) {
+ // Highlight the first item in the list
+ this.highlightItem( item );
+ firstItemFound = true;
+ }
}
}
+ // Process the final section
+ if ( section ) {
+ section.toggle( showAll || !sectionEmpty );
+ }
this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
/**
* Choose an item.
*
- * When a user chooses an item, the menu is closed.
+ * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
*
* Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
* or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
*/
OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
- this.toggle( false );
+ if ( this.hideOnChoose ) {
+ this.toggle( false );
+ }
return this;
};
};
/**
+ * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
+ * `.toggle( true )` after its #$element is attached to the DOM.
+ *
+ * Do not show the menu while it is not attached to the DOM. The calculations required to display
+ * it in the right place and with the right dimensions only work correctly while it is attached.
+ * Side-effects may include broken interface and exceptions being thrown. This wasn't always
+ * strictly enforced, so currently it only generates a warning in the browser console.
+ *
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
change = visible !== this.isVisible();
+ if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
+ OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
+ this.warnedUnattached = true;
+ }
+
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
* Deprecated, omit this parameter and specify `$container` instead.
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under
+ * @cfg {number} [width] Width of the menu
*/
OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) {
// Allow 'inputWidget' parameter and config for backwards compatibility
// Configuration initialization
config = config || {};
+ this.width = config.width;
+
// Parent constructor
OO.ui.FloatingMenuSelectWidget.parent.call( this, config );
if ( change && visible ) {
// Make sure the width is set before the parent method runs.
- this.setIdealSize( this.$container.width() );
+ this.setIdealSize( this.width || this.$container.width() );
}
// Parent method
return this;
};
-/*
- * The old name for the FloatingMenuSelectWidget widget, provided for backwards-compatibility.
- *
- * @class
- * @extends OO.ui.FloatingMenuSelectWidget
- *
- * @constructor
- * @deprecated since v0.12.5.
- */
-OO.ui.TextInputMenuSelectWidget = function OoUiTextInputMenuSelectWidget() {
- OO.ui.warnDeprecation( 'TextInputMenuSelectWidget is deprecated. Use the FloatingMenuSelectWidget instead.' );
- // Parent constructor
- OO.ui.TextInputMenuSelectWidget.parent.apply( this, arguments );
-};
-
-OO.inheritClass( OO.ui.TextInputMenuSelectWidget, OO.ui.FloatingMenuSelectWidget );
-
/**
* Progress bars visually display the status of an operation, such as a download,
* and can be either determinate or indeterminate:
*/
OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.ButtonInputWidget.static.tagName = 'span';
+
/* Methods */
/**
OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
+/* Static Properties */
+
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.static.tagName = 'span';
+
/* Static Methods */
/**
* @inheritdoc
*/
OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
+ var selected;
value = this.cleanUpValue( value );
this.dropdownWidget.getMenu().selectItemByData( value );
+ // Only allow setting values that are actually present in the dropdown
+ selected = this.dropdownWidget.getMenu().getSelectedItem();
+ value = selected ? selected.getData() : '';
OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
return this;
};
.clearItems()
.addItems( options.map( function ( opt ) {
var optValue = widget.cleanUpValue( opt.data );
- return new OO.ui.MenuOptionWidget( {
- data: optValue,
- label: opt.label !== undefined ? opt.label : optValue
- } );
+
+ if ( opt.optgroup === undefined ) {
+ return new OO.ui.MenuOptionWidget( {
+ data: optValue,
+ label: opt.label !== undefined ? opt.label : optValue
+ } );
+ } else {
+ return new OO.ui.MenuSectionOptionWidget( {
+ label: opt.optgroup
+ } );
+ }
} ) );
// Restore the previous value, or reset to something sensible
OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
+/* Static Properties */
+
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.static.tagName = 'span';
+
/* Static Methods */
/**
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
- * 'email', 'url', 'date', 'month' or 'number'. Ignored if `multiline` is true.
+ * 'email', 'url' or 'number'. Ignored if `multiline` is true.
*
* Some values of `type` result in additional behaviors:
*
* specifies minimum number of rows to display.
* @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
* Use the #maxRows config to specify a maximum number of displayed rows.
- * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
+ * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
* Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
* @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
* the value or placeholder text: `'before'` or `'after'`
this.required = !!state;
if ( this.required ) {
this.$input
- .attr( 'required', 'required' )
+ .prop( 'required', true )
.attr( 'aria-required', 'true' );
if ( this.getIndicator() === null ) {
this.setIndicator( 'required' );
}
} else {
this.$input
- .removeAttr( 'required' )
+ .prop( 'required', false )
.removeAttr( 'aria-required' );
if ( this.getIndicator() === 'required' ) {
this.setIndicator( null );
'search',
'email',
'url',
- 'date',
- 'month',
'number'
];
return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
* - by choosing a value from the menu. The value of the chosen option will then appear in the text
* input field.
*
+ * After the user chooses an option, its `data` will be used as a new value for the widget.
+ * A `label` also can be specified for each option: if given, it will be shown instead of the
+ * `data` in the dropdown menu.
+ *
* This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
*
* For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
* @example
* // Example: A ComboBoxInputWidget.
* var comboBox = new OO.ui.ComboBoxInputWidget( {
- * label: 'ComboBoxInputWidget',
* value: 'Option 1',
- * menu: {
- * items: [
- * new OO.ui.MenuOptionWidget( {
- * data: 'Option 1',
- * label: 'Option One'
- * } ),
- * new OO.ui.MenuOptionWidget( {
- * data: 'Option 2',
- * label: 'Option Two'
- * } ),
- * new OO.ui.MenuOptionWidget( {
- * data: 'Option 3',
- * label: 'Option Three'
- * } ),
- * new OO.ui.MenuOptionWidget( {
- * data: 'Option 4',
- * label: 'Option Four'
- * } ),
- * new OO.ui.MenuOptionWidget( {
- * data: 'Option 5',
- * label: 'Option Five'
- * } )
- * ]
- * }
+ * options: [
+ * { data: 'Option 1' },
+ * { data: 'Option 2' },
+ * { data: 'Option 3' }
+ * ]
+ * } );
+ * $( 'body' ).append( comboBox.$element );
+ *
+ * @example
+ * // Example: A ComboBoxInputWidget with additional option labels.
+ * var comboBox = new OO.ui.ComboBoxInputWidget( {
+ * value: 'Option 1',
+ * options: [
+ * {
+ * data: 'Option 1',
+ * label: 'Option One'
+ * },
+ * {
+ * data: 'Option 2',
+ * label: 'Option Two'
+ * },
+ * {
+ * data: 'Option 3',
+ * label: 'Option Three'
+ * }
+ * ]
* } );
* $( 'body' ).append( comboBox.$element );
*
autocomplete: false
}, config );
- // ComboBoxInputWidget shouldn't support multiline
+ // ComboBoxInputWidget shouldn't support `multiline`
config.multiline = false;
+ // See InputWidget#reusePreInfuseDOM about `config.$input`
+ if ( config.$input ) {
+ config.$input.removeAttr( 'list' );
+ }
+
// Parent constructor
OO.ui.ComboBoxInputWidget.parent.call( this, config );
* @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
* in the upper-right corner of the rendered field; clicking it will display the text in a popup.
* For important messages, you are advised to use `notices`, as they are always shown.
+ * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
*
* @throws {Error} An error is thrown if no widget is specified
*/
this.fieldWidget = fieldWidget;
this.errors = [];
this.notices = [];
- this.$field = $( '<div>' );
+ this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
this.$messages = $( '<ul>' );
- this.$header = $( '<div>' );
+ this.$header = $( '<span>' );
this.$body = $( '<div>' );
this.align = null;
if ( config.help ) {
this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+ $overlay: config.$overlay,
popup: {
padded: true
},
if ( fieldWidget.constructor.static.supportsSimpleLabel ) {
if ( this.fieldWidget.getInputId() ) {
this.$label.attr( 'for', this.fieldWidget.getInputId() );
+ } else {
+ this.$label.on( 'click', function () {
+ this.fieldWidget.focus();
+ return false;
+ }.bind( this ) );
}
}
this.$element
return this.fieldWidget;
};
+/**
+ * Return `true` if the given field widget can be used with `'inline'` alignment (see
+ * #setAlignment). Return `false` if it can't or if this can't be determined.
+ *
+ * @return {boolean}
+ */
+OO.ui.FieldLayout.prototype.isFieldInline = function () {
+ // This is very simplistic, but should be good enough.
+ return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
+};
+
/**
* @protected
* @param {string} kind 'error' or 'notice'
if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
value = 'left';
}
+ // Validate
+ if ( value === 'inline' && !this.isFieldInline() ) {
+ value = 'top';
+ }
// Reorder elements
if ( value === 'top' ) {
this.$header.append( this.$label, this.$help );
// Properties
this.buttonWidget = buttonWidget;
- this.$button = $( '<div>' );
- this.$input = $( '<div>' );
+ this.$button = $( '<span>' );
+ this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
// Initialization
this.$element
* @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
* in the upper-right corner of the rendered field; clicking it will display the text in a popup.
* For important messages, you are advised to use `notices`, as they are always shown.
+ * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
*/
OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
// Configuration initialization
this.$header = $( '<div>' );
if ( config.help ) {
this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+ $overlay: config.$overlay,
popup: {
padded: true
},