X-Git-Url: https://git.heureux-cyclage.org/?a=blobdiff_plain;f=resources%2Flib%2Foojs-ui%2Foojs-ui-core.js;h=af099a94d55e90bc9dd43b9d3e976763b0b8cecd;hb=870103526c6e9bc6dd9750c89e51f9ac3f2a6481;hp=fd238fff451aff5e5e187f7871285b31d844f2b8;hpb=f4fb49ca65bae507d9d49796d4ba3e2890bae82d;p=lhc%2Fweb%2Fwiklou.git diff --git a/resources/lib/oojs-ui/oojs-ui-core.js b/resources/lib/oojs-ui/oojs-ui-core.js index fd238fff45..e0d165f9b1 100644 --- a/resources/lib/oojs-ui/oojs-ui-core.js +++ b/resources/lib/oojs-ui/oojs-ui-core.js @@ -1,12 +1,12 @@ /*! - * OOjs UI v0.22.5 + * OOjs UI v0.24.3 * 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-08-22T21:37:37Z + * Date: 2017-11-28T23:28:05Z */ ( function ( OO ) { @@ -539,6 +539,23 @@ OO.ui.isMobile = function () { return false; }; +/** + * Get the additional spacing that should be taken into account when displaying elements that are + * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid + * such menus overlapping any fixed headers/toolbars/navigation used by the site. + * + * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing + * the extra spacing from that edge of viewport (in pixels) + */ +OO.ui.getViewportSpacing = function () { + return { + top: 0, + right: 0, + bottom: 0, + left: 0 + }; +}; + /*! * Mixin namespace. */ @@ -581,7 +598,9 @@ OO.ui.mixin = {}; * Data can also be specified with the #setData method. */ OO.ui.Element = function OoUiElement( config ) { - this.initialConfig = config; + if ( OO.ui.isDemo ) { + this.initialConfig = config; + } // Configuration initialization config = config || {}; @@ -683,7 +702,7 @@ OO.ui.Element.static.infuse = function ( idOrNode ) { */ 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, infusedChildren; + var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren; if ( typeof idOrNode === 'string' ) { id = idOrNode; $elem = $( document.getElementById( id ) ); @@ -692,7 +711,14 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) { id = $elem.attr( 'id' ); } if ( !$elem.length ) { - throw new Error( 'Widget not found: ' + id ); + if ( typeof idOrNode === 'string' ) { + error = 'Widget not found: ' + idOrNode; + } else if ( idOrNode && idOrNode.selector ) { + error = 'Widget not found: ' + idOrNode.selector; + } else { + error = 'Widget not found'; + } + throw new Error( error ); } if ( $elem[ 0 ].oouiInfused ) { $elem = $elem[ 0 ].oouiInfused; @@ -737,12 +763,7 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) { parts = data._.split( '.' ); cls = OO.getProp.apply( OO, [ window ].concat( parts ) ); if ( cls === undefined ) { - // The PHP output might be old and not including the "OO.ui" prefix - // TODO: Remove this back-compat after next major release - cls = OO.getProp.apply( OO, [ OO.ui ].concat( parts ) ); - if ( cls === undefined ) { - throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ ); - } + throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ ); } // Verify that we're creating an OO.ui.Element instance @@ -2951,23 +2972,38 @@ OO.ui.mixin.LabelElement.static.label = null; * * @param {string} text Text * @param {string} query Query to find + * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare * @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 = $( '' ), +OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) { + var i, tLen, qLen, + offset = -1, + $result = $( '' ); + + if ( compare ) { + tLen = text.length; + qLen = query.length; + for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) { + if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) { + offset = i; + } + } + } else { offset = text.toLowerCase().indexOf( query.toLowerCase() ); + } if ( !query.length || offset === -1 ) { - return $result.text( text ); - } - $result.append( - document.createTextNode( text.slice( 0, offset ) ), - $( '' ) - .addClass( 'oo-ui-labelElement-label-highlight' ) - .text( text.slice( offset, offset + query.length ) ), - document.createTextNode( text.slice( offset + query.length ) ) - ); + $result.text( text ); + } else { + $result.append( + document.createTextNode( text.slice( 0, offset ) ), + $( '' ) + .addClass( 'oo-ui-labelElement-label-highlight' ) + .text( text.slice( offset, offset + query.length ) ), + document.createTextNode( text.slice( offset + query.length ) ) + ); + } return $result.contents(); }; @@ -3021,10 +3057,11 @@ OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) { * * @param {string} text Text label to set * @param {string} query Substring of text to highlight + * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare * @chainable */ -OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query ) { - return this.setLabel( this.constructor.static.highlightQuery( text, query ) ); +OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) { + return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) ); }; /** @@ -3074,25 +3111,21 @@ OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) { * * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process. * - **destructive**: Destructive styling is applied to convey that the widget will remove something. - * - **constructive**: Constructive styling is applied to convey that the widget will create something. + * - **constructive**: Constructive styling is deprecated since v0.23.2 and equivalent to progressive. * * The flags affect the appearance of the buttons: * * @example * // FlaggedElement is mixed into ButtonWidget to provide styling flags * var button1 = new OO.ui.ButtonWidget( { - * label: 'Constructive', - * flags: 'constructive' + * label: 'Progressive', + * flags: 'progressive' * } ); * var button2 = new OO.ui.ButtonWidget( { * label: 'Destructive', * flags: 'destructive' * } ); - * var button3 = new OO.ui.ButtonWidget( { - * label: 'Progressive', - * flags: 'progressive' - * } ); - * $( 'body' ).append( button1.$element, button2.$element, button3.$element ); + * $( 'body' ).append( button1.$element, button2.$element ); * * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**. * Please see the [OOjs UI documentation on MediaWiki] [1] for more information. @@ -3104,7 +3137,7 @@ OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) { * * @constructor * @param {Object} [config] Configuration options - * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'constructive' or 'primary') to apply. + * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply. * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged * @cfg {jQuery} [$flagged] The flagged element. By default, @@ -4377,17 +4410,22 @@ OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positionin */ OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) { var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds, - startEdgeInBounds, endEdgeInBounds, + startEdgeInBounds, endEdgeInBounds, viewportSpacing, direction = $element.css( 'direction' ); elemRect = $element[ 0 ].getBoundingClientRect(); if ( $container[ 0 ] === window ) { + viewportSpacing = OO.ui.getViewportSpacing(); contRect = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight }; + contRect.top += viewportSpacing.top; + contRect.left += viewportSpacing.left; + contRect.right -= viewportSpacing.right; + contRect.bottom -= viewportSpacing.bottom; } else { contRect = $container[ 0 ].getBoundingClientRect(); } @@ -4778,6 +4816,50 @@ OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) // While clipping, idealWidth and idealHeight are not considered }; +/** + * Return the side of the clippable on which it is "anchored" (aligned to something else). + * ClippableElement will clip the opposite side when reducing element's width. + * + * Classes that mix in ClippableElement should override this to return 'right' if their + * clippable is absolutely positioned and using 'right: Npx' (and not using 'left'). + * If your class also mixes in FloatableElement, this is handled automatically. + * + * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are + * always in pixels, even if they were unset or set to 'auto'.) + * + * When in doubt, 'left' (or 'right' in RTL) is a sane fallback. + * + * @return {string} 'left' or 'right' + */ +OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () { + if ( this.computePosition && this.computePosition().right !== '' ) { + return 'right'; + } + return 'left'; +}; + +/** + * Return the side of the clippable on which it is "anchored" (aligned to something else). + * ClippableElement will clip the opposite side when reducing element's width. + * + * Classes that mix in ClippableElement should override this to return 'bottom' if their + * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top'). + * If your class also mixes in FloatableElement, this is handled automatically. + * + * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are + * always in pixels, even if they were unset or set to 'auto'.) + * + * When in doubt, 'top' is a sane fallback. + * + * @return {string} 'top' or 'bottom' + */ +OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () { + if ( this.computePosition && this.computePosition().bottom !== '' ) { + return 'bottom'; + } + return 'top'; +}; + /** * Clip element to visible boundaries and allow scrolling when needed. You should call this method * when the element's natural height changes. @@ -4792,42 +4874,100 @@ OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) * @chainable */ OO.ui.mixin.ClippableElement.prototype.clip = function () { - var $container, extraHeight, extraWidth, ccOffset, - $scrollableContainer, scOffset, scHeight, scWidth, - ccWidth, scrollerIsWindow, scrollTop, scrollLeft, + var extraHeight, extraWidth, viewportSpacing, desiredWidth, desiredHeight, allotedWidth, allotedHeight, naturalWidth, naturalHeight, clipWidth, clipHeight, - buffer = 7; // Chosen by fair dice roll + $item, itemRect, $viewport, viewportRect, availableRect, + direction, vertScrollbarWidth, horizScrollbarHeight, + // Extra tolerance so that the sloppy code below doesn't result in results that are off + // by one or two pixels. (And also so that we have space to display drop shadows.) + // Chosen by fair dice roll. + buffer = 7; if ( !this.clipping ) { // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail return this; } - $container = this.$clippableContainer || this.$clippable; - extraHeight = $container.outerHeight() - this.$clippable.outerHeight(); - extraWidth = $container.outerWidth() - this.$clippable.outerWidth(); - ccOffset = $container.offset(); + function rectIntersection( a, b ) { + var out = {}; + out.top = Math.max( a.top, b.top ); + out.left = Math.max( a.left, b.left ); + out.bottom = Math.min( a.bottom, b.bottom ); + out.right = Math.min( a.right, b.right ); + return out; + } + + viewportSpacing = OO.ui.getViewportSpacing(); + if ( this.$clippableScrollableContainer.is( 'html, body' ) ) { - $scrollableContainer = this.$clippableWindow; - scOffset = { top: 0, left: 0 }; + $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body ); + // Dimensions of the browser window, rather than the element! + viewportRect = { + top: 0, + left: 0, + right: document.documentElement.clientWidth, + bottom: document.documentElement.clientHeight + }; + viewportRect.top += viewportSpacing.top; + viewportRect.left += viewportSpacing.left; + viewportRect.right -= viewportSpacing.right; + viewportRect.bottom -= viewportSpacing.bottom; + } else { + $viewport = this.$clippableScrollableContainer; + viewportRect = $viewport[ 0 ].getBoundingClientRect(); + // Convert into a plain object + viewportRect = $.extend( {}, viewportRect ); + } + + // Account for scrollbar gutter + direction = $viewport.css( 'direction' ); + vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' ); + horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' ); + viewportRect.bottom -= horizScrollbarHeight; + if ( direction === 'rtl' ) { + viewportRect.left += vertScrollbarWidth; + } else { + viewportRect.right -= vertScrollbarWidth; + } + + // Add arbitrary tolerance + viewportRect.top += buffer; + viewportRect.left += buffer; + viewportRect.right -= buffer; + viewportRect.bottom -= buffer; + + $item = this.$clippableContainer || this.$clippable; + + extraHeight = $item.outerHeight() - this.$clippable.outerHeight(); + extraWidth = $item.outerWidth() - this.$clippable.outerWidth(); + + itemRect = $item[ 0 ].getBoundingClientRect(); + // Convert into a plain object + itemRect = $.extend( {}, itemRect ); + + // Item might already be clipped, so we can't just use its dimensions (in case we might need to + // make it larger than before). Extend the rectangle to the maximum size we are allowed to take. + if ( this.getHorizontalAnchorEdge() === 'right' ) { + itemRect.left = viewportRect.left; } else { - $scrollableContainer = this.$clippableScrollableContainer; - scOffset = $scrollableContainer.offset(); - } - scHeight = $scrollableContainer.innerHeight() - buffer; - scWidth = $scrollableContainer.innerWidth() - buffer; - ccWidth = $container.outerWidth() + buffer; - scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ]; - scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0; - scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0; - desiredWidth = ccOffset.left < 0 ? - ccWidth + ccOffset.left : - ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left; - desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top; + itemRect.right = viewportRect.right; + } + if ( this.getVerticalAnchorEdge() === 'bottom' ) { + itemRect.top = viewportRect.top; + } else { + itemRect.bottom = viewportRect.bottom; + } + + availableRect = rectIntersection( viewportRect, itemRect ); + + desiredWidth = Math.max( 0, availableRect.right - availableRect.left ); + desiredHeight = Math.max( 0, availableRect.bottom - availableRect.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 ); + desiredWidth = Math.min( desiredWidth, + document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right ); + desiredHeight = Math.min( desiredHeight, + document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right ); allotedWidth = Math.ceil( desiredWidth - extraWidth ); allotedHeight = Math.ceil( desiredHeight - extraHeight ); naturalWidth = this.$clippable.prop( 'scrollWidth' ); @@ -4931,6 +5071,9 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () { * 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 {boolean} [autoFlip=true] Whether to automatically switch the popup's position between + * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the + * desired direction to display the popup without clipping * @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 @@ -4983,6 +5126,7 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { this.toggleAnchor( config.anchor === undefined || config.anchor ); this.setAlignment( config.align || 'center' ); this.setPosition( config.position || 'below' ); + this.setAutoFlip( config.autoFlip === undefined || config.autoFlip ); this.$body.addClass( 'oo-ui-popupWidget-body' ); this.$anchor.addClass( 'oo-ui-popupWidget-anchor' ); this.$popup @@ -5140,6 +5284,7 @@ OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) { this.anchored = show; } }; + /** * Change which edge the anchor appears on. * @@ -5180,7 +5325,7 @@ OO.ui.PopupWidget.prototype.hasAnchor = function () { * @inheritdoc */ OO.ui.PopupWidget.prototype.toggle = function ( show ) { - var change; + var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth; show = show === undefined ? !this.isVisible() : !!show; change = show !== this.isVisible(); @@ -5194,6 +5339,12 @@ OO.ui.PopupWidget.prototype.toggle = function ( show ) { this.setFloatableContainer( this.$element.parent() ); } + if ( change && show && this.autoFlip ) { + // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip + // (e.g. if the user scrolled). + this.isAutoFlipped = false; + } + // Parent method OO.ui.PopupWidget.parent.prototype.toggle.call( this, show ); @@ -5207,6 +5358,54 @@ OO.ui.PopupWidget.prototype.toggle = function ( show ) { } this.updateDimensions(); this.toggleClipping( true ); + + if ( this.autoFlip ) { + if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) { + if ( this.isClippedVertically() ) { + // If opening the popup in the normal direction causes it to be clipped, open + // in the opposite one instead + normalHeight = this.$element.height(); + this.isAutoFlipped = !this.isAutoFlipped; + this.position(); + if ( this.isClippedVertically() ) { + // If that also causes it to be clipped, open in whichever direction + // we have more space + oppositeHeight = this.$element.height(); + if ( oppositeHeight < normalHeight ) { + this.isAutoFlipped = !this.isAutoFlipped; + this.position(); + } + } + } + } + if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) { + if ( this.isClippedHorizontally() ) { + // If opening the popup in the normal direction causes it to be clipped, open + // in the opposite one instead + normalWidth = this.$element.width(); + this.isAutoFlipped = !this.isAutoFlipped; + // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions, + // which causes positioning to be off. Toggle clipping back and fort to work around. + this.toggleClipping( false ); + this.position(); + this.toggleClipping( true ); + if ( this.isClippedHorizontally() ) { + // If that also causes it to be clipped, open in whichever direction + // we have more space + oppositeWidth = this.$element.width(); + if ( oppositeWidth < normalWidth ) { + this.isAutoFlipped = !this.isAutoFlipped; + // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions, + // which causes positioning to be off. Toggle clipping back and fort to work around. + this.toggleClipping( false ); + this.position(); + this.toggleClipping( true ); + } + } + } + } + } + this.emit( 'ready' ); } else { this.toggleClipping( false ); @@ -5276,9 +5475,15 @@ OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) { 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, + offsetParentPos, containerPos, popupPosition, viewportSpacing, popupPos = {}, anchorCss = { left: '', right: '', top: '', bottom: '' }, + popupPositionOppositeMap = { + above: 'below', + below: 'above', + before: 'after', + after: 'before' + }, alignMap = { ltr: { 'force-left': 'backwards', @@ -5320,8 +5525,13 @@ OO.ui.PopupWidget.prototype.computePosition = function () { } ); align = alignMap[ direction ][ this.align ] || this.align; + popupPosition = this.popupPosition; + if ( this.isAutoFlipped ) { + popupPosition = popupPositionOppositeMap[ popupPosition ]; + } + // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal - vertical = this.popupPosition === 'before' || this.popupPosition === 'after'; + vertical = popupPosition === 'before' || popupPosition === 'after'; start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' ); end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' ); near = vertical ? 'top' : 'left'; @@ -5329,9 +5539,9 @@ OO.ui.PopupWidget.prototype.computePosition = function () { 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; + this.setAnchorEdge( anchorEdgeMap[ popupPosition ] ); + this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ]; + this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition; // Parent method parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this ); @@ -5344,7 +5554,9 @@ OO.ui.PopupWidget.prototype.computePosition = function () { 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(); + offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ? + { top: 0, left: 0 } : + this.$element.offsetParent().offset(); if ( positionProp === near ) { popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ]; @@ -5380,8 +5592,15 @@ OO.ui.PopupWidget.prototype.computePosition = function () { } // Check if the popup will go beyond the edge of this.$container - containerPos = this.$container.offset(); + containerPos = this.$container[ 0 ] === document.documentElement ? + { top: 0, left: 0 } : + this.$container.offset(); containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ](); + if ( this.$container[ 0 ] === document.documentElement ) { + viewportSpacing = OO.ui.getViewportSpacing(); + containerPos[ near ] += viewportSpacing[ near ]; + containerPos[ far ] -= viewportSpacing[ far ]; + } // 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; @@ -5458,6 +5677,21 @@ OO.ui.PopupWidget.prototype.getPosition = function () { return this.popupPosition; }; +/** + * Set popup auto-flipping. + * + * @param {boolean} autoFlip Whether to automatically switch the popup's position between + * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the + * desired direction to display the popup without clipping + */ +OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) { + autoFlip = !!autoFlip; + + if ( this.autoFlip !== autoFlip ) { + this.autoFlip = autoFlip; + } +}; + /** * Get an ID of the body element, this can be used as the * `aria-describedby` attribute for an input field. @@ -6066,12 +6300,19 @@ OO.ui.SelectWidget.prototype.onFocus = function ( event ) { // This widget was focussed, e.g. by the user tabbing to it. // The styles for focus state depend on one of the items being selected. if ( !this.getSelectedItem() ) { - item = this.getFirstSelectableItem(); + item = this.findFirstSelectableItem(); } } else { - // One of the options got focussed (and the event bubbled up here). - // They can't be tabbed to, but they can be activated using accesskeys. - item = this.getTargetItem( event ); + if ( event.target.tabIndex === -1 ) { + // One of the options got focussed (and the event bubbled up here). + // They can't be tabbed to, but they can be activated using accesskeys. + // OptionWidgets and focusable UI elements inside them have tabindex="-1" set. + item = this.findTargetItem( event ); + } else { + // There is something actually user-focusable in one of the labels of the options, and the + // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus). + return; + } } if ( item ) { @@ -6098,7 +6339,7 @@ OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) { if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) { this.togglePressed( true ); - item = this.getTargetItem( e ); + item = this.findTargetItem( e ); if ( item && item.isSelectable() ) { this.pressItem( item ); this.selecting = item; @@ -6120,7 +6361,7 @@ OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) { this.togglePressed( false ); if ( !this.selecting ) { - item = this.getTargetItem( e ); + item = this.findTargetItem( e ); if ( item && item.isSelectable() ) { this.selecting = item; } @@ -6147,7 +6388,7 @@ OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) { var item; if ( !this.isDisabled() && this.pressed ) { - item = this.getTargetItem( e ); + item = this.findTargetItem( e ); if ( item && item !== this.selecting && item.isSelectable() ) { this.pressItem( item ); this.selecting = item; @@ -6167,7 +6408,7 @@ OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) { return; } if ( !this.isDisabled() ) { - item = this.getTargetItem( e ); + item = this.findTargetItem( e ); this.highlightItem( item && item.isHighlightable() ? item : null ); } return false; @@ -6195,7 +6436,7 @@ OO.ui.SelectWidget.prototype.onMouseLeave = function () { OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { var nextItem, handled = false, - currentItem = this.getHighlightedItem() || this.getSelectedItem(); + currentItem = this.findHighlightedItem() || this.getSelectedItem(); if ( !this.isDisabled() && this.isVisible() ) { switch ( e.keyCode ) { @@ -6209,13 +6450,13 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { case OO.ui.Keys.UP: case OO.ui.Keys.LEFT: this.clearKeyPressBuffer(); - nextItem = this.getRelativeSelectableItem( currentItem, -1 ); + nextItem = this.findRelativeSelectableItem( currentItem, -1 ); handled = true; break; case OO.ui.Keys.DOWN: case OO.ui.Keys.RIGHT: this.clearKeyPressBuffer(); - nextItem = this.getRelativeSelectableItem( currentItem, 1 ); + nextItem = this.findRelativeSelectableItem( currentItem, 1 ); handled = true; break; case OO.ui.Keys.ESCAPE: @@ -6321,13 +6562,13 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) { } this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 ); - item = this.getHighlightedItem() || this.getSelectedItem(); + item = this.findHighlightedItem() || this.getSelectedItem(); if ( this.keyPressBuffer === c ) { // Common (if weird) special case: typing "xxxx" will cycle through all // the items beginning with "x". if ( item ) { - item = this.getRelativeSelectableItem( item, 1 ); + item = this.findRelativeSelectableItem( item, 1 ); } } else { this.keyPressBuffer += c; @@ -6335,7 +6576,7 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) { filter = this.getItemMatcher( this.keyPressBuffer, false ); if ( !item || !filter( item ) ) { - item = this.getRelativeSelectableItem( item, 1, filter ); + item = this.findRelativeSelectableItem( item, 1, filter ); } if ( item ) { if ( this.isVisible() && item.constructor.static.highlightable ) { @@ -6420,8 +6661,12 @@ OO.ui.SelectWidget.prototype.onToggle = function ( visible ) { * @param {jQuery.Event} e * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found */ -OO.ui.SelectWidget.prototype.getTargetItem = function ( e ) { - return $( e.target ).closest( '.oo-ui-optionWidget' ).data( 'oo-ui-optionWidget' ) || null; +OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) { + var $option = $( e.target ).closest( '.oo-ui-optionWidget' ); + if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) { + return null; + } + return $option.data( 'oo-ui-optionWidget' ) || null; }; /** @@ -6441,11 +6686,11 @@ OO.ui.SelectWidget.prototype.getSelectedItem = function () { }; /** - * Get highlighted item. + * Find highlighted item. * * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted */ -OO.ui.SelectWidget.prototype.getHighlightedItem = function () { +OO.ui.SelectWidget.prototype.findHighlightedItem = function () { var i, len; for ( i = 0, len = this.items.length; i < len; i++ ) { @@ -6456,6 +6701,17 @@ OO.ui.SelectWidget.prototype.getHighlightedItem = function () { return null; }; +/** + * Get highlighted item. + * + * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead. + * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted + */ +OO.ui.SelectWidget.prototype.getHighlightedItem = function () { + OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' ); + return this.findHighlightedItem(); +}; + /** * Toggle pressed state. * @@ -6666,7 +6922,7 @@ OO.ui.SelectWidget.prototype.chooseItem = function ( item ) { }; /** - * Get an option by its position relative to the specified item (or to the start of the option array, + * Find an option by its position relative to the specified item (or to the start of the option array, * if item is `null`). The direction in which to search through the option array is specified with a * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or * `null` if there are no options in the array. @@ -6677,7 +6933,7 @@ OO.ui.SelectWidget.prototype.chooseItem = function ( item ) { * true. Function takes an OO.ui.OptionWidget and returns a boolean. * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select */ -OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) { +OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) { var currentIndex, nextIndex, i, increase = direction > 0 ? 1 : -1, len = this.items.length; @@ -6704,14 +6960,44 @@ OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direct return null; }; +/** + * Get an option by its position relative to the specified item (or to the start of the option array, + * if item is `null`). The direction in which to search through the option array is specified with a + * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or + * `null` if there are no options in the array. + * + * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead + * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array. + * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward + * @param {Function} [filter] Only consider items for which this function returns + * true. Function takes an OO.ui.OptionWidget and returns a boolean. + * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select + */ +OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) { + OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' ); + return this.findRelativeSelectableItem( item, direction, filter ); +}; + +/** + * Find the next selectable item or `null` if there are no selectable items. + * Disabled options and menu-section markers and breaks are not selectable. + * + * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items + */ +OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () { + return this.findRelativeSelectableItem( null, 1 ); +}; + /** * Get the next selectable item or `null` if there are no selectable items. * Disabled options and menu-section markers and breaks are not selectable. * + * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead. * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items */ OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () { - return this.getRelativeSelectableItem( null, 1 ); + OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' ); + return this.findFirstSelectableItem(); }; /** @@ -7069,7 +7355,7 @@ OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) { * @inheritdoc */ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) { - var currentItem = this.getHighlightedItem() || this.getSelectedItem(); + var currentItem = this.findHighlightedItem() || this.getSelectedItem(); if ( !this.isDisabled() && this.isVisible() ) { switch ( e.keyCode ) { @@ -7275,7 +7561,7 @@ OO.ui.MenuSelectWidget.prototype.clearItems = function () { * @inheritdoc */ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { - var change; + var change, belowHeight, aboveHeight; visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length; change = visible !== this.isVisible(); @@ -7285,8 +7571,15 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { this.warnedUnattached = true; } - if ( change && visible && ( this.width || this.$floatableContainer ) ) { - this.setIdealSize( this.width || this.$floatableContainer.width() ); + if ( change ) { + if ( visible && ( this.width || this.$floatableContainer ) ) { + this.setIdealSize( this.width || this.$floatableContainer.width() ); + } + if ( visible ) { + // Reset position before showing the popup again. It's possible we no longer need to flip + // (e.g. if the user scrolled). + this.setVerticalPosition( 'below' ); + } } // Parent method @@ -7300,6 +7593,22 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { this.togglePositioning( !!this.$floatableContainer ); this.toggleClipping( true ); + if ( this.isClippedVertically() ) { + // If opening the menu downwards causes it to be clipped, flip it to open upwards instead + belowHeight = this.$element.height(); + this.setVerticalPosition( 'above' ); + if ( this.isClippedVertically() ) { + // If opening upwards also causes it to be clipped, flip it to open in whichever direction + // we have more space + aboveHeight = this.$element.height(); + if ( aboveHeight < belowHeight ) { + this.setVerticalPosition( 'below' ); + } + } + } + // Note that we do not flip the menu's opening direction if the clipping changes + // later (e.g. after the user scrolls), that seems like it would be annoying + this.$focusOwner.attr( 'aria-expanded', 'true' ); if ( this.getSelectedItem() ) { @@ -7483,7 +7792,7 @@ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { * Handle menu toggle events. * * @private - * @param {boolean} isVisible Menu toggle event + * @param {boolean} isVisible Open state of the menu */ OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) { this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible ); @@ -7517,10 +7826,15 @@ OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) { !this.isDisabled() && ( e.which === OO.ui.Keys.ENTER || + ( + e.which === OO.ui.Keys.SPACE && + // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress. + // Space only closes the menu is the user is not typing to search. + this.menu.keyPressBuffer === '' + ) || ( !this.menu.isVisible() && ( - e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.UP || e.which === OO.ui.Keys.DOWN ) @@ -8149,51 +8463,6 @@ OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () { this.focus(); }; -/** - * FloatingMenuSelectWidget was a menu that would stick under a specified - * container, even when it is inserted elsewhere in the document. - * This functionality is now included in MenuSelectWidget, and FloatingMenuSelectWidget - * is preserved for backwards-compatibility. - * - * @class - * @extends OO.ui.MenuSelectWidget - * @deprecated since v0.21.3, use MenuSelectWidget instead. - * - * @constructor - * @param {OO.ui.Widget} [inputWidget] Widget to provide the menu for. - * Deprecated, omit this parameter and specify `$container` instead. - * @param {Object} [config] Configuration options - * @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under - */ -OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) { - OO.ui.warnDeprecation( 'FloatingMenuSelectWidget is deprecated. Use the MenuSelectWidget instead.' ); - - // Allow 'inputWidget' parameter and config for backwards compatibility - if ( OO.isPlainObject( inputWidget ) && config === undefined ) { - config = inputWidget; - inputWidget = config.inputWidget; - } - - // Configuration initialization - config = config || {}; - - // Properties - this.inputWidget = inputWidget; // For backwards compatibility - this.$container = config.$floatableContainer || config.$container || this.inputWidget.$element; - - // Parent constructor - OO.ui.FloatingMenuSelectWidget.parent.call( this, $.extend( {}, config, { $floatableContainer: this.$container } ) ); - - // Initialization - this.$element.addClass( 'oo-ui-floatingMenuSelectWidget' ); - // For backwards compatibility - this.$element.addClass( 'oo-ui-textInputMenuSelectWidget' ); -}; - -/* Setup */ - -OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget ); - /** * Progress bars visually display the status of an operation, such as a download, * and can be either determinate or indeterminate: @@ -8897,7 +9166,6 @@ OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) { * * @class * @extends OO.ui.InputWidget - * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {Object} [config] Configuration options @@ -8908,20 +9176,12 @@ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) { // Configuration initialization config = config || {}; - // See InputWidget#reusePreInfuseDOM about config.$input - if ( config.$input ) { - config.$input.addClass( 'oo-ui-element-hidden' ); - } - // Properties (must be done before parent constructor which calls #setDisabled) this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown ); // Parent constructor OO.ui.DropdownInputWidget.parent.call( this, config ); - // Mixin constructors - OO.ui.mixin.TitledElement.call( this, config ); - // Events this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } ); @@ -8939,7 +9199,6 @@ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget ); -OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement ); /* Methods */ @@ -8948,7 +9207,7 @@ OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement ); * @protected */ OO.ui.DropdownInputWidget.prototype.getInputElement = function () { - return $( '' ).attr( 'type', 'hidden' ); + return $( '` + options.forEach( function ( opt ) { + var optValue, $optionNode, optionWidget; + + if ( opt.optgroup === undefined ) { + optValue = widget.cleanUpValue( opt.data ); + + $optionNode = $( '' ) + .attr( 'label', opt.optgroup ); + optionWidget = new OO.ui.MenuSectionOptionWidget( { + label: opt.optgroup + } ); + + widget.$input.append( $optionNode ); + $optionsContainer = $optionNode; + optionWidgets.push( optionWidget ); + } + } ); + this.dropdownWidget.getMenu().addItems( optionWidgets ); // Restore the previous value, or reset to something sensible if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) { @@ -9576,6 +9853,8 @@ OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () { * the value or placeholder text: `'before'` or `'after'` * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`. * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field + * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means + * leaving it up to the browser). * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' * (the value must contain only numbers); when RegExp, a regular expression that must match the @@ -9655,6 +9934,9 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { }.bind( this ) } ); } + if ( config.spellcheck !== undefined ) { + this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' ); + } if ( this.label ) { this.isWaitingToBeAttached = true; this.installParentChangeDetector(); @@ -9927,7 +10209,7 @@ OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) { * * @param {Object} config Configuration options * @return {string|null} - * @private + * @protected */ OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) { var allowedTypes = [ @@ -10255,10 +10537,6 @@ OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) { icon: 'search' }, config ); - // Set type to text so that TextInputWidget doesn't - // get stuck in an infinite loop. - config.type = 'text'; - // Parent constructor OO.ui.SearchInputWidget.parent.call( this, config ); @@ -10268,7 +10546,6 @@ OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) { } ); // Initialization - this.$element.addClass( 'oo-ui-textInputWidget-type-search' ); this.updateSearchIndicator(); this.connect( this, { disable: 'onDisable' @@ -10285,8 +10562,8 @@ OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget ); * @inheritdoc * @protected */ -OO.ui.SearchInputWidget.prototype.getInputElement = function () { - return $( '' ).attr( 'type', 'search' ); +OO.ui.SearchInputWidget.prototype.getSaneType = function () { + return 'search'; }; /** @@ -10657,7 +10934,8 @@ OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) { this.menu.connect( this, { choose: 'onMenuChoose', add: 'onMenuItemsChange', - remove: 'onMenuItemsChange' + remove: 'onMenuItemsChange', + toggle: 'onMenuToggle' } ); // Initialization @@ -10714,7 +10992,7 @@ OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) { var match = this.menu.getItemFromData( value ); this.menu.selectItem( match ); - if ( this.menu.getHighlightedItem() ) { + if ( this.menu.findHighlightedItem() ) { this.menu.highlightItem( match ); } @@ -10762,12 +11040,22 @@ OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) { OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () { var match = this.menu.getItemFromData( this.getValue() ); this.menu.selectItem( match ); - if ( this.menu.getHighlightedItem() ) { + if ( this.menu.findHighlightedItem() ) { this.menu.highlightItem( match ); } this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() ); }; +/** + * Handle menu toggle events. + * + * @private + * @param {boolean} isVisible Open state of the menu + */ +OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) { + this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible ); +}; + /** * @inheritdoc */