diff options
Diffstat (limited to 'resources/lib/oojs-ui/oojs-ui.js')
-rw-r--r-- | resources/lib/oojs-ui/oojs-ui.js | 5869 |
1 files changed, 4717 insertions, 1152 deletions
diff --git a/resources/lib/oojs-ui/oojs-ui.js b/resources/lib/oojs-ui/oojs-ui.js index 9692d5cf..aeff69e0 100644 --- a/resources/lib/oojs-ui/oojs-ui.js +++ b/resources/lib/oojs-ui/oojs-ui.js @@ -1,12 +1,12 @@ /*! - * OOjs UI v0.11.3 + * OOjs UI v0.12.12 * https://www.mediawiki.org/wiki/OOjs_UI * - * Copyright 2011–2015 OOjs Team and other contributors. + * Copyright 2011–2015 OOjs UI Team and other contributors. * Released under the MIT license * http://oojs.mit-license.org * - * Date: 2015-05-12T12:15:37Z + * Date: 2015-10-13T20:38:18Z */ ( function ( OO ) { @@ -45,36 +45,101 @@ OO.ui.Keys = { }; /** + * @property {Number} + */ +OO.ui.elementId = 0; + +/** + * Generate a unique ID for element + * + * @return {String} [id] + */ +OO.ui.generateElementId = function () { + OO.ui.elementId += 1; + return 'oojsui-' + OO.ui.elementId; +}; + +/** * Check if an element is focusable. * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14 * * @param {jQuery} element Element to test - * @return {Boolean} [description] + * @return {boolean} */ OO.ui.isFocusableElement = function ( $element ) { - var node = $element[0], - nodeName = node.nodeName.toLowerCase(), - // Check if the element have tabindex set - isInElementGroup = /^(input|select|textarea|button|object)$/.test( nodeName ), - // Check if the element is a link with href or if it has tabindex - isOtherElement = ( - ( nodeName === 'a' && node.href ) || - !isNaN( $element.attr( 'tabindex' ) ) - ), - // Check if the element is visible - isVisible = ( - // This is quicker than calling $element.is( ':visible' ) - $.expr.filters.visible( node ) && - // Check that all parents are visible - !$element.parents().addBack().filter( function () { - return $.css( this, 'visibility' ) === 'hidden'; - } ).length - ); + var nodeName, + element = $element[ 0 ]; - return ( - ( isInElementGroup ? !node.disabled : isOtherElement ) && - isVisible - ); + // Anything disabled is not focusable + if ( element.disabled ) { + return false; + } + + // Check if the element is visible + if ( !( + // This is quicker than calling $element.is( ':visible' ) + $.expr.filters.visible( element ) && + // Check that all parents are visible + !$element.parents().addBack().filter( function () { + return $.css( this, 'visibility' ) === 'hidden'; + } ).length + ) ) { + return false; + } + + // Check if the element is ContentEditable, which is the string 'true' + if ( element.contentEditable === 'true' ) { + return true; + } + + // Anything with a non-negative numeric tabIndex is focusable. + // Use .prop to avoid browser bugs + if ( $element.prop( 'tabIndex' ) >= 0 ) { + return true; + } + + // Some element types are naturally focusable + // (indexOf is much faster than regex in Chrome and about the + // same in FF: https://jsperf.com/regex-vs-indexof-array2) + nodeName = element.nodeName.toLowerCase(); + if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) { + return true; + } + + // Links and areas are focusable if they have an href + if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) { + return true; + } + + return false; +}; + +/** + * Find a focusable child + * + * @param {jQuery} $container Container to search in + * @param {boolean} [backwards] Search backwards + * @return {jQuery} Focusable child, an empty jQuery object if none found + */ +OO.ui.findFocusable = function ( $container, backwards ) { + var $focusable = $( [] ), + // $focusableCandidates is a superset of things that + // could get matched by isFocusableElement + $focusableCandidates = $container + .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' ); + + if ( backwards ) { + $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates ); + } + + $focusableCandidates.each( function () { + var $this = $( this ); + if ( OO.ui.isFocusableElement( $this ) ) { + $focusable = $this; + return false; + } + } ); + return $focusable; }; /** @@ -183,6 +248,38 @@ OO.ui.debounce = function ( func, wait, immediate ) { }; /** + * Proxy for `node.addEventListener( eventName, handler, true )`, if the browser supports it. + * Otherwise falls back to non-capturing event listeners. + * + * @param {HTMLElement} node + * @param {string} eventName + * @param {Function} handler + */ +OO.ui.addCaptureEventListener = function ( node, eventName, handler ) { + if ( node.addEventListener ) { + node.addEventListener( eventName, handler, true ); + } else { + node.attachEvent( 'on' + eventName, handler ); + } +}; + +/** + * Proxy for `node.removeEventListener( eventName, handler, true )`, if the browser supports it. + * Otherwise falls back to non-capturing event listeners. + * + * @param {HTMLElement} node + * @param {string} eventName + * @param {Function} handler + */ +OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) { + if ( node.addEventListener ) { + node.removeEventListener( eventName, handler, true ); + } else { + node.detachEvent( 'on' + eventName, handler ); + } +}; + +/** * Reconstitute a JavaScript object corresponding to a widget created by * the PHP implementation. * @@ -230,7 +327,15 @@ OO.ui.infuse = function ( idOrNode ) { // Label for process dialog retry action button, visible when describing only recoverable errors 'ooui-dialog-process-retry': 'Try again', // Label for process dialog retry action button, visible when describing only warnings - 'ooui-dialog-process-continue': 'Continue' + 'ooui-dialog-process-continue': 'Continue', + // Label for the file selection widget's select file button + 'ooui-selectfile-button-select': 'Select a file', + // Label for the file selection widget if file selection is not supported + 'ooui-selectfile-not-supported': 'File selection is not supported', + // Label for the file selection widget when no file is currently selected + 'ooui-selectfile-placeholder': 'No file is selected', + // Label for the file selection widget's drop target + 'ooui-selectfile-dragdrop-placeholder': 'Drop file here' }; /** @@ -297,10 +402,102 @@ OO.ui.infuse = function ( idOrNode ) { return msg; }; + /** + * @param {string} url + * @return {boolean} + */ + OO.ui.isSafeUrl = function ( url ) { + var protocol, + // Keep in sync with php/Tag.php + whitelist = [ + 'bitcoin:', 'ftp:', 'ftps:', 'geo:', 'git:', 'gopher:', 'http:', 'https:', 'irc:', 'ircs:', + 'magnet:', 'mailto:', 'mms:', 'news:', 'nntp:', 'redis:', 'sftp:', 'sip:', 'sips:', 'sms:', 'ssh:', + 'svn:', 'tel:', 'telnet:', 'urn:', 'worldwind:', 'xmpp:' + ]; + + if ( url.indexOf( ':' ) === -1 ) { + // No protocol, safe + return true; + } + + protocol = url.split( ':', 1 )[ 0 ] + ':'; + if ( !protocol.match( /^([A-za-z0-9\+\.\-])+:/ ) ) { + // Not a valid protocol, safe + return true; + } + + // Safe if in the whitelist + return whitelist.indexOf( protocol ) !== -1; + }; + } )(); +/*! + * Mixin namespace. + */ + /** - * Element that can be marked as pending. + * Namespace for OOjs UI mixins. + * + * Mixins are named according to the type of object they are intended to + * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be + * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget + * is intended to be mixed in to an instance of OO.ui.Widget. + * + * @class + * @singleton + */ +OO.ui.mixin = {}; + +/** + * PendingElement is a mixin that is used to create elements that notify users that something is happening + * and that they should wait before proceeding. The pending state is visually represented with a pending + * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input + * field of a {@link OO.ui.TextInputWidget text input widget}. + * + * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when + * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used + * in process dialogs. + * + * @example + * function MessageDialog( config ) { + * MessageDialog.parent.call( this, config ); + * } + * OO.inheritClass( MessageDialog, OO.ui.MessageDialog ); + * + * MessageDialog.static.actions = [ + * { action: 'save', label: 'Done', flags: 'primary' }, + * { label: 'Cancel', flags: 'safe' } + * ]; + * + * MessageDialog.prototype.initialize = function () { + * MessageDialog.parent.prototype.initialize.apply( this, arguments ); + * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } ); + * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' ); + * this.$body.append( this.content.$element ); + * }; + * MessageDialog.prototype.getBodyHeight = function () { + * return 100; + * } + * MessageDialog.prototype.getActionProcess = function ( action ) { + * var dialog = this; + * if ( action === 'save' ) { + * dialog.getActions().get({actions: 'save'})[0].pushPending(); + * return new OO.ui.Process() + * .next( 1000 ) + * .next( function () { + * dialog.getActions().get({actions: 'save'})[0].popPending(); + * } ); + * } + * return MessageDialog.parent.prototype.getActionProcess.call( this, action ); + * }; + * + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * + * var dialog = new MessageDialog(); + * windowManager.addWindows( [ dialog ] ); + * windowManager.openWindow( dialog ); * * @abstract * @class @@ -309,7 +506,7 @@ OO.ui.infuse = function ( idOrNode ) { * @param {Object} [config] Configuration options * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element */ -OO.ui.PendingElement = function OoUiPendingElement( config ) { +OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) { // Configuration initialization config = config || {}; @@ -323,7 +520,7 @@ OO.ui.PendingElement = function OoUiPendingElement( config ) { /* Setup */ -OO.initClass( OO.ui.PendingElement ); +OO.initClass( OO.ui.mixin.PendingElement ); /* Methods */ @@ -332,7 +529,7 @@ OO.initClass( OO.ui.PendingElement ); * * @param {jQuery} $pending The element to set to pending. */ -OO.ui.PendingElement.prototype.setPendingElement = function ( $pending ) { +OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) { if ( this.$pending ) { this.$pending.removeClass( 'oo-ui-pendingElement-pending' ); } @@ -344,20 +541,21 @@ OO.ui.PendingElement.prototype.setPendingElement = function ( $pending ) { }; /** - * Check if input is pending. + * Check if an element is pending. * - * @return {boolean} + * @return {boolean} Element is pending */ -OO.ui.PendingElement.prototype.isPending = function () { +OO.ui.mixin.PendingElement.prototype.isPending = function () { return !!this.pending; }; /** - * Increase the pending stack. + * Increase the pending counter. The pending state will remain active until the counter is zero + * (i.e., the number of calls to #pushPending and #popPending is the same). * * @chainable */ -OO.ui.PendingElement.prototype.pushPending = function () { +OO.ui.mixin.PendingElement.prototype.pushPending = function () { if ( this.pending === 0 ) { this.$pending.addClass( 'oo-ui-pendingElement-pending' ); this.updateThemeClasses(); @@ -368,13 +566,12 @@ OO.ui.PendingElement.prototype.pushPending = function () { }; /** - * Reduce the pending stack. - * - * Clamped at zero. + * Decrease the pending counter. The pending state will remain active until the counter is zero + * (i.e., the number of calls to #pushPending and #popPending is the same). * * @chainable */ -OO.ui.PendingElement.prototype.popPending = function () { +OO.ui.mixin.PendingElement.prototype.popPending = function () { if ( this.pending === 1 ) { this.$pending.removeClass( 'oo-ui-pendingElement-pending' ); this.updateThemeClasses(); @@ -399,7 +596,7 @@ OO.ui.PendingElement.prototype.popPending = function () { * @example * // Example: An action set used in a process dialog * function MyProcessDialog( config ) { - * MyProcessDialog.super.call( this, config ); + * MyProcessDialog.parent.call( this, config ); * } * OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog ); * MyProcessDialog.static.title = 'An action set in a process dialog'; @@ -412,7 +609,7 @@ OO.ui.PendingElement.prototype.popPending = function () { * ]; * * MyProcessDialog.prototype.initialize = function () { - * MyProcessDialog.super.prototype.initialize.apply( this, arguments ); + * MyProcessDialog.parent.prototype.initialize.apply( this, arguments ); * this.panel1 = new OO.ui.PanelLayout( { padded: true, expanded: false } ); * this.panel1.$element.append( '<p>This dialog uses an action set (continue, help, cancel, back) configured with modes. This is edit mode. Click \'help\' to see help mode.</p>' ); * this.panel2 = new OO.ui.PanelLayout( { padded: true, expanded: false } ); @@ -423,7 +620,7 @@ OO.ui.PendingElement.prototype.popPending = function () { * this.$body.append( this.stackLayout.$element ); * }; * MyProcessDialog.prototype.getSetupProcess = function ( data ) { - * return MyProcessDialog.super.prototype.getSetupProcess.call( this, data ) + * return MyProcessDialog.parent.prototype.getSetupProcess.call( this, data ) * .next( function () { * this.actions.setMode( 'edit' ); * }, this ); @@ -441,7 +638,7 @@ OO.ui.PendingElement.prototype.popPending = function () { * dialog.close(); * } ); * } - * return MyProcessDialog.super.prototype.getActionProcess.call( this, action ); + * return MyProcessDialog.parent.prototype.getActionProcess.call( this, action ); * }; * MyProcessDialog.prototype.getBodyHeight = function () { * return this.panel1.$element.outerHeight( true ); @@ -908,7 +1105,8 @@ OO.ui.ActionSet.prototype.organize = function () { * @cfg {Array} [content] An array of content elements to append (after #text). * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML. * Instances of OO.ui.Element will have their $element appended. - * @cfg {jQuery} [$content] Content elements to append (after #text) + * @cfg {jQuery} [$content] Content elements to append (after #text). + * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName. * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object). * Data can also be specified with the #setData method. */ @@ -923,8 +1121,7 @@ OO.ui.Element = function OoUiElement( config ) { this.$element = config.$element || $( document.createElement( this.getTagName() ) ); this.elementGroup = null; - this.debouncedUpdateThemeClassesHandler = this.debouncedUpdateThemeClasses.bind( this ); - this.updateThemeClassesPending = false; + this.debouncedUpdateThemeClassesHandler = OO.ui.debounce( this.debouncedUpdateThemeClasses ); // Initialization if ( Array.isArray( config.classes ) ) { @@ -991,7 +1188,7 @@ OO.ui.Element.static.tagName = 'div'; * DOM node. */ OO.ui.Element.static.infuse = function ( idOrNode ) { - var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, true ); + var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false ); // Verify that the type matches up. // FIXME: uncomment after T89721 is fixed (see T90929) /* @@ -1007,12 +1204,14 @@ OO.ui.Element.static.infuse = function ( idOrNode ) { * extra property so that only the top-level invocation touches the DOM. * @private * @param {string|HTMLElement|jQuery} idOrNode - * @param {boolean} top True only for top-level invocation. + * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved + * when the top-level widget of this infusion is inserted into DOM, + * replacing the original node; or false for top-level invocation. * @return {OO.ui.Element} */ -OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { +OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) { // look for a cached result of a previous infusion. - var id, $elem, data, cls, obj; + var id, $elem, data, cls, parts, parent, obj, top, state; if ( typeof idOrNode === 'string' ) { id = idOrNode; $elem = $( document.getElementById( id ) ); @@ -1020,7 +1219,10 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { $elem = $( idOrNode ); id = $elem.attr( 'id' ); } - data = $elem.data( 'ooui-infused' ); + if ( !$elem.length ) { + throw new Error( 'Widget not found: ' + id ); + } + data = $elem.data( 'ooui-infused' ) || $elem[ 0 ].oouiInfused; if ( data ) { // cached! if ( data === true ) { @@ -1028,9 +1230,6 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { } return data; } - if ( !$elem.length ) { - throw new Error( 'Widget not found: ' + id ); - } data = $elem.attr( 'data-ooui' ); if ( !data ) { throw new Error( 'No infusion data found: ' + id ); @@ -1047,16 +1246,43 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { // Special case: this is a raw Tag; wrap existing node, don't rebuild. return new OO.ui.Element( { $element: $elem } ); } - cls = OO.ui[data._]; - if ( !cls ) { - throw new Error( 'Unknown widget type: ' + id ); + 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._ ); + } + } + + // Verify that we're creating an OO.ui.Element instance + parent = cls.parent; + + while ( parent !== undefined ) { + if ( parent === OO.ui.Element ) { + // Safe + break; + } + + parent = parent.parent; + } + + if ( parent !== OO.ui.Element ) { + throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ ); + } + + if ( domPromise === false ) { + top = $.Deferred(); + domPromise = top.promise(); } $elem.data( 'ooui-infused', true ); // prevent loops data.id = id; // implicit data = OO.copy( data, null, function deserialize( value ) { if ( OO.isPlainObject( value ) ) { if ( value.tag ) { - return OO.ui.Element.static.unsafeInfuse( value.tag, false ); + return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise ); } if ( value.html ) { return new OO.ui.HtmlSnippet( value.html ); @@ -1065,13 +1291,22 @@ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { } ); // jscs:disable requireCapitalizedConstructors obj = new cls( data ); // rebuild widget + // pick up dynamic state, like focus, value of form inputs, scroll position, etc. + state = obj.gatherPreInfuseState( $elem ); // now replace old DOM with this new DOM. if ( top ) { $elem.replaceWith( obj.$element ); + // This element is now gone from the DOM, but if anyone is holding a reference to it, + // let's allow them to OO.ui.infuse() it and do what they expect (T105828). + // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design. + $elem[ 0 ].oouiInfused = obj; + top.resolve(); } obj.$element.data( 'ooui-infused', obj ); // set the 'data-ooui' attribute so we can identify infused widgets obj.$element.attr( 'data-ooui', '' ); + // restore dynamic state after the new element is inserted into DOM + domPromise.done( obj.restorePreInfuseState.bind( obj, state ) ); return obj; }; @@ -1128,6 +1363,8 @@ OO.ui.Element.static.getDocument = function ( obj ) { */ OO.ui.Element.static.getWindow = function ( obj ) { var doc = this.getDocument( obj ); + // Support: IE 8 + // Standard Document.defaultView is IE9+ return doc.parentWindow || doc.defaultView; }; @@ -1243,9 +1480,13 @@ OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) { */ OO.ui.Element.static.getBorders = function ( el ) { var doc = el.ownerDocument, + // Support: IE 8 + // Standard Document.defaultView is IE9+ win = doc.parentWindow || doc.defaultView, style = win && win.getComputedStyle ? win.getComputedStyle( el, null ) : + // Support: IE 8 + // Standard getComputedStyle() is IE9+ el.currentStyle, $el = $( el ), top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0, @@ -1271,6 +1512,8 @@ OO.ui.Element.static.getBorders = function ( el ) { OO.ui.Element.static.getDimensions = function ( el ) { var $el, $win, doc = el.ownerDocument || el.document, + // Support: IE 8 + // Standard Document.defaultView is IE9+ win = doc.parentWindow || doc.defaultView; if ( win === el || el === doc.documentElement ) { @@ -1351,11 +1594,12 @@ OO.ui.Element.static.getRootScrollableElement = function ( el ) { */ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) { var i, val, - props = [ 'overflow' ], + // props = [ 'overflow' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091 + props = [ 'overflow-x', 'overflow-y' ], $parent = $( el ).parent(); if ( dimension === 'x' || dimension === 'y' ) { - props.push( 'overflow-' + dimension ); + props = [ 'overflow-' + dimension ]; } while ( $parent.length ) { @@ -1386,16 +1630,18 @@ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) * @param {Function} [config.complete] Function to call when scrolling completes */ OO.ui.Element.static.scrollIntoView = function ( el, config ) { + var rel, anim, callback, sc, $sc, eld, scd, $win; + // Configuration initialization config = config || {}; - var rel, anim = {}, - callback = typeof config.complete === 'function' && config.complete, - sc = this.getClosestScrollableContainer( el, config.direction ), - $sc = $( sc ), - eld = this.getDimensions( el ), - scd = this.getDimensions( sc ), - $win = $( this.getWindow( el ) ); + anim = {}; + callback = typeof config.complete === 'function' && config.complete; + sc = this.getClosestScrollableContainer( el, config.direction ); + $sc = $( sc ); + eld = this.getDimensions( el ); + scd = this.getDimensions( sc ); + $win = $( this.getWindow( el ) ); // Compute the distances between the edges of el and the edges of the scroll viewport if ( $sc.is( 'html, body' ) ) { @@ -1458,7 +1704,10 @@ OO.ui.Element.static.scrollIntoView = function ( el, config ) { * @param {HTMLElement} el Element to reconsider the scrollbars on */ OO.ui.Element.static.reconsiderScrollbars = function ( el ) { - var i, len, nodes = []; + var i, len, scrollLeft, scrollTop, nodes = []; + // Save scroll position + scrollLeft = el.scrollLeft; + scrollTop = el.scrollTop; // Detach all children while ( el.firstChild ) { nodes.push( el.firstChild ); @@ -1470,6 +1719,9 @@ OO.ui.Element.static.reconsiderScrollbars = function ( el ) { for ( i = 0, len = nodes.length; i < len; i++ ) { el.appendChild( nodes[ i ] ); } + // Restore scroll position (no-op if scrollbars disappeared) + el.scrollLeft = scrollLeft; + el.scrollTop = scrollTop; }; /* Methods */ @@ -1550,18 +1802,16 @@ OO.ui.Element.prototype.supports = function ( methods ) { * guaranteeing that theme updates do not occur within an element's constructor */ OO.ui.Element.prototype.updateThemeClasses = function () { - if ( !this.updateThemeClassesPending ) { - this.updateThemeClassesPending = true; - setTimeout( this.debouncedUpdateThemeClassesHandler ); - } + this.debouncedUpdateThemeClassesHandler(); }; /** * @private + * @localdoc This method is called directly from the QUnit tests instead of #updateThemeClasses, to + * make them synchronous. */ OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () { OO.ui.theme.updateElementClasses( this ); - this.updateThemeClassesPending = false; }; /** @@ -1612,7 +1862,7 @@ OO.ui.Element.prototype.getClosestScrollableElementContainer = function () { /** * Get group element is in. * - * @return {OO.ui.GroupElement|null} Group element, null if none + * @return {OO.ui.mixin.GroupElement|null} Group element, null if none */ OO.ui.Element.prototype.getElementGroup = function () { return this.elementGroup; @@ -1621,7 +1871,7 @@ OO.ui.Element.prototype.getElementGroup = function () { /** * Set group element is in. * - * @param {OO.ui.GroupElement|null} group Group element, null if none + * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none * @chainable */ OO.ui.Element.prototype.setElementGroup = function ( group ) { @@ -1639,11 +1889,40 @@ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) { }; /** + * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node + * (and its children) that represent an Element of the same type and configuration as the current + * one, generated by the PHP implementation. + * + * This method is called just before `node` is detached from the DOM. The return value of this + * function will be passed to #restorePreInfuseState after this widget's #$element is inserted into + * DOM to replace `node`. + * + * @protected + * @param {HTMLElement} node + * @return {Object} + */ +OO.ui.Element.prototype.gatherPreInfuseState = function () { + return {}; +}; + +/** + * Restore the pre-infusion dynamic state for this widget. + * + * This method is called after #$element has been inserted into DOM. The parameter is the return + * value of #gatherPreInfuseState. + * + * @protected + * @param {Object} state + */ +OO.ui.Element.prototype.restorePreInfuseState = function () { +}; + +/** * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined. * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout}, * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout}, - * and {@link OO.ui.BookletLayout BookletLayout} for more information and examples. + * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples. * * @abstract * @class @@ -1658,7 +1937,7 @@ OO.ui.Layout = function OoUiLayout( config ) { config = config || {}; // Parent constructor - OO.ui.Layout.super.call( this, config ); + OO.ui.Layout.parent.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); @@ -1692,7 +1971,7 @@ OO.ui.Widget = function OoUiWidget( config ) { config = $.extend( { disabled: false }, config ); // Parent constructor - OO.ui.Widget.super.call( this, config ); + OO.ui.Widget.parent.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); @@ -1711,12 +1990,26 @@ OO.ui.Widget = function OoUiWidget( config ) { OO.inheritClass( OO.ui.Widget, OO.ui.Element ); OO.mixinClass( OO.ui.Widget, OO.EventEmitter ); +/* Static Properties */ + +/** + * Whether this widget will behave reasonably when wrapped in a HTML `<label>`. If this is true, + * wrappers such as OO.ui.FieldLayout may use a `<label>` instead of implementing own label click + * handling. + * + * @static + * @inheritable + * @property {boolean} + */ +OO.ui.Widget.static.supportsSimpleLabel = false; + /* Events */ /** * @event disable * - * A 'disable' event is emitted when a widget is disabled. + * A 'disable' event is emitted when the disabled state of the widget changes + * (i.e. on disable **and** enable). * * @param {boolean} disabled Widget is disabled */ @@ -1825,7 +2118,7 @@ OO.ui.Window = function OoUiWindow( config ) { config = config || {}; // Parent constructor - OO.ui.Window.super.call( this, config ); + OO.ui.Window.parent.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); @@ -1837,6 +2130,10 @@ OO.ui.Window = function OoUiWindow( config ) { this.$overlay = $( '<div>' ); this.$content = $( '<div>' ); + this.$focusTrapBefore = $( '<div>' ).prop( 'tabIndex', 0 ); + this.$focusTrapAfter = $( '<div>' ).prop( 'tabIndex', 0 ); + this.$focusTraps = this.$focusTrapBefore.add( this.$focusTrapAfter ); + // Initialization this.$overlay.addClass( 'oo-ui-window-overlay' ); this.$content @@ -1844,7 +2141,7 @@ OO.ui.Window = function OoUiWindow( config ) { .attr( 'tabindex', 0 ); this.$frame .addClass( 'oo-ui-window-frame' ) - .append( this.$content ); + .append( this.$focusTrapBefore, this.$content, this.$focusTrapAfter ); this.$element .addClass( 'oo-ui-window' ) @@ -1962,7 +2259,27 @@ OO.ui.Window.prototype.getManager = function () { * @return {string} Symbolic name of the size: `small`, `medium`, `large`, `larger`, `full` */ OO.ui.Window.prototype.getSize = function () { - return this.size; + var viewport = OO.ui.Element.static.getDimensions( this.getElementWindow() ), + sizes = this.manager.constructor.static.sizes, + size = this.size; + + if ( !sizes[ size ] ) { + size = this.manager.constructor.static.defaultSize; + } + if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) { + size = 'full'; + } + + return size; +}; + +/** + * Get the size properties associated with the current window size + * + * @return {Object} Size properties + */ +OO.ui.Window.prototype.getSizeProperties = function () { + return this.manager.constructor.static.sizes[ this.getSize() ]; }; /** @@ -2048,7 +2365,7 @@ OO.ui.Window.prototype.getBodyHeight = function () { * @return {string} Directionality: `'ltr'` or `'rtl'` */ OO.ui.Window.prototype.getDir = function () { - return this.dir; + return OO.ui.Element.static.getDir( this.$content ) || 'ltr'; }; /** @@ -2243,7 +2560,6 @@ OO.ui.Window.prototype.initialize = function () { this.$head = $( '<div>' ); this.$body = $( '<div>' ); this.$foot = $( '<div>' ); - this.dir = OO.ui.Element.static.getDir( this.$content ) || 'ltr'; this.$document = $( this.getElementDocument() ); // Events @@ -2259,6 +2575,21 @@ OO.ui.Window.prototype.initialize = function () { }; /** + * Called when someone tries to focus the hidden element at the end of the dialog. + * Sends focus back to the start of the dialog. + * + * @param {jQuery.Event} event Focus event + */ +OO.ui.Window.prototype.onFocusTrapFocused = function ( event ) { + if ( this.$focusTrapBefore.is( event.target ) ) { + OO.ui.findFocusable( this.$content, true ).focus(); + } else { + // this.$content is the part of the focus cycle, and is the first focusable element + this.$content.focus(); + } +}; + +/** * Open the window. * * This method is a wrapper around a call to the window manager’s {@link OO.ui.WindowManager#openWindow openWindow} @@ -2318,6 +2649,9 @@ OO.ui.Window.prototype.setup = function ( data ) { this.toggle( true ); + this.focusTrapHandler = OO.ui.bind( this.onFocusTrapFocused, this ); + this.$focusTraps.on( 'focus', this.focusTrapHandler ); + this.getSetupProcess( data ).execute().done( function () { // Force redraw by asking the browser to measure the elements' widths win.$element.addClass( 'oo-ui-window-active oo-ui-window-setup' ).width(); @@ -2400,6 +2734,7 @@ OO.ui.Window.prototype.teardown = function ( data ) { // Force redraw by asking the browser to measure the elements' widths win.$element.removeClass( 'oo-ui-window-active oo-ui-window-setup' ).width(); win.$content.removeClass( 'oo-ui-window-content-setup' ).width(); + win.$focusTraps.off( 'focus', win.focusTrapHandler ); win.toggle( false ); } ); }; @@ -2414,11 +2749,11 @@ OO.ui.Window.prototype.teardown = function ( data ) { * @example * // A simple dialog window. * function MyDialog( config ) { - * MyDialog.super.call( this, config ); + * MyDialog.parent.call( this, config ); * } * OO.inheritClass( MyDialog, OO.ui.Dialog ); * MyDialog.prototype.initialize = function () { - * MyDialog.super.prototype.initialize.call( this ); + * MyDialog.parent.prototype.initialize.call( this ); * this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); * this.content.$element.append( '<p>A simple dialog window. Press \'Esc\' to close.</p>' ); * this.$body.append( this.content.$element ); @@ -2441,23 +2776,23 @@ OO.ui.Window.prototype.teardown = function ( data ) { * @abstract * @class * @extends OO.ui.Window - * @mixins OO.ui.PendingElement + * @mixins OO.ui.mixin.PendingElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.Dialog = function OoUiDialog( config ) { // Parent constructor - OO.ui.Dialog.super.call( this, config ); + OO.ui.Dialog.parent.call( this, config ); // Mixin constructors - OO.ui.PendingElement.call( this ); + OO.ui.mixin.PendingElement.call( this ); // Properties this.actions = new OO.ui.ActionSet(); this.attachedActions = []; this.currentAction = null; - this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this ); + this.onDialogKeyDownHandler = this.onDialogKeyDown.bind( this ); // Events this.actions.connect( this, { @@ -2475,7 +2810,7 @@ OO.ui.Dialog = function OoUiDialog( config ) { /* Setup */ OO.inheritClass( OO.ui.Dialog, OO.ui.Window ); -OO.mixinClass( OO.ui.Dialog, OO.ui.PendingElement ); +OO.mixinClass( OO.ui.Dialog, OO.ui.mixin.PendingElement ); /* Static Properties */ @@ -2497,7 +2832,7 @@ OO.ui.Dialog.static.name = ''; /** * The dialog title. * - * The title can be specified as a plaintext string, a {@link OO.ui.LabelElement Label} node, or a function + * The title can be specified as a plaintext string, a {@link OO.ui.mixin.LabelElement Label} node, or a function * that will produce a Label node or string. The title can also be specified with data passed to the * constructor (see #getSetupProcess). In this case, the static value will be overriden. * @@ -2540,7 +2875,7 @@ OO.ui.Dialog.static.escapable = true; * @private * @param {jQuery.Event} e Key down event */ -OO.ui.Dialog.prototype.onDocumentKeyDown = function ( e ) { +OO.ui.Dialog.prototype.onDialogKeyDown = function ( e ) { if ( e.which === OO.ui.Keys.ESCAPE ) { this.close(); e.preventDefault(); @@ -2626,7 +2961,7 @@ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { data = data || {}; // Parent method - return OO.ui.Dialog.super.prototype.getSetupProcess.call( this, data ) + return OO.ui.Dialog.parent.prototype.getSetupProcess.call( this, data ) .next( function () { var config = this.constructor.static, actions = data.actions !== undefined ? data.actions : config.actions; @@ -2637,7 +2972,7 @@ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { this.actions.add( this.getActionWidgets( actions ) ); if ( this.constructor.static.escapable ) { - this.$document.on( 'keydown', this.onDocumentKeyDownHandler ); + this.$element.on( 'keydown', this.onDialogKeyDownHandler ); } }, this ); }; @@ -2647,10 +2982,10 @@ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { */ OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) { // Parent method - return OO.ui.Dialog.super.prototype.getTeardownProcess.call( this, data ) + return OO.ui.Dialog.parent.prototype.getTeardownProcess.call( this, data ) .first( function () { if ( this.constructor.static.escapable ) { - this.$document.off( 'keydown', this.onDocumentKeyDownHandler ); + this.$element.off( 'keydown', this.onDialogKeyDownHandler ); } this.actions.clear(); @@ -2662,14 +2997,21 @@ OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) { * @inheritdoc */ OO.ui.Dialog.prototype.initialize = function () { + var titleId; + // Parent method - OO.ui.Dialog.super.prototype.initialize.call( this ); + OO.ui.Dialog.parent.prototype.initialize.call( this ); + + titleId = OO.ui.generateElementId(); // Properties - this.title = new OO.ui.LabelWidget(); + this.title = new OO.ui.LabelWidget( { + id: titleId + } ); // Initialization this.$content.addClass( 'oo-ui-dialog-content' ); + this.$element.attr( 'aria-labelledby', titleId ); this.setPendingElement( this.$head ); }; @@ -2789,7 +3131,7 @@ OO.ui.WindowManager = function OoUiWindowManager( config ) { config = config || {}; // Parent constructor - OO.ui.WindowManager.super.call( this, config ); + OO.ui.WindowManager.parent.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); @@ -3286,25 +3628,18 @@ OO.ui.WindowManager.prototype.clearWindows = function () { * @chainable */ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) { + var isFullscreen; + // Bypass for non-current, and thus invisible, windows if ( win !== this.currentWindow ) { return; } - var viewport = OO.ui.Element.static.getDimensions( win.getElementWindow() ), - sizes = this.constructor.static.sizes, - size = win.getSize(); - - if ( !sizes[ size ] ) { - size = this.constructor.static.defaultSize; - } - if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) { - size = 'full'; - } + isFullscreen = win.getSize() === 'full'; - this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', size === 'full' ); - this.$element.toggleClass( 'oo-ui-windowManager-floating', size !== 'full' ); - win.setDimensions( sizes[ size ] ); + this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', isFullscreen ); + this.$element.toggleClass( 'oo-ui-windowManager-floating', !isFullscreen ); + win.setDimensions( win.getSizeProperties() ); this.emit( 'resize', win ); @@ -3319,14 +3654,14 @@ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) { * @chainable */ OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) { - on = on === undefined ? !!this.globalEvents : !!on; - var scrollWidth, bodyMargin, $body = $( this.getElementDocument().body ), // We could have multiple window managers open so only modify // the body css at the bottom of the stack stackDepth = $body.data( 'windowManagerGlobalEvents' ) || 0 ; + on = on === undefined ? !!this.globalEvents : !!on; + if ( on ) { if ( !this.globalEvents ) { $( this.getElementWindow() ).on( { @@ -3694,7 +4029,13 @@ OO.ui.Process.prototype.next = function ( step, context ) { }; /** - * Factory for tools. + * A ToolFactory creates tools on demand. All tools ({@link OO.ui.Tool Tools}, {@link OO.ui.PopupTool PopupTools}, + * and {@link OO.ui.ToolGroupTool ToolGroupTools}) must be registered with a tool factory. Tools are + * registered by their symbolic name. See {@link OO.ui.Toolbar toolbars} for an example. + * + * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @class * @extends OO.Factory @@ -3702,7 +4043,7 @@ OO.ui.Process.prototype.next = function ( step, context ) { */ OO.ui.ToolFactory = function OoUiToolFactory() { // Parent constructor - OO.ui.ToolFactory.super.call( this ); + OO.ui.ToolFactory.parent.call( this ); }; /* Setup */ @@ -3815,18 +4156,29 @@ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { }; /** - * Factory for tool groups. + * ToolGroupFactories create {@link OO.ui.ToolGroup toolgroups} on demand. The toolgroup classes must + * specify a symbolic name and be registered with the factory. The following classes are registered by + * default: + * + * - {@link OO.ui.BarToolGroup BarToolGroups} (‘bar’) + * - {@link OO.ui.MenuToolGroup MenuToolGroups} (‘menu’) + * - {@link OO.ui.ListToolGroup ListToolGroups} (‘list’) + * + * See {@link OO.ui.Toolbar toolbars} for an example. + * + * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1]. * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * @class * @extends OO.Factory * @constructor */ OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() { + var i, l, defaultClasses; // Parent constructor OO.Factory.call( this ); - var i, l, - defaultClasses = this.constructor.static.getDefaultClasses(); + defaultClasses = this.constructor.static.getDefaultClasses(); // Register default toolgroups for ( i = 0, l = defaultClasses.length; i < l; i++ ) { @@ -3841,7 +4193,7 @@ OO.inheritClass( OO.ui.ToolGroupFactory, OO.Factory ); /* Static Methods */ /** - * Get a default set of classes to be registered on construction + * Get a default set of classes to be registered on construction. * * @return {Function[]} Default classes */ @@ -3882,7 +4234,7 @@ OO.initClass( OO.ui.Theme ); * @param {OO.ui.Element} element Element for which to get classes * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists */ -OO.ui.Theme.prototype.getElementClasses = function ( /* element */ ) { +OO.ui.Theme.prototype.getElementClasses = function () { return { on: [], off: [] }; }; @@ -3895,9 +4247,17 @@ OO.ui.Theme.prototype.getElementClasses = function ( /* element */ ) { * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists */ OO.ui.Theme.prototype.updateElementClasses = function ( element ) { - var classes = this.getElementClasses( element ); + var $elements = $( [] ), + classes = this.getElementClasses( element ); - element.$element + if ( element.$icon ) { + $elements = $elements.add( element.$icon ); + } + if ( element.$indicator ) { + $elements = $elements.add( element.$indicator ); + } + + $elements .removeClass( classes.off.join( ' ' ) ) .addClass( classes.on.join( ' ' ) ); }; @@ -3940,7 +4300,7 @@ OO.ui.Theme.prototype.updateElementClasses = function ( element ) { * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1 * to remove the element from the tab-navigation flow. */ -OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { +OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) { // Configuration initialization config = $.extend( { tabIndex: 0 }, config ); @@ -3949,7 +4309,7 @@ OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { this.tabIndex = null; // Events - this.connect( this, { disable: 'onDisable' } ); + this.connect( this, { disable: 'onTabIndexedElementDisable' } ); // Initialization this.setTabIndex( config.tabIndex ); @@ -3958,7 +4318,7 @@ OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { /* Setup */ -OO.initClass( OO.ui.TabIndexedElement ); +OO.initClass( OO.ui.mixin.TabIndexedElement ); /* Methods */ @@ -3972,7 +4332,7 @@ OO.initClass( OO.ui.TabIndexedElement ); * @param {jQuery} $tabIndexed Element that should use the tabindex functionality * @chainable */ -OO.ui.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) { +OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) { var tabIndex = this.tabIndex; // Remove attributes from old $tabIndexed this.setTabIndex( null ); @@ -3988,7 +4348,7 @@ OO.ui.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed * @param {number|null} tabIndex Tabindex value, or `null` for no tabindex * @chainable */ -OO.ui.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { +OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { tabIndex = typeof tabIndex === 'number' ? tabIndex : null; if ( this.tabIndex !== tabIndex ) { @@ -4006,13 +4366,14 @@ OO.ui.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { * @private * @chainable */ -OO.ui.TabIndexedElement.prototype.updateTabIndex = function () { +OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () { if ( this.$tabIndexed ) { if ( this.tabIndex !== null ) { // Do not index over disabled elements this.$tabIndexed.attr( { tabindex: this.isDisabled() ? -1 : this.tabIndex, - // ChromeVox and NVDA do not seem to inherit this from parent elements + // Support: ChromeVox and NVDA + // These do not seem to inherit aria-disabled from parent elements 'aria-disabled': this.isDisabled().toString() } ); } else { @@ -4028,7 +4389,7 @@ OO.ui.TabIndexedElement.prototype.updateTabIndex = function () { * @private * @param {boolean} disabled Element is disabled */ -OO.ui.TabIndexedElement.prototype.onDisable = function () { +OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () { this.updateTabIndex(); }; @@ -4037,7 +4398,7 @@ OO.ui.TabIndexedElement.prototype.onDisable = function () { * * @return {number|null} Tabindex value */ -OO.ui.TabIndexedElement.prototype.getTabIndex = function () { +OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () { return this.tabIndex; }; @@ -4055,16 +4416,14 @@ OO.ui.TabIndexedElement.prototype.getTabIndex = function () { * @cfg {jQuery} [$button] The button element created by the class. * If this configuration is omitted, the button element will use a generated `<a>`. * @cfg {boolean} [framed=true] Render the button with a frame - * @cfg {string} [accessKey] Button's access key */ -OO.ui.ButtonElement = function OoUiButtonElement( config ) { +OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) { // Configuration initialization config = config || {}; // Properties this.$button = null; this.framed = null; - this.accessKey = null; this.active = false; this.onMouseUpHandler = this.onMouseUp.bind( this ); this.onMouseDownHandler = this.onMouseDown.bind( this ); @@ -4076,13 +4435,12 @@ OO.ui.ButtonElement = function OoUiButtonElement( config ) { // Initialization this.$element.addClass( 'oo-ui-buttonElement' ); this.toggleFramed( config.framed === undefined || config.framed ); - this.setAccessKey( config.accessKey ); this.setButtonElement( config.$button || $( '<a>' ) ); }; /* Setup */ -OO.initClass( OO.ui.ButtonElement ); +OO.initClass( OO.ui.mixin.ButtonElement ); /* Static Properties */ @@ -4090,7 +4448,7 @@ OO.initClass( OO.ui.ButtonElement ); * Cancel mouse down events. * * This property is usually set to `true` to prevent the focus from changing when the button is clicked. - * Classes such as {@link OO.ui.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} + * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a * parent widget. * @@ -4098,7 +4456,7 @@ OO.initClass( OO.ui.ButtonElement ); * @inheritable * @property {boolean} */ -OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; +OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true; /* Events */ @@ -4119,7 +4477,7 @@ OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; * * @param {jQuery} $button Element to use as button */ -OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { +OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) { if ( this.$button ) { this.$button .removeClass( 'oo-ui-buttonElement-button' ) @@ -4134,7 +4492,7 @@ OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { this.$button = $button .addClass( 'oo-ui-buttonElement-button' ) - .attr( { role: 'button', accesskey: this.accessKey } ) + .attr( { role: 'button' } ) .on( { mousedown: this.onMouseDownHandler, keydown: this.onKeyDownHandler, @@ -4149,14 +4507,14 @@ OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { * @protected * @param {jQuery.Event} e Mouse down event */ -OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) { if ( this.isDisabled() || e.which !== 1 ) { return; } this.$element.addClass( 'oo-ui-buttonElement-pressed' ); // Run the mouseup handler no matter where the mouse is when the button is let go, so we can // reliably remove the pressed class - this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler ); // Prevent change of focus unless specifically configured otherwise if ( this.constructor.static.cancelButtonMouseDownEvents ) { return false; @@ -4169,13 +4527,13 @@ OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { * @protected * @param {jQuery.Event} e Mouse up event */ -OO.ui.ButtonElement.prototype.onMouseUp = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) { if ( this.isDisabled() || e.which !== 1 ) { return; } this.$element.removeClass( 'oo-ui-buttonElement-pressed' ); // Stop listening for mouseup, since we only needed this once - this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler ); }; /** @@ -4185,7 +4543,7 @@ OO.ui.ButtonElement.prototype.onMouseUp = function ( e ) { * @param {jQuery.Event} e Mouse click event * @fires click */ -OO.ui.ButtonElement.prototype.onClick = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) { if ( !this.isDisabled() && e.which === 1 ) { if ( this.emit( 'click' ) ) { return false; @@ -4199,14 +4557,14 @@ OO.ui.ButtonElement.prototype.onClick = function ( e ) { * @protected * @param {jQuery.Event} e Key down event */ -OO.ui.ButtonElement.prototype.onKeyDown = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) { if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) { return; } this.$element.addClass( 'oo-ui-buttonElement-pressed' ); // Run the keyup handler no matter where the key is when the button is let go, so we can // reliably remove the pressed class - this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler ); }; /** @@ -4215,13 +4573,13 @@ OO.ui.ButtonElement.prototype.onKeyDown = function ( e ) { * @protected * @param {jQuery.Event} e Key up event */ -OO.ui.ButtonElement.prototype.onKeyUp = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) { if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) { return; } this.$element.removeClass( 'oo-ui-buttonElement-pressed' ); // Stop listening for keyup, since we only needed this once - this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler ); }; /** @@ -4231,7 +4589,7 @@ OO.ui.ButtonElement.prototype.onKeyUp = function ( e ) { * @param {jQuery.Event} e Key press event * @fires click */ -OO.ui.ButtonElement.prototype.onKeyPress = function ( e ) { +OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) { if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { if ( this.emit( 'click' ) ) { return false; @@ -4244,7 +4602,7 @@ OO.ui.ButtonElement.prototype.onKeyPress = function ( e ) { * * @return {boolean} Button is framed */ -OO.ui.ButtonElement.prototype.isFramed = function () { +OO.ui.mixin.ButtonElement.prototype.isFramed = function () { return this.framed; }; @@ -4254,7 +4612,7 @@ OO.ui.ButtonElement.prototype.isFramed = function () { * @param {boolean} [framed] Make button framed, omit to toggle * @chainable */ -OO.ui.ButtonElement.prototype.toggleFramed = function ( framed ) { +OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) { framed = framed === undefined ? !this.framed : !!framed; if ( framed !== this.framed ) { this.framed = framed; @@ -4268,41 +4626,28 @@ OO.ui.ButtonElement.prototype.toggleFramed = function ( framed ) { }; /** - * Set the button's access key. + * Set the button's active state. + * + * The active state occurs when a {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} or + * a {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} is pressed. This method does nothing + * for other button types. * - * @param {string} accessKey Button's access key, use empty string to remove + * @param {boolean} value Make button active * @chainable */ -OO.ui.ButtonElement.prototype.setAccessKey = function ( accessKey ) { - accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null; - - if ( this.accessKey !== accessKey ) { - if ( this.$button ) { - if ( accessKey !== null ) { - this.$button.attr( 'accesskey', accessKey ); - } else { - this.$button.removeAttr( 'accesskey' ); - } - } - this.accessKey = accessKey; - } - +OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) { + this.active = !!value; + this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active ); return this; }; /** - * Set the button to its 'active' state. - * - * The active state occurs when a {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} or - * a {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} is pressed. This method does nothing - * for other button types. + * Check if the button is active * - * @param {boolean} [value] Make button active - * @chainable + * @return {boolean} The button is active */ -OO.ui.ButtonElement.prototype.setActive = function ( value ) { - this.$element.toggleClass( 'oo-ui-buttonElement-active', !!value ); - return this; +OO.ui.mixin.ButtonElement.prototype.isActive = function () { + return this.active; }; /** @@ -4321,7 +4666,7 @@ OO.ui.ButtonElement.prototype.setActive = function ( value ) { * @cfg {jQuery} [$group] The container element created by the class. If this configuration * is omitted, the group element will use a generated `<div>`. */ -OO.ui.GroupElement = function OoUiGroupElement( config ) { +OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) { // Configuration initialization config = config || {}; @@ -4343,7 +4688,7 @@ OO.ui.GroupElement = function OoUiGroupElement( config ) { * * @param {jQuery} $group Element to use as group */ -OO.ui.GroupElement.prototype.setGroupElement = function ( $group ) { +OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) { var i, len; this.$group = $group; @@ -4357,7 +4702,7 @@ OO.ui.GroupElement.prototype.setGroupElement = function ( $group ) { * * @return {boolean} Group is empty */ -OO.ui.GroupElement.prototype.isEmpty = function () { +OO.ui.mixin.GroupElement.prototype.isEmpty = function () { return !this.items.length; }; @@ -4370,7 +4715,7 @@ OO.ui.GroupElement.prototype.isEmpty = function () { * * @return {OO.ui.Element[]} An array of items. */ -OO.ui.GroupElement.prototype.getItems = function () { +OO.ui.mixin.GroupElement.prototype.getItems = function () { return this.items.slice( 0 ); }; @@ -4383,7 +4728,7 @@ OO.ui.GroupElement.prototype.getItems = function () { * @param {Object} data Item data to search for * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists */ -OO.ui.GroupElement.prototype.getItemFromData = function ( data ) { +OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) { var i, len, item, hash = OO.getHash( data ); @@ -4405,7 +4750,7 @@ OO.ui.GroupElement.prototype.getItemFromData = function ( data ) { * @param {Object} data Item data to search for * @return {OO.ui.Element[]} Items with equivalent data */ -OO.ui.GroupElement.prototype.getItemsFromData = function ( data ) { +OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) { var i, len, item, hash = OO.getHash( data ), items = []; @@ -4434,7 +4779,7 @@ OO.ui.GroupElement.prototype.getItemsFromData = function ( data ) { * @throws {Error} An error is thrown if aggregation already exists. */ -OO.ui.GroupElement.prototype.aggregate = function ( events ) { +OO.ui.mixin.GroupElement.prototype.aggregate = function ( events ) { var i, len, item, add, remove, itemEvent, groupEvent; for ( itemEvent in events ) { @@ -4451,7 +4796,7 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { item = this.items[ i ]; if ( item.connect && item.disconnect ) { remove = {}; - remove[ itemEvent ] = [ 'emit', groupEvent, item ]; + remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; item.disconnect( this, remove ); } } @@ -4486,7 +4831,7 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { * @param {number} [index] Index of the insertion point * @chainable */ -OO.ui.GroupElement.prototype.addItems = function ( items, index ) { +OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) { var i, len, item, event, events, currentIndex, itemElements = []; @@ -4494,7 +4839,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { item = items[ i ]; // Check if item exists then remove it first, effectively "moving" it - currentIndex = $.inArray( item, this.items ); + currentIndex = this.items.indexOf( item ); if ( currentIndex >= 0 ) { this.removeItems( [ item ] ); // Adjust index to compensate for removal @@ -4537,13 +4882,13 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { * @param {OO.ui.Element[]} items An array of items to remove * @chainable */ -OO.ui.GroupElement.prototype.removeItems = function ( items ) { +OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) { var i, len, item, index, remove, itemEvent; // Remove specific items for ( i = 0, len = items.length; i < len; i++ ) { item = items[ i ]; - index = $.inArray( item, this.items ); + index = this.items.indexOf( item ); if ( index !== -1 ) { if ( item.connect && item.disconnect && @@ -4572,7 +4917,7 @@ OO.ui.GroupElement.prototype.removeItems = function ( items ) { * * @chainable */ -OO.ui.GroupElement.prototype.clearItems = function () { +OO.ui.mixin.GroupElement.prototype.clearItems = function () { var i, len, item, remove, itemEvent; // Remove all items @@ -4599,7 +4944,7 @@ OO.ui.GroupElement.prototype.clearItems = function () { /** * DraggableElement is a mixin class used to create elements that can be clicked * and dragged by a mouse to a new position within a group. This class must be used - * in conjunction with OO.ui.DraggableGroupElement, which provides a container for + * in conjunction with OO.ui.mixin.DraggableGroupElement, which provides a container for * the draggable elements. * * @abstract @@ -4607,7 +4952,7 @@ OO.ui.GroupElement.prototype.clearItems = function () { * * @constructor */ -OO.ui.DraggableElement = function OoUiDraggableElement() { +OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement() { // Properties this.index = null; @@ -4623,7 +4968,7 @@ OO.ui.DraggableElement = function OoUiDraggableElement() { } ); }; -OO.initClass( OO.ui.DraggableElement ); +OO.initClass( OO.ui.mixin.DraggableElement ); /* Events */ @@ -4631,7 +4976,7 @@ OO.initClass( OO.ui.DraggableElement ); * @event dragstart * * A dragstart event is emitted when the user clicks and begins dragging an item. - * @param {OO.ui.DraggableElement} item The item the user has clicked and is dragging with the mouse. + * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with the mouse. */ /** @@ -4649,9 +4994,9 @@ OO.initClass( OO.ui.DraggableElement ); /* Static Properties */ /** - * @inheritdoc OO.ui.ButtonElement + * @inheritdoc OO.ui.mixin.ButtonElement */ -OO.ui.DraggableElement.static.cancelButtonMouseDownEvents = false; +OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false; /* Methods */ @@ -4662,18 +5007,18 @@ OO.ui.DraggableElement.static.cancelButtonMouseDownEvents = false; * @param {jQuery.Event} event jQuery event * @fires dragstart */ -OO.ui.DraggableElement.prototype.onDragStart = function ( e ) { +OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) { var dataTransfer = e.originalEvent.dataTransfer; // Define drop effect dataTransfer.dropEffect = 'none'; dataTransfer.effectAllowed = 'move'; + // Support: Firefox // We must set up a dataTransfer data property or Firefox seems to // ignore the fact the element is draggable. try { dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() ); } catch ( err ) { - // The above is only for firefox. No need to set a catch clause - // if it fails, move on. + // The above is only for Firefox. Move on if it fails. } // Add dragging class this.$element.addClass( 'oo-ui-draggableElement-dragging' ); @@ -4688,7 +5033,7 @@ OO.ui.DraggableElement.prototype.onDragStart = function ( e ) { * @private * @fires dragend */ -OO.ui.DraggableElement.prototype.onDragEnd = function () { +OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () { this.$element.removeClass( 'oo-ui-draggableElement-dragging' ); this.emit( 'dragend' ); }; @@ -4700,7 +5045,7 @@ OO.ui.DraggableElement.prototype.onDragEnd = function () { * @param {jQuery.Event} event jQuery event * @fires drop */ -OO.ui.DraggableElement.prototype.onDrop = function ( e ) { +OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) { e.preventDefault(); this.emit( 'drop', e ); }; @@ -4711,7 +5056,7 @@ OO.ui.DraggableElement.prototype.onDrop = function ( e ) { * * @private */ -OO.ui.DraggableElement.prototype.onDragOver = function ( e ) { +OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) { e.preventDefault(); }; @@ -4722,7 +5067,7 @@ OO.ui.DraggableElement.prototype.onDragOver = function ( e ) { * @private * @param {number} Item index */ -OO.ui.DraggableElement.prototype.setIndex = function ( index ) { +OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) { if ( this.index !== index ) { this.index = index; this.$element.data( 'index', index ); @@ -4735,18 +5080,18 @@ OO.ui.DraggableElement.prototype.setIndex = function ( index ) { * @private * @return {number} Item index */ -OO.ui.DraggableElement.prototype.getIndex = function () { +OO.ui.mixin.DraggableElement.prototype.getIndex = function () { return this.index; }; /** * DraggableGroupElement is a mixin class used to create a group element to * contain draggable elements, which are items that can be clicked and dragged by a mouse. - * The class is used with OO.ui.DraggableElement. + * The class is used with OO.ui.mixin.DraggableElement. * * @abstract * @class - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options @@ -4755,12 +5100,12 @@ OO.ui.DraggableElement.prototype.getIndex = function () { * or in several rows should use horizontal orientation. The vertical orientation should only be * used when the items are displayed in a single column. Defaults to 'vertical' */ -OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { +OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) { // Configuration initialization config = config || {}; // Parent constructor - OO.ui.GroupElement.call( this, config ); + OO.ui.mixin.GroupElement.call( this, config ); // Properties this.orientation = config.orientation || 'vertical'; @@ -4781,8 +5126,8 @@ OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { itemDragEnd: 'onItemDragEnd' } ); this.$element.on( { - dragover: $.proxy( this.onDragOver, this ), - dragleave: $.proxy( this.onDragLeave, this ) + dragover: this.onDragOver.bind( this ), + dragleave: this.onDragLeave.bind( this ) } ); // Initialize @@ -4799,7 +5144,7 @@ OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { }; /* Setup */ -OO.mixinClass( OO.ui.DraggableGroupElement, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement ); /* Events */ @@ -4807,7 +5152,7 @@ OO.mixinClass( OO.ui.DraggableGroupElement, OO.ui.GroupElement ); * A 'reorder' event is emitted when the order of items in the group changes. * * @event reorder - * @param {OO.ui.DraggableElement} item Reordered item + * @param {OO.ui.mixin.DraggableElement} item Reordered item * @param {number} [newIndex] New index for the item */ @@ -4817,9 +5162,9 @@ OO.mixinClass( OO.ui.DraggableGroupElement, OO.ui.GroupElement ); * Respond to item drag start event * * @private - * @param {OO.ui.DraggableElement} item Dragged item + * @param {OO.ui.mixin.DraggableElement} item Dragged item */ -OO.ui.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { +OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { var i, len; // Map the index of each object @@ -4848,7 +5193,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { * * @private */ -OO.ui.DraggableGroupElement.prototype.onItemDragEnd = function () { +OO.ui.mixin.DraggableGroupElement.prototype.onItemDragEnd = function () { this.unsetDragItem(); return false; }; @@ -4857,10 +5202,10 @@ OO.ui.DraggableGroupElement.prototype.onItemDragEnd = function () { * Handle drop event and switch the order of the items accordingly * * @private - * @param {OO.ui.DraggableElement} item Dropped item + * @param {OO.ui.mixin.DraggableElement} item Dropped item * @fires reorder */ -OO.ui.DraggableGroupElement.prototype.onItemDrop = function ( item ) { +OO.ui.mixin.DraggableGroupElement.prototype.onItemDrop = function ( item ) { var toIndex = item.getIndex(); // Check if the dropped item is from the current group // TODO: Figure out a way to configure a list of legally droppable @@ -4884,7 +5229,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDrop = function ( item ) { * * @private */ -OO.ui.DraggableGroupElement.prototype.onDragLeave = function () { +OO.ui.mixin.DraggableGroupElement.prototype.onDragLeave = function () { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) @@ -4897,7 +5242,7 @@ OO.ui.DraggableGroupElement.prototype.onDragLeave = function () { * @private * @param {jQuery.Event} event Event details */ -OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { +OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) { var dragOverObj, $optionWidget, itemOffset, itemMidpoint, itemBoundingRect, itemSize, cssOutput, dragPosition, itemIndex, itemPosition, clientX = e.originalEvent.clientX, @@ -4967,16 +5312,16 @@ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { /** * Set a dragged item * - * @param {OO.ui.DraggableElement} item Dragged item + * @param {OO.ui.mixin.DraggableElement} item Dragged item */ -OO.ui.DraggableGroupElement.prototype.setDragItem = function ( item ) { +OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) { this.dragItem = item; }; /** * Unset the current dragged item */ -OO.ui.DraggableGroupElement.prototype.unsetDragItem = function () { +OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () { this.dragItem = null; this.itemDragOver = null; this.$placeholder.addClass( 'oo-ui-element-hidden' ); @@ -4986,9 +5331,9 @@ OO.ui.DraggableGroupElement.prototype.unsetDragItem = function () { /** * Get the item that is currently being dragged. * - * @return {OO.ui.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged + * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged */ -OO.ui.DraggableGroupElement.prototype.getDragItem = function () { +OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () { return this.dragItem; }; @@ -4997,7 +5342,7 @@ OO.ui.DraggableGroupElement.prototype.getDragItem = function () { * * @return {Boolean} Item is being dragged */ -OO.ui.DraggableGroupElement.prototype.isDragging = function () { +OO.ui.mixin.DraggableGroupElement.prototype.isDragging = function () { return this.getDragItem() !== null; }; @@ -5039,7 +5384,7 @@ OO.ui.DraggableGroupElement.prototype.isDragging = function () { * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title * text. The icon title is displayed when users move the mouse over the icon. */ -OO.ui.IconElement = function OoUiIconElement( config ) { +OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) { // Configuration initialization config = config || {}; @@ -5056,7 +5401,7 @@ OO.ui.IconElement = function OoUiIconElement( config ) { /* Setup */ -OO.initClass( OO.ui.IconElement ); +OO.initClass( OO.ui.mixin.IconElement ); /* Static Properties */ @@ -5075,7 +5420,7 @@ OO.initClass( OO.ui.IconElement ); * @inheritable * @property {Object|string} */ -OO.ui.IconElement.static.icon = null; +OO.ui.mixin.IconElement.static.icon = null; /** * The icon title, displayed when users move the mouse over the icon. The value can be text, a @@ -5087,7 +5432,7 @@ OO.ui.IconElement.static.icon = null; * @inheritable * @property {string|Function|null} */ -OO.ui.IconElement.static.iconTitle = null; +OO.ui.mixin.IconElement.static.iconTitle = null; /* Methods */ @@ -5099,7 +5444,7 @@ OO.ui.IconElement.static.iconTitle = null; * * @param {jQuery} $icon Element to use as icon */ -OO.ui.IconElement.prototype.setIconElement = function ( $icon ) { +OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) { if ( this.$icon ) { this.$icon .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon ) @@ -5112,6 +5457,8 @@ OO.ui.IconElement.prototype.setIconElement = function ( $icon ) { if ( this.iconTitle !== null ) { this.$icon.attr( 'title', this.iconTitle ); } + + this.updateThemeClasses(); }; /** @@ -5123,7 +5470,7 @@ OO.ui.IconElement.prototype.setIconElement = function ( $icon ) { * by language code, or `null` to remove the icon. * @chainable */ -OO.ui.IconElement.prototype.setIcon = function ( icon ) { +OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) { icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon; icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null; @@ -5152,7 +5499,7 @@ OO.ui.IconElement.prototype.setIcon = function ( icon ) { * a function that returns title text, or `null` for no title. * @chainable */ -OO.ui.IconElement.prototype.setIconTitle = function ( iconTitle ) { +OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) { iconTitle = typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ? OO.ui.resolveMsg( iconTitle ) : null; @@ -5176,7 +5523,7 @@ OO.ui.IconElement.prototype.setIconTitle = function ( iconTitle ) { * * @return {string} Icon name */ -OO.ui.IconElement.prototype.getIcon = function () { +OO.ui.mixin.IconElement.prototype.getIcon = function () { return this.icon; }; @@ -5185,7 +5532,7 @@ OO.ui.IconElement.prototype.getIcon = function () { * * @return {string} Icon title text */ -OO.ui.IconElement.prototype.getIconTitle = function () { +OO.ui.mixin.IconElement.prototype.getIconTitle = function () { return this.iconTitle; }; @@ -5218,7 +5565,7 @@ OO.ui.IconElement.prototype.getIconTitle = function () { * or a function that returns title text. The indicator title is displayed when users move * the mouse over the indicator. */ -OO.ui.IndicatorElement = function OoUiIndicatorElement( config ) { +OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) { // Configuration initialization config = config || {}; @@ -5235,7 +5582,7 @@ OO.ui.IndicatorElement = function OoUiIndicatorElement( config ) { /* Setup */ -OO.initClass( OO.ui.IndicatorElement ); +OO.initClass( OO.ui.mixin.IndicatorElement ); /* Static Properties */ @@ -5247,7 +5594,7 @@ OO.initClass( OO.ui.IndicatorElement ); * @inheritable * @property {string|null} */ -OO.ui.IndicatorElement.static.indicator = null; +OO.ui.mixin.IndicatorElement.static.indicator = null; /** * A text string used as the indicator title, a function that returns title text, or `null` @@ -5257,7 +5604,7 @@ OO.ui.IndicatorElement.static.indicator = null; * @inheritable * @property {string|Function|null} */ -OO.ui.IndicatorElement.static.indicatorTitle = null; +OO.ui.mixin.IndicatorElement.static.indicatorTitle = null; /* Methods */ @@ -5268,7 +5615,7 @@ OO.ui.IndicatorElement.static.indicatorTitle = null; * * @param {jQuery} $indicator Element to use as indicator */ -OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { +OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { if ( this.$indicator ) { this.$indicator .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator ) @@ -5281,6 +5628,8 @@ OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { if ( this.indicatorTitle !== null ) { this.$indicator.attr( 'title', this.indicatorTitle ); } + + this.updateThemeClasses(); }; /** @@ -5289,7 +5638,7 @@ OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator * @chainable */ -OO.ui.IndicatorElement.prototype.setIndicator = function ( indicator ) { +OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) { indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null; if ( this.indicator !== indicator ) { @@ -5319,7 +5668,7 @@ OO.ui.IndicatorElement.prototype.setIndicator = function ( indicator ) { * `null` for no indicator title * @chainable */ -OO.ui.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) { +OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) { indicatorTitle = typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ? OO.ui.resolveMsg( indicatorTitle ) : null; @@ -5343,7 +5692,7 @@ OO.ui.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) * * @return {string} Symbolic name of indicator */ -OO.ui.IndicatorElement.prototype.getIndicator = function () { +OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () { return this.indicator; }; @@ -5354,7 +5703,7 @@ OO.ui.IndicatorElement.prototype.getIndicator = function () { * * @return {string} Indicator title text */ -OO.ui.IndicatorElement.prototype.getIndicatorTitle = function () { +OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () { return this.indicatorTitle; }; @@ -5379,7 +5728,7 @@ OO.ui.IndicatorElement.prototype.getIndicatorTitle = function () { * @cfg {boolean} [autoFitLabel=true] Fit the label to the width of the parent element. * The label will be truncated to fit if necessary. */ -OO.ui.LabelElement = function OoUiLabelElement( config ) { +OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) { // Configuration initialization config = config || {}; @@ -5395,7 +5744,7 @@ OO.ui.LabelElement = function OoUiLabelElement( config ) { /* Setup */ -OO.initClass( OO.ui.LabelElement ); +OO.initClass( OO.ui.mixin.LabelElement ); /* Events */ @@ -5415,7 +5764,7 @@ OO.initClass( OO.ui.LabelElement ); * @inheritable * @property {string|Function|null} */ -OO.ui.LabelElement.static.label = null; +OO.ui.mixin.LabelElement.static.label = null; /* Methods */ @@ -5426,7 +5775,7 @@ OO.ui.LabelElement.static.label = null; * * @param {jQuery} $label Element to use as label */ -OO.ui.LabelElement.prototype.setLabelElement = function ( $label ) { +OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) { if ( this.$label ) { this.$label.removeClass( 'oo-ui-labelElement-label' ).empty(); } @@ -5445,7 +5794,7 @@ OO.ui.LabelElement.prototype.setLabelElement = function ( $label ) { * text; or null for no label * @chainable */ -OO.ui.LabelElement.prototype.setLabel = function ( label ) { +OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) { label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label; label = ( ( typeof label === 'string' && label.length ) || label instanceof jQuery || label instanceof OO.ui.HtmlSnippet ) ? label : null; @@ -5468,7 +5817,7 @@ OO.ui.LabelElement.prototype.setLabel = function ( label ) { * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or * text; or null for no label */ -OO.ui.LabelElement.prototype.getLabel = function () { +OO.ui.mixin.LabelElement.prototype.getLabel = function () { return this.label; }; @@ -5477,7 +5826,7 @@ OO.ui.LabelElement.prototype.getLabel = function () { * * @chainable */ -OO.ui.LabelElement.prototype.fitLabel = function () { +OO.ui.mixin.LabelElement.prototype.fitLabel = function () { if ( this.$label && this.$label.autoEllipsis && this.autoFitLabel ) { this.$label.autoEllipsis( { hasSpan: false, tooltip: true } ); } @@ -5494,7 +5843,7 @@ OO.ui.LabelElement.prototype.fitLabel = function () { * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or * text; or null for no label */ -OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { +OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) { if ( typeof label === 'string' ) { if ( label.match( /^\s*$/ ) ) { // Convert whitespace only string to a single non-breaking space @@ -5512,7 +5861,7 @@ OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { }; /** - * LookupElement is a mixin that creates a {@link OO.ui.TextInputMenuSelectWidget menu} of suggested values for + * LookupElement is a mixin that creates a {@link OO.ui.FloatingMenuSelectWidget menu} of suggested values for * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen * from the lookup menu, that value becomes the value of the input field. @@ -5535,16 +5884,16 @@ OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty. * By default, the lookup menu is not generated and displayed until the user begins to type. */ -OO.ui.LookupElement = function OoUiLookupElement( config ) { +OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) { // Configuration initialization config = config || {}; // Properties this.$overlay = config.$overlay || this.$element; - this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { + this.lookupMenu = new OO.ui.FloatingMenuSelectWidget( { widget: this, input: this, - $container: config.$container + $container: config.$container || this.$element } ); this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false; @@ -5581,7 +5930,7 @@ OO.ui.LookupElement = function OoUiLookupElement( config ) { * @protected * @param {jQuery.Event} e Input focus event */ -OO.ui.LookupElement.prototype.onLookupInputFocus = function () { +OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () { this.lookupInputFocused = true; this.populateLookupMenu(); }; @@ -5592,7 +5941,7 @@ OO.ui.LookupElement.prototype.onLookupInputFocus = function () { * @protected * @param {jQuery.Event} e Input blur event */ -OO.ui.LookupElement.prototype.onLookupInputBlur = function () { +OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () { this.closeLookupMenu(); this.lookupInputFocused = false; }; @@ -5603,7 +5952,7 @@ OO.ui.LookupElement.prototype.onLookupInputBlur = function () { * @protected * @param {jQuery.Event} e Input mouse down event */ -OO.ui.LookupElement.prototype.onLookupInputMouseDown = function () { +OO.ui.mixin.LookupElement.prototype.onLookupInputMouseDown = function () { // Only open the menu if the input was already focused. // This way we allow the user to open the menu again after closing it with Esc // by clicking in the input. Opening (and populating) the menu when initially @@ -5619,7 +5968,7 @@ OO.ui.LookupElement.prototype.onLookupInputMouseDown = function () { * @protected * @param {string} value New input value */ -OO.ui.LookupElement.prototype.onLookupInputChange = function () { +OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () { if ( this.lookupInputFocused ) { this.populateLookupMenu(); } @@ -5631,7 +5980,7 @@ OO.ui.LookupElement.prototype.onLookupInputChange = function () { * @protected * @param {boolean} visible Whether the lookup menu is now visible. */ -OO.ui.LookupElement.prototype.onLookupMenuToggle = function ( visible ) { +OO.ui.mixin.LookupElement.prototype.onLookupMenuToggle = function ( visible ) { if ( !visible ) { // When the menu is hidden, abort any active request and clear the menu. // This has to be done here in addition to closeLookupMenu(), because @@ -5647,7 +5996,7 @@ OO.ui.LookupElement.prototype.onLookupMenuToggle = function ( visible ) { * @protected * @param {OO.ui.MenuOptionWidget} item Selected item */ -OO.ui.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { +OO.ui.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { this.setValue( item.getData() ); }; @@ -5655,9 +6004,9 @@ OO.ui.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { * Get lookup menu. * * @private - * @return {OO.ui.TextInputMenuSelectWidget} + * @return {OO.ui.FloatingMenuSelectWidget} */ -OO.ui.LookupElement.prototype.getLookupMenu = function () { +OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () { return this.lookupMenu; }; @@ -5668,7 +6017,7 @@ OO.ui.LookupElement.prototype.getLookupMenu = function () { * * @param {boolean} disabled Disable lookups */ -OO.ui.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { +OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { this.lookupsDisabled = !!disabled; }; @@ -5678,7 +6027,7 @@ OO.ui.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { * @private * @chainable */ -OO.ui.LookupElement.prototype.openLookupMenu = function () { +OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () { if ( !this.lookupMenu.isEmpty() ) { this.lookupMenu.toggle( true ); } @@ -5691,7 +6040,7 @@ OO.ui.LookupElement.prototype.openLookupMenu = function () { * @private * @chainable */ -OO.ui.LookupElement.prototype.closeLookupMenu = function () { +OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () { this.lookupMenu.toggle( false ); this.abortLookupRequest(); this.lookupMenu.clearItems(); @@ -5707,11 +6056,11 @@ OO.ui.LookupElement.prototype.closeLookupMenu = function () { * @private * @chainable */ -OO.ui.LookupElement.prototype.populateLookupMenu = function () { +OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () { var widget = this, value = this.getValue(); - if ( this.lookupsDisabled ) { + if ( this.lookupsDisabled || this.isReadOnly() ) { return; } @@ -5746,7 +6095,7 @@ OO.ui.LookupElement.prototype.populateLookupMenu = function () { * @private * @chainable */ -OO.ui.LookupElement.prototype.initializeLookupMenuSelection = function () { +OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () { if ( !this.lookupMenu.getSelectedItem() ) { this.lookupMenu.highlightItem( this.lookupMenu.getFirstSelectableItem() ); } @@ -5760,7 +6109,7 @@ OO.ui.LookupElement.prototype.initializeLookupMenuSelection = function () { * the done event. If the request was aborted to make way for a subsequent request, this promise * will not be rejected: it will remain pending forever. */ -OO.ui.LookupElement.prototype.getLookupMenuItems = function () { +OO.ui.mixin.LookupElement.prototype.getLookupMenuItems = function () { var widget = this, value = this.getValue(), deferred = $.Deferred(), @@ -5811,7 +6160,7 @@ OO.ui.LookupElement.prototype.getLookupMenuItems = function () { * * @private */ -OO.ui.LookupElement.prototype.abortLookupRequest = function () { +OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () { var oldRequest = this.lookupRequest; if ( oldRequest ) { // First unset this.lookupRequest to the fail handler will notice @@ -5829,7 +6178,7 @@ OO.ui.LookupElement.prototype.abortLookupRequest = function () { * @abstract * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method */ -OO.ui.LookupElement.prototype.getLookupRequest = function () { +OO.ui.mixin.LookupElement.prototype.getLookupRequest = function () { // Stub, implemented in subclass return null; }; @@ -5845,7 +6194,7 @@ OO.ui.LookupElement.prototype.getLookupRequest = function () { * @param {Mixed} response Response from server * @return {Mixed} Cached result data */ -OO.ui.LookupElement.prototype.getLookupCacheDataFromResponse = function () { +OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = function () { // Stub, implemented in subclass return []; }; @@ -5859,12 +6208,33 @@ OO.ui.LookupElement.prototype.getLookupCacheDataFromResponse = function () { * @param {Mixed} data Cached result data, usually an array * @return {OO.ui.MenuOptionWidget[]} Menu items */ -OO.ui.LookupElement.prototype.getLookupMenuOptionsFromData = function () { +OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = function () { // Stub, implemented in subclass return []; }; /** + * Set the read-only state of the widget. + * + * This will also disable/enable the lookups functionality. + * + * @param {boolean} readOnly Make input read-only + * @chainable + */ +OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) { + // Parent method + // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget + OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly ); + + // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor + if ( this.isReadOnly() && this.lookupMenu ) { + this.closeLookupMenu(); + } + + return this; +}; + +/** * 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 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin. @@ -5878,7 +6248,7 @@ OO.ui.LookupElement.prototype.getLookupMenuOptionsFromData = function () { * @cfg {Object} [popup] Configuration to pass to popup * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus */ -OO.ui.PopupElement = function OoUiPopupElement( config ) { +OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) { // Configuration initialization config = config || {}; @@ -5897,7 +6267,7 @@ OO.ui.PopupElement = function OoUiPopupElement( config ) { * * @return {OO.ui.PopupWidget} Popup widget */ -OO.ui.PopupElement.prototype.getPopup = function () { +OO.ui.mixin.PopupElement.prototype.getPopup = function () { return this.popup; }; @@ -5949,7 +6319,7 @@ OO.ui.PopupElement.prototype.getPopup = function () { * the flagged functionality is applied to the element created by the class ($element). * If a different element is specified, the flagged functionality will be applied to it instead. */ -OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { +OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) { // Configuration initialization config = config || {}; @@ -5984,7 +6354,7 @@ OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { * * @param {jQuery} $flagged Element that should be flagged */ -OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { +OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { var classNames = Object.keys( this.flags ).map( function ( flag ) { return 'oo-ui-flaggedElement-' + flag; } ).join( ' ' ); @@ -6002,8 +6372,9 @@ OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { * @param {string} flag Name of flag * @return {boolean} The flag is set */ -OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { - return flag in this.flags; +OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) { + // This may be called before the constructor, thus before this.flags is set + return this.flags && ( flag in this.flags ); }; /** @@ -6011,8 +6382,9 @@ OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { * * @return {string[]} Flag names */ -OO.ui.FlaggedElement.prototype.getFlags = function () { - return Object.keys( this.flags ); +OO.ui.mixin.FlaggedElement.prototype.getFlags = function () { + // This may be called before the constructor, thus before this.flags is set + return Object.keys( this.flags || {} ); }; /** @@ -6021,7 +6393,7 @@ OO.ui.FlaggedElement.prototype.getFlags = function () { * @chainable * @fires flag */ -OO.ui.FlaggedElement.prototype.clearFlags = function () { +OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () { var flag, className, changes = {}, remove = [], @@ -6053,7 +6425,7 @@ OO.ui.FlaggedElement.prototype.clearFlags = function () { * @chainable * @fires flag */ -OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { +OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) { var i, len, flag, className, changes = {}, add = [], @@ -6136,7 +6508,7 @@ OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { * @cfg {string|Function} [title] The title text or a function that returns text. If * this config is omitted, the value of the {@link #static-title static title} property is used. */ -OO.ui.TitledElement = function OoUiTitledElement( config ) { +OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) { // Configuration initialization config = config || {}; @@ -6151,7 +6523,7 @@ OO.ui.TitledElement = function OoUiTitledElement( config ) { /* Setup */ -OO.initClass( OO.ui.TitledElement ); +OO.initClass( OO.ui.mixin.TitledElement ); /* Static Properties */ @@ -6163,7 +6535,7 @@ OO.initClass( OO.ui.TitledElement ); * @inheritable * @property {string|Function|null} */ -OO.ui.TitledElement.static.title = null; +OO.ui.mixin.TitledElement.static.title = null; /* Methods */ @@ -6175,7 +6547,7 @@ OO.ui.TitledElement.static.title = null; * * @param {jQuery} $titled Element that should use the 'titled' functionality */ -OO.ui.TitledElement.prototype.setTitledElement = function ( $titled ) { +OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) { if ( this.$titled ) { this.$titled.removeAttr( 'title' ); } @@ -6192,7 +6564,7 @@ OO.ui.TitledElement.prototype.setTitledElement = function ( $titled ) { * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title * @chainable */ -OO.ui.TitledElement.prototype.setTitle = function ( title ) { +OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) { title = typeof title === 'string' ? OO.ui.resolveMsg( title ) : null; if ( this.title !== title ) { @@ -6214,7 +6586,7 @@ OO.ui.TitledElement.prototype.setTitle = function ( title ) { * * @return {string} Title string */ -OO.ui.TitledElement.prototype.getTitle = function () { +OO.ui.mixin.TitledElement.prototype.getTitle = function () { return this.title; }; @@ -6222,33 +6594,47 @@ OO.ui.TitledElement.prototype.getTitle = function () { * Element that can be automatically clipped to visible boundaries. * * Whenever the element's natural height changes, you have to call - * #clip to make sure it's still clipping correctly. + * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still + * clipping correctly. + * + * The dimensions of #$clippableContainer will be compared to the boundaries of the + * nearest scrollable container. If #$clippableContainer is too tall and/or too wide, + * then #$clippable will be given a fixed reduced height and/or width and will be made + * scrollable. By default, #$clippable and #$clippableContainer are the same element, + * but you can build a static footer by setting #$clippableContainer to an element that contains + * #$clippable and the footer. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {jQuery} [$clippable] Nodes to clip, assigned to #$clippable, omit to use #$element + * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element + * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer, + * omit to use #$clippable */ -OO.ui.ClippableElement = function OoUiClippableElement( config ) { +OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) { // Configuration initialization config = config || {}; // Properties this.$clippable = null; + this.$clippableContainer = null; this.clipping = false; this.clippedHorizontally = false; this.clippedVertically = false; - this.$clippableContainer = null; + this.$clippableScrollableContainer = null; this.$clippableScroller = null; this.$clippableWindow = null; this.idealWidth = null; this.idealHeight = null; - this.onClippableContainerScrollHandler = this.clip.bind( this ); + this.onClippableScrollHandler = this.clip.bind( this ); this.onClippableWindowResizeHandler = this.clip.bind( this ); // Initialization + if ( config.$clippableContainer ) { + this.setClippableContainer( config.$clippableContainer ); + } this.setClippableElement( config.$clippable || this.$element ); }; @@ -6261,7 +6647,7 @@ OO.ui.ClippableElement = function OoUiClippableElement( config ) { * * @param {jQuery} $clippable Element to make clippable */ -OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { +OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) { if ( this.$clippable ) { this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' ); this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); @@ -6273,6 +6659,23 @@ OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { }; /** + * Set clippable container. + * + * This is the container that will be measured when deciding whether to clip. When clipping, + * #$clippable will be resized in order to keep the clippable container fully visible. + * + * If the clippable container is unset, #$clippable will be used. + * + * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset + */ +OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) { + this.$clippableContainer = $clippableContainer; + if ( this.$clippable ) { + this.clip(); + } +}; + +/** * Toggle clipping. * * Do not turn clipping on until after the element is attached to the DOM and visible. @@ -6280,19 +6683,19 @@ OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { * @param {boolean} [clipping] Enable clipping, omit to toggle * @chainable */ -OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { +OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) { clipping = clipping === undefined ? !this.clipping : !!clipping; if ( this.clipping !== clipping ) { this.clipping = clipping; if ( clipping ) { - this.$clippableContainer = $( this.getClosestScrollableElementContainer() ); + this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() ); // If the clippable container is the root, we have to listen to scroll events and check // jQuery.scrollTop on the window because of browser inconsistencies - this.$clippableScroller = this.$clippableContainer.is( 'html, body' ) ? - $( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : - this.$clippableContainer; - this.$clippableScroller.on( 'scroll', this.onClippableContainerScrollHandler ); + this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ? + $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) : + this.$clippableScrollableContainer; + this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler ); this.$clippableWindow = $( this.getElementWindow() ) .on( 'resize', this.onClippableWindowResizeHandler ); // Initial clip after visible @@ -6301,8 +6704,8 @@ OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); - this.$clippableContainer = null; - this.$clippableScroller.off( 'scroll', this.onClippableContainerScrollHandler ); + this.$clippableScrollableContainer = null; + this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler ); this.$clippableScroller = null; this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler ); this.$clippableWindow = null; @@ -6317,7 +6720,7 @@ OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { * * @return {boolean} Element will be clipped to the visible area */ -OO.ui.ClippableElement.prototype.isClipping = function () { +OO.ui.mixin.ClippableElement.prototype.isClipping = function () { return this.clipping; }; @@ -6326,7 +6729,7 @@ OO.ui.ClippableElement.prototype.isClipping = function () { * * @return {boolean} Part of the element is being clipped */ -OO.ui.ClippableElement.prototype.isClipped = function () { +OO.ui.mixin.ClippableElement.prototype.isClipped = function () { return this.clippedHorizontally || this.clippedVertically; }; @@ -6335,7 +6738,7 @@ OO.ui.ClippableElement.prototype.isClipped = function () { * * @return {boolean} Part of the element is being clipped */ -OO.ui.ClippableElement.prototype.isClippedHorizontally = function () { +OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () { return this.clippedHorizontally; }; @@ -6344,7 +6747,7 @@ OO.ui.ClippableElement.prototype.isClippedHorizontally = function () { * * @return {boolean} Part of the element is being clipped */ -OO.ui.ClippableElement.prototype.isClippedVertically = function () { +OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () { return this.clippedVertically; }; @@ -6354,7 +6757,7 @@ OO.ui.ClippableElement.prototype.isClippedVertically = function () { * @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 */ -OO.ui.ClippableElement.prototype.setIdealSize = function ( width, height ) { +OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) { this.idealWidth = width; this.idealHeight = height; @@ -6374,47 +6777,56 @@ OO.ui.ClippableElement.prototype.setIdealSize = function ( width, height ) { * * @chainable */ -OO.ui.ClippableElement.prototype.clip = function () { +OO.ui.mixin.ClippableElement.prototype.clip = function () { + var $container, extraHeight, extraWidth, ccOffset, + $scrollableContainer, scOffset, scHeight, scWidth, + ccWidth, scrollerIsWindow, scrollTop, scrollLeft, + desiredWidth, desiredHeight, allotedWidth, allotedHeight, + naturalWidth, naturalHeight, clipWidth, clipHeight, + buffer = 7; // Chosen by fair dice roll + if ( !this.clipping ) { - // this.$clippableContainer and this.$clippableWindow are null, so the below will fail + // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail return this; } - var buffer = 7, // Chosen by fair dice roll - cOffset = this.$clippable.offset(), - $container = this.$clippableContainer.is( 'html, body' ) ? - this.$clippableWindow : this.$clippableContainer, - ccOffset = $container.offset() || { top: 0, left: 0 }, - ccHeight = $container.innerHeight() - buffer, - ccWidth = $container.innerWidth() - buffer, - cHeight = this.$clippable.outerHeight() + buffer, - cWidth = this.$clippable.outerWidth() + buffer, - scrollTop = this.$clippableScroller.scrollTop(), - scrollLeft = this.$clippableScroller.scrollLeft(), - desiredWidth = cOffset.left < 0 ? - cWidth + cOffset.left : - ( ccOffset.left + scrollLeft + ccWidth ) - cOffset.left, - desiredHeight = cOffset.top < 0 ? - cHeight + cOffset.top : - ( ccOffset.top + scrollTop + ccHeight ) - cOffset.top, - naturalWidth = this.$clippable.prop( 'scrollWidth' ), - naturalHeight = this.$clippable.prop( 'scrollHeight' ), - clipWidth = desiredWidth < naturalWidth, - clipHeight = desiredHeight < naturalHeight; + $container = this.$clippableContainer || this.$clippable; + extraHeight = $container.outerHeight() - this.$clippable.outerHeight(); + extraWidth = $container.outerWidth() - this.$clippable.outerWidth(); + ccOffset = $container.offset(); + $scrollableContainer = this.$clippableScrollableContainer.is( 'html, body' ) ? + this.$clippableWindow : this.$clippableScrollableContainer; + scOffset = $scrollableContainer.offset() || { top: 0, left: 0 }; + 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; + allotedWidth = desiredWidth - extraWidth; + allotedHeight = desiredHeight - extraHeight; + naturalWidth = this.$clippable.prop( 'scrollWidth' ); + naturalHeight = this.$clippable.prop( 'scrollHeight' ); + clipWidth = allotedWidth < naturalWidth; + clipHeight = allotedHeight < naturalHeight; if ( clipWidth ) { - this.$clippable.css( { overflowX: 'scroll', width: desiredWidth } ); + this.$clippable.css( { overflowX: 'scroll', width: Math.max( 0, allotedWidth ) } ); } else { - this.$clippable.css( { width: this.idealWidth || '', overflowX: '' } ); + this.$clippable.css( { width: this.idealWidth ? this.idealWidth - extraWidth : '', overflowX: '' } ); } if ( clipHeight ) { - this.$clippable.css( { overflowY: 'scroll', height: desiredHeight } ); + this.$clippable.css( { overflowY: 'scroll', height: Math.max( 0, allotedHeight ) } ); } else { - this.$clippable.css( { height: this.idealHeight || '', overflowY: '' } ); + this.$clippable.css( { height: this.idealHeight ? this.idealHeight - extraHeight : '', overflowY: '' } ); } // If we stopped clipping in at least one of the dimensions - if ( !clipWidth || !clipHeight ) { + if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) { OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } @@ -6425,19 +6837,302 @@ OO.ui.ClippableElement.prototype.clip = function () { }; /** - * Generic toolbar tool. + * 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). + * + * 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 + * sure the element is still placed correctly. + * + * As positioning is only possible when both the element and the container are attached to the DOM + * and visible, it's only done after you call #togglePositioning. You might want to do this inside + * the #toggle method to display a floating popup, for example. + * + * @abstract + * @class + * + * @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 + */ +OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$floatable = null; + this.$floatableContainer = null; + this.$floatableWindow = null; + this.$floatableClosestScrollable = null; + this.onFloatableScrollHandler = this.position.bind( this ); + this.onFloatableWindowResizeHandler = this.position.bind( this ); + + // Initialization + this.setFloatableContainer( config.$floatableContainer ); + this.setFloatableElement( config.$floatable || this.$element ); +}; + +/* Methods */ + +/** + * Set floatable element. + * + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $floatable Element to make floatable + */ +OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) { + if ( this.$floatable ) { + this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' ); + this.$floatable.css( { left: '', top: '' } ); + } + + this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' ); + this.position(); +}; + +/** + * Set floatable container. + * + * The element will be always positioned under the specified container. + * + * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset + */ +OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) { + this.$floatableContainer = $floatableContainer; + if ( this.$floatable ) { + this.position(); + } +}; + +/** + * Toggle positioning. + * + * Do not turn positioning on until after the element is attached to the DOM and visible. + * + * @param {boolean} [positioning] Enable positioning, omit to toggle + * @chainable + */ +OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) { + var closestScrollableOfContainer, closestScrollableOfFloatable; + + positioning = positioning === undefined ? !this.positioning : !!positioning; + + if ( this.positioning !== positioning ) { + this.positioning = positioning; + + closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] ); + closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] ); + if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) { + // If the scrollable is the root, we have to listen to scroll events + // on the window because of browser inconsistencies (or do we? someone should verify this) + if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) { + closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer ); + } + } + + if ( positioning ) { + this.$floatableWindow = $( this.getElementWindow() ); + this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler ); + + if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) { + this.$floatableClosestScrollable = $( closestScrollableOfContainer ); + this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler ); + } + + // Initial position after visible + this.position(); + } else { + if ( this.$floatableWindow ) { + this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler ); + this.$floatableWindow = null; + } + + if ( this.$floatableClosestScrollable ) { + this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler ); + this.$floatableClosestScrollable = null; + } + + this.$floatable.css( { left: '', top: '' } ); + } + } + + return this; +}; + +/** + * Position the floatable below its container. + * + * This should only be done when both of them are attached to the DOM and visible. + * + * @chainable + */ +OO.ui.mixin.FloatableElement.prototype.position = function () { + var pos; + + if ( !this.positioning ) { + return this; + } + + pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() ); + + // Position under container + pos.top += this.$floatableContainer.height(); + this.$floatable.css( pos ); + + // We updated the position, so re-evaluate the clipping state. + // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so + // will not notice the need to update itself.) + // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does + // it not listen to the right events in the right places? + if ( this.clip ) { + this.clip(); + } + + return this; +}; + +/** + * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute. + * Accesskeys allow an user to go to a specific element by using + * a shortcut combination of a browser specific keys + the key + * set to the field. + * + * @example + * // AccessKeyedElement provides an 'accesskey' attribute to the + * // ButtonWidget class + * var button = new OO.ui.ButtonWidget( { + * label: 'Button with Accesskey', + * accessKey: 'k' + * } ); + * $( 'body' ).append( button.$element ); + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied. + * If this config is omitted, the accesskey functionality is applied to $element, the + * element created by the class. + * @cfg {string|Function} [accessKey] The key or a function that returns the key. If + * this config is omitted, no accesskey will be added. + */ +OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$accessKeyed = null; + this.accessKey = null; + + // Initialization + this.setAccessKey( config.accessKey || null ); + this.setAccessKeyedElement( config.$accessKeyed || this.$element ); +}; + +/* Setup */ + +OO.initClass( OO.ui.mixin.AccessKeyedElement ); + +/* Static Properties */ + +/** + * The access key, a function that returns a key, or `null` for no accesskey. + * + * @static + * @inheritable + * @property {string|Function|null} + */ +OO.ui.mixin.AccessKeyedElement.static.accessKey = null; + +/* Methods */ + +/** + * Set the accesskeyed element. + * + * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element. + * If an element is already set, the mixin's effect on that element is removed before the new element is set up. + * + * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality + */ +OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) { + if ( this.$accessKeyed ) { + this.$accessKeyed.removeAttr( 'accesskey' ); + } + + this.$accessKeyed = $accessKeyed; + if ( this.accessKey ) { + this.$accessKeyed.attr( 'accesskey', this.accessKey ); + } +}; + +/** + * Set accesskey. + * + * @param {string|Function|null} accesskey Key, a function that returns a key, or `null` for no accesskey + * @chainable + */ +OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) { + accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null; + + if ( this.accessKey !== accessKey ) { + if ( this.$accessKeyed ) { + if ( accessKey !== null ) { + this.$accessKeyed.attr( 'accesskey', accessKey ); + } else { + this.$accessKeyed.removeAttr( 'accesskey' ); + } + } + this.accessKey = accessKey; + } + + return this; +}; + +/** + * Get accesskey. + * + * @return {string} accessKey string + */ +OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () { + return this.accessKey; +}; + +/** + * Tools, together with {@link OO.ui.ToolGroup toolgroups}, constitute {@link OO.ui.Toolbar toolbars}. + * Each tool is configured with a static name, title, and icon and is customized with the command to carry + * out when the tool is selected. Tools must also be registered with a {@link OO.ui.ToolFactory tool factory}, + * which creates the tools on demand. + * + * Tools are added to toolgroups ({@link OO.ui.ListToolGroup ListToolGroup}, + * {@link OO.ui.BarToolGroup BarToolGroup}, or {@link OO.ui.MenuToolGroup MenuToolGroup}), which determine how + * the tool is displayed in the toolbar. See {@link OO.ui.Toolbar toolbars} for an example. + * + * For more information, please see the [OOjs UI documentation on MediaWiki][1]. + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @abstract * @class * @extends OO.ui.Widget - * @mixins OO.ui.IconElement - * @mixins OO.ui.FlaggedElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.FlaggedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {OO.ui.ToolGroup} toolGroup * @param {Object} [config] Configuration options - * @cfg {string|Function} [title] Title text or a function that returns text + * @cfg {string|Function} [title] Title text or a function that returns text. If this config is omitted, the value of + * the {@link #static-title static title} property is used. + * + * The title is used in different ways depending on the type of toolgroup that contains the tool. The + * title is used as a tooltip if the tool is part of a {@link OO.ui.BarToolGroup bar} toolgroup, or as the label text if the tool is + * part of a {@link OO.ui.ListToolGroup list} or {@link OO.ui.MenuToolGroup menu} toolgroup. + * + * For bar toolgroups, a description of the accelerator key is appended to the title if an accelerator key + * is associated with an action by the same name as the tool and accelerator functionality has been added to the application. + * To add accelerator key functionality, you must subclass OO.ui.Toolbar and override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method. */ OO.ui.Tool = function OoUiTool( toolGroup, config ) { // Allow passing positional parameters inside the config object @@ -6450,7 +7145,7 @@ OO.ui.Tool = function OoUiTool( toolGroup, config ) { config = config || {}; // Parent constructor - OO.ui.Tool.super.call( this, config ); + OO.ui.Tool.parent.call( this, config ); // Properties this.toolGroup = toolGroup; @@ -6462,9 +7157,9 @@ OO.ui.Tool = function OoUiTool( toolGroup, config ) { this.title = null; // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.FlaggedElement.call( this, config ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.FlaggedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) ); // Events this.toolbar.connect( this, { updateState: 'onUpdateState' } ); @@ -6497,15 +7192,9 @@ OO.ui.Tool = function OoUiTool( toolGroup, config ) { /* Setup */ OO.inheritClass( OO.ui.Tool, OO.ui.Widget ); -OO.mixinClass( OO.ui.Tool, OO.ui.IconElement ); -OO.mixinClass( OO.ui.Tool, OO.ui.FlaggedElement ); -OO.mixinClass( OO.ui.Tool, OO.ui.TabIndexedElement ); - -/* Events */ - -/** - * @event select - */ +OO.mixinClass( OO.ui.Tool, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.Tool, OO.ui.mixin.FlaggedElement ); +OO.mixinClass( OO.ui.Tool, OO.ui.mixin.TabIndexedElement ); /* Static Properties */ @@ -6518,6 +7207,9 @@ OO.ui.Tool.static.tagName = 'span'; /** * Symbolic name of tool. * + * The symbolic name is used internally to register the tool with a {@link OO.ui.ToolFactory ToolFactory}. It can + * also be used when adding tools to toolgroups. + * * @abstract * @static * @inheritable @@ -6526,7 +7218,10 @@ OO.ui.Tool.static.tagName = 'span'; OO.ui.Tool.static.name = ''; /** - * Tool group. + * Symbolic name of the group. + * + * The group name is used to associate tools with each other so that they can be selected later by + * a {@link OO.ui.ToolGroup toolgroup}. * * @abstract * @static @@ -6536,22 +7231,17 @@ OO.ui.Tool.static.name = ''; OO.ui.Tool.static.group = ''; /** - * Tool title. - * - * Title is used as a tooltip when the tool is part of a bar tool group, or a label when the tool - * is part of a list or menu tool group. If a trigger is associated with an action by the same name - * as the tool, a description of its keyboard shortcut for the appropriate platform will be - * appended to the title if the tool is part of a bar tool group. + * Tool title text or a function that returns title text. The value of the static property is overridden if the #title config option is used. * * @abstract * @static * @inheritable - * @property {string|Function} Title text or a function that returns text + * @property {string|Function} */ OO.ui.Tool.static.title = ''; /** - * Whether this tool should be displayed with both title and label when used in a bar tool group. + * Display both icon and label when the tool is used in a {@link OO.ui.BarToolGroup bar} toolgroup. * Normally only the icon is displayed, or only the label if no icon is given. * * @static @@ -6561,7 +7251,10 @@ OO.ui.Tool.static.title = ''; OO.ui.Tool.static.displayBothIconAndLabel = false; /** - * Tool can be automatically added to catch-all groups. + * Add tool to catch-all groups automatically. + * + * A catch-all group, which contains all tools that do not currently belong to a toolgroup, + * can be included in a toolgroup using the wildcard selector, an asterisk (*). * * @static * @inheritable @@ -6570,7 +7263,11 @@ OO.ui.Tool.static.displayBothIconAndLabel = false; OO.ui.Tool.static.autoAddToCatchall = true; /** - * Tool can be automatically added to named groups. + * Add tool to named groups automatically. + * + * By default, tools that are configured with a static ‘group’ property are added + * to that group and will be selected when the symbolic name of the group is specified (e.g., when + * toolgroups include tools by group name). * * @static * @property {boolean} @@ -6581,6 +7278,10 @@ OO.ui.Tool.static.autoAddToGroup = true; /** * Check if this tool is compatible with given data. * + * This is a stub that can be overriden to provide support for filtering tools based on an + * arbitrary piece of information (e.g., where the cursor is in a document). The implementation + * must also call this method so that the compatibility check can be performed. + * * @static * @inheritable * @param {Mixed} data Data to check @@ -6597,6 +7298,7 @@ OO.ui.Tool.static.isCompatibleWith = function () { * * This is an abstract method that must be overridden in a concrete subclass. * + * @protected * @abstract */ OO.ui.Tool.prototype.onUpdateState = function () { @@ -6610,6 +7312,7 @@ OO.ui.Tool.prototype.onUpdateState = function () { * * This is an abstract method that must be overridden in a concrete subclass. * + * @protected * @abstract */ OO.ui.Tool.prototype.onSelect = function () { @@ -6619,18 +7322,24 @@ OO.ui.Tool.prototype.onSelect = function () { }; /** - * Check if the button is active. + * Check if the tool is active. + * + * Tools become active when their #onSelect or #onUpdateState handlers change them to appear pressed + * with the #setActive method. Additional CSS is applied to the tool to reflect the active state. * - * @return {boolean} Button is active + * @return {boolean} Tool is active */ OO.ui.Tool.prototype.isActive = function () { return this.active; }; /** - * Make the button appear active or inactive. + * Make the tool appear active or inactive. * - * @param {boolean} state Make button appear active + * This method should be called within #onSelect or #onUpdateState event handlers to make the tool + * appear pressed or not. + * + * @param {boolean} state Make tool appear active */ OO.ui.Tool.prototype.setActive = function ( state ) { this.active = !!state; @@ -6642,7 +7351,7 @@ OO.ui.Tool.prototype.setActive = function ( state ) { }; /** - * Get the tool title. + * Set the tool #title. * * @param {string|Function} title Title text or a function that returns text * @chainable @@ -6654,7 +7363,7 @@ OO.ui.Tool.prototype.setTitle = function ( title ) { }; /** - * Get the tool title. + * Get the tool #title. * * @return {string} Title text */ @@ -6698,6 +7407,9 @@ OO.ui.Tool.prototype.updateTitle = function () { /** * Destroy tool. + * + * Destroying the tool removes all event handlers and the tool’s DOM elements. + * Call this method whenever you are done using a tool. */ OO.ui.Tool.prototype.destroy = function () { this.toolbar.disconnect( this ); @@ -6705,11 +7417,23 @@ OO.ui.Tool.prototype.destroy = function () { }; /** - * Collection of tool groups. + * Toolbars are complex interface components that permit users to easily access a variety + * of {@link OO.ui.Tool tools} (e.g., formatting commands) and actions, which are additional commands that are + * part of the toolbar, but not configured as tools. * - * The following is a minimal example using several tools and tool groups. + * Individual tools are customized and then registered with a {@link OO.ui.ToolFactory tool factory}, which creates + * the tools on demand. Each tool has a symbolic name (used when registering the tool), a title (e.g., ‘Insert + * picture’), and an icon. + * + * Individual tools are organized in {@link OO.ui.ToolGroup toolgroups}, which can be {@link OO.ui.MenuToolGroup menus} + * of tools, {@link OO.ui.ListToolGroup lists} of tools, or a single {@link OO.ui.BarToolGroup bar} of tools. + * The arrangement and order of the toolgroups is customized when the toolbar is set up. Tools can be presented in + * any order, but each can only appear once in the toolbar. + * + * The following is an example of a basic toolbar. * * @example + * // Example of a toolbar * // Create the toolbar * var toolFactory = new OO.ui.ToolFactory(); * var toolGroupFactory = new OO.ui.ToolGroupFactory(); @@ -6722,7 +7446,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Create a class inheriting from OO.ui.Tool * function PictureTool() { - * PictureTool.super.apply( this, arguments ); + * PictureTool.parent.apply( this, arguments ); * } * OO.inheritClass( PictureTool, OO.ui.Tool ); * // Each tool must have a 'name' (used as an internal identifier, see later) and at least one @@ -6741,7 +7465,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Register two more tools, nothing interesting here * function SettingsTool() { - * SettingsTool.super.apply( this, arguments ); + * SettingsTool.parent.apply( this, arguments ); * } * OO.inheritClass( SettingsTool, OO.ui.Tool ); * SettingsTool.static.name = 'settings'; @@ -6755,7 +7479,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Register two more tools, nothing interesting here * function StuffTool() { - * StuffTool.super.apply( this, arguments ); + * StuffTool.parent.apply( this, arguments ); * } * OO.inheritClass( StuffTool, OO.ui.Tool ); * StuffTool.static.name = 'stuff'; @@ -6822,7 +7546,7 @@ OO.ui.Tool.prototype.destroy = function () { * // document. * toolbar.initialize(); * - * The following example extends the previous one to illustrate 'menu' tool groups and the usage of + * The following example extends the previous one to illustrate 'menu' toolgroups and the usage of * 'updateState' event. * * @example @@ -6838,7 +7562,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Create a class inheriting from OO.ui.Tool * function PictureTool() { - * PictureTool.super.apply( this, arguments ); + * PictureTool.parent.apply( this, arguments ); * } * OO.inheritClass( PictureTool, OO.ui.Tool ); * // Each tool must have a 'name' (used as an internal identifier, see later) and at least one @@ -6862,7 +7586,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Register two more tools, nothing interesting here * function SettingsTool() { - * SettingsTool.super.apply( this, arguments ); + * SettingsTool.parent.apply( this, arguments ); * this.reallyActive = false; * } * OO.inheritClass( SettingsTool, OO.ui.Tool ); @@ -6883,7 +7607,7 @@ OO.ui.Tool.prototype.destroy = function () { * * // Register two more tools, nothing interesting here * function StuffTool() { - * StuffTool.super.apply( this, arguments ); + * StuffTool.parent.apply( this, arguments ); * this.reallyActive = false; * } * OO.inheritClass( StuffTool, OO.ui.Tool ); @@ -6958,14 +7682,16 @@ OO.ui.Tool.prototype.destroy = function () { * @class * @extends OO.ui.Element * @mixins OO.EventEmitter - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {OO.ui.ToolFactory} toolFactory Factory for creating tools - * @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating tool groups + * @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating toolgroups * @param {Object} [config] Configuration options - * @cfg {boolean} [actions] Add an actions section opposite to the tools - * @cfg {boolean} [shadow] Add a shadow below the toolbar + * @cfg {boolean} [actions] Add an actions section to the toolbar. Actions are commands that are included + * in the toolbar, but are not configured as tools. By default, actions are displayed on the right side of + * the toolbar. + * @cfg {boolean} [shadow] Add a shadow below the toolbar. */ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { // Allow passing positional parameters inside the config object @@ -6979,11 +7705,11 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { config = config || {}; // Parent constructor - OO.ui.Toolbar.super.call( this, config ); + OO.ui.Toolbar.parent.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); - OO.ui.GroupElement.call( this, config ); + OO.ui.mixin.GroupElement.call( this, config ); // Properties this.toolFactory = toolFactory; @@ -7018,7 +7744,7 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { OO.inheritClass( OO.ui.Toolbar, OO.ui.Element ); OO.mixinClass( OO.ui.Toolbar, OO.EventEmitter ); -OO.mixinClass( OO.ui.Toolbar, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.Toolbar, OO.ui.mixin.GroupElement ); /* Methods */ @@ -7032,9 +7758,9 @@ OO.ui.Toolbar.prototype.getToolFactory = function () { }; /** - * Get the tool group factory. + * Get the toolgroup factory. * - * @return {OO.Factory} Tool group factory + * @return {OO.Factory} Toolgroup factory */ OO.ui.Toolbar.prototype.getToolGroupFactory = function () { return this.toolGroupFactory; @@ -7043,6 +7769,7 @@ OO.ui.Toolbar.prototype.getToolGroupFactory = function () { /** * Handles mouse down events. * + * @private * @param {jQuery.Event} e Mouse down event */ OO.ui.Toolbar.prototype.onPointerDown = function ( e ) { @@ -7071,26 +7798,27 @@ OO.ui.Toolbar.prototype.onWindowResize = function () { * This must be called after it is attached to a visible document and before doing anything else. */ OO.ui.Toolbar.prototype.initialize = function () { - this.initialized = true; - this.narrowThreshold = this.$group.width() + this.$actions.width(); - $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler ); - this.onWindowResize(); + if ( !this.initialized ) { + this.initialized = true; + this.narrowThreshold = this.$group.width() + this.$actions.width(); + $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler ); + this.onWindowResize(); + } }; /** - * Setup toolbar. + * Set up the toolbar. * - * Tools can be specified in the following ways: + * The toolbar is set up with a list of toolgroup configurations that specify the type of + * toolgroup ({@link OO.ui.BarToolGroup bar}, {@link OO.ui.MenuToolGroup menu}, or {@link OO.ui.ListToolGroup list}) + * to add and which tools to include, exclude, promote, or demote within that toolgroup. Please + * see {@link OO.ui.ToolGroup toolgroups} for more information about including tools in toolgroups. * - * - A specific tool: `{ name: 'tool-name' }` or `'tool-name'` - * - All tools in a group: `{ group: 'group-name' }` - * - All tools: `'*'` - Using this will make the group a list with a "More" label by default - * - * @param {Object.<string,Array>} groups List of tool group configurations - * @param {Array|string} [groups.include] Tools to include - * @param {Array|string} [groups.exclude] Tools to exclude - * @param {Array|string} [groups.promote] Tools to promote to the beginning - * @param {Array|string} [groups.demote] Tools to demote to the end + * @param {Object.<string,Array>} groups List of toolgroup configurations + * @param {Array|string} [groups.include] Tools to include in the toolgroup + * @param {Array|string} [groups.exclude] Tools to exclude from the toolgroup + * @param {Array|string} [groups.promote] Tools to promote to the beginning of the toolgroup + * @param {Array|string} [groups.demote] Tools to demote to the end of the toolgroup */ OO.ui.Toolbar.prototype.setup = function ( groups ) { var i, len, type, group, @@ -7122,7 +7850,7 @@ OO.ui.Toolbar.prototype.setup = function ( groups ) { }; /** - * Remove all tools and groups from the toolbar. + * Remove all tools and toolgroups from the toolbar. */ OO.ui.Toolbar.prototype.reset = function () { var i, len; @@ -7136,9 +7864,10 @@ OO.ui.Toolbar.prototype.reset = function () { }; /** - * Destroys toolbar, removing event handlers and DOM elements. + * Destroy the toolbar. * - * Call this whenever you are done using a toolbar. + * Destroying the toolbar removes all event handlers and DOM elements that constitute the toolbar. Call + * this method whenever you are done using a toolbar. */ OO.ui.Toolbar.prototype.destroy = function () { $( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler ); @@ -7147,7 +7876,9 @@ OO.ui.Toolbar.prototype.destroy = function () { }; /** - * Check if tool has not been used yet. + * Check if the tool is available. + * + * Available tools are ones that have not yet been added to the toolbar. * * @param {string} name Symbolic name of tool * @return {boolean} Tool is available @@ -7177,7 +7908,9 @@ OO.ui.Toolbar.prototype.releaseTool = function ( tool ) { /** * Get accelerator label for tool. * - * This is a stub that should be overridden to provide access to accelerator information. + * The OOjs UI library does not contain an accelerator system, but this is the hook for one. To + * use an accelerator system, subclass the toolbar and override this method, which is meant to return a label + * that describes the accelerator keys for the tool passed (by symbolic name) to the method. * * @param {string} name Symbolic name of tool * @return {string|undefined} Tool accelerator label if available @@ -7187,26 +7920,44 @@ OO.ui.Toolbar.prototype.getToolAccelerator = function () { }; /** - * Collection of tools. + * ToolGroups are collections of {@link OO.ui.Tool tools} that are used in a {@link OO.ui.Toolbar toolbar}. + * The type of toolgroup ({@link OO.ui.ListToolGroup list}, {@link OO.ui.BarToolGroup bar}, or {@link OO.ui.MenuToolGroup menu}) + * to which a tool belongs determines how the tool is arranged and displayed in the toolbar. Toolgroups + * themselves are created on demand with a {@link OO.ui.ToolGroupFactory toolgroup factory}. * - * Tools can be specified in the following ways: + * Toolgroups can contain individual tools, groups of tools, or all available tools: * - * - A specific tool: `{ name: 'tool-name' }` or `'tool-name'` - * - All tools in a group: `{ group: 'group-name' }` - * - All tools: `'*'` + * To include an individual tool (or array of individual tools), specify tools by symbolic name: + * + * include: [ 'tool-name' ] or [ { name: 'tool-name' }] + * + * To include a group of tools, specify the group name. (The tool's static ‘group’ config is used to assign the tool to a group.) + * + * include: [ { group: 'group-name' } ] + * + * To include all tools that are not yet assigned to a toolgroup, use the catch-all selector, an asterisk (*): + * + * include: '*' + * + * See {@link OO.ui.Toolbar toolbars} for a full example. For more information about toolbars in general, + * please see the [OOjs UI documentation on MediaWiki][1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @abstract * @class * @extends OO.ui.Widget - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options - * @cfg {Array|string} [include=[]] List of tools to include - * @cfg {Array|string} [exclude=[]] List of tools to exclude - * @cfg {Array|string} [promote=[]] List of tools to promote to the beginning - * @cfg {Array|string} [demote=[]] List of tools to demote to the end + * @cfg {Array|string} [include=[]] List of tools to include in the toolgroup. + * @cfg {Array|string} [exclude=[]] List of tools to exclude from the toolgroup. + * @cfg {Array|string} [promote=[]] List of tools to promote to the beginning of the toolgroup. + * @cfg {Array|string} [demote=[]] List of tools to demote to the end of the toolgroup. + * This setting is particularly useful when tools have been added to the toolgroup + * en masse (e.g., via the catch-all selector). */ OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object @@ -7219,10 +7970,10 @@ OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) { config = config || {}; // Parent constructor - OO.ui.ToolGroup.super.call( this, config ); + OO.ui.ToolGroup.parent.call( this, config ); // Mixin constructors - OO.ui.GroupElement.call( this, config ); + OO.ui.mixin.GroupElement.call( this, config ); // Properties this.toolbar = toolbar; @@ -7261,7 +8012,7 @@ OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) { /* Setup */ OO.inheritClass( OO.ui.ToolGroup, OO.ui.Widget ); -OO.mixinClass( OO.ui.ToolGroup, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.ToolGroup, OO.ui.mixin.GroupElement ); /* Events */ @@ -7283,6 +8034,11 @@ OO.ui.ToolGroup.static.titleTooltips = false; /** * Show acceleration labels in tooltips. * + * Note: The OOjs UI library does not include an accelerator system, but does contain + * a hook for one. To use an accelerator system, subclass the {@link OO.ui.Toolbar toolbar} and + * override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method, which is + * meant to return a label that describes the accelerator keys for a given tool (e.g., 'Ctrl + M'). + * * @static * @inheritable * @property {boolean} @@ -7304,7 +8060,7 @@ OO.ui.ToolGroup.static.autoDisable = true; * @inheritdoc */ OO.ui.ToolGroup.prototype.isDisabled = function () { - return this.autoDisabled || OO.ui.ToolGroup.super.prototype.isDisabled.apply( this, arguments ); + return this.autoDisabled || OO.ui.ToolGroup.parent.prototype.isDisabled.apply( this, arguments ); }; /** @@ -7323,12 +8079,13 @@ OO.ui.ToolGroup.prototype.updateDisabled = function () { } this.autoDisabled = allDisabled; } - OO.ui.ToolGroup.super.prototype.updateDisabled.apply( this, arguments ); + OO.ui.ToolGroup.parent.prototype.updateDisabled.apply( this, arguments ); }; /** * Handle mouse down and key down events. * + * @protected * @param {jQuery.Event} e Mouse down or key down event */ OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) { @@ -7339,8 +8096,8 @@ OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) { this.pressed = this.getTargetTool( e ); if ( this.pressed ) { this.pressed.setActive( true ); - this.getElementDocument().addEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true ); - this.getElementDocument().addEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler ); } return false; } @@ -7349,11 +8106,12 @@ OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) { /** * Handle captured mouse up and key up events. * + * @protected * @param {Event} e Mouse up or key up event */ OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) { - this.getElementDocument().removeEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true ); - this.getElementDocument().removeEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler ); // onMouseKeyUp may be called a second time, depending on where the mouse is when the button is // released, but since `this.pressed` will no longer be true, the second call will be ignored. this.onMouseKeyUp( e ); @@ -7362,6 +8120,7 @@ OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) { /** * Handle mouse up and key up events. * + * @protected * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) { @@ -7382,6 +8141,7 @@ OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) { /** * Handle mouse over and focus events. * + * @protected * @param {jQuery.Event} e Mouse over or focus event */ OO.ui.ToolGroup.prototype.onMouseOverFocus = function ( e ) { @@ -7395,6 +8155,7 @@ OO.ui.ToolGroup.prototype.onMouseOverFocus = function ( e ) { /** * Handle mouse out and blur events. * + * @protected * @param {jQuery.Event} e Mouse out or blur event */ OO.ui.ToolGroup.prototype.onMouseOutBlur = function ( e ) { @@ -7434,6 +8195,7 @@ OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) { * - a tool being added that may be included * - a tool already included being overridden * + * @protected * @param {string} name Symbolic name of tool */ OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () { @@ -7441,9 +8203,9 @@ OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () { }; /** - * Get the toolbar this group is in. + * Get the toolbar that contains the toolgroup. * - * @return {OO.ui.Toolbar} Toolbar of group + * @return {OO.ui.Toolbar} Toolbar that contains the toolgroup */ OO.ui.ToolGroup.prototype.getToolbar = function () { return this.toolbar; @@ -7510,7 +8272,7 @@ OO.ui.ToolGroup.prototype.populate = function () { }; /** - * Destroy tool group. + * Destroy toolgroup. */ OO.ui.ToolGroup.prototype.destroy = function () { var name; @@ -7568,7 +8330,7 @@ OO.ui.ToolGroup.prototype.destroy = function () { */ OO.ui.MessageDialog = function OoUiMessageDialog( config ) { // Parent constructor - OO.ui.MessageDialog.super.call( this, config ); + OO.ui.MessageDialog.parent.call( this, config ); // Properties this.verticalActionLayout = null; @@ -7577,7 +8339,7 @@ OO.ui.MessageDialog = function OoUiMessageDialog( config ) { this.$element.addClass( 'oo-ui-messageDialog' ); }; -/* Inheritance */ +/* Setup */ OO.inheritClass( OO.ui.MessageDialog, OO.ui.Dialog ); @@ -7624,7 +8386,7 @@ OO.ui.MessageDialog.static.actions = [ * @inheritdoc */ OO.ui.MessageDialog.prototype.setManager = function ( manager ) { - OO.ui.MessageDialog.super.prototype.setManager.call( this, manager ); + OO.ui.MessageDialog.parent.prototype.setManager.call( this, manager ); // Events this.manager.connect( this, { @@ -7639,7 +8401,7 @@ OO.ui.MessageDialog.prototype.setManager = function ( manager ) { */ OO.ui.MessageDialog.prototype.onActionResize = function ( action ) { this.fitActions(); - return OO.ui.MessageDialog.super.prototype.onActionResize.call( this, action ); + return OO.ui.MessageDialog.parent.prototype.onActionResize.call( this, action ); }; /** @@ -7659,7 +8421,6 @@ OO.ui.MessageDialog.prototype.onResize = function () { /** * Toggle action layout between vertical and horizontal. * - * * @private * @param {boolean} [value] Layout actions vertically, omit to toggle * @chainable @@ -7686,7 +8447,7 @@ OO.ui.MessageDialog.prototype.getActionProcess = function ( action ) { this.close( { action: action } ); }, this ); } - return OO.ui.MessageDialog.super.prototype.getActionProcess.call( this, action ); + return OO.ui.MessageDialog.parent.prototype.getActionProcess.call( this, action ); }; /** @@ -7703,7 +8464,7 @@ OO.ui.MessageDialog.prototype.getSetupProcess = function ( data ) { data = data || {}; // Parent method - return OO.ui.MessageDialog.super.prototype.getSetupProcess.call( this, data ) + return OO.ui.MessageDialog.parent.prototype.getSetupProcess.call( this, data ) .next( function () { this.title.setLabel( data.title !== undefined ? data.title : this.constructor.static.title @@ -7721,6 +8482,26 @@ OO.ui.MessageDialog.prototype.getSetupProcess = function ( data ) { /** * @inheritdoc */ +OO.ui.MessageDialog.prototype.getReadyProcess = function ( data ) { + data = data || {}; + + // Parent method + return OO.ui.MessageDialog.parent.prototype.getReadyProcess.call( this, data ) + .next( function () { + // Focus the primary action button + var actions = this.actions.get(); + actions = actions.filter( function ( action ) { + return action.getFlags().indexOf( 'primary' ) > -1; + } ); + if ( actions.length > 0 ) { + actions[ 0 ].$button.focus(); + } + }, this ); +}; + +/** + * @inheritdoc + */ OO.ui.MessageDialog.prototype.getBodyHeight = function () { var bodyHeight, oldOverflow, $scrollable = this.container.$element; @@ -7741,7 +8522,7 @@ OO.ui.MessageDialog.prototype.getBodyHeight = function () { */ OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) { var $scrollable = this.container.$element; - OO.ui.MessageDialog.super.prototype.setDimensions.call( this, dim ); + OO.ui.MessageDialog.parent.prototype.setDimensions.call( this, dim ); // Twiddle the overflow property, otherwise an unnecessary scrollbar will be produced. // Need to do it after transition completes (250ms), add 50ms just in case. @@ -7762,7 +8543,7 @@ OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) { */ OO.ui.MessageDialog.prototype.initialize = function () { // Parent method - OO.ui.MessageDialog.super.prototype.initialize.call( this ); + OO.ui.MessageDialog.parent.prototype.initialize.call( this ); // Properties this.$actions = $( '<div>' ); @@ -7793,10 +8574,11 @@ OO.ui.MessageDialog.prototype.attachActions = function () { var i, len, other, special, others; // Parent method - OO.ui.MessageDialog.super.prototype.attachActions.call( this ); + OO.ui.MessageDialog.parent.prototype.attachActions.call( this ); special = this.actions.getSpecial(); others = this.actions.getOthers(); + if ( special.safe ) { this.$actions.append( special.safe.$element ); special.safe.toggleFramed( false ); @@ -7869,7 +8651,7 @@ OO.ui.MessageDialog.prototype.fitActions = function () { * @example * // Example: Creating and opening a process dialog window. * function MyProcessDialog( config ) { - * MyProcessDialog.super.call( this, config ); + * MyProcessDialog.parent.call( this, config ); * } * OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog ); * @@ -7880,7 +8662,7 @@ OO.ui.MessageDialog.prototype.fitActions = function () { * ]; * * MyProcessDialog.prototype.initialize = function () { - * MyProcessDialog.super.prototype.initialize.apply( this, arguments ); + * MyProcessDialog.parent.prototype.initialize.apply( this, arguments ); * this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); * this.content.$element.append( '<p>This is a process dialog window. The header contains the title and two buttons: \'Cancel\' (a safe action) on the left and \'Done\' (a primary action) on the right.</p>' ); * this.$body.append( this.content.$element ); @@ -7892,7 +8674,7 @@ OO.ui.MessageDialog.prototype.fitActions = function () { * dialog.close( { action: action } ); * } ); * } - * return MyProcessDialog.super.prototype.getActionProcess.call( this, action ); + * return MyProcessDialog.parent.prototype.getActionProcess.call( this, action ); * }; * * var windowManager = new OO.ui.WindowManager(); @@ -7913,7 +8695,10 @@ OO.ui.MessageDialog.prototype.fitActions = function () { */ OO.ui.ProcessDialog = function OoUiProcessDialog( config ) { // Parent constructor - OO.ui.ProcessDialog.super.call( this, config ); + OO.ui.ProcessDialog.parent.call( this, config ); + + // Properties + this.fitOnOpen = false; // Initialization this.$element.addClass( 'oo-ui-processDialog' ); @@ -7955,7 +8740,7 @@ OO.ui.ProcessDialog.prototype.onActionResize = function ( action ) { if ( this.actions.isSpecial( action ) ) { this.fitLabel(); } - return OO.ui.ProcessDialog.super.prototype.onActionResize.call( this, action ); + return OO.ui.ProcessDialog.parent.prototype.onActionResize.call( this, action ); }; /** @@ -7963,7 +8748,7 @@ OO.ui.ProcessDialog.prototype.onActionResize = function ( action ) { */ OO.ui.ProcessDialog.prototype.initialize = function () { // Parent method - OO.ui.ProcessDialog.super.prototype.initialize.call( this ); + OO.ui.ProcessDialog.parent.prototype.initialize.call( this ); // Properties this.$navigation = $( '<div>' ); @@ -8026,7 +8811,7 @@ OO.ui.ProcessDialog.prototype.attachActions = function () { var i, len, other, special, others; // Parent method - OO.ui.ProcessDialog.super.prototype.attachActions.call( this ); + OO.ui.ProcessDialog.parent.prototype.attachActions.call( this ); special = this.actions.getSpecial(); others = this.actions.getOthers(); @@ -8050,24 +8835,70 @@ OO.ui.ProcessDialog.prototype.attachActions = function () { */ OO.ui.ProcessDialog.prototype.executeAction = function ( action ) { var process = this; - return OO.ui.ProcessDialog.super.prototype.executeAction.call( this, action ) + return OO.ui.ProcessDialog.parent.prototype.executeAction.call( this, action ) .fail( function ( errors ) { process.showErrors( errors || [] ); } ); }; /** + * @inheritdoc + */ +OO.ui.ProcessDialog.prototype.setDimensions = function () { + // Parent method + OO.ui.ProcessDialog.parent.prototype.setDimensions.apply( this, arguments ); + + this.fitLabel(); +}; + +/** * Fit label between actions. * * @private * @chainable */ OO.ui.ProcessDialog.prototype.fitLabel = function () { - var width = Math.max( - this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0, - this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0 - ); - this.$location.css( { paddingLeft: width, paddingRight: width } ); + var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth, + size = this.getSizeProperties(); + + if ( typeof size.width !== 'number' ) { + if ( this.isOpened() ) { + navigationWidth = this.$head.width() - 20; + } else if ( this.isOpening() ) { + if ( !this.fitOnOpen ) { + // Size is relative and the dialog isn't open yet, so wait. + this.manager.opening.done( this.fitLabel.bind( this ) ); + this.fitOnOpen = true; + } + return; + } else { + return; + } + } else { + navigationWidth = size.width - 20; + } + + safeWidth = this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0; + primaryWidth = this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0; + biggerWidth = Math.max( safeWidth, primaryWidth ); + + labelWidth = this.title.$element.width(); + + if ( 2 * biggerWidth + labelWidth < navigationWidth ) { + // We have enough space to center the label + leftWidth = rightWidth = biggerWidth; + } else { + // Let's hope we at least have enough space not to overlap, because we can't wrap the label… + if ( this.getDir() === 'ltr' ) { + leftWidth = safeWidth; + rightWidth = primaryWidth; + } else { + leftWidth = primaryWidth; + rightWidth = safeWidth; + } + } + + this.$location.css( { paddingLeft: leftWidth, paddingRight: rightWidth } ); return this; }; @@ -8103,14 +8934,14 @@ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { } this.$errorItems = $( items ); if ( recoverable ) { - abilities[this.currentAction] = true; + abilities[ this.currentAction ] = true; // Copy the flags from the first matching action actions = this.actions.get( { actions: this.currentAction } ); if ( actions.length ) { - this.retryButton.clearFlags().setFlags( actions[0].getFlags() ); + this.retryButton.clearFlags().setFlags( actions[ 0 ].getFlags() ); } } else { - abilities[this.currentAction] = false; + abilities[ this.currentAction ] = false; this.actions.setAbilities( abilities ); } if ( warning ) { @@ -8141,10 +8972,11 @@ OO.ui.ProcessDialog.prototype.hideErrors = function () { */ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) { // Parent method - return OO.ui.ProcessDialog.super.prototype.getTeardownProcess.call( this, data ) + return OO.ui.ProcessDialog.parent.prototype.getTeardownProcess.call( this, data ) .first( function () { // Make sure to hide errors this.hideErrors(); + this.fitOnOpen = false; }, this ); }; @@ -8170,36 +9002,55 @@ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) { * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets * @class * @extends OO.ui.Layout - * @mixins OO.ui.LabelElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {OO.ui.Widget} fieldWidget Field widget * @param {Object} [config] Configuration options * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline' - * @cfg {string} [help] Help text. When help text is specified, a help icon will appear - * in the upper-right corner of the rendered field. + * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget. + * The array may contain strings or OO.ui.HtmlSnippet instances. + * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget. + * The array may contain strings or OO.ui.HtmlSnippet instances. + * @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. + * + * @throws {Error} An error is thrown if no widget is specified */ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { + var hasInputWidget, div, i; + // Allow passing positional parameters inside the config object if ( OO.isPlainObject( fieldWidget ) && config === undefined ) { config = fieldWidget; fieldWidget = config.fieldWidget; } - var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; + // Make sure we have required constructor arguments + if ( fieldWidget === undefined ) { + throw new Error( 'Widget not found' ); + } + + hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel; // Configuration initialization config = $.extend( { align: 'left' }, config ); // Parent constructor - OO.ui.FieldLayout.super.call( this, config ); + OO.ui.FieldLayout.parent.call( this, config ); // Mixin constructors - OO.ui.LabelElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) ); // Properties this.fieldWidget = fieldWidget; + this.errors = config.errors || []; + this.notices = config.notices || []; this.$field = $( '<div>' ); + this.$messages = $( '<ul>' ); this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' ); this.align = null; if ( config.help ) { @@ -8209,10 +9060,14 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { icon: 'info' } ); + div = $( '<div>' ); + if ( config.help instanceof OO.ui.HtmlSnippet ) { + div.html( config.help.toString() ); + } else { + div.text( config.help ); + } this.popupButtonWidget.getPopup().$body.append( - $( '<div>' ) - .text( config.help ) - .addClass( 'oo-ui-fieldLayout-help-content' ) + div.addClass( 'oo-ui-fieldLayout-help-content' ) ); this.$help = this.popupButtonWidget.$element; } else { @@ -8229,19 +9084,31 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { this.$element .addClass( 'oo-ui-fieldLayout' ) .append( this.$help, this.$body ); + if ( this.errors.length || this.notices.length ) { + this.$element.append( this.$messages ); + } this.$body.addClass( 'oo-ui-fieldLayout-body' ); + this.$messages.addClass( 'oo-ui-fieldLayout-messages' ); this.$field .addClass( 'oo-ui-fieldLayout-field' ) .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() ) .append( this.fieldWidget.$element ); + for ( i = 0; i < this.notices.length; i++ ) { + this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) ); + } + for ( i = 0; i < this.errors.length; i++ ) { + this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) ); + } + this.setAlignment( config.align ); }; /* Setup */ OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); +OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement ); /* Methods */ @@ -8276,6 +9143,28 @@ OO.ui.FieldLayout.prototype.getField = function () { }; /** + * @param {string} kind 'error' or 'notice' + * @param {string|OO.ui.HtmlSnippet} text + * @return {jQuery} + */ +OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) { + var $listItem, $icon, message; + $listItem = $( '<li>' ); + if ( kind === 'error' ) { + $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element; + } else if ( kind === 'notice' ) { + $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element; + } else { + $icon = ''; + } + message = new OO.ui.LabelWidget( { label: text } ); + $listItem + .append( $icon, message.$element ) + .addClass( 'oo-ui-fieldLayout-messages-' + kind ); + return $listItem; +}; + +/** * Set the field alignment mode. * * @private @@ -8347,17 +9236,12 @@ OO.ui.FieldLayout.prototype.setAlignment = function ( value ) { * * $( 'body' ).append( actionFieldLayout.$element ); * - * * @class * @extends OO.ui.FieldLayout * * @constructor * @param {OO.ui.Widget} fieldWidget Field widget * @param {OO.ui.ButtonWidget} buttonWidget Button widget - * @param {Object} [config] Configuration options - * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline' - * @cfg {string} [help] Help text. When help text is specified, a help icon will appear in the - * upper-right corner of the rendered field. */ OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) { // Allow passing positional parameters inside the config object @@ -8367,23 +9251,24 @@ OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWid buttonWidget = config.buttonWidget; } - // Configuration initialization - config = $.extend( { align: 'left' }, config ); - // Parent constructor - OO.ui.ActionFieldLayout.super.call( this, fieldWidget, config ); + OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config ); // Properties - this.fieldWidget = fieldWidget; this.buttonWidget = buttonWidget; - this.$button = $( '<div>' ) + this.$button = $( '<div>' ); + this.$input = $( '<div>' ); + + // Initialization + this.$element + .addClass( 'oo-ui-actionFieldLayout' ); + this.$button .addClass( 'oo-ui-actionFieldLayout-button' ) .append( this.buttonWidget.$element ); - this.$input = $( '<div>' ) + this.$input .addClass( 'oo-ui-actionFieldLayout-input' ) .append( this.fieldWidget.$element ); this.$field - .addClass( 'oo-ui-actionFieldLayout' ) .append( this.$input, this.$button ); }; @@ -8425,9 +9310,9 @@ OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout ); * * @class * @extends OO.ui.Layout - * @mixins OO.ui.IconElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options @@ -8438,12 +9323,12 @@ OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { config = config || {}; // Parent constructor - OO.ui.FieldsetLayout.super.call( this, config ); + OO.ui.FieldsetLayout.parent.call( this, config ); // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.GroupElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.GroupElement.call( this, config ); if ( config.help ) { this.popupButtonWidget = new OO.ui.PopupButtonWidget( { @@ -8474,9 +9359,9 @@ OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { /* Setup */ OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.IconElement ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement ); /** * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based @@ -8531,7 +9416,7 @@ OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.GroupElement ); * * @class * @extends OO.ui.Layout - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options @@ -8545,14 +9430,19 @@ OO.ui.FormLayout = function OoUiFormLayout( config ) { config = config || {}; // Parent constructor - OO.ui.FormLayout.super.call( this, config ); + OO.ui.FormLayout.parent.call( this, config ); // Mixin constructors - OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); + OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Events this.$element.on( 'submit', this.onFormSubmit.bind( this ) ); + // Make sure the action is safe + if ( config.action !== undefined && !OO.ui.isSafeUrl( config.action ) ) { + throw new Error( 'Potentially unsafe action provided: ' + config.action ); + } + // Initialization this.$element .addClass( 'oo-ui-formLayout' ) @@ -8569,7 +9459,7 @@ OO.ui.FormLayout = function OoUiFormLayout( config ) { /* Setup */ OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FormLayout, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement ); /* Events */ @@ -8671,7 +9561,7 @@ OO.ui.MenuLayout = function OoUiMenuLayout( config ) { }, config ); // Parent constructor - OO.ui.MenuLayout.super.call( this, config ); + OO.ui.MenuLayout.parent.call( this, config ); /** * Menu DOM node @@ -8768,7 +9658,7 @@ OO.ui.MenuLayout.prototype.getMenuPosition = function () { * // Example of a BookletLayout that contains two PageLayouts. * * function PageOneLayout( name, config ) { - * PageOneLayout.super.call( this, name, config ); + * PageOneLayout.parent.call( this, name, config ); * this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' ); * } * OO.inheritClass( PageOneLayout, OO.ui.PageLayout ); @@ -8777,7 +9667,7 @@ OO.ui.MenuLayout.prototype.getMenuPosition = function () { * }; * * function PageTwoLayout( name, config ) { - * PageTwoLayout.super.call( this, name, config ); + * PageTwoLayout.parent.call( this, name, config ); * this.$element.append( '<p>Second page</p>' ); * } * OO.inheritClass( PageTwoLayout, OO.ui.PageLayout ); @@ -8810,7 +9700,7 @@ OO.ui.BookletLayout = function OoUiBookletLayout( config ) { config = config || {}; // Parent constructor - OO.ui.BookletLayout.super.call( this, config ); + OO.ui.BookletLayout.parent.call( this, config ); // Properties this.currentPageName = null; @@ -8936,7 +9826,7 @@ OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { * @param {number} [itemIndex] A specific item to focus on */ OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) { - var $input, page, + var page, items = this.stackLayout.getItems(); if ( itemIndex !== undefined && items[ itemIndex ] ) { @@ -8953,11 +9843,8 @@ OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) { return; } // Only change the focus if is not already in the current page - if ( !page.$element.find( ':focus' ).length ) { - $input = page.$element.find( ':input:first' ); - if ( $input.length ) { - $input[ 0 ].focus(); - } + if ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) { + page.focus(); } }; @@ -8966,28 +9853,7 @@ OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) { * on it. */ OO.ui.BookletLayout.prototype.focusFirstFocusable = function () { - var i, len, - found = false, - items = this.stackLayout.getItems(), - checkAndFocus = function () { - if ( OO.ui.isFocusableElement( $( this ) ) ) { - $( this ).focus(); - found = true; - return false; - } - }; - - for ( i = 0, len = items.length; i < len; i++ ) { - if ( found ) { - break; - } - // Find all potentially focusable elements in the item - // and check if they are focusable - items[i].$element - .find( 'input, select, textarea, button, object' ) - /* jshint loopfunc:true */ - .each( checkAndFocus ); - } + OO.ui.findFocusable( this.stackLayout.$element ).focus(); }; /** @@ -9054,7 +9920,7 @@ OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { var next, prev, level, pages = this.stackLayout.getItems(), - index = $.inArray( page, pages ); + index = pages.indexOf( page ); if ( index !== -1 ) { next = pages[ index + 1 ]; @@ -9154,7 +10020,7 @@ OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) { if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { // Correct the insertion index - currentIndex = $.inArray( this.pages[ name ], stackLayoutPages ); + currentIndex = stackLayoutPages.indexOf( this.pages[ name ] ); if ( currentIndex !== -1 && currentIndex + 1 < index ) { index--; } @@ -9255,7 +10121,8 @@ OO.ui.BookletLayout.prototype.clearPages = function () { OO.ui.BookletLayout.prototype.setPage = function ( name ) { var selectedItem, $focused, - page = this.pages[ name ]; + page = this.pages[ name ], + previousPage = this.currentPageName && this.pages[ this.currentPageName ]; if ( name !== this.currentPageName ) { if ( this.outlined ) { @@ -9265,21 +10132,34 @@ OO.ui.BookletLayout.prototype.setPage = function ( name ) { } } if ( page ) { - if ( this.currentPageName && this.pages[ this.currentPageName ] ) { - this.pages[ this.currentPageName ].setActive( false ); - // Blur anything focused if the next page doesn't have anything focusable - this - // is not needed if the next page has something focusable because once it is focused - // this blur happens automatically - if ( this.autoFocus && !page.$element.find( ':input' ).length ) { - $focused = this.pages[ this.currentPageName ].$element.find( ':focus' ); + if ( previousPage ) { + previousPage.setActive( false ); + // Blur anything focused if the next page doesn't have anything focusable. + // This is not needed if the next page has something focusable (because once it is focused + // this blur happens automatically). If the layout is non-continuous, this check is + // meaningless because the next page is not visible yet and thus can't hold focus. + if ( + this.autoFocus && + this.stackLayout.continuous && + OO.ui.findFocusable( page.$element ).length !== 0 + ) { + $focused = previousPage.$element.find( ':focus' ); if ( $focused.length ) { $focused[ 0 ].blur(); } } } this.currentPageName = name; - this.stackLayout.setItem( page ); page.setActive( true ); + this.stackLayout.setItem( page ); + if ( !this.stackLayout.continuous && previousPage ) { + // This should not be necessary, since any inputs on the previous page should have been + // blurred when it was hidden, but browsers are not very consistent about this. + $focused = previousPage.$element.find( ':focus' ); + if ( $focused.length ) { + $focused[ 0 ].blur(); + } + } this.emit( 'set', page ); } } @@ -9311,25 +10191,18 @@ OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { * // Example of a IndexLayout that contains two CardLayouts. * * function CardOneLayout( name, config ) { - * CardOneLayout.super.call( this, name, config ); + * CardOneLayout.parent.call( this, name, config ); * this.$element.append( '<p>First card</p>' ); * } * OO.inheritClass( CardOneLayout, OO.ui.CardLayout ); * CardOneLayout.prototype.setupTabItem = function () { - * this.tabItem.setLabel( 'Card One' ); - * }; - * - * function CardTwoLayout( name, config ) { - * CardTwoLayout.super.call( this, name, config ); - * this.$element.append( '<p>Second card</p>' ); - * } - * OO.inheritClass( CardTwoLayout, OO.ui.CardLayout ); - * CardTwoLayout.prototype.setupTabItem = function () { - * this.tabItem.setLabel( 'Card Two' ); + * this.tabItem.setLabel( 'Card one' ); * }; * * var card1 = new CardOneLayout( 'one' ), - * card2 = new CardTwoLayout( 'two' ); + * card2 = new CardLayout( 'two', { label: 'Card two' } ); + * + * card2.$element.append( '<p>Second card</p>' ); * * var index = new OO.ui.IndexLayout(); * @@ -9342,6 +10215,7 @@ OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [continuous=false] Show all cards, one after another + * @cfg {boolean} [expanded=true] Expand the content panel to fill the entire parent element. * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed. */ OO.ui.IndexLayout = function OoUiIndexLayout( config ) { @@ -9349,13 +10223,16 @@ OO.ui.IndexLayout = function OoUiIndexLayout( config ) { config = $.extend( {}, config, { menuPosition: 'top' } ); // Parent constructor - OO.ui.IndexLayout.super.call( this, config ); + OO.ui.IndexLayout.parent.call( this, config ); // Properties this.currentCardName = null; this.cards = {}; this.ignoreFocus = false; - this.stackLayout = new OO.ui.StackLayout( { continuous: !!config.continuous } ); + this.stackLayout = new OO.ui.StackLayout( { + continuous: !!config.continuous, + expanded: config.expanded + } ); this.$content.append( this.stackLayout.$element ); this.autoFocus = config.autoFocus === undefined || !!config.autoFocus; @@ -9456,7 +10333,7 @@ OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) { * @param {number} [itemIndex] A specific item to focus on */ OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) { - var $input, card, + var card, items = this.stackLayout.getItems(); if ( itemIndex !== undefined && items[ itemIndex ] ) { @@ -9472,12 +10349,9 @@ OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) { if ( !card ) { return; } - // Only change the focus if is not already in the current card - if ( !card.$element.find( ':focus' ).length ) { - $input = card.$element.find( ':input:first' ); - if ( $input.length ) { - $input[ 0 ].focus(); - } + // Only change the focus if is not already in the current page + if ( !OO.ui.contains( card.$element[ 0 ], this.getElementDocument().activeElement, true ) ) { + card.focus(); } }; @@ -9486,27 +10360,7 @@ OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) { * on it. */ OO.ui.IndexLayout.prototype.focusFirstFocusable = function () { - var i, len, - found = false, - items = this.stackLayout.getItems(), - checkAndFocus = function () { - if ( OO.ui.isFocusableElement( $( this ) ) ) { - $( this ).focus(); - found = true; - return false; - } - }; - - for ( i = 0, len = items.length; i < len; i++ ) { - if ( found ) { - break; - } - // Find all potentially focusable elements in the item - // and check if they are focusable - items[i].$element - .find( 'input, select, textarea, button, object' ) - .each( checkAndFocus ); - } + OO.ui.findFocusable( this.stackLayout.$element ).focus(); }; /** @@ -9530,7 +10384,7 @@ OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) { OO.ui.IndexLayout.prototype.getClosestCard = function ( card ) { var next, prev, level, cards = this.stackLayout.getItems(), - index = $.inArray( card, cards ); + index = cards.indexOf( card ); if ( index !== -1 ) { next = cards[ index + 1 ]; @@ -9615,7 +10469,7 @@ OO.ui.IndexLayout.prototype.addCards = function ( cards, index ) { if ( Object.prototype.hasOwnProperty.call( this.cards, name ) ) { // Correct the insertion index - currentIndex = $.inArray( this.cards[ name ], stackLayoutCards ); + currentIndex = stackLayoutCards.indexOf( this.cards[ name ] ); if ( currentIndex !== -1 && currentIndex + 1 < index ) { index--; } @@ -9710,7 +10564,8 @@ OO.ui.IndexLayout.prototype.clearCards = function () { OO.ui.IndexLayout.prototype.setCard = function ( name ) { var selectedItem, $focused, - card = this.cards[ name ]; + card = this.cards[ name ], + previousCard = this.currentCardName && this.cards[ this.currentCardName ]; if ( name !== this.currentCardName ) { selectedItem = this.tabSelectWidget.getSelectedItem(); @@ -9718,21 +10573,34 @@ OO.ui.IndexLayout.prototype.setCard = function ( name ) { this.tabSelectWidget.selectItemByData( name ); } if ( card ) { - if ( this.currentCardName && this.cards[ this.currentCardName ] ) { - this.cards[ this.currentCardName ].setActive( false ); - // Blur anything focused if the next card doesn't have anything focusable - this - // is not needed if the next card has something focusable because once it is focused - // this blur happens automatically - if ( this.autoFocus && !card.$element.find( ':input' ).length ) { - $focused = this.cards[ this.currentCardName ].$element.find( ':focus' ); + if ( previousCard ) { + previousCard.setActive( false ); + // Blur anything focused if the next card doesn't have anything focusable. + // This is not needed if the next card has something focusable (because once it is focused + // this blur happens automatically). If the layout is non-continuous, this check is + // meaningless because the next card is not visible yet and thus can't hold focus. + if ( + this.autoFocus && + this.stackLayout.continuous && + OO.ui.findFocusable( card.$element ).length !== 0 + ) { + $focused = previousCard.$element.find( ':focus' ); if ( $focused.length ) { $focused[ 0 ].blur(); } } } this.currentCardName = name; - this.stackLayout.setItem( card ); card.setActive( true ); + this.stackLayout.setItem( card ); + if ( !this.stackLayout.continuous && previousCard ) { + // This should not be necessary, since any inputs on the previous card should have been + // blurred when it was hidden, but browsers are not very consistent about this. + $focused = previousCard.$element.find( ':focus' ); + if ( $focused.length ) { + $focused[ 0 ].blur(); + } + } this.emit( 'set', card ); } } @@ -9785,7 +10653,7 @@ OO.ui.PanelLayout = function OoUiPanelLayout( config ) { }, config ); // Parent constructor - OO.ui.PanelLayout.super.call( this, config ); + OO.ui.PanelLayout.parent.call( this, config ); // Initialization this.$element.addClass( 'oo-ui-panelLayout' ); @@ -9807,6 +10675,17 @@ OO.ui.PanelLayout = function OoUiPanelLayout( config ) { OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout ); +/* Methods */ + +/** + * Focus the panel layout + * + * The default implementation just focuses the first focusable element in the panel + */ +OO.ui.PanelLayout.prototype.focus = function () { + OO.ui.findFocusable( this.$element ).focus(); +}; + /** * CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display * from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly, @@ -9822,6 +10701,7 @@ OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout ); * @constructor * @param {string} name Unique symbolic name of card * @param {Object} [config] Configuration options + * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for card's tab */ OO.ui.CardLayout = function OoUiCardLayout( name, config ) { // Allow passing positional parameters inside the config object @@ -9834,10 +10714,11 @@ OO.ui.CardLayout = function OoUiCardLayout( name, config ) { config = $.extend( { scrollable: true }, config ); // Parent constructor - OO.ui.CardLayout.super.call( this, config ); + OO.ui.CardLayout.parent.call( this, config ); // Properties this.name = name; + this.label = config.label; this.tabItem = null; this.active = false; @@ -9923,6 +10804,9 @@ OO.ui.CardLayout.prototype.setTabItem = function ( tabItem ) { * @chainable */ OO.ui.CardLayout.prototype.setupTabItem = function () { + if ( this.label ) { + this.tabItem.setLabel( this.label ); + } return this; }; @@ -9973,7 +10857,7 @@ OO.ui.PageLayout = function OoUiPageLayout( name, config ) { config = $.extend( { scrollable: true }, config ); // Parent constructor - OO.ui.PageLayout.super.call( this, config ); + OO.ui.PageLayout.parent.call( this, config ); // Properties this.name = name; @@ -10111,7 +10995,7 @@ OO.ui.PageLayout.prototype.setActive = function ( active ) { * * @class * @extends OO.ui.PanelLayout - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options @@ -10123,10 +11007,10 @@ OO.ui.StackLayout = function OoUiStackLayout( config ) { config = $.extend( { scrollable: true }, config ); // Parent constructor - OO.ui.StackLayout.super.call( this, config ); + OO.ui.StackLayout.parent.call( this, config ); // Mixin constructors - OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); + OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Properties this.currentItem = null; @@ -10145,7 +11029,7 @@ OO.ui.StackLayout = function OoUiStackLayout( config ) { /* Setup */ OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout ); -OO.mixinClass( OO.ui.StackLayout, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement ); /* Events */ @@ -10201,7 +11085,7 @@ OO.ui.StackLayout.prototype.addItems = function ( items, index ) { this.updateHiddenState( items, this.currentItem ); // Mixin method - OO.ui.GroupElement.prototype.addItems.call( this, items, index ); + OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index ); if ( !this.currentItem && items.length ) { this.setItem( items[ 0 ] ); @@ -10222,9 +11106,9 @@ OO.ui.StackLayout.prototype.addItems = function ( items, index ) { */ OO.ui.StackLayout.prototype.removeItems = function ( items ) { // Mixin method - OO.ui.GroupElement.prototype.removeItems.call( this, items ); + OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items ); - if ( $.inArray( this.currentItem, items ) !== -1 ) { + if ( items.indexOf( this.currentItem ) !== -1 ) { if ( this.items.length ) { this.setItem( this.items[ 0 ] ); } else { @@ -10246,7 +11130,7 @@ OO.ui.StackLayout.prototype.removeItems = function ( items ) { */ OO.ui.StackLayout.prototype.clearItems = function () { this.unsetCurrentItem(); - OO.ui.GroupElement.prototype.clearItems.call( this ); + OO.ui.mixin.GroupElement.prototype.clearItems.call( this ); return this; }; @@ -10264,7 +11148,7 @@ OO.ui.StackLayout.prototype.setItem = function ( item ) { if ( item !== this.currentItem ) { this.updateHiddenState( this.items, item ); - if ( $.inArray( item, this.items ) !== -1 ) { + if ( this.items.indexOf( item ) !== -1 ) { this.currentItem = item; this.emit( 'set', item ); } else { @@ -10301,7 +11185,141 @@ OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) }; /** - * Horizontal bar layout of tools as icon buttons. + * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its + * items), with small margins between them. Convenient when you need to put a number of block-level + * widgets on a single line next to each other. + * + * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper. + * + * @example + * // HorizontalLayout with a text input and a label + * var layout = new OO.ui.HorizontalLayout( { + * items: [ + * new OO.ui.LabelWidget( { label: 'Label' } ), + * new OO.ui.TextInputWidget( { value: 'Text' } ) + * ] + * } ); + * $( 'body' ).append( layout.$element ); + * + * @class + * @extends OO.ui.Layout + * @mixins OO.ui.mixin.GroupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout. + */ +OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.HorizontalLayout.parent.call( this, config ); + + // Mixin constructors + OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); + + // Initialization + this.$element.addClass( 'oo-ui-horizontalLayout' ); + if ( Array.isArray( config.items ) ) { + this.addItems( config.items ); + } +}; + +/* Setup */ + +OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout ); +OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement ); + +/** + * BarToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to + * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup} + * and {@link OO.ui.ListToolGroup ListToolGroup}). The {@link OO.ui.Tool tools} in a BarToolGroup are + * displayed by icon in a single row. The title of the tool is displayed when users move the mouse over + * the tool. + * + * BarToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar is + * set up. + * + * @example + * // Example of a BarToolGroup with two tools + * var toolFactory = new OO.ui.ToolFactory(); + * var toolGroupFactory = new OO.ui.ToolGroupFactory(); + * var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory ); + * + * // We will be placing status text in this element when tools are used + * var $area = $( '<p>' ).text( 'Example of a BarToolGroup with two tools.' ); + * + * // Define the tools that we're going to place in our toolbar + * + * // Create a class inheriting from OO.ui.Tool + * function PictureTool() { + * PictureTool.parent.apply( this, arguments ); + * } + * OO.inheritClass( PictureTool, OO.ui.Tool ); + * // Each tool must have a 'name' (used as an internal identifier, see later) and at least one + * // of 'icon' and 'title' (displayed icon and text). + * PictureTool.static.name = 'picture'; + * PictureTool.static.icon = 'picture'; + * PictureTool.static.title = 'Insert picture'; + * // Defines the action that will happen when this tool is selected (clicked). + * PictureTool.prototype.onSelect = function () { + * $area.text( 'Picture tool clicked!' ); + * // Never display this tool as "active" (selected). + * this.setActive( false ); + * }; + * // Make this tool available in our toolFactory and thus our toolbar + * toolFactory.register( PictureTool ); + * + * // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a + * // little popup window (a PopupWidget). + * function HelpTool( toolGroup, config ) { + * OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: { + * padded: true, + * label: 'Help', + * head: true + * } }, config ) ); + * this.popup.$body.append( '<p>I am helpful!</p>' ); + * } + * OO.inheritClass( HelpTool, OO.ui.PopupTool ); + * HelpTool.static.name = 'help'; + * HelpTool.static.icon = 'help'; + * HelpTool.static.title = 'Help'; + * toolFactory.register( HelpTool ); + * + * // Finally define which tools and in what order appear in the toolbar. Each tool may only be + * // used once (but not all defined tools must be used). + * toolbar.setup( [ + * { + * // 'bar' tool groups display tools by icon only + * type: 'bar', + * include: [ 'picture', 'help' ] + * } + * ] ); + * + * // Create some UI around the toolbar and place it in the document + * var frame = new OO.ui.PanelLayout( { + * expanded: false, + * framed: true + * } ); + * var contentFrame = new OO.ui.PanelLayout( { + * expanded: false, + * padded: true + * } ); + * frame.$element.append( + * toolbar.$element, + * contentFrame.$element.append( $area ) + * ); + * $( 'body' ).append( frame.$element ); + * + * // Here is where the toolbar is actually built. This must be done after inserting it into the + * // document. + * toolbar.initialize(); + * + * For more information about how to add tools to a bar tool group, please see {@link OO.ui.ToolGroup toolgroup}. + * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @class * @extends OO.ui.ToolGroup @@ -10318,7 +11336,7 @@ OO.ui.BarToolGroup = function OoUiBarToolGroup( toolbar, config ) { } // Parent constructor - OO.ui.BarToolGroup.super.call( this, toolbar, config ); + OO.ui.BarToolGroup.parent.call( this, toolbar, config ); // Initialization this.$element.addClass( 'oo-ui-barToolGroup' ); @@ -10337,22 +11355,24 @@ OO.ui.BarToolGroup.static.accelTooltips = true; OO.ui.BarToolGroup.static.name = 'bar'; /** - * Popup list of tools with an icon and optional label. + * PopupToolGroup is an abstract base class used by both {@link OO.ui.MenuToolGroup MenuToolGroup} + * and {@link OO.ui.ListToolGroup ListToolGroup} to provide a popup--an overlaid menu or list of tools with an + * optional icon and label. This class can be used for other base classes that also use this functionality. * * @abstract * @class * @extends OO.ui.ToolGroup - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.TitledElement - * @mixins OO.ui.ClippableElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.ClippableElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options - * @cfg {string} [header] Text to display at the top of the pop-up + * @cfg {string} [header] Text to display at the top of the popup */ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object @@ -10365,7 +11385,7 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { config = config || {}; // Parent constructor - OO.ui.PopupToolGroup.super.call( this, toolbar, config ); + OO.ui.PopupToolGroup.parent.call( this, toolbar, config ); // Properties this.active = false; @@ -10374,12 +11394,12 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { this.$handle = $( '<span>' ); // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.TitledElement.call( this, config ); - OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, config ); + OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); // Events this.$handle.on( { @@ -10411,12 +11431,12 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { /* Setup */ OO.inheritClass( OO.ui.PopupToolGroup, OO.ui.ToolGroup ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.IconElement ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.TitledElement ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.ClippableElement ); -OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.ClippableElement ); +OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TabIndexedElement ); /* Methods */ @@ -10425,7 +11445,7 @@ OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.TabIndexedElement ); */ OO.ui.PopupToolGroup.prototype.setDisabled = function () { // Parent method - OO.ui.PopupToolGroup.super.prototype.setDisabled.apply( this, arguments ); + OO.ui.PopupToolGroup.parent.prototype.setDisabled.apply( this, arguments ); if ( this.isDisabled() && this.isElementAttached() ) { this.setActive( false ); @@ -10437,6 +11457,7 @@ OO.ui.PopupToolGroup.prototype.setDisabled = function () { * * The event is actually generated from a mouseup/keyup, so it is not a normal blur event object. * + * @protected * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) { @@ -10457,12 +11478,13 @@ OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) { ) { this.setActive( false ); } - return OO.ui.PopupToolGroup.super.prototype.onMouseKeyUp.call( this, e ); + return OO.ui.PopupToolGroup.parent.prototype.onMouseKeyUp.call( this, e ); }; /** * Handle mouse up and key up events. * + * @protected * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) { @@ -10477,6 +11499,7 @@ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) { /** * Handle mouse down and key down events. * + * @protected * @param {jQuery.Event} e Mouse down or key down event */ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) { @@ -10490,18 +11513,21 @@ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) { }; /** - * Switch into active mode. + * Switch into 'active' mode. * - * When active, mouseup events anywhere in the document will trigger deactivation. + * When active, the popup is visible. A mouseup event anywhere in the document will trigger + * deactivation. */ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) { + var containerWidth, containerLeft; value = !!value; if ( this.active !== value ) { this.active = value; if ( value ) { - this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true ); - this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler ); + this.$clippable.css( 'left', '' ); // Try anchoring the popup to the left first this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' ); this.toggleClipping( true ); @@ -10513,9 +11539,22 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) { .addClass( 'oo-ui-popupToolGroup-right' ); this.toggleClipping( true ); } + if ( this.isClippedHorizontally() ) { + // Anchoring to the right also caused the popup to clip, so just make it fill the container + containerWidth = this.$clippableScrollableContainer.width(); + containerLeft = this.$clippableScrollableContainer.offset().left; + + this.toggleClipping( false ); + this.$element.removeClass( 'oo-ui-popupToolGroup-right' ); + + this.$clippable.css( { + left: -( this.$element.offset().left - containerLeft ), + width: containerWidth + } ); + } } else { - this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true ); - this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler ); this.$element.removeClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left oo-ui-popupToolGroup-right' ); @@ -10525,11 +11564,79 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) { }; /** - * Drop down list layout of tools as labeled icon buttons. + * ListToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to + * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup} + * and {@link OO.ui.BarToolGroup BarToolGroup}). The {@link OO.ui.Tool tools} in a ListToolGroup are displayed + * by label in a dropdown menu. The title of the tool is used as the label text. The menu itself can be configured + * with a label, icon, indicator, header, and title. + * + * ListToolGroups can be configured to be expanded and collapsed. Collapsed lists will have a ‘More’ option that + * users can select to see the full list of tools. If a collapsed toolgroup is expanded, a ‘Fewer’ option permits + * users to collapse the list again. * - * This layout allows some tools to be collapsible, controlled by a "More" / "Fewer" option at the - * bottom of the main list. These are not automatically positioned at the bottom of the list; you - * may want to use the 'promote' and 'demote' configuration options to achieve this. + * ListToolGroups are created by a {@link OO.ui.ToolGroupFactory toolgroup factory} when the toolbar is set up. The factory + * requires the ListToolGroup's symbolic name, 'list', which is specified along with the other configurations. For more + * information about how to add tools to a ListToolGroup, please see {@link OO.ui.ToolGroup toolgroup}. + * + * @example + * // Example of a ListToolGroup + * var toolFactory = new OO.ui.ToolFactory(); + * var toolGroupFactory = new OO.ui.ToolGroupFactory(); + * var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory ); + * + * // Configure and register two tools + * function SettingsTool() { + * SettingsTool.parent.apply( this, arguments ); + * } + * OO.inheritClass( SettingsTool, OO.ui.Tool ); + * SettingsTool.static.name = 'settings'; + * SettingsTool.static.icon = 'settings'; + * SettingsTool.static.title = 'Change settings'; + * SettingsTool.prototype.onSelect = function () { + * this.setActive( false ); + * }; + * toolFactory.register( SettingsTool ); + * // Register two more tools, nothing interesting here + * function StuffTool() { + * StuffTool.parent.apply( this, arguments ); + * } + * OO.inheritClass( StuffTool, OO.ui.Tool ); + * StuffTool.static.name = 'stuff'; + * StuffTool.static.icon = 'ellipsis'; + * StuffTool.static.title = 'Change the world'; + * StuffTool.prototype.onSelect = function () { + * this.setActive( false ); + * }; + * toolFactory.register( StuffTool ); + * toolbar.setup( [ + * { + * // Configurations for list toolgroup. + * type: 'list', + * label: 'ListToolGroup', + * indicator: 'down', + * icon: 'picture', + * title: 'This is the title, displayed when user moves the mouse over the list toolgroup', + * header: 'This is the header', + * include: [ 'settings', 'stuff' ], + * allowCollapse: ['stuff'] + * } + * ] ); + * + * // Create some UI around the toolbar and place it in the document + * var frame = new OO.ui.PanelLayout( { + * expanded: false, + * framed: true + * } ); + * frame.$element.append( + * toolbar.$element + * ); + * $( 'body' ).append( frame.$element ); + * // Build the toolbar. This must be done after the toolbar has been appended to the document. + * toolbar.initialize(); + * + * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @class * @extends OO.ui.PopupToolGroup @@ -10537,11 +11644,16 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) { * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options - * @cfg {Array} [allowCollapse] List of tools that can be collapsed. Remaining tools will be always - * shown. - * @cfg {Array} [forceExpand] List of tools that *may not* be collapsed. All remaining tools will be - * allowed to be collapsed. - * @cfg {boolean} [expanded=false] Whether the collapsible tools are expanded by default + * @cfg {Array} [allowCollapse] Allow the specified tools to be collapsed. By default, collapsible tools + * will only be displayed if users click the ‘More’ option displayed at the bottom of the list. If + * the list is expanded, a ‘Fewer’ option permits users to collapse the list again. Any tools that + * are included in the toolgroup, but are not designated as collapsible, will always be displayed. + * To open a collapsible list in its expanded state, set #expanded to 'true'. + * @cfg {Array} [forceExpand] Expand the specified tools. All other tools will be designated as collapsible. + * Unless #expanded is set to true, the collapsible tools will be collapsed when the list is first opened. + * @cfg {boolean} [expanded=false] Expand collapsible tools. This config is only relevant if tools have + * been designated as collapsible. When expanded is set to true, all tools in the group will be displayed + * when the list is first opened. Users can collapse the list with a ‘Fewer’ option at the bottom. */ OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object @@ -10560,7 +11672,7 @@ OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) { this.collapsibleTools = []; // Parent constructor - OO.ui.ListToolGroup.super.call( this, toolbar, config ); + OO.ui.ListToolGroup.parent.call( this, toolbar, config ); // Initialization this.$element.addClass( 'oo-ui-listToolGroup' ); @@ -10582,7 +11694,7 @@ OO.ui.ListToolGroup.static.name = 'list'; OO.ui.ListToolGroup.prototype.populate = function () { var i, len, allowCollapse = []; - OO.ui.ListToolGroup.super.prototype.populate.call( this ); + OO.ui.ListToolGroup.parent.prototype.populate.call( this ); // Update the list of collapsible tools if ( this.allowCollapse !== undefined ) { @@ -10606,9 +11718,10 @@ OO.ui.ListToolGroup.prototype.populate = function () { }; OO.ui.ListToolGroup.prototype.getExpandCollapseTool = function () { + var ExpandCollapseTool; if ( this.expandCollapseTool === undefined ) { - var ExpandCollapseTool = function () { - ExpandCollapseTool.super.apply( this, arguments ); + ExpandCollapseTool = function () { + ExpandCollapseTool.parent.apply( this, arguments ); }; OO.inheritClass( ExpandCollapseTool, OO.ui.Tool ); @@ -10640,9 +11753,9 @@ OO.ui.ListToolGroup.prototype.onMouseKeyUp = function ( e ) { ) { // HACK: Prevent the popup list from being hidden. Skip the PopupToolGroup implementation (which // hides the popup list when a tool is selected) and call ToolGroup's implementation directly. - return OO.ui.ListToolGroup.super.super.prototype.onMouseKeyUp.call( this, e ); + return OO.ui.ListToolGroup.parent.parent.prototype.onMouseKeyUp.call( this, e ); } else { - return OO.ui.ListToolGroup.super.prototype.onMouseKeyUp.call( this, e ); + return OO.ui.ListToolGroup.parent.prototype.onMouseKeyUp.call( this, e ); } }; @@ -10659,7 +11772,104 @@ OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () { }; /** - * Drop down menu layout of tools as selectable menu items. + * MenuToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to + * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.BarToolGroup BarToolGroup} + * and {@link OO.ui.ListToolGroup ListToolGroup}). MenuToolGroups contain selectable {@link OO.ui.Tool tools}, + * which are displayed by label in a dropdown menu. The tool's title is used as the label text, and the + * menu label is updated to reflect which tool or tools are currently selected. If no tools are selected, + * the menu label is empty. The menu can be configured with an indicator, icon, title, and/or header. + * + * MenuToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar + * is set up. Note that all tools must define an {@link OO.ui.Tool#onUpdateState onUpdateState} method if + * a MenuToolGroup is used. + * + * @example + * // Example of a MenuToolGroup + * var toolFactory = new OO.ui.ToolFactory(); + * var toolGroupFactory = new OO.ui.ToolGroupFactory(); + * var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory ); + * + * // We will be placing status text in this element when tools are used + * var $area = $( '<p>' ).text( 'An example of a MenuToolGroup. Select a tool from the dropdown menu.' ); + * + * // Define the tools that we're going to place in our toolbar + * + * function SettingsTool() { + * SettingsTool.parent.apply( this, arguments ); + * this.reallyActive = false; + * } + * OO.inheritClass( SettingsTool, OO.ui.Tool ); + * SettingsTool.static.name = 'settings'; + * SettingsTool.static.icon = 'settings'; + * SettingsTool.static.title = 'Change settings'; + * SettingsTool.prototype.onSelect = function () { + * $area.text( 'Settings tool clicked!' ); + * // Toggle the active state on each click + * this.reallyActive = !this.reallyActive; + * this.setActive( this.reallyActive ); + * // To update the menu label + * this.toolbar.emit( 'updateState' ); + * }; + * SettingsTool.prototype.onUpdateState = function () { + * }; + * toolFactory.register( SettingsTool ); + * + * function StuffTool() { + * StuffTool.parent.apply( this, arguments ); + * this.reallyActive = false; + * } + * OO.inheritClass( StuffTool, OO.ui.Tool ); + * StuffTool.static.name = 'stuff'; + * StuffTool.static.icon = 'ellipsis'; + * StuffTool.static.title = 'More stuff'; + * StuffTool.prototype.onSelect = function () { + * $area.text( 'More stuff tool clicked!' ); + * // Toggle the active state on each click + * this.reallyActive = !this.reallyActive; + * this.setActive( this.reallyActive ); + * // To update the menu label + * this.toolbar.emit( 'updateState' ); + * }; + * StuffTool.prototype.onUpdateState = function () { + * }; + * toolFactory.register( StuffTool ); + * + * // Finally define which tools and in what order appear in the toolbar. Each tool may only be + * // used once (but not all defined tools must be used). + * toolbar.setup( [ + * { + * type: 'menu', + * header: 'This is the (optional) header', + * title: 'This is the (optional) title', + * indicator: 'down', + * include: [ 'settings', 'stuff' ] + * } + * ] ); + * + * // Create some UI around the toolbar and place it in the document + * var frame = new OO.ui.PanelLayout( { + * expanded: false, + * framed: true + * } ); + * var contentFrame = new OO.ui.PanelLayout( { + * expanded: false, + * padded: true + * } ); + * frame.$element.append( + * toolbar.$element, + * contentFrame.$element.append( $area ) + * ); + * $( 'body' ).append( frame.$element ); + * + * // Here is where the toolbar is actually built. This must be done after inserting it into the + * // document. + * toolbar.initialize(); + * toolbar.emit( 'updateState' ); + * + * For more information about how to add tools to a MenuToolGroup, please see {@link OO.ui.ToolGroup toolgroup}. + * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @class * @extends OO.ui.PopupToolGroup @@ -10679,7 +11889,7 @@ OO.ui.MenuToolGroup = function OoUiMenuToolGroup( toolbar, config ) { config = config || {}; // Parent constructor - OO.ui.MenuToolGroup.super.call( this, toolbar, config ); + OO.ui.MenuToolGroup.parent.call( this, toolbar, config ); // Events this.toolbar.connect( this, { updateState: 'onUpdateState' } ); @@ -10703,6 +11913,8 @@ OO.ui.MenuToolGroup.static.name = 'menu'; * * When the state changes, the title of each active item in the menu will be joined together and * used as a label for the group. The label will be empty if none of the items are active. + * + * @private */ OO.ui.MenuToolGroup.prototype.onUpdateState = function () { var name, @@ -10718,12 +11930,35 @@ OO.ui.MenuToolGroup.prototype.onUpdateState = function () { }; /** - * Tool that shows a popup when selected. + * Popup tools open a popup window when they are selected from the {@link OO.ui.Toolbar toolbar}. Each popup tool is configured + * with a static name, title, and icon, as well with as any popup configurations. Unlike other tools, popup tools do not require that developers specify + * an #onSelect or #onUpdateState method, as these methods have been implemented already. + * + * // Example of a popup tool. When selected, a popup tool displays + * // a popup window. + * function HelpTool( toolGroup, config ) { + * OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: { + * padded: true, + * label: 'Help', + * head: true + * } }, config ) ); + * this.popup.$body.append( '<p>I am helpful!</p>' ); + * }; + * OO.inheritClass( HelpTool, OO.ui.PopupTool ); + * HelpTool.static.name = 'help'; + * HelpTool.static.icon = 'help'; + * HelpTool.static.title = 'Help'; + * toolFactory.register( HelpTool ); + * + * For an example of a toolbar that contains a popup tool, see {@link OO.ui.Toolbar toolbars}. For more information about + * toolbars in genreral, please see the [OOjs UI documentation on MediaWiki][1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars * * @abstract * @class * @extends OO.ui.Tool - * @mixins OO.ui.PopupElement + * @mixins OO.ui.mixin.PopupElement * * @constructor * @param {OO.ui.ToolGroup} toolGroup @@ -10737,10 +11972,10 @@ OO.ui.PopupTool = function OoUiPopupTool( toolGroup, config ) { } // Parent constructor - OO.ui.PopupTool.super.call( this, toolGroup, config ); + OO.ui.PopupTool.parent.call( this, toolGroup, config ); // Mixin constructors - OO.ui.PopupElement.call( this, config ); + OO.ui.mixin.PopupElement.call( this, config ); // Initialization this.$element @@ -10751,7 +11986,7 @@ OO.ui.PopupTool = function OoUiPopupTool( toolGroup, config ) { /* Setup */ OO.inheritClass( OO.ui.PopupTool, OO.ui.Tool ); -OO.mixinClass( OO.ui.PopupTool, OO.ui.PopupElement ); +OO.mixinClass( OO.ui.PopupTool, OO.ui.mixin.PopupElement ); /* Methods */ @@ -10778,8 +12013,33 @@ OO.ui.PopupTool.prototype.onUpdateState = function () { }; /** - * Tool that has a tool group inside. This is a bad workaround for the lack of proper hierarchical - * menus in toolbars (T74159). + * A ToolGroupTool is a special sort of tool that can contain other {@link OO.ui.Tool tools} + * and {@link OO.ui.ToolGroup toolgroups}. The ToolGroupTool was specifically designed to be used + * inside a {@link OO.ui.BarToolGroup bar} toolgroup to provide access to additional tools from + * the bar item. Included tools will be displayed in a dropdown {@link OO.ui.ListToolGroup list} + * when the ToolGroupTool is selected. + * + * // Example: ToolGroupTool with two nested tools, 'setting1' and 'setting2', defined elsewhere. + * + * function SettingsTool() { + * SettingsTool.parent.apply( this, arguments ); + * }; + * OO.inheritClass( SettingsTool, OO.ui.ToolGroupTool ); + * SettingsTool.static.name = 'settings'; + * SettingsTool.static.title = 'Change settings'; + * SettingsTool.static.groupConfig = { + * icon: 'settings', + * label: 'ToolGroupTool', + * include: [ 'setting1', 'setting2' ] + * }; + * toolFactory.register( SettingsTool ); + * + * For more information, please see the [OOjs UI documentation on MediaWiki][1]. + * + * Please note that this implementation is subject to change per [T74159] [2]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars#ToolGroupTool + * [2]: https://phabricator.wikimedia.org/T74159 * * @abstract * @class @@ -10797,7 +12057,7 @@ OO.ui.ToolGroupTool = function OoUiToolGroupTool( toolGroup, config ) { } // Parent constructor - OO.ui.ToolGroupTool.super.call( this, toolGroup, config ); + OO.ui.ToolGroupTool.parent.call( this, toolGroup, config ); // Properties this.innerToolGroup = this.createGroup( this.constructor.static.groupConfig ); @@ -10819,7 +12079,11 @@ OO.inheritClass( OO.ui.ToolGroupTool, OO.ui.Tool ); /* Static Properties */ /** - * Tool group configuration. See OO.ui.Toolbar#setup for the accepted values. + * Toolgroup configuration. + * + * The toolgroup configuration consists of the tools to include, as well as an icon and label + * to use for the bar item. Tools can be included by symbolic name, group, or with the + * wildcard selector. Please see {@link OO.ui.ToolGroup toolgroup} for more information. * * @property {Object.<string,Array>} */ @@ -10857,10 +12121,10 @@ OO.ui.ToolGroupTool.prototype.onUpdateState = function () { }; /** - * Build a OO.ui.ToolGroup from the configuration. + * Build a {@link OO.ui.ToolGroup toolgroup} from the specified configuration. * - * @param {Object.<string,Array>} group Tool group configuration. See OO.ui.Toolbar#setup for the - * accepted values. + * @param {Object.<string,Array>} group Toolgroup configuration. Please see {@link OO.ui.ToolGroup toolgroup} for + * more information. * @return {OO.ui.ListToolGroup} */ OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) { @@ -10875,26 +12139,26 @@ OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) { }; /** - * Mixin for OO.ui.Widget subclasses to provide OO.ui.GroupElement. + * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement. * - * Use together with OO.ui.ItemWidget to make disabled state inheritable. + * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable. * * @private * @abstract * @class - * @extends OO.ui.GroupElement + * @extends OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options */ -OO.ui.GroupWidget = function OoUiGroupWidget( config ) { +OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) { // Parent constructor - OO.ui.GroupWidget.super.call( this, config ); + OO.ui.mixin.GroupWidget.parent.call( this, config ); }; /* Setup */ -OO.inheritClass( OO.ui.GroupWidget, OO.ui.GroupElement ); +OO.inheritClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement ); /* Methods */ @@ -10906,14 +12170,14 @@ OO.inheritClass( OO.ui.GroupWidget, OO.ui.GroupElement ); * @param {boolean} disabled Disable widget * @chainable */ -OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { +OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) { var i, len; // Parent method // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget OO.ui.Widget.prototype.setDisabled.call( this, disabled ); - // During construction, #setDisabled is called before the OO.ui.GroupElement constructor + // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor if ( this.items ) { for ( i = 0, len = this.items.length; i < len; i++ ) { this.items[ i ].updateDisabled(); @@ -10924,12 +12188,12 @@ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { }; /** - * Mixin for widgets used as items in widgets that inherit OO.ui.GroupWidget. + * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget. * - * Item widgets have a reference to a OO.ui.GroupWidget while they are attached to the group. This + * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This * allows bidirectional communication. * - * Use together with OO.ui.GroupWidget to make disabled state inheritable. + * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable. * * @private * @abstract @@ -10937,7 +12201,7 @@ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { * * @constructor */ -OO.ui.ItemWidget = function OoUiItemWidget() { +OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() { // }; @@ -10950,7 +12214,7 @@ OO.ui.ItemWidget = function OoUiItemWidget() { * * @return {boolean} Widget is disabled */ -OO.ui.ItemWidget.prototype.isDisabled = function () { +OO.ui.mixin.ItemWidget.prototype.isDisabled = function () { return this.disabled || ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() ); }; @@ -10958,10 +12222,10 @@ OO.ui.ItemWidget.prototype.isDisabled = function () { /** * Set group element is in. * - * @param {OO.ui.GroupElement|null} group Group element, null if none + * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none * @chainable */ -OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) { +OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) { // Parent method // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element OO.ui.Element.prototype.setElementGroup.call( this, group ); @@ -10975,12 +12239,13 @@ OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) { /** * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}. * Controls include moving items up and down, removing items, and adding different kinds of items. - * ####Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.#### + * + * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.** * * @class * @extends OO.ui.Widget - * @mixins OO.ui.GroupElement - * @mixins OO.ui.IconElement + * @mixins OO.ui.mixin.GroupElement + * @mixins OO.ui.mixin.IconElement * * @constructor * @param {OO.ui.OutlineSelectWidget} outline Outline to control @@ -11000,11 +12265,11 @@ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, confi config = $.extend( { icon: 'add' }, config ); // Parent constructor - OO.ui.OutlineControlsWidget.super.call( this, config ); + OO.ui.OutlineControlsWidget.parent.call( this, config ); // Mixin constructors - OO.ui.GroupElement.call( this, config ); - OO.ui.IconElement.call( this, config ); + OO.ui.mixin.GroupElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); // Properties this.outline = outline; @@ -11049,8 +12314,8 @@ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, confi /* Setup */ OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.GroupElement ); -OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.IconElement ); +OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement ); +OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.IconElement ); /* Events */ @@ -11076,8 +12341,8 @@ OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) { var ability; for ( ability in this.abilities ) { - if ( abilities[ability] !== undefined ) { - this.abilities[ability] = !!abilities[ability]; + if ( abilities[ ability ] !== undefined ) { + this.abilities[ ability ] = !!abilities[ ability ]; } } @@ -11085,7 +12350,6 @@ OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) { }; /** - * * @private * Handle outline change events. */ @@ -11136,7 +12400,7 @@ OO.ui.ToggleWidget = function OoUiToggleWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ToggleWidget.super.call( this, config ); + OO.ui.ToggleWidget.parent.call( this, config ); // Properties this.value = null; @@ -11216,7 +12480,7 @@ OO.ui.ToggleWidget.prototype.setValue = function ( value ) { * * @class * @extends OO.ui.Widget - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupElement * * @constructor * @param {Object} [config] Configuration options @@ -11227,10 +12491,10 @@ OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ButtonGroupWidget.super.call( this, config ); + OO.ui.ButtonGroupWidget.parent.call( this, config ); // Mixin constructors - OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); + OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-buttonGroupWidget' ); @@ -11242,7 +12506,7 @@ OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); +OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement ); /** * ButtonWidget is a generic widget for buttons. A wide variety of looks, @@ -11265,13 +12529,14 @@ OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); * * @class * @extends OO.ui.Widget - * @mixins OO.ui.ButtonElement - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.TitledElement - * @mixins OO.ui.FlaggedElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.ButtonElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.FlaggedElement + * @mixins OO.ui.mixin.TabIndexedElement + * @mixins OO.ui.mixin.AccessKeyedElement * * @constructor * @param {Object} [config] Configuration options @@ -11284,16 +12549,17 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ButtonWidget.super.call( this, config ); + OO.ui.ButtonWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ButtonElement.call( this, config ); - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); - OO.ui.FlaggedElement.call( this, config ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); + OO.ui.mixin.ButtonElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); + OO.ui.mixin.FlaggedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); + OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) ); // Properties this.href = null; @@ -11316,13 +12582,14 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.ButtonElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TitledElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.FlaggedElement ); -OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement ); /* Methods */ @@ -11335,7 +12602,7 @@ OO.ui.ButtonWidget.prototype.onMouseDown = function ( e ) { this.$button.removeAttr( 'tabindex' ); } - return OO.ui.ButtonElement.prototype.onMouseDown.call( this, e ); + return OO.ui.mixin.ButtonElement.prototype.onMouseDown.call( this, e ); }; /** @@ -11347,7 +12614,7 @@ OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) { this.$button.attr( 'tabindex', this.tabIndex ); } - return OO.ui.ButtonElement.prototype.onMouseUp.call( this, e ); + return OO.ui.mixin.ButtonElement.prototype.onMouseUp.call( this, e ); }; /** @@ -11384,6 +12651,12 @@ OO.ui.ButtonWidget.prototype.getNoFollow = function () { */ OO.ui.ButtonWidget.prototype.setHref = function ( href ) { href = typeof href === 'string' ? href : null; + if ( href !== null ) { + if ( !OO.ui.isSafeUrl( href ) ) { + throw new Error( 'Potentially unsafe href provided: ' + href ); + } + + } if ( href !== this.href ) { this.href = href; @@ -11473,7 +12746,7 @@ OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) { * * @class * @extends OO.ui.ButtonWidget - * @mixins OO.ui.PendingElement + * @mixins OO.ui.mixin.PendingElement * * @constructor * @param {Object} [config] Configuration options @@ -11488,10 +12761,10 @@ OO.ui.ActionWidget = function OoUiActionWidget( config ) { config = $.extend( { framed: false }, config ); // Parent constructor - OO.ui.ActionWidget.super.call( this, config ); + OO.ui.ActionWidget.parent.call( this, config ); // Mixin constructors - OO.ui.PendingElement.call( this, config ); + OO.ui.mixin.PendingElement.call( this, config ); // Properties this.action = config.action || ''; @@ -11506,7 +12779,7 @@ OO.ui.ActionWidget = function OoUiActionWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ActionWidget, OO.ui.ButtonWidget ); -OO.mixinClass( OO.ui.ActionWidget, OO.ui.PendingElement ); +OO.mixinClass( OO.ui.ActionWidget, OO.ui.mixin.PendingElement ); /* Events */ @@ -11578,7 +12851,7 @@ OO.ui.ActionWidget.prototype.propagateResize = function () { */ OO.ui.ActionWidget.prototype.setIcon = function () { // Mixin method - OO.ui.IconElement.prototype.setIcon.apply( this, arguments ); + OO.ui.mixin.IconElement.prototype.setIcon.apply( this, arguments ); this.propagateResize(); return this; @@ -11589,7 +12862,7 @@ OO.ui.ActionWidget.prototype.setIcon = function () { */ OO.ui.ActionWidget.prototype.setLabel = function () { // Mixin method - OO.ui.LabelElement.prototype.setLabel.apply( this, arguments ); + OO.ui.mixin.LabelElement.prototype.setLabel.apply( this, arguments ); this.propagateResize(); return this; @@ -11600,7 +12873,7 @@ OO.ui.ActionWidget.prototype.setLabel = function () { */ OO.ui.ActionWidget.prototype.setFlags = function () { // Mixin method - OO.ui.FlaggedElement.prototype.setFlags.apply( this, arguments ); + OO.ui.mixin.FlaggedElement.prototype.setFlags.apply( this, arguments ); this.propagateResize(); return this; @@ -11611,7 +12884,7 @@ OO.ui.ActionWidget.prototype.setFlags = function () { */ OO.ui.ActionWidget.prototype.clearFlags = function () { // Mixin method - OO.ui.FlaggedElement.prototype.clearFlags.apply( this, arguments ); + OO.ui.mixin.FlaggedElement.prototype.clearFlags.apply( this, arguments ); this.propagateResize(); return this; @@ -11625,7 +12898,7 @@ OO.ui.ActionWidget.prototype.clearFlags = function () { */ OO.ui.ActionWidget.prototype.toggle = function () { // Parent method - OO.ui.ActionWidget.super.prototype.toggle.apply( this, arguments ); + OO.ui.ActionWidget.parent.prototype.toggle.apply( this, arguments ); this.propagateResize(); return this; @@ -11651,17 +12924,17 @@ OO.ui.ActionWidget.prototype.toggle = function () { * * @class * @extends OO.ui.ButtonWidget - * @mixins OO.ui.PopupElement + * @mixins OO.ui.mixin.PopupElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) { // Parent constructor - OO.ui.PopupButtonWidget.super.call( this, config ); + OO.ui.PopupButtonWidget.parent.call( this, config ); // Mixin constructors - OO.ui.PopupElement.call( this, config ); + OO.ui.mixin.PopupElement.call( this, config ); // Events this.connect( this, { click: 'onAction' } ); @@ -11676,7 +12949,7 @@ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget ); -OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.PopupElement ); +OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement ); /* Methods */ @@ -11692,9 +12965,9 @@ OO.ui.PopupButtonWidget.prototype.onAction = function () { /** * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be - * configured with {@link OO.ui.IconElement icons}, {@link OO.ui.IndicatorElement indicators}, - * {@link OO.ui.TitledElement titles}, {@link OO.ui.FlaggedElement styling flags}, - * and {@link OO.ui.LabelElement labels}. Please see + * configured with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, + * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags}, + * and {@link OO.ui.mixin.LabelElement labels}. Please see * the [OOjs UI documentation][1] on MediaWiki for more information. * * @example @@ -11713,13 +12986,13 @@ OO.ui.PopupButtonWidget.prototype.onAction = function () { * * @class * @extends OO.ui.ToggleWidget - * @mixins OO.ui.ButtonElement - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.TitledElement - * @mixins OO.ui.FlaggedElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.ButtonElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.FlaggedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -11731,16 +13004,16 @@ OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ToggleButtonWidget.super.call( this, config ); + OO.ui.ToggleButtonWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ButtonElement.call( this, config ); - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); - OO.ui.FlaggedElement.call( this, config ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); + OO.ui.mixin.ButtonElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); + OO.ui.mixin.FlaggedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); // Events this.connect( this, { click: 'onAction' } ); @@ -11755,13 +13028,13 @@ OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.ButtonElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.TitledElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.FlaggedElement ); -OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement ); +OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement ); /* Methods */ @@ -11788,7 +13061,7 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { } // Parent method - OO.ui.ToggleButtonWidget.super.prototype.setValue.call( this, value ); + OO.ui.ToggleButtonWidget.parent.prototype.setValue.call( this, value ); return this; }; @@ -11800,15 +13073,702 @@ OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) { if ( this.$button ) { this.$button.removeAttr( 'aria-pressed' ); } - OO.ui.ButtonElement.prototype.setButtonElement.call( this, $button ); + OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button ); this.$button.attr( 'aria-pressed', this.value.toString() ); }; /** + * CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxWidget combo box widget} + * that allows for selecting multiple values. + * + * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1]. + * + * @example + * // Example: A CapsuleMultiSelectWidget. + * var capsule = new OO.ui.CapsuleMultiSelectWidget( { + * label: 'CapsuleMultiSelectWidget', + * selected: [ 'Option 1', 'Option 3' ], + * 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' + * } ) + * ] + * } + * } ); + * $( 'body' ).append( capsule.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.TabIndexedElement + * @mixins OO.ui.mixin.GroupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu. + * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}. + * @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}. + * If specified, this popup will be shown instead of the menu (but the menu + * will still be used for item labels and allowArbitrary=false). The widgets + * in the popup should use this.addItemsFromData() or this.addItems() as necessary. + * @cfg {jQuery} [$overlay] Render the menu or popup into a separate layer. + * This configuration is useful in cases where the expanded menu is larger than + * its containing `<div>`. The specified overlay layer is usually on top of + * the containing `<div>` and has a larger area. By default, the menu uses + * relative positioning. + */ +OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget( config ) { + var $tabFocus; + + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.CapsuleMultiSelectWidget.parent.call( this, config ); + + // Properties (must be set before mixin constructor calls) + this.$input = config.popup ? null : $( '<input>' ); + this.$handle = $( '<div>' ); + + // Mixin constructors + OO.ui.mixin.GroupElement.call( this, config ); + if ( config.popup ) { + config.popup = $.extend( {}, config.popup, { + align: 'forwards', + anchor: false + } ); + OO.ui.mixin.PopupElement.call( this, config ); + $tabFocus = $( '<span>' ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) ); + } else { + this.popup = null; + $tabFocus = null; + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) ); + } + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + + // Properties + this.allowArbitrary = !!config.allowArbitrary; + this.$overlay = config.$overlay || this.$element; + this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( + { + widget: this, + $input: this.$input, + $container: this.$element, + filterFromInput: true, + disabled: this.isDisabled() + }, + config.menu + ) ); + + // Events + if ( this.popup ) { + $tabFocus.on( { + focus: this.onFocusForPopup.bind( this ) + } ); + this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) ); + if ( this.popup.$autoCloseIgnore ) { + this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) ); + } + this.popup.connect( this, { + toggle: function ( visible ) { + $tabFocus.toggle( !visible ); + } + } ); + } else { + this.$input.on( { + focus: this.onInputFocus.bind( this ), + blur: this.onInputBlur.bind( this ), + 'propertychange change click mouseup keydown keyup input cut paste select': this.onInputChange.bind( this ), + keydown: this.onKeyDown.bind( this ), + keypress: this.onKeyPress.bind( this ) + } ); + } + this.menu.connect( this, { + choose: 'onMenuChoose', + add: 'onMenuItemsChange', + remove: 'onMenuItemsChange' + } ); + this.$handle.on( { + click: this.onClick.bind( this ) + } ); + + // Initialization + if ( this.$input ) { + this.$input.prop( 'disabled', this.isDisabled() ); + this.$input.attr( { + role: 'combobox', + 'aria-autocomplete': 'list' + } ); + this.$input.width( '1em' ); + } + if ( config.data ) { + this.setItemsFromData( config.data ); + } + this.$group.addClass( 'oo-ui-capsuleMultiSelectWidget-group' ); + this.$handle.addClass( 'oo-ui-capsuleMultiSelectWidget-handle' ) + .append( this.$indicator, this.$icon, this.$group ); + this.$element.addClass( 'oo-ui-capsuleMultiSelectWidget' ) + .append( this.$handle ); + if ( this.popup ) { + this.$handle.append( $tabFocus ); + this.$overlay.append( this.popup.$element ); + } else { + this.$handle.append( this.$input ); + this.$overlay.append( this.menu.$element ); + } + this.onMenuItemsChange(); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.Widget ); +OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.GroupElement ); +OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.PopupElement ); +OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.TabIndexedElement ); +OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IconElement ); + +/* Events */ + +/** + * @event change + * + * A change event is emitted when the set of selected items changes. + * + * @param {Mixed[]} datas Data of the now-selected items + */ + +/* Methods */ + +/** + * Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data. + * + * @protected + * @param {Mixed} data Custom data of any type. + * @param {string} label The label text. + * @return {OO.ui.CapsuleItemWidget} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.createItemWidget = function ( data, label ) { + return new OO.ui.CapsuleItemWidget( { data: data, label: label } ); +}; + +/** + * Get the data of the items in the capsule + * @return {Mixed[]} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.getItemsData = function () { + return $.map( this.getItems(), function ( e ) { return e.data; } ); +}; + +/** + * Set the items in the capsule by providing data + * @chainable + * @param {Mixed[]} datas + * @return {OO.ui.CapsuleMultiSelectWidget} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.setItemsFromData = function ( datas ) { + var widget = this, + menu = this.menu, + items = this.getItems(); + + $.each( datas, function ( i, data ) { + var j, label, + item = menu.getItemFromData( data ); + + if ( item ) { + label = item.label; + } else if ( widget.allowArbitrary ) { + label = String( data ); + } else { + return; + } + + item = null; + for ( j = 0; j < items.length; j++ ) { + if ( items[ j ].data === data && items[ j ].label === label ) { + item = items[ j ]; + items.splice( j, 1 ); + break; + } + } + if ( !item ) { + item = widget.createItemWidget( data, label ); + } + widget.addItems( [ item ], i ); + } ); + + if ( items.length ) { + widget.removeItems( items ); + } + + return this; +}; + +/** + * Add items to the capsule by providing their data + * @chainable + * @param {Mixed[]} datas + * @return {OO.ui.CapsuleMultiSelectWidget} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) { + var widget = this, + menu = this.menu, + items = []; + + $.each( datas, function ( i, data ) { + var item; + + if ( !widget.getItemFromData( data ) ) { + item = menu.getItemFromData( data ); + if ( item ) { + items.push( widget.createItemWidget( data, item.label ) ); + } else if ( widget.allowArbitrary ) { + items.push( widget.createItemWidget( data, String( data ) ) ); + } + } + } ); + + if ( items.length ) { + this.addItems( items ); + } + + return this; +}; + +/** + * Remove items by data + * @chainable + * @param {Mixed[]} datas + * @return {OO.ui.CapsuleMultiSelectWidget} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) { + var widget = this, + items = []; + + $.each( datas, function ( i, data ) { + var item = widget.getItemFromData( data ); + if ( item ) { + items.push( item ); + } + } ); + + if ( items.length ) { + this.removeItems( items ); + } + + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) { + var same, i, l, + oldItems = this.items.slice(); + + OO.ui.mixin.GroupElement.prototype.addItems.call( this, items ); + + if ( this.items.length !== oldItems.length ) { + same = false; + } else { + same = true; + for ( i = 0, l = oldItems.length; same && i < l; i++ ) { + same = same && this.items[ i ] === oldItems[ i ]; + } + } + if ( !same ) { + this.emit( 'change', this.getItemsData() ); + } + + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) { + var same, i, l, + oldItems = this.items.slice(); + + OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items ); + + if ( this.items.length !== oldItems.length ) { + same = false; + } else { + same = true; + for ( i = 0, l = oldItems.length; same && i < l; i++ ) { + same = same && this.items[ i ] === oldItems[ i ]; + } + } + if ( !same ) { + this.emit( 'change', this.getItemsData() ); + } + + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () { + if ( this.items.length ) { + OO.ui.mixin.GroupElement.prototype.clearItems.call( this ); + this.emit( 'change', this.getItemsData() ); + } + return this; +}; + +/** + * Get the capsule widget's menu. + * @return {OO.ui.MenuSelectWidget} Menu widget + */ +OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () { + return this.menu; +}; + +/** + * Handle focus events + * + * @private + * @param {jQuery.Event} event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () { + if ( !this.isDisabled() ) { + this.menu.toggle( true ); + } +}; + +/** + * Handle blur events + * + * @private + * @param {jQuery.Event} event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () { + if ( this.allowArbitrary && this.$input.val().trim() !== '' ) { + this.addItemsFromData( [ this.$input.val() ] ); + } + this.clearInput(); +}; + +/** + * Handle focus events + * + * @private + * @param {jQuery.Event} event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () { + if ( !this.isDisabled() ) { + this.popup.setSize( this.$handle.width() ); + this.popup.toggle( true ); + this.popup.$element.find( '*' ) + .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } ) + .first() + .focus(); + } +}; + +/** + * Handles popup focus out events. + * + * @private + * @param {Event} e Focus out event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onPopupFocusOut = function () { + var widget = this.popup; + + setTimeout( function () { + if ( + widget.isVisible() && + !OO.ui.contains( widget.$element[ 0 ], document.activeElement, true ) && + ( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length ) + ) { + widget.toggle( false ); + } + } ); +}; + +/** + * Handle mouse click events. + * + * @private + * @param {jQuery.Event} e Mouse click event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onClick = function ( e ) { + if ( e.which === 1 ) { + this.focus(); + return false; + } +}; + +/** + * Handle key press events. + * + * @private + * @param {jQuery.Event} e Key press event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) { + var item; + + if ( !this.isDisabled() ) { + if ( e.which === OO.ui.Keys.ESCAPE ) { + this.clearInput(); + return false; + } + + if ( !this.popup ) { + this.menu.toggle( true ); + if ( e.which === OO.ui.Keys.ENTER ) { + item = this.menu.getItemFromLabel( this.$input.val(), true ); + if ( item ) { + this.addItemsFromData( [ item.data ] ); + this.clearInput(); + } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) { + this.addItemsFromData( [ this.$input.val() ] ); + this.clearInput(); + } + return false; + } + + // Make sure the input gets resized. + setTimeout( this.onInputChange.bind( this ), 0 ); + } + } +}; + +/** + * Handle key down events. + * + * @private + * @param {jQuery.Event} e Key down event + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) { + if ( !this.isDisabled() ) { + // 'keypress' event is not triggered for Backspace + if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) { + if ( this.items.length ) { + this.removeItems( this.items.slice( -1 ) ); + } + return false; + } + } +}; + +/** + * Handle input change events. + * + * @private + * @param {jQuery.Event} e Event of some sort + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onInputChange = function () { + if ( !this.isDisabled() ) { + this.$input.width( this.$input.val().length + 'em' ); + } +}; + +/** + * Handle menu choose events. + * + * @private + * @param {OO.ui.OptionWidget} item Chosen item + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) { + if ( item && item.isVisible() ) { + this.addItemsFromData( [ item.getData() ] ); + this.clearInput(); + } +}; + +/** + * Handle menu item change events. + * + * @private + */ +OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () { + this.setItemsFromData( this.getItemsData() ); + this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() ); +}; + +/** + * Clear the input field + * @private + */ +OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () { + if ( this.$input ) { + this.$input.val( '' ); + this.$input.width( '1em' ); + } + if ( this.popup ) { + this.popup.toggle( false ); + } + this.menu.toggle( false ); + this.menu.selectItem(); + this.menu.highlightItem(); +}; + +/** + * @inheritdoc + */ +OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) { + var i, len; + + // Parent method + OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled ); + + if ( this.$input ) { + this.$input.prop( 'disabled', this.isDisabled() ); + } + if ( this.menu ) { + this.menu.setDisabled( this.isDisabled() ); + } + if ( this.popup ) { + this.popup.setDisabled( this.isDisabled() ); + } + + if ( this.items ) { + for ( i = 0, len = this.items.length; i < len; i++ ) { + this.items[ i ].updateDisabled(); + } + } + + return this; +}; + +/** + * Focus the widget + * @chainable + * @return {OO.ui.CapsuleMultiSelectWidget} + */ +OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () { + if ( !this.isDisabled() ) { + if ( this.popup ) { + this.popup.setSize( this.$handle.width() ); + this.popup.toggle( true ); + this.popup.$element.find( '*' ) + .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } ) + .first() + .focus(); + } else { + this.menu.toggle( true ); + this.$input.focus(); + } + } + return this; +}; + +/** + * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget + * CapsuleMultiSelectWidget} to display the selected items. + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.ItemWidget + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.FlaggedElement + * @mixins OO.ui.mixin.TabIndexedElement + * + * @constructor + * @param {Object} [config] Configuration options + */ +OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.CapsuleItemWidget.parent.call( this, config ); + + // Properties (must be set before mixin constructor calls) + this.$indicator = $( '<span>' ); + + // Mixin constructors + OO.ui.mixin.ItemWidget.call( this ); + OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.FlaggedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) ); + + // Events + this.$indicator.on( { + keydown: this.onCloseKeyDown.bind( this ), + click: this.onCloseClick.bind( this ) + } ); + + // Initialization + this.$element + .addClass( 'oo-ui-capsuleItemWidget' ) + .append( this.$indicator, this.$label ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget ); +OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget ); +OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement ); +OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement ); + +/* Methods */ + +/** + * Handle close icon clicks + * @param {jQuery.Event} event + */ +OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () { + var element = this.getElementGroup(); + + if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) { + element.removeItems( [ this ] ); + element.focus(); + } +}; + +/** + * Handle close keyboard events + * @param {jQuery.Event} event Key down event + */ +OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) { + if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) { + switch ( e.which ) { + case OO.ui.Keys.ENTER: + case OO.ui.Keys.BACKSPACE: + case OO.ui.Keys.SPACE: + this.getElementGroup().removeItems( [ this ] ); + return false; + } + } +}; + +/** * DropdownWidgets are not menus themselves, rather they contain a menu of options created with * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that * users can interact with it. * + * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use + * OO.ui.DropdownInputWidget instead. + * * @example * // Example: A DropdownWidget with a menu that contains three options * var dropDown = new OO.ui.DropdownWidget( { @@ -11833,41 +13793,52 @@ OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) { * * $( 'body' ).append( dropDown.$element ); * + * dropDown.getMenu().selectItemByData( 'b' ); + * + * dropDown.getMenu().getSelectedItem().getData(); // returns 'b' + * * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options * * @class * @extends OO.ui.Widget - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.TitledElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options - * @cfg {Object} [menu] Configuration options to pass to menu widget + * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.FloatingMenuSelectWidget menu select widget} + * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where + * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the + * containing `<div>` and has a larger area. By default, the menu uses relative positioning. */ OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) { // Configuration initialization config = $.extend( { indicator: 'down' }, config ); // Parent constructor - OO.ui.DropdownWidget.super.call( this, config ); + OO.ui.DropdownWidget.parent.call( this, config ); // Properties (must be set before TabIndexedElement constructor call) this.$handle = this.$( '<span>' ); + this.$overlay = config.$overlay || this.$element; // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); // Properties - this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) ); + this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( { + widget: this, + $container: this.$element + }, config.menu ) ); // Events this.$handle.on( { @@ -11882,17 +13853,18 @@ OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) { .append( this.$icon, this.$label, this.$indicator ); this.$element .addClass( 'oo-ui-dropdownWidget' ) - .append( this.$handle, this.menu.$element ); + .append( this.$handle ); + this.$overlay.append( this.menu.$element ); }; /* Setup */ OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.DropdownWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TitledElement ); -OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement ); /* Methods */ @@ -11915,6 +13887,7 @@ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { var selectedLabel; if ( !item ) { + this.setLabel( null ); return; } @@ -11948,14 +13921,450 @@ OO.ui.DropdownWidget.prototype.onClick = function ( e ) { * @param {jQuery.Event} e Key press event */ OO.ui.DropdownWidget.prototype.onKeyPress = function ( e ) { - if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { + if ( !this.isDisabled() && + ( ( e.which === OO.ui.Keys.SPACE && !this.menu.isVisible() ) || e.which === OO.ui.Keys.ENTER ) + ) { this.menu.toggle(); return false; } }; /** - * IconWidget is a generic widget for {@link OO.ui.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget, + * SelectFileWidgets allow for selecting files, using the HTML5 File API. These + * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link + * OO.ui.mixin.IndicatorElement indicators}. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * @example + * // Example of a file select widget + * var selectFile = new OO.ui.SelectFileWidget(); + * $( 'body' ).append( selectFile.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.PendingElement + * @mixins OO.ui.mixin.LabelElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types. + * @cfg {string} [placeholder] Text to display when no file is selected. + * @cfg {string} [notsupported] Text to display when file support is missing in the browser. + * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop. + * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true. + * @cfg {boolean} [dragDropUI=false] Deprecated alias for showDropTarget + */ +OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) { + var dragHandler; + + // TODO: Remove in next release + if ( config && config.dragDropUI ) { + config.showDropTarget = true; + } + + // Configuration initialization + config = $.extend( { + accept: null, + placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ), + notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ), + droppable: true, + showDropTarget: false + }, config ); + + // Parent constructor + OO.ui.SelectFileWidget.parent.call( this, config ); + + // Mixin constructors + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) ); + OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { autoFitLabel: true } ) ); + + // Properties + this.$info = $( '<span>' ); + + // Properties + this.showDropTarget = config.showDropTarget; + this.isSupported = this.constructor.static.isSupported(); + this.currentFile = null; + if ( Array.isArray( config.accept ) ) { + this.accept = config.accept; + } else { + this.accept = null; + } + this.placeholder = config.placeholder; + this.notsupported = config.notsupported; + this.onFileSelectedHandler = this.onFileSelected.bind( this ); + + this.selectButton = new OO.ui.ButtonWidget( { + classes: [ 'oo-ui-selectFileWidget-selectButton' ], + label: 'Select a file', + disabled: this.disabled || !this.isSupported + } ); + + this.clearButton = new OO.ui.ButtonWidget( { + classes: [ 'oo-ui-selectFileWidget-clearButton' ], + framed: false, + icon: 'remove', + disabled: this.disabled + } ); + + // Events + this.selectButton.$button.on( { + keypress: this.onKeyPress.bind( this ) + } ); + this.clearButton.connect( this, { + click: 'onClearClick' + } ); + if ( config.droppable ) { + dragHandler = this.onDragEnterOrOver.bind( this ); + this.$element.on( { + dragenter: dragHandler, + dragover: dragHandler, + dragleave: this.onDragLeave.bind( this ), + drop: this.onDrop.bind( this ) + } ); + } + + // Initialization + this.addInput(); + this.updateUI(); + this.$label.addClass( 'oo-ui-selectFileWidget-label' ); + this.$info + .addClass( 'oo-ui-selectFileWidget-info' ) + .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator ); + this.$element + .addClass( 'oo-ui-selectFileWidget' ) + .append( this.$info, this.selectButton.$element ); + if ( config.droppable && config.showDropTarget ) { + this.$dropTarget = $( '<div>' ) + .addClass( 'oo-ui-selectFileWidget-dropTarget' ) + .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) ) + .on( { + click: this.onDropTargetClick.bind( this ) + } ); + this.$element.prepend( this.$dropTarget ); + } +}; + +/* Setup */ + +OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget ); +OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement ); +OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement ); + +/* Static Properties */ + +/** + * Check if this widget is supported + * + * @static + * @return {boolean} + */ +OO.ui.SelectFileWidget.static.isSupported = function () { + var $input; + if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) { + $input = $( '<input type="file">' ); + OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined; + } + return OO.ui.SelectFileWidget.static.isSupportedCache; +}; + +OO.ui.SelectFileWidget.static.isSupportedCache = null; + +/* Events */ + +/** + * @event change + * + * A change event is emitted when the on/off state of the toggle changes. + * + * @param {File|null} value New value + */ + +/* Methods */ + +/** + * Get the current value of the field + * + * @return {File|null} + */ +OO.ui.SelectFileWidget.prototype.getValue = function () { + return this.currentFile; +}; + +/** + * Set the current value of the field + * + * @param {File|null} file File to select + */ +OO.ui.SelectFileWidget.prototype.setValue = function ( file ) { + if ( this.currentFile !== file ) { + this.currentFile = file; + this.updateUI(); + this.emit( 'change', this.currentFile ); + } +}; + +/** + * Focus the widget. + * + * Focusses the select file button. + * + * @chainable + */ +OO.ui.SelectFileWidget.prototype.focus = function () { + this.selectButton.$button[ 0 ].focus(); + return this; +}; + +/** + * Update the user interface when a file is selected or unselected + * + * @protected + */ +OO.ui.SelectFileWidget.prototype.updateUI = function () { + var $label; + if ( !this.isSupported ) { + this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' ); + this.$element.removeClass( 'oo-ui-selectFileWidget-empty' ); + this.setLabel( this.notsupported ); + } else { + this.$element.addClass( 'oo-ui-selectFileWidget-supported' ); + if ( this.currentFile ) { + this.$element.removeClass( 'oo-ui-selectFileWidget-empty' ); + $label = $( [] ); + if ( this.currentFile.type !== '' ) { + $label = $label.add( $( '<span>' ).addClass( 'oo-ui-selectFileWidget-fileType' ).text( this.currentFile.type ) ); + } + $label = $label.add( $( '<span>' ).text( this.currentFile.name ) ); + this.setLabel( $label ); + } else { + this.$element.addClass( 'oo-ui-selectFileWidget-empty' ); + this.setLabel( this.placeholder ); + } + } + + if ( this.$input ) { + this.$input.attr( 'title', this.getLabel() ); + } +}; + +/** + * Add the input to the widget + * + * @private + */ +OO.ui.SelectFileWidget.prototype.addInput = function () { + if ( this.$input ) { + this.$input.remove(); + } + + if ( !this.isSupported ) { + this.$input = null; + return; + } + + this.$input = $( '<input type="file">' ); + this.$input.on( 'change', this.onFileSelectedHandler ); + this.$input.attr( { + tabindex: -1, + title: this.getLabel() + } ); + if ( this.accept ) { + this.$input.attr( 'accept', this.accept.join( ', ' ) ); + } + this.selectButton.$button.append( this.$input ); +}; + +/** + * Determine if we should accept this file + * + * @private + * @param {string} File MIME type + * @return {boolean} + */ +OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) { + var i, mimeTest; + + if ( !this.accept || !mimeType ) { + return true; + } + + for ( i = 0; i < this.accept.length; i++ ) { + mimeTest = this.accept[ i ]; + if ( mimeTest === mimeType ) { + return true; + } else if ( mimeTest.substr( -2 ) === '/*' ) { + mimeTest = mimeTest.substr( 0, mimeTest.length - 1 ); + if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) { + return true; + } + } + } + + return false; +}; + +/** + * Handle file selection from the input + * + * @private + * @param {jQuery.Event} e + */ +OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) { + var file = OO.getProp( e.target, 'files', 0 ) || null; + + if ( file && !this.isAllowedType( file.type ) ) { + file = null; + } + + this.setValue( file ); + this.addInput(); +}; + +/** + * Handle clear button click events. + * + * @private + */ +OO.ui.SelectFileWidget.prototype.onClearClick = function () { + this.setValue( null ); + return false; +}; + +/** + * Handle key press events. + * + * @private + * @param {jQuery.Event} e Key press event + */ +OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) { + if ( this.isSupported && !this.isDisabled() && this.$input && + ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) + ) { + this.$input.click(); + return false; + } +}; + +/** + * Handle drop target click events. + * + * @private + * @param {jQuery.Event} e Key press event + */ +OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () { + if ( this.isSupported && !this.isDisabled() && this.$input ) { + this.$input.click(); + return false; + } +}; + +/** + * Handle drag enter and over events + * + * @private + * @param {jQuery.Event} e Drag event + */ +OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) { + var itemOrFile, + droppableFile = false, + dt = e.originalEvent.dataTransfer; + + e.preventDefault(); + e.stopPropagation(); + + if ( this.isDisabled() || !this.isSupported ) { + this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' ); + dt.dropEffect = 'none'; + return false; + } + + // DataTransferItem and File both have a type property, but in Chrome files + // have no information at this point. + itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 ); + if ( itemOrFile ) { + if ( this.isAllowedType( itemOrFile.type ) ) { + droppableFile = true; + } + // dt.types is Array-like, but not an Array + } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) { + // File information is not available at this point for security so just assume + // it is acceptable for now. + // https://bugzilla.mozilla.org/show_bug.cgi?id=640534 + droppableFile = true; + } + + this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile ); + if ( !droppableFile ) { + dt.dropEffect = 'none'; + } + + return false; +}; + +/** + * Handle drag leave events + * + * @private + * @param {jQuery.Event} e Drag event + */ +OO.ui.SelectFileWidget.prototype.onDragLeave = function () { + this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' ); +}; + +/** + * Handle drop events + * + * @private + * @param {jQuery.Event} e Drop event + */ +OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) { + var file = null, + dt = e.originalEvent.dataTransfer; + + e.preventDefault(); + e.stopPropagation(); + this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' ); + + if ( this.isDisabled() || !this.isSupported ) { + return false; + } + + file = OO.getProp( dt, 'files', 0 ); + if ( file && !this.isAllowedType( file.type ) ) { + file = null; + } + if ( file ) { + this.setValue( file ); + } + + return false; +}; + +/** + * @inheritdoc + */ +OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) { + OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled ); + if ( this.selectButton ) { + this.selectButton.setDisabled( disabled ); + } + if ( this.clearButton ) { + this.clearButton.setDisabled( disabled ); + } + return this; +}; + +/** + * 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] * for a list of icons included in the library. * @@ -11975,9 +14384,9 @@ OO.ui.DropdownWidget.prototype.onKeyPress = function ( e ) { * * @class * @extends OO.ui.Widget - * @mixins OO.ui.IconElement - * @mixins OO.ui.TitledElement - * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.FlaggedElement * * @constructor * @param {Object} [config] Configuration options @@ -11987,12 +14396,12 @@ OO.ui.IconWidget = function OoUiIconWidget( config ) { config = config || {}; // Parent constructor - OO.ui.IconWidget.super.call( this, config ); + OO.ui.IconWidget.parent.call( this, config ); // Mixin constructors - OO.ui.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); - OO.ui.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) ); + OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); + OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-iconWidget' ); @@ -12001,9 +14410,9 @@ OO.ui.IconWidget = function OoUiIconWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.IconWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.IconWidget, OO.ui.TitledElement ); -OO.mixinClass( OO.ui.IconWidget, OO.ui.FlaggedElement ); +OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement ); /* Static Properties */ @@ -12031,8 +14440,8 @@ OO.ui.IconWidget.static.tagName = 'span'; * * @class * @extends OO.ui.Widget - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.TitledElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {Object} [config] Configuration options @@ -12042,11 +14451,11 @@ OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) { config = config || {}; // Parent constructor - OO.ui.IndicatorWidget.super.call( this, config ); + OO.ui.IndicatorWidget.parent.call( this, config ); // Mixin constructors - OO.ui.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); + OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-indicatorWidget' ); @@ -12055,8 +14464,8 @@ OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.TitledElement ); +OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement ); /* Static Properties */ @@ -12073,13 +14482,16 @@ OO.ui.IndicatorWidget.static.tagName = 'span'; * @abstract * @class * @extends OO.ui.Widget - * @mixins OO.ui.FlaggedElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.FlaggedElement + * @mixins OO.ui.mixin.TabIndexedElement + * @mixins OO.ui.mixin.TitledElement + * @mixins OO.ui.mixin.AccessKeyedElement * * @constructor * @param {Object} [config] Configuration options * @cfg {string} [name=''] The value of the input’s HTML `name` attribute. * @cfg {string} [value=''] The value of the input. + * @cfg {string} [accessKey=''] The access key of the input. * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input * before it is accepted. */ @@ -12088,7 +14500,7 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { config = config || {}; // Parent constructor - OO.ui.InputWidget.super.call( this, config ); + OO.ui.InputWidget.parent.call( this, config ); // Properties this.$input = this.getInputElement( config ); @@ -12096,25 +14508,37 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { this.inputFilter = config.inputFilter; // Mixin constructors - OO.ui.FlaggedElement.call( this, config ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) ); + OO.ui.mixin.FlaggedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) ); + OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) ); // Events this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) ); // Initialization this.$input + .addClass( 'oo-ui-inputWidget-input' ) .attr( 'name', config.name ) .prop( 'disabled', this.isDisabled() ); - this.$element.addClass( 'oo-ui-inputWidget' ).append( this.$input, $( '<span>' ) ); + this.$element + .addClass( 'oo-ui-inputWidget' ) + .append( this.$input ); this.setValue( config.value ); + this.setAccessKey( config.accessKey ); }; /* Setup */ OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); -OO.mixinClass( OO.ui.InputWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement ); + +/* Static Properties */ + +OO.ui.InputWidget.static.supportsSimpleLabel = true; /* Events */ @@ -12134,7 +14558,7 @@ OO.mixinClass( OO.ui.InputWidget, OO.ui.TabIndexedElement ); * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in * different circumstances. The element must have a `value` property (like form elements). * - * @private + * @protected * @param {Object} config Configuration options * @return {jQuery} Input element */ @@ -12205,6 +14629,30 @@ OO.ui.InputWidget.prototype.setValue = function ( value ) { }; /** + * Set the input's access key. + * FIXME: This is the same code as in OO.ui.mixin.ButtonElement, maybe find a better place for it? + * + * @param {string} accessKey Input's access key, use empty string to remove + * @chainable + */ +OO.ui.InputWidget.prototype.setAccessKey = function ( accessKey ) { + accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null; + + if ( this.accessKey !== accessKey ) { + if ( this.$input ) { + if ( accessKey !== null ) { + this.$input.attr( 'accesskey', accessKey ); + } else { + this.$input.removeAttr( 'accesskey' ); + } + } + this.accessKey = accessKey; + } + + return this; +}; + +/** * Clean up incoming value. * * Ensures value is a string, and converts undefined and null to empty string. @@ -12243,7 +14691,7 @@ OO.ui.InputWidget.prototype.simulateLabelClick = function () { * @inheritdoc */ OO.ui.InputWidget.prototype.setDisabled = function ( state ) { - OO.ui.InputWidget.super.prototype.setDisabled.call( this, state ); + OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state ); if ( this.$input ) { this.$input.prop( 'disabled', this.isDisabled() ); } @@ -12271,6 +14719,32 @@ OO.ui.InputWidget.prototype.blur = function () { }; /** + * @inheritdoc + */ +OO.ui.InputWidget.prototype.gatherPreInfuseState = function ( node ) { + var + state = OO.ui.InputWidget.parent.prototype.gatherPreInfuseState.call( this, node ), + $input = state.$input || $( node ).find( '.oo-ui-inputWidget-input' ); + state.value = $input.val(); + // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward + state.focus = $input.is( ':focus' ); + return state; +}; + +/** + * @inheritdoc + */ +OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) { + OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state ); + if ( state.value !== undefined && state.value !== this.getValue() ) { + this.setValue( state.value ); + } + if ( state.focus ) { + this.focus(); + } +}; + +/** * ButtonInputWidget is used to submit HTML forms and is intended to be used within * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an @@ -12290,11 +14764,11 @@ OO.ui.InputWidget.prototype.blur = function () { * * @class * @extends OO.ui.InputWidget - * @mixins OO.ui.ButtonElement - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.TitledElement + * @mixins OO.ui.mixin.ButtonElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {Object} [config] Configuration options @@ -12312,14 +14786,14 @@ OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) { this.useInputTag = config.useInputTag; // Parent constructor - OO.ui.ButtonInputWidget.super.call( this, config ); + OO.ui.ButtonInputWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) ); - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) ); + OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) ); // Initialization if ( !config.useInputTag ) { @@ -12331,21 +14805,31 @@ OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget ); -OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.ButtonElement ); -OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.TitledElement ); +OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement ); +OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement ); + +/* Static Properties */ + +/** + * Disable generating `<label>` elements for buttons. One would very rarely need additional label + * for a button, and it's already a big clickable target, and it causes unexpected rendering. + */ +OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false; /* Methods */ /** * @inheritdoc - * @private + * @protected */ OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) { - var html = '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + config.type + '">'; - return $( html ); + var type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? + config.type : + 'button'; + return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' ); }; /** @@ -12358,7 +14842,7 @@ OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) { * @chainable */ OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) { - OO.ui.LabelElement.prototype.setLabel.call( this, label ); + OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label ); if ( this.useInputTag ) { if ( typeof label === 'function' ) { @@ -12387,7 +14871,7 @@ OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) { */ OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) { if ( !this.useInputTag ) { - OO.ui.ButtonInputWidget.super.prototype.setValue.call( this, value ); + OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value ); } return this; }; @@ -12438,10 +14922,13 @@ OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) { config = config || {}; // Parent constructor - OO.ui.CheckboxInputWidget.super.call( this, config ); + OO.ui.CheckboxInputWidget.parent.call( this, config ); // Initialization - this.$element.addClass( 'oo-ui-checkboxInputWidget' ); + this.$element + .addClass( 'oo-ui-checkboxInputWidget' ) + // Required for pretty styling in MediaWiki theme + .append( $( '<span>' ) ); this.setSelected( config.selected !== undefined ? config.selected : false ); }; @@ -12453,7 +14940,7 @@ OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget ); /** * @inheritdoc - * @private + * @protected */ OO.ui.CheckboxInputWidget.prototype.getInputElement = function () { return $( '<input type="checkbox" />' ); @@ -12504,41 +14991,73 @@ OO.ui.CheckboxInputWidget.prototype.isSelected = function () { }; /** + * @inheritdoc + */ +OO.ui.CheckboxInputWidget.prototype.gatherPreInfuseState = function ( node ) { + var + state = OO.ui.CheckboxInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ), + $input = $( node ).find( '.oo-ui-inputWidget-input' ); + state.$input = $input; // shortcut for performance, used in InputWidget + state.checked = $input.prop( 'checked' ); + return state; +}; + +/** + * @inheritdoc + */ +OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) { + OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state ); + if ( state.checked !== undefined && state.checked !== this.isSelected() ) { + this.setSelected( state.checked ); + } +}; + +/** * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for * more information about input widgets. * + * A DropdownInputWidget always has a value (one of the options is always selected), unless there + * are no options. If no `value` configuration option is provided, the first option is selected. + * If you need a state representing no value (no option being selected), use a DropdownWidget. + * + * This and OO.ui.RadioSelectInputWidget support the same configuration options. + * * @example * // Example: A DropdownInputWidget with three options - * var dropDown = new OO.ui.DropdownInputWidget( { - * label: 'Dropdown menu: Select a menu option', + * var dropdownInput = new OO.ui.DropdownInputWidget( { * options: [ - * { data: 'a', label: 'First' } , - * { data: 'b', label: 'Second'} , + * { data: 'a', label: 'First' }, + * { data: 'b', label: 'Second'}, * { data: 'c', label: 'Third' } * ] * } ); - * $( 'body' ).append( dropDown.$element ); + * $( 'body' ).append( dropdownInput.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs * * @class * @extends OO.ui.InputWidget + * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {Object} [config] Configuration options * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }` + * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget} */ OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) { // Configuration initialization config = config || {}; // Properties (must be done before parent constructor which calls #setDisabled) - this.dropdownWidget = new OO.ui.DropdownWidget(); + this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown ); // Parent constructor - OO.ui.DropdownInputWidget.super.call( this, config ); + OO.ui.DropdownInputWidget.parent.call( this, config ); + + // Mixin constructors + OO.ui.mixin.TitledElement.call( this, config ); // Events this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } ); @@ -12553,12 +15072,13 @@ 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 */ /** * @inheritdoc - * @private + * @protected */ OO.ui.DropdownInputWidget.prototype.getInputElement = function () { return $( '<input type="hidden">' ); @@ -12578,8 +15098,9 @@ OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) { * @inheritdoc */ OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) { + value = this.cleanUpValue( value ); this.dropdownWidget.getMenu().selectItemByData( value ); - OO.ui.DropdownInputWidget.super.prototype.setValue.call( this, value ); + OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value ); return this; }; @@ -12588,7 +15109,7 @@ OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) { */ OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) { this.dropdownWidget.setDisabled( state ); - OO.ui.DropdownInputWidget.super.prototype.setDisabled.call( this, state ); + OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state ); return this; }; @@ -12599,15 +15120,18 @@ OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) { * @chainable */ OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) { - var value = this.getValue(); + var + value = this.getValue(), + widget = this; // Rebuild the dropdown menu this.dropdownWidget.getMenu() .clearItems() .addItems( options.map( function ( opt ) { + var optValue = widget.cleanUpValue( opt.data ); return new OO.ui.MenuOptionWidget( { - data: opt.data, - label: opt.label !== undefined ? opt.label : opt.data + data: optValue, + label: opt.label !== undefined ? opt.label : optValue } ); } ) ); @@ -12687,10 +15211,13 @@ OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) { config = config || {}; // Parent constructor - OO.ui.RadioInputWidget.super.call( this, config ); + OO.ui.RadioInputWidget.parent.call( this, config ); // Initialization - this.$element.addClass( 'oo-ui-radioInputWidget' ); + this.$element + .addClass( 'oo-ui-radioInputWidget' ) + // Required for pretty styling in MediaWiki theme + .append( $( '<span>' ) ); this.setSelected( config.selected !== undefined ? config.selected : false ); }; @@ -12702,7 +15229,7 @@ OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget ); /** * @inheritdoc - * @private + * @protected */ OO.ui.RadioInputWidget.prototype.getInputElement = function () { return $( '<input type="radio" />' ); @@ -12737,9 +15264,171 @@ OO.ui.RadioInputWidget.prototype.isSelected = function () { }; /** + * @inheritdoc + */ +OO.ui.RadioInputWidget.prototype.gatherPreInfuseState = function ( node ) { + var + state = OO.ui.RadioInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ), + $input = $( node ).find( '.oo-ui-inputWidget-input' ); + state.$input = $input; // shortcut for performance, used in InputWidget + state.checked = $input.prop( 'checked' ); + return state; +}; + +/** + * @inheritdoc + */ +OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) { + OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state ); + if ( state.checked !== undefined && state.checked !== this.isSelected() ) { + this.setSelected( state.checked ); + } +}; + +/** + * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used + * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value + * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for + * more information about input widgets. + * + * This and OO.ui.DropdownInputWidget support the same configuration options. + * + * @example + * // Example: A RadioSelectInputWidget with three options + * var radioSelectInput = new OO.ui.RadioSelectInputWidget( { + * options: [ + * { data: 'a', label: 'First' }, + * { data: 'b', label: 'Second'}, + * { data: 'c', label: 'Third' } + * ] + * } ); + * $( 'body' ).append( radioSelectInput.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs + * + * @class + * @extends OO.ui.InputWidget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }` + */ +OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) { + // Configuration initialization + config = config || {}; + + // Properties (must be done before parent constructor which calls #setDisabled) + this.radioSelectWidget = new OO.ui.RadioSelectWidget(); + + // Parent constructor + OO.ui.RadioSelectInputWidget.parent.call( this, config ); + + // Events + this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } ); + + // Initialization + this.setOptions( config.options || [] ); + this.$element + .addClass( 'oo-ui-radioSelectInputWidget' ) + .append( this.radioSelectWidget.$element ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget ); + +/* Static Properties */ + +OO.ui.RadioSelectInputWidget.static.supportsSimpleLabel = false; + +/* Methods */ + +/** + * @inheritdoc + * @protected + */ +OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () { + return $( '<input type="hidden">' ); +}; + +/** + * Handles menu select events. + * + * @private + * @param {OO.ui.RadioOptionWidget} item Selected menu item + */ +OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) { + this.setValue( item.getData() ); +}; + +/** + * @inheritdoc + */ +OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) { + value = this.cleanUpValue( value ); + this.radioSelectWidget.selectItemByData( value ); + OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value ); + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) { + this.radioSelectWidget.setDisabled( state ); + OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state ); + return this; +}; + +/** + * Set the options available for this input. + * + * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }` + * @chainable + */ +OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) { + var + value = this.getValue(), + widget = this; + + // Rebuild the radioSelect menu + this.radioSelectWidget + .clearItems() + .addItems( options.map( function ( opt ) { + var optValue = widget.cleanUpValue( opt.data ); + return new OO.ui.RadioOptionWidget( { + data: optValue, + label: opt.label !== undefined ? opt.label : optValue + } ); + } ) ); + + // Restore the previous value, or reset to something sensible + if ( this.radioSelectWidget.getItemFromData( value ) ) { + // Previous value is still available, ensure consistency with the radioSelect + this.setValue( value ); + } else { + // No longer valid, reset + if ( options.length ) { + this.setValue( options[ 0 ].data ); + } + } + + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.RadioSelectInputWidget.prototype.gatherPreInfuseState = function ( node ) { + var state = OO.ui.RadioSelectInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ); + state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val(); + return state; +}; + +/** * TextInputWidgets, like HTML text inputs, can be configured with options that customize the * size of the field as well as its presentation. In addition, these widgets can be configured - * with {@link OO.ui.IconElement icons}, {@link OO.ui.IndicatorElement indicators}, an optional + * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional * validation-pattern (used to determine if an input value is valid or not) and an input filter, * which modifies incoming values rather than validating them. * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. @@ -12757,26 +15446,36 @@ OO.ui.RadioInputWidget.prototype.isSelected = function () { * * @class * @extends OO.ui.InputWidget - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement - * @mixins OO.ui.PendingElement - * @mixins OO.ui.LabelElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.PendingElement + * @mixins OO.ui.mixin.LabelElement * * @constructor * @param {Object} [config] Configuration options - * @cfg {string} [type='text'] The value of the HTML `type` attribute + * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search', + * 'email' or 'url'. Ignored if `multiline` is true. + * + * Some values of `type` result in additional behaviors: + * + * - `search`: implies `icon: 'search'` and `indicator: 'clear'`; when clicked, the indicator + * empties the text field * @cfg {string} [placeholder] Placeholder text * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to * instruct the browser to focus this widget. * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input. * @cfg {number} [maxLength] Maximum number of characters allowed in the input. * @cfg {boolean} [multiline=false] Allow multiple lines of text + * @cfg {number} [rows] If multiline, number of visible lines in textarea. If used with `autosize`, + * 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=10] Maximum number of rows to display when #autosize is set to true. + * @cfg {boolean} [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'` - * @cfg {boolean} [required=false] Mark the field as required + * @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 {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 @@ -12787,24 +15486,36 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { // Configuration initialization config = $.extend( { type: 'text', - labelPosition: 'after', - maxRows: 10 + labelPosition: 'after' }, config ); + if ( config.type === 'search' ) { + if ( config.icon === undefined ) { + config.icon = 'search'; + } + // indicator: 'clear' is set dynamically later, depending on value + } + if ( config.required ) { + if ( config.indicator === undefined ) { + config.indicator = 'required'; + } + } // Parent constructor - OO.ui.TextInputWidget.super.call( this, config ); + OO.ui.TextInputWidget.parent.call( this, config ); // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); - OO.ui.PendingElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) ); + OO.ui.mixin.LabelElement.call( this, config ); // Properties + this.type = this.getSaneType( config ); this.readOnly = false; this.multiline = !!config.multiline; this.autosize = !!config.autosize; - this.maxRows = config.maxRows; + this.minRows = config.rows !== undefined ? config.rows : ''; + this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 ); this.validate = null; // Clone for resizing @@ -12830,13 +15541,17 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) ); this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) ); this.on( 'labelChange', this.updatePosition.bind( this ) ); - this.connect( this, { change: 'onChange' } ); + this.connect( this, { + change: 'onChange', + disable: 'onDisable' + } ); // Initialization this.$element - .addClass( 'oo-ui-textInputWidget' ) + .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type ) .append( this.$icon, this.$indicator ); this.setReadOnly( !!config.readOnly ); + this.updateSearchIndicator(); if ( config.placeholder ) { this.$input.attr( 'placeholder', config.placeholder ); } @@ -12850,6 +15565,24 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { this.$input.attr( 'required', 'required' ); this.$input.attr( 'aria-required', 'true' ); } + if ( config.autocomplete === false ) { + this.$input.attr( 'autocomplete', 'off' ); + // Turning off autocompletion also disables "form caching" when the user navigates to a + // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI. + $( window ).on( { + beforeunload: function () { + this.$input.removeAttr( 'autocomplete' ); + }.bind( this ), + pageshow: function () { + // Browsers don't seem to actually fire this event on "Back", they instead just reload the + // whole page... it shouldn't hurt, though. + this.$input.attr( 'autocomplete', 'off' ); + }.bind( this ) + } ); + } + if ( this.multiline && config.rows ) { + this.$input.attr( 'rows', config.rows ); + } if ( this.label || config.autosize ) { this.installParentChangeDetector(); } @@ -12858,12 +15591,12 @@ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget ); -OO.mixinClass( OO.ui.TextInputWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.TextInputWidget, OO.ui.IndicatorElement ); -OO.mixinClass( OO.ui.TextInputWidget, OO.ui.PendingElement ); -OO.mixinClass( OO.ui.TextInputWidget, OO.ui.LabelElement ); +OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement ); +OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement ); +OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement ); -/* Static properties */ +/* Static Properties */ OO.ui.TextInputWidget.static.validationPatterns = { 'non-empty': /.+/, @@ -12905,6 +15638,10 @@ OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) { */ OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) { if ( e.which === 1 ) { + if ( this.type === 'search' ) { + // Clear the text field + this.setValue( '' ); + } this.$input[ 0 ].focus(); return false; } @@ -12953,11 +15690,22 @@ OO.ui.TextInputWidget.prototype.onElementAttach = function () { * @private */ OO.ui.TextInputWidget.prototype.onChange = function () { + this.updateSearchIndicator(); this.setValidityFlag(); this.adjustSize(); }; /** + * Handle disable events. + * + * @param {boolean} disabled Element is disabled + * @private + */ +OO.ui.TextInputWidget.prototype.onDisable = function () { + this.updateSearchIndicator(); +}; + +/** * Check if the input is {@link #readOnly read-only}. * * @return {boolean} @@ -12975,6 +15723,7 @@ OO.ui.TextInputWidget.prototype.isReadOnly = function () { OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) { this.readOnly = !!state; this.$input.prop( 'readOnly', this.readOnly ); + this.updateSearchIndicator(); return this; }; @@ -13004,7 +15753,7 @@ OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () { } // Find topmost node in the tree - topmostNode = this.$element[0]; + topmostNode = this.$element[ 0 ]; while ( topmostNode.parentNode ) { topmostNode = topmostNode.parentNode; } @@ -13038,7 +15787,7 @@ OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () { }; // Create a fake parent and observe it - fakeParentNode = $( '<div>' ).append( this.$element )[0]; + fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ]; mutationObserver.observe( fakeParentNode, { childList: true } ); } else { // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for @@ -13060,7 +15809,7 @@ OO.ui.TextInputWidget.prototype.adjustSize = function () { if ( this.multiline && this.autosize && this.$input.val() !== this.valCache ) { this.$clone .val( this.$input.val() ) - .attr( 'rows', '' ) + .attr( 'rows', this.minRows ) // Set inline height property to 0 to measure scroll height .css( 'height', 0 ); @@ -13102,10 +15851,26 @@ OO.ui.TextInputWidget.prototype.adjustSize = function () { /** * @inheritdoc - * @private + * @protected */ OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) { - return config.multiline ? $( '<textarea>' ) : $( '<input type="' + config.type + '" />' ); + return config.multiline ? + $( '<textarea>' ) : + $( '<input type="' + this.getSaneType( config ) + '" />' ); +}; + +/** + * Get sanitized value for 'type' for given config. + * + * @param {Object} config Configuration options + * @return {string|null} + * @private + */ +OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) { + var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ? + config.type : + 'text'; + return config.multiline ? 'multiline' : type; }; /** @@ -13137,6 +15902,23 @@ OO.ui.TextInputWidget.prototype.select = function () { }; /** + * Focus the input and move the cursor to the end. + */ +OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () { + var textRange, + element = this.$input[ 0 ]; + this.focus(); + if ( element.selectionStart !== undefined ) { + element.selectionStart = element.selectionEnd = element.value.length; + } else if ( element.createTextRange ) { + // IE 8 and below + textRange = element.createTextRange(); + textRange.collapse( false ); + textRange.select(); + } +}; + +/** * Set the validation pattern. * * The validation pattern is either a regular expression, a function, or the symbolic name of a @@ -13173,7 +15955,11 @@ OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) { if ( isValid !== undefined ) { setFlag( isValid ); } else { - this.isValid().done( setFlag ); + this.getValidity().then( function () { + setFlag( true ); + }, function () { + setFlag( false ); + } ); } }; @@ -13183,11 +15969,14 @@ OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) { * This method returns a promise that resolves with a boolean `true` if the current value is * considered valid according to the supplied {@link #validate validation pattern}. * + * @deprecated * @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid. */ OO.ui.TextInputWidget.prototype.isValid = function () { + var result; + if ( this.validate instanceof Function ) { - var result = this.validate( this.getValue() ); + result = this.validate( this.getValue() ); if ( $.isFunction( result.promise ) ) { return result.promise(); } else { @@ -13199,6 +15988,50 @@ OO.ui.TextInputWidget.prototype.isValid = function () { }; /** + * Get the validity of current value. + * + * This method returns a promise that resolves if the value is valid and rejects if + * it isn't. Uses the {@link #validate validation pattern} to check for validity. + * + * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not. + */ +OO.ui.TextInputWidget.prototype.getValidity = function () { + var result, promise; + + function rejectOrResolve( valid ) { + if ( valid ) { + return $.Deferred().resolve().promise(); + } else { + return $.Deferred().reject().promise(); + } + } + + if ( this.validate instanceof Function ) { + result = this.validate( this.getValue() ); + + if ( $.isFunction( result.promise ) ) { + promise = $.Deferred(); + + result.then( function ( valid ) { + if ( valid ) { + promise.resolve(); + } else { + promise.reject(); + } + }, function () { + promise.reject(); + } ); + + return promise.promise(); + } else { + return rejectOrResolve( result ); + } + } else { + return rejectOrResolve( this.getValue().match( this.validate ) ); + } +}; + +/** * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`. * * @param {string} labelPosition Label position, 'before' or 'after' @@ -13224,7 +16057,6 @@ OO.ui.TextInputWidget.prototype.setPosition = * This method is called by #setLabelPosition, and can also be called on its own if * something causes the label to be mispositioned. * - * * @chainable */ OO.ui.TextInputWidget.prototype.updatePosition = function () { @@ -13234,20 +16066,33 @@ OO.ui.TextInputWidget.prototype.updatePosition = function () { .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after ) .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after ); - if ( this.label ) { - this.positionLabel(); - } + this.positionLabel(); return this; }; /** + * Update the 'clear' indicator displayed on type: 'search' text fields, hiding it when the field is + * already empty or when it's not editable. + */ +OO.ui.TextInputWidget.prototype.updateSearchIndicator = function () { + if ( this.type === 'search' ) { + if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) { + this.setIndicator( null ); + } else { + this.setIndicator( 'clear' ); + } + } +}; + +/** * Position the label by setting the correct padding on the input. * * @private * @chainable */ OO.ui.TextInputWidget.prototype.positionLabel = function () { + var after, rtl, property; // Clear old values this.$input // Clear old values if present @@ -13263,9 +16108,9 @@ OO.ui.TextInputWidget.prototype.positionLabel = function () { return; } - var after = this.labelPosition === 'after', - rtl = this.$element.css( 'direction' ) === 'rtl', - property = after === rtl ? 'padding-left' : 'padding-right'; + after = this.labelPosition === 'after'; + rtl = this.$element.css( 'direction' ) === 'rtl'; + property = after === rtl ? 'padding-left' : 'padding-right'; this.$input.css( property, this.$label.outerWidth( true ) ); @@ -13273,6 +16118,30 @@ OO.ui.TextInputWidget.prototype.positionLabel = function () { }; /** + * @inheritdoc + */ +OO.ui.TextInputWidget.prototype.gatherPreInfuseState = function ( node ) { + var + state = OO.ui.TextInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ), + $input = $( node ).find( '.oo-ui-inputWidget-input' ); + state.$input = $input; // shortcut for performance, used in InputWidget + if ( this.multiline ) { + state.scrollTop = $input.scrollTop(); + } + return state; +}; + +/** + * @inheritdoc + */ +OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) { + OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state ); + if ( state.scrollTop !== undefined ) { + this.$input.scrollTop( state.scrollTop ); + } +}; + +/** * ComboBoxWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which * a value can be chosen instead). Users can choose options from the combo box in one of two ways: @@ -13320,11 +16189,11 @@ OO.ui.TextInputWidget.prototype.positionLabel = function () { * * @class * @extends OO.ui.Widget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options - * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}. + * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.FloatingMenuSelectWidget menu select widget}. * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}. * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the @@ -13335,13 +16204,13 @@ OO.ui.ComboBoxWidget = function OoUiComboBoxWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ComboBoxWidget.super.call( this, config ); + OO.ui.ComboBoxWidget.parent.call( this, config ); // Properties (must be set before TabIndexedElement constructor call) this.$indicator = this.$( '<span>' ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) ); // Properties this.$overlay = config.$overlay || this.$element; @@ -13357,10 +16226,11 @@ OO.ui.ComboBoxWidget = function OoUiComboBoxWidget( config ) { role: 'combobox', 'aria-autocomplete': 'list' } ); - this.menu = new OO.ui.TextInputMenuSelectWidget( this.input, $.extend( + this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( { widget: this, input: this.input, + $container: this.input.$element, disabled: this.isDisabled() }, config.menu @@ -13390,19 +16260,27 @@ OO.ui.ComboBoxWidget = function OoUiComboBoxWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ComboBoxWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.ComboBoxWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ComboBoxWidget, OO.ui.mixin.TabIndexedElement ); /* Methods */ /** * Get the combobox's menu. - * @return {OO.ui.TextInputMenuSelectWidget} Menu widget + * @return {OO.ui.FloatingMenuSelectWidget} Menu widget */ OO.ui.ComboBoxWidget.prototype.getMenu = function () { return this.menu; }; /** + * Get the combobox's text input widget. + * @return {OO.ui.TextInputWidget} Text input widget + */ +OO.ui.ComboBoxWidget.prototype.getInput = function () { + return this.input; +}; + +/** * Handle input change events. * * @private @@ -13424,7 +16302,6 @@ OO.ui.ComboBoxWidget.prototype.onInputChange = function ( value ) { /** * Handle mouse click events. * - * * @private * @param {jQuery.Event} e Mouse click event */ @@ -13439,7 +16316,6 @@ OO.ui.ComboBoxWidget.prototype.onClick = function ( e ) { /** * Handle key press events. * - * * @private * @param {jQuery.Event} e Key press event */ @@ -13491,7 +16367,7 @@ OO.ui.ComboBoxWidget.prototype.onMenuItemsChange = function () { */ OO.ui.ComboBoxWidget.prototype.setDisabled = function ( disabled ) { // Parent method - OO.ui.ComboBoxWidget.super.prototype.setDisabled.call( this, disabled ); + OO.ui.ComboBoxWidget.parent.prototype.setDisabled.call( this, disabled ); if ( this.input ) { this.input.setDisabled( this.isDisabled() ); @@ -13532,10 +16408,9 @@ OO.ui.ComboBoxWidget.prototype.setDisabled = function ( disabled ) { * ] ); * $( 'body' ).append( fieldset.$element ); * - * * @class * @extends OO.ui.Widget - * @mixins OO.ui.LabelElement + * @mixins OO.ui.mixin.LabelElement * * @constructor * @param {Object} [config] Configuration options @@ -13547,11 +16422,11 @@ OO.ui.LabelWidget = function OoUiLabelWidget( config ) { config = config || {}; // Parent constructor - OO.ui.LabelWidget.super.call( this, config ); + OO.ui.LabelWidget.parent.call( this, config ); // Mixin constructors - OO.ui.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) ); - OO.ui.TitledElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) ); + OO.ui.mixin.TitledElement.call( this, config ); // Properties this.input = config.input; @@ -13568,8 +16443,8 @@ OO.ui.LabelWidget = function OoUiLabelWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.LabelWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.LabelWidget, OO.ui.TitledElement ); +OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement ); /* Static Properties */ @@ -13598,8 +16473,8 @@ OO.ui.LabelWidget.prototype.onClick = function () { * * @class * @extends OO.ui.Widget - * @mixins OO.ui.LabelElement - * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.FlaggedElement * * @constructor * @param {Object} [config] Configuration options @@ -13609,12 +16484,12 @@ OO.ui.OptionWidget = function OoUiOptionWidget( config ) { config = config || {}; // Parent constructor - OO.ui.OptionWidget.super.call( this, config ); + OO.ui.OptionWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ItemWidget.call( this ); - OO.ui.LabelElement.call( this, config ); - OO.ui.FlaggedElement.call( this, config ); + OO.ui.mixin.ItemWidget.call( this ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.FlaggedElement.call( this, config ); // Properties this.selected = false; @@ -13625,6 +16500,7 @@ OO.ui.OptionWidget = function OoUiOptionWidget( config ) { this.$element .data( 'oo-ui-optionWidget', this ) .attr( 'role', 'option' ) + .attr( 'aria-selected', 'false' ) .addClass( 'oo-ui-optionWidget' ) .append( this.$label ); }; @@ -13632,9 +16508,9 @@ OO.ui.OptionWidget = function OoUiOptionWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.OptionWidget, OO.ui.ItemWidget ); -OO.mixinClass( OO.ui.OptionWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.OptionWidget, OO.ui.FlaggedElement ); +OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget ); +OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement ); /* Static Properties */ @@ -13654,7 +16530,7 @@ OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false; * @return {boolean} Item is selectable */ OO.ui.OptionWidget.prototype.isSelectable = function () { - return this.constructor.static.selectable && !this.isDisabled(); + return this.constructor.static.selectable && !this.isDisabled() && this.isVisible(); }; /** @@ -13665,7 +16541,7 @@ OO.ui.OptionWidget.prototype.isSelectable = function () { * @return {boolean} Item is highlightable */ OO.ui.OptionWidget.prototype.isHighlightable = function () { - return this.constructor.static.highlightable && !this.isDisabled(); + return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible(); }; /** @@ -13675,7 +16551,7 @@ OO.ui.OptionWidget.prototype.isHighlightable = function () { * @return {boolean} Item is pressable */ OO.ui.OptionWidget.prototype.isPressable = function () { - return this.constructor.static.pressable && !this.isDisabled(); + return this.constructor.static.pressable && !this.isDisabled() && this.isVisible(); }; /** @@ -13768,7 +16644,7 @@ OO.ui.OptionWidget.prototype.setPressed = function ( state ) { /** * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured - * with an {@link OO.ui.IconElement icon} and/or {@link OO.ui.IndicatorElement indicator}. + * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}. * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive * options. For more information about options and selects, please see the * [OOjs UI documentation on MediaWiki][1]. @@ -13795,19 +16671,19 @@ OO.ui.OptionWidget.prototype.setPressed = function ( state ) { * * @class * @extends OO.ui.OptionWidget - * @mixins OO.ui.IconElement - * @mixins OO.ui.IndicatorElement + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) { // Parent constructor - OO.ui.DecoratedOptionWidget.super.call( this, config ); + OO.ui.DecoratedOptionWidget.parent.call( this, config ); // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.IndicatorElement.call( this, config ); + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); // Initialization this.$element @@ -13819,11 +16695,11 @@ OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget ); -OO.mixinClass( OO.ui.OptionWidget, OO.ui.IconElement ); -OO.mixinClass( OO.ui.OptionWidget, OO.ui.IndicatorElement ); +OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement ); /** - * ButtonOptionWidget is a special type of {@link OO.ui.ButtonElement button element} that + * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that * can be selected and configured with data. The class is * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the * [OOjs UI documentation on MediaWiki] [1] for more information. @@ -13832,22 +16708,27 @@ OO.mixinClass( OO.ui.OptionWidget, OO.ui.IndicatorElement ); * * @class * @extends OO.ui.DecoratedOptionWidget - * @mixins OO.ui.ButtonElement - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.ButtonElement + * @mixins OO.ui.mixin.TabIndexedElement + * @mixins OO.ui.mixin.TitledElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) { // Configuration initialization - config = $.extend( { tabIndex: -1 }, config ); + config = config || {}; // Parent constructor - OO.ui.ButtonOptionWidget.super.call( this, config ); + OO.ui.ButtonOptionWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ButtonElement.call( this, config ); - OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); + OO.ui.mixin.ButtonElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { + $tabIndexed: this.$button, + tabIndex: -1 + } ) ); // Initialization this.$element.addClass( 'oo-ui-buttonOptionWidget' ); @@ -13858,8 +16739,9 @@ OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.DecoratedOptionWidget ); -OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.ButtonElement ); -OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement ); +OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TabIndexedElement ); /* Static Properties */ @@ -13874,7 +16756,7 @@ OO.ui.ButtonOptionWidget.static.highlightable = false; * @inheritdoc */ OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) { - OO.ui.ButtonOptionWidget.super.prototype.setSelected.call( this, state ); + OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state ); if ( this.constructor.static.selectable ) { this.setActive( state ); @@ -13904,11 +16786,19 @@ OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) { this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } ); // Parent constructor - OO.ui.RadioOptionWidget.super.call( this, config ); + OO.ui.RadioOptionWidget.parent.call( this, config ); + + // Events + this.radio.$input.on( 'focus', this.onInputFocus.bind( this ) ); // Initialization + // Remove implicit role, we're handling it ourselves + this.radio.$input.attr( 'role', 'presentation' ); this.$element .addClass( 'oo-ui-radioOptionWidget' ) + .attr( 'role', 'radio' ) + .attr( 'aria-checked', 'false' ) + .removeAttr( 'aria-selected' ) .prepend( this.radio.$element ); }; @@ -13929,12 +16819,24 @@ OO.ui.RadioOptionWidget.static.tagName = 'label'; /* Methods */ /** + * @param {jQuery.Event} e Focus event + * @private + */ +OO.ui.RadioOptionWidget.prototype.onInputFocus = function () { + this.radio.$input.blur(); + this.$element.parent().focus(); +}; + +/** * @inheritdoc */ OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) { - OO.ui.RadioOptionWidget.super.prototype.setSelected.call( this, state ); + OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state ); this.radio.setSelected( state ); + this.$element + .attr( 'aria-checked', state.toString() ) + .removeAttr( 'aria-selected' ); return this; }; @@ -13943,7 +16845,7 @@ OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) { * @inheritdoc */ OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) { - OO.ui.RadioOptionWidget.super.prototype.setDisabled.call( this, disabled ); + OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled ); this.radio.setDisabled( this.isDisabled() ); @@ -13968,7 +16870,7 @@ OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) { config = $.extend( { icon: 'check' }, config ); // Parent constructor - OO.ui.MenuOptionWidget.super.call( this, config ); + OO.ui.MenuOptionWidget.parent.call( this, config ); // Initialization this.$element @@ -14015,7 +16917,6 @@ OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true; * } ); * $( 'body' ).append( myDropdown.$element ); * - * * @class * @extends OO.ui.DecoratedOptionWidget * @@ -14024,7 +16925,7 @@ OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true; */ OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) { // Parent constructor - OO.ui.MenuSectionOptionWidget.super.call( this, config ); + OO.ui.MenuSectionOptionWidget.parent.call( this, config ); // Initialization this.$element.addClass( 'oo-ui-menuSectionOptionWidget' ); @@ -14060,7 +16961,7 @@ OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) { config = config || {}; // Parent constructor - OO.ui.OutlineOptionWidget.super.call( this, config ); + OO.ui.OutlineOptionWidget.parent.call( this, config ); // Properties this.level = 0; @@ -14189,7 +17090,7 @@ OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) { config = config || {}; // Parent constructor - OO.ui.TabOptionWidget.super.call( this, config ); + OO.ui.TabOptionWidget.parent.call( this, config ); // Initialization this.$element.addClass( 'oo-ui-tabOptionWidget' ); @@ -14224,7 +17125,8 @@ OO.ui.TabOptionWidget.static.highlightable = false; * * @class * @extends OO.ui.Widget - * @mixins OO.ui.LabelElement + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.ClippableElement * * @constructor * @param {Object} [config] Configuration options @@ -14243,6 +17145,7 @@ OO.ui.TabOptionWidget.static.highlightable = false; * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels. * @cfg {jQuery} [$content] Content to append to the popup's body + * @cfg {jQuery} [$footer] Content to append to the popup's footer * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus. * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked. * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2] @@ -14257,18 +17160,22 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { config = config || {}; // Parent constructor - OO.ui.PopupWidget.super.call( this, config ); + OO.ui.PopupWidget.parent.call( this, config ); // Properties (must be set before ClippableElement constructor call) this.$body = $( '<div>' ); + this.$popup = $( '<div>' ); // Mixin constructors - OO.ui.LabelElement.call( this, config ); - OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$body } ) ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { + $clippable: this.$body, + $clippableContainer: this.$popup + } ) ); // Properties - this.$popup = $( '<div>' ); this.$head = $( '<div>' ); + this.$footer = $( '<div>' ); this.$anchor = $( '<div>' ); // If undefined, will be computed lazily in updateDimensions() this.$container = config.$container; @@ -14294,12 +17201,16 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { this.$head .addClass( 'oo-ui-popupWidget-head' ) .append( this.$label, this.closeButton.$element ); + this.$footer.addClass( 'oo-ui-popupWidget-footer' ); if ( !config.head ) { this.$head.addClass( 'oo-ui-element-hidden' ); } + if ( !config.$footer ) { + this.$footer.addClass( 'oo-ui-element-hidden' ); + } this.$popup .addClass( 'oo-ui-popupWidget-popup' ) - .append( this.$head, this.$body ); + .append( this.$head, this.$body, this.$footer ); this.$element .addClass( 'oo-ui-popupWidget' ) .append( this.$popup, this.$anchor ); @@ -14307,6 +17218,9 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { if ( config.$content instanceof jQuery ) { this.$body.append( config.$content ); } + if ( config.$footer instanceof jQuery ) { + this.$footer.append( config.$footer ); + } if ( config.padded ) { this.$body.addClass( 'oo-ui-popupWidget-body-padded' ); } @@ -14321,8 +17235,8 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget ); -OO.mixinClass( OO.ui.PopupWidget, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.PopupWidget, OO.ui.ClippableElement ); +OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement ); /* Methods */ @@ -14349,7 +17263,7 @@ OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) { */ OO.ui.PopupWidget.prototype.bindMouseDownListener = function () { // Capture clicks outside popup - this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true ); + OO.ui.addCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler ); }; /** @@ -14369,7 +17283,7 @@ OO.ui.PopupWidget.prototype.onCloseButtonClick = function () { * @private */ OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () { - this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler ); }; /** @@ -14395,7 +17309,7 @@ OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) { * @private */ OO.ui.PopupWidget.prototype.bindKeyDownListener = function () { - this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true ); + OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler ); }; /** @@ -14404,7 +17318,7 @@ OO.ui.PopupWidget.prototype.bindKeyDownListener = function () { * @private */ OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () { - this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler ); }; /** @@ -14438,12 +17352,13 @@ OO.ui.PopupWidget.prototype.hasAnchor = function () { * @inheritdoc */ OO.ui.PopupWidget.prototype.toggle = function ( show ) { + var change; show = show === undefined ? !this.isVisible() : !!show; - var change = show !== this.isVisible(); + change = show !== this.isVisible(); // Parent method - OO.ui.PopupWidget.super.prototype.toggle.call( this, show ); + OO.ui.PopupWidget.parent.prototype.toggle.call( this, show ); if ( change ) { if ( show ) { @@ -14640,7 +17555,7 @@ OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) { config = config || {}; // Parent constructor - OO.ui.ProgressBarWidget.super.call( this, config ); + OO.ui.ProgressBarWidget.parent.call( this, config ); // Properties this.$bar = $( '<div>' ); @@ -14698,8 +17613,8 @@ OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) { /** * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query, - * and a {@link OO.ui.TextInputMenuSelectWidget menu} of search results, which is displayed beneath the query - * field. Unlike {@link OO.ui.LookupElement lookup menus}, search result menus are always visible to the user. + * and a menu of search results, which is displayed beneath the query + * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user. * Users can choose an item from the menu or type a query into the text field to search for a matching result item. * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window. * @@ -14721,7 +17636,7 @@ OO.ui.SearchWidget = function OoUiSearchWidget( config ) { config = config || {}; // Parent constructor - OO.ui.SearchWidget.super.call( this, config ); + OO.ui.SearchWidget.parent.call( this, config ); // Properties this.query = new OO.ui.TextInputWidget( { @@ -14738,10 +17653,6 @@ OO.ui.SearchWidget = function OoUiSearchWidget( config ) { change: 'onQueryChange', enter: 'onQueryEnter' } ); - this.results.connect( this, { - highlight: 'onResultsHighlight', - select: 'onResultsSelect' - } ); this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) ); // Initialization @@ -14760,28 +17671,6 @@ OO.ui.SearchWidget = function OoUiSearchWidget( config ) { OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget ); -/* Events */ - -/** - * A 'highlight' event is emitted when an item is highlighted. The highlight indicates which - * item will be selected. When a user mouses over a menu item, it is highlighted. If a search - * string is typed into the query field instead, the first menu item that matches the query - * will be highlighted. - - * @event highlight - * @deprecated Connect straight to getResults() events instead - * @param {Object|null} item Item data or null if no item is highlighted - */ - -/** - * A 'select' event is emitted when an item is selected. A menu item is selected when it is clicked, - * or when a user types a search query, a menu result is highlighted, and the user presses enter. - * - * @event select - * @deprecated Connect straight to getResults() events instead - * @param {Object|null} item Item data or null if no item is selected - */ - /* Methods */ /** @@ -14821,38 +17710,16 @@ OO.ui.SearchWidget.prototype.onQueryChange = function () { /** * Handle select widget enter key events. * - * Selects highlighted item. + * Chooses highlighted item. * * @private * @param {string} value New value */ OO.ui.SearchWidget.prototype.onQueryEnter = function () { - // Reset - this.results.selectItem( this.results.getHighlightedItem() ); -}; - -/** - * Handle select widget highlight events. - * - * @private - * @deprecated Connect straight to getResults() events instead - * @param {OO.ui.OptionWidget} item Highlighted item - * @fires highlight - */ -OO.ui.SearchWidget.prototype.onResultsHighlight = function ( item ) { - this.emit( 'highlight', item ? item.getData() : null ); -}; - -/** - * Handle select widget select events. - * - * @private - * @deprecated Connect straight to getResults() events instead - * @param {OO.ui.OptionWidget} item Selected item - * @fires select - */ -OO.ui.SearchWidget.prototype.onResultsSelect = function ( item ) { - this.emit( 'select', item ? item.getData() : null ); + var highlightedItem = this.results.getHighlightedItem(); + if ( highlightedItem ) { + this.results.chooseItem( highlightedItem ); + } }; /** @@ -14907,7 +17774,7 @@ OO.ui.SearchWidget.prototype.getResults = function () { * @abstract * @class * @extends OO.ui.Widget - * @mixins OO.ui.GroupElement + * @mixins OO.ui.mixin.GroupWidget * * @constructor * @param {Object} [config] Configuration options @@ -14921,10 +17788,10 @@ OO.ui.SelectWidget = function OoUiSelectWidget( config ) { config = config || {}; // Parent constructor - OO.ui.SelectWidget.super.call( this, config ); + OO.ui.SelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) ); + OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Properties this.pressed = false; @@ -14932,8 +17799,14 @@ OO.ui.SelectWidget = function OoUiSelectWidget( config ) { this.onMouseUpHandler = this.onMouseUp.bind( this ); this.onMouseMoveHandler = this.onMouseMove.bind( this ); this.onKeyDownHandler = this.onKeyDown.bind( this ); + this.onKeyPressHandler = this.onKeyPress.bind( this ); + this.keyPressBuffer = ''; + this.keyPressBufferTimer = null; // Events + this.connect( this, { + toggle: 'onToggle' + } ); this.$element.on( { mousedown: this.onMouseDown.bind( this ), mouseover: this.onMouseOver.bind( this ), @@ -14954,8 +17827,13 @@ OO.ui.SelectWidget = function OoUiSelectWidget( config ) { OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget ); // Need to mixin base class as well -OO.mixinClass( OO.ui.SelectWidget, OO.ui.GroupElement ); -OO.mixinClass( OO.ui.SelectWidget, OO.ui.GroupWidget ); +OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupElement ); +OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget ); + +/* Static */ +OO.ui.SelectWidget.static.passAllFilter = function () { + return true; +}; /* Events */ @@ -15025,15 +17903,15 @@ OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) { if ( item && item.isSelectable() ) { this.pressItem( item ); this.selecting = item; - this.getElementDocument().addEventListener( + OO.ui.addCaptureEventListener( + this.getElementDocument(), 'mouseup', - this.onMouseUpHandler, - true + this.onMouseUpHandler ); - this.getElementDocument().addEventListener( + OO.ui.addCaptureEventListener( + this.getElementDocument(), 'mousemove', - this.onMouseMoveHandler, - true + this.onMouseMoveHandler ); } } @@ -15062,16 +17940,10 @@ OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) { this.selecting = null; } - this.getElementDocument().removeEventListener( - 'mouseup', - this.onMouseUpHandler, - true - ); - this.getElementDocument().removeEventListener( - 'mousemove', - this.onMouseMoveHandler, - true - ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', + this.onMouseUpHandler ); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousemove', + this.onMouseMoveHandler ); return false; }; @@ -15146,11 +18018,13 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { break; case OO.ui.Keys.UP: case OO.ui.Keys.LEFT: + this.clearKeyPressBuffer(); nextItem = this.getRelativeSelectableItem( currentItem, -1 ); handled = true; break; case OO.ui.Keys.DOWN: case OO.ui.Keys.RIGHT: + this.clearKeyPressBuffer(); nextItem = this.getRelativeSelectableItem( currentItem, 1 ); handled = true; break; @@ -15160,6 +18034,7 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { currentItem.setHighlighted( false ); } this.unbindKeyDownListener(); + this.unbindKeyPressListener(); // Don't prevent tabbing away / defocusing handled = false; break; @@ -15188,7 +18063,7 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) { * @protected */ OO.ui.SelectWidget.prototype.bindKeyDownListener = function () { - this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true ); + OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler ); }; /** @@ -15197,7 +18072,141 @@ OO.ui.SelectWidget.prototype.bindKeyDownListener = function () { * @protected */ OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () { - this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true ); + OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler ); +}; + +/** + * Clear the key-press buffer + * + * @protected + */ +OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () { + if ( this.keyPressBufferTimer ) { + clearTimeout( this.keyPressBufferTimer ); + this.keyPressBufferTimer = null; + } + this.keyPressBuffer = ''; +}; + +/** + * Handle key press events. + * + * @protected + * @param {jQuery.Event} e Key press event + */ +OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) { + var c, filter, item; + + if ( !e.charCode ) { + if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) { + this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 ); + return false; + } + return; + } + if ( String.fromCodePoint ) { + c = String.fromCodePoint( e.charCode ); + } else { + c = String.fromCharCode( e.charCode ); + } + + if ( this.keyPressBufferTimer ) { + clearTimeout( this.keyPressBufferTimer ); + } + this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 ); + + item = this.getHighlightedItem() || 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 ); + } + } else { + this.keyPressBuffer += c; + } + + filter = this.getItemMatcher( this.keyPressBuffer, false ); + if ( !item || !filter( item ) ) { + item = this.getRelativeSelectableItem( item, 1, filter ); + } + if ( item ) { + if ( item.constructor.static.highlightable ) { + this.highlightItem( item ); + } else { + this.chooseItem( item ); + } + item.scrollElementIntoView(); + } + + return false; +}; + +/** + * Get a matcher for the specific string + * + * @protected + * @param {string} s String to match against items + * @param {boolean} [exact=false] Only accept exact matches + * @return {Function} function ( OO.ui.OptionItem ) => boolean + */ +OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) { + var re; + + if ( s.normalize ) { + s = s.normalize(); + } + s = exact ? s.trim() : s.replace( /^\s+/, '' ); + re = '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' ); + if ( exact ) { + re += '\\s*$'; + } + re = new RegExp( re, 'i' ); + return function ( item ) { + var l = item.getLabel(); + if ( typeof l !== 'string' ) { + l = item.$label.text(); + } + if ( l.normalize ) { + l = l.normalize(); + } + return re.test( l ); + }; +}; + +/** + * Bind key press listener. + * + * @protected + */ +OO.ui.SelectWidget.prototype.bindKeyPressListener = function () { + OO.ui.addCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler ); +}; + +/** + * Unbind key down listener. + * + * If you override this, be sure to call this.clearKeyPressBuffer() from your + * implementation. + * + * @protected + */ +OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () { + OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler ); + this.clearKeyPressBuffer(); +}; + +/** + * Visibility change handler + * + * @protected + * @param {boolean} visible + */ +OO.ui.SelectWidget.prototype.onToggle = function ( visible ) { + if ( !visible ) { + this.clearKeyPressBuffer(); + } }; /** @@ -15291,6 +18300,62 @@ OO.ui.SelectWidget.prototype.highlightItem = function ( item ) { }; /** + * Fetch an item by its label. + * + * @param {string} label Label of the item to select. + * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches + * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists + */ +OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) { + var i, item, found, + len = this.items.length, + filter = this.getItemMatcher( label, true ); + + for ( i = 0; i < len; i++ ) { + item = this.items[ i ]; + if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) { + return item; + } + } + + if ( prefix ) { + found = null; + filter = this.getItemMatcher( label, false ); + for ( i = 0; i < len; i++ ) { + item = this.items[ i ]; + if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) { + if ( found ) { + return null; + } + found = item; + } + } + if ( found ) { + return found; + } + } + + return null; +}; + +/** + * Programmatically select an option by its label. If the item does not exist, + * all options will be deselected. + * + * @param {string} [label] Label of the item to select. + * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches + * @fires select + * @chainable + */ +OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) { + var itemFromLabel = this.getItemFromLabel( label, !!prefix ); + if ( label === undefined || !itemFromLabel ) { + return this.selectItem(); + } + return this.selectItem( itemFromLabel ); +}; + +/** * Programmatically select an option by its data. If the `data` parameter is omitted, * or if the item does not exist, all options will be deselected. * @@ -15376,8 +18441,10 @@ OO.ui.SelectWidget.prototype.pressItem = function ( item ) { * @chainable */ OO.ui.SelectWidget.prototype.chooseItem = function ( item ) { - this.selectItem( item ); - this.emit( 'choose', item ); + if ( item ) { + this.selectItem( item ); + this.emit( 'choose', item ); + } return this; }; @@ -15390,15 +18457,21 @@ OO.ui.SelectWidget.prototype.chooseItem = function ( item ) { * * @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 ) { +OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) { var currentIndex, nextIndex, i, increase = direction > 0 ? 1 : -1, len = this.items.length; + if ( !$.isFunction( filter ) ) { + filter = OO.ui.SelectWidget.static.passAllFilter; + } + if ( item instanceof OO.ui.OptionWidget ) { - currentIndex = $.inArray( item, this.items ); + currentIndex = this.items.indexOf( item ); nextIndex = ( currentIndex + increase + len ) % len; } else { // If no item is selected and moving forward, start at the beginning. @@ -15408,7 +18481,7 @@ OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direct for ( i = 0; i < len; i++ ) { item = this.items[ nextIndex ]; - if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) { + if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) { return item; } nextIndex = ( nextIndex + increase + len ) % len; @@ -15446,7 +18519,7 @@ OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () { */ OO.ui.SelectWidget.prototype.addItems = function ( items, index ) { // Mixin method - OO.ui.GroupWidget.prototype.addItems.call( this, items, index ); + OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index ); // Always provide an index, even if it was omitted this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index ); @@ -15475,7 +18548,7 @@ OO.ui.SelectWidget.prototype.removeItems = function ( items ) { } // Mixin method - OO.ui.GroupWidget.prototype.removeItems.call( this, items ); + OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items ); this.emit( 'remove', items ); @@ -15494,7 +18567,7 @@ OO.ui.SelectWidget.prototype.clearItems = function () { var items = this.items.slice(); // Mixin method - OO.ui.GroupWidget.prototype.clearItems.call( this ); + OO.ui.mixin.GroupWidget.prototype.clearItems.call( this ); // Clear selection this.selectItem( null ); @@ -15540,17 +18613,17 @@ OO.ui.SelectWidget.prototype.clearItems = function () { * * @class * @extends OO.ui.SelectWidget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) { // Parent constructor - OO.ui.ButtonSelectWidget.super.call( this, config ); + OO.ui.ButtonSelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, config ); // Events this.$element.on( { @@ -15565,7 +18638,7 @@ OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget ); -OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement ); /** * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio @@ -15573,6 +18646,9 @@ OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.TabIndexedElement ); * an interface for adding, removing and selecting options. * Please see the [OOjs UI documentation on MediaWiki][1] for more information. * + * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use + * OO.ui.RadioSelectInputWidget instead. + * * @example * // A RadioSelectWidget with RadioOptions. * var option1 = new OO.ui.RadioOptionWidget( { @@ -15599,17 +18675,17 @@ OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.TabIndexedElement ); * * @class * @extends OO.ui.SelectWidget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) { // Parent constructor - OO.ui.RadioSelectWidget.super.call( this, config ); + OO.ui.RadioSelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, config ); // Events this.$element.on( { @@ -15618,19 +18694,21 @@ OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) { } ); // Initialization - this.$element.addClass( 'oo-ui-radioSelectWidget' ); + this.$element + .addClass( 'oo-ui-radioSelectWidget' ) + .attr( 'role', 'radiogroup' ); }; /* Setup */ OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget ); -OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement ); /** * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget. * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxWidget ComboBoxWidget}, - * and {@link OO.ui.LookupElement LookupElement} for examples of widgets that contain menus. + * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus. * MenuSelectWidgets themselves are not instantiated directly, rather subclassed * and customized to be opened, closed, and displayed as needed. * @@ -15649,33 +18727,40 @@ OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.TabIndexedElement ); * * @class * @extends OO.ui.SelectWidget - * @mixins OO.ui.ClippableElement + * @mixins OO.ui.mixin.ClippableElement * * @constructor * @param {Object} [config] Configuration options * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match * the text the user types. This config is used by {@link OO.ui.ComboBoxWidget ComboBoxWidget} - * and {@link OO.ui.LookupElement LookupElement} - * @cfg {OO.ui.Widget} [widget] Widget associated with the menu’s active state. If the user clicks the mouse - * anywhere on the page outside of this widget, the menu is hidden. + * and {@link OO.ui.mixin.LookupElement LookupElement} + * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match + * the text the user types. This config is used by {@link OO.ui.CapsuleMultiSelectWidget CapsuleMultiSelectWidget} + * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse + * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button + * 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 {boolean} [filterFromInput=false] Filter the displayed options from the input */ OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor - OO.ui.MenuSelectWidget.super.call( this, config ); + OO.ui.MenuSelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) ); + OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) ); // Properties this.newItems = null; this.autoHide = config.autoHide === undefined || !!config.autoHide; - this.$input = config.input ? config.input.$input : null; + 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.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this ); + this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 ); // Initialization this.$element @@ -15692,7 +18777,7 @@ OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget ); -OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.ClippableElement ); +OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement ); /* Methods */ @@ -15723,7 +18808,7 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) { case OO.ui.Keys.RIGHT: // Do nothing if a text field is associated, arrow keys will be handled natively if ( !this.$input ) { - OO.ui.MenuSelectWidget.super.prototype.onKeyDown.call( this, e ); + OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e ); } break; case OO.ui.Keys.ESCAPE: @@ -15739,20 +18824,41 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) { } break; default: - OO.ui.MenuSelectWidget.super.prototype.onKeyDown.call( this, e ); + OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e ); return; } } }; /** + * Update menu item visibility after input changes. + * @protected + */ +OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () { + var i, item, + len = this.items.length, + showAll = !this.isVisible(), + filter = showAll ? null : this.getItemMatcher( this.$input.val() ); + + for ( i = 0; i < len; i++ ) { + item = this.items[ i ]; + if ( item instanceof OO.ui.OptionWidget ) { + item.toggle( showAll || filter( item ) ); + } + } + + // Reevaluate clipping + this.clip(); +}; + +/** * @inheritdoc */ OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () { if ( this.$input ) { this.$input.on( 'keydown', this.onKeyDownHandler ); } else { - OO.ui.MenuSelectWidget.super.prototype.bindKeyDownListener.call( this ); + OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this ); } }; @@ -15763,7 +18869,34 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () { if ( this.$input ) { this.$input.off( 'keydown', this.onKeyDownHandler ); } else { - OO.ui.MenuSelectWidget.super.prototype.unbindKeyDownListener.call( this ); + OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this ); + } +}; + +/** + * @inheritdoc + */ +OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () { + if ( this.$input ) { + if ( this.filterFromInput ) { + this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler ); + } + } else { + OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this ); + } +}; + +/** + * @inheritdoc + */ +OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () { + if ( this.$input ) { + if ( this.filterFromInput ) { + this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler ); + this.updateItemVisibility(); + } + } else { + OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this ); } }; @@ -15778,7 +18911,7 @@ OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () { * @chainable */ OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) { - OO.ui.MenuSelectWidget.super.prototype.chooseItem.call( this, item ); + OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item ); this.toggle( false ); return this; }; @@ -15790,7 +18923,7 @@ OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) { var i, len, item; // Parent method - OO.ui.MenuSelectWidget.super.prototype.addItems.call( this, items, index ); + OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index ); // Auto-initialize if ( !this.newItems ) { @@ -15818,7 +18951,7 @@ OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) { */ OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) { // Parent method - OO.ui.MenuSelectWidget.super.prototype.removeItems.call( this, items ); + OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items ); // Reevaluate clipping this.clip(); @@ -15831,7 +18964,7 @@ OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) { */ OO.ui.MenuSelectWidget.prototype.clearItems = function () { // Parent method - OO.ui.MenuSelectWidget.super.prototype.clearItems.call( this ); + OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this ); // Reevaluate clipping this.clip(); @@ -15843,17 +18976,18 @@ OO.ui.MenuSelectWidget.prototype.clearItems = function () { * @inheritdoc */ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { - visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length; + var i, len, change; - var i, len, - change = visible !== this.isVisible(); + visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length; + change = visible !== this.isVisible(); // Parent method - OO.ui.MenuSelectWidget.super.prototype.toggle.call( this, visible ); + OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible ); if ( change ) { if ( visible ) { this.bindKeyDownListener(); + this.bindKeyPressListener(); if ( this.newItems && this.newItems.length ) { for ( i = 0, len = this.newItems.length; i < len; i++ ) { @@ -15865,15 +18999,12 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { // Auto-hide if ( this.autoHide ) { - this.getElementDocument().addEventListener( - 'mousedown', this.onDocumentMouseDownHandler, true - ); + OO.ui.addCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler ); } } else { this.unbindKeyDownListener(); - this.getElementDocument().removeEventListener( - 'mousedown', this.onDocumentMouseDownHandler, true - ); + this.unbindKeyPressListener(); + OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler ); this.toggleClipping( false ); } } @@ -15882,21 +19013,28 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) { }; /** - * TextInputMenuSelectWidget is a menu that is specially designed to be positioned beneath - * a {@link OO.ui.TextInputWidget text input} field. The menu's position is automatically - * calculated and maintained when the menu is toggled or the window is resized. + * FloatingMenuSelectWidget is a menu 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). This is sometimes necessary to prevent the + * menu from being clipped too aggresively. + * + * The menu's position is automatically calculated and maintained when the menu + * is toggled or the window is resized. + * * See OO.ui.ComboBoxWidget for an example of a widget that uses this class. * * @class * @extends OO.ui.MenuSelectWidget + * @mixins OO.ui.mixin.FloatableElement * * @constructor - * @param {OO.ui.TextInputWidget} inputWidget Text input widget to provide menu for + * @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=input.$element] Element to render menu under + * @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under */ -OO.ui.TextInputMenuSelectWidget = function OoUiTextInputMenuSelectWidget( inputWidget, config ) { - // Allow passing positional parameters inside the config object +OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) { + // Allow 'inputWidget' parameter and config for backwards compatibility if ( OO.isPlainObject( inputWidget ) && config === undefined ) { config = inputWidget; inputWidget = config.inputWidget; @@ -15906,104 +19044,74 @@ OO.ui.TextInputMenuSelectWidget = function OoUiTextInputMenuSelectWidget( inputW config = config || {}; // Parent constructor - OO.ui.TextInputMenuSelectWidget.super.call( this, config ); + OO.ui.FloatingMenuSelectWidget.parent.call( this, config ); - // Properties - this.inputWidget = inputWidget; + // Properties (must be set before mixin constructors) + this.inputWidget = inputWidget; // For backwards compatibility this.$container = config.$container || this.inputWidget.$element; - this.onWindowResizeHandler = this.onWindowResize.bind( this ); + + // Mixins constructors + OO.ui.mixin.FloatableElement.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.TextInputMenuSelectWidget, OO.ui.MenuSelectWidget ); +OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget ); +OO.mixinClass( OO.ui.FloatingMenuSelectWidget, OO.ui.mixin.FloatableElement ); -/* Methods */ +// For backwards compatibility +OO.ui.TextInputMenuSelectWidget = OO.ui.FloatingMenuSelectWidget; -/** - * Handle window resize event. - * - * @private - * @param {jQuery.Event} e Window resize event - */ -OO.ui.TextInputMenuSelectWidget.prototype.onWindowResize = function () { - this.position(); -}; +/* Methods */ /** * @inheritdoc */ -OO.ui.TextInputMenuSelectWidget.prototype.toggle = function ( visible ) { +OO.ui.FloatingMenuSelectWidget.prototype.toggle = function ( visible ) { + var change; visible = visible === undefined ? !this.isVisible() : !!visible; - - var change = visible !== this.isVisible(); + change = visible !== this.isVisible(); if ( change && visible ) { // Make sure the width is set before the parent method runs. - // After this we have to call this.position(); again to actually - // position ourselves correctly. - this.position(); + this.setIdealSize( this.$container.width() ); } // Parent method - OO.ui.TextInputMenuSelectWidget.super.prototype.toggle.call( this, visible ); + // This will call this.clip(), which is nonsensical since we're not positioned yet... + OO.ui.FloatingMenuSelectWidget.parent.prototype.toggle.call( this, visible ); if ( change ) { - if ( this.isVisible() ) { - this.position(); - $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler ); - } else { - $( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler ); - } + this.togglePositioning( this.isVisible() ); } return this; }; /** - * Position the menu. - * - * @private - * @chainable - */ -OO.ui.TextInputMenuSelectWidget.prototype.position = function () { - var $container = this.$container, - pos = OO.ui.Element.static.getRelativePosition( $container, this.$element.offsetParent() ); - - // Position under input - pos.top += $container.height(); - this.$element.css( pos ); - - // Set width - this.setIdealSize( $container.width() ); - // We updated the position, so re-evaluate the clipping state - this.clip(); - - return this; -}; - -/** * OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options} * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget. * - * ####Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.#### + * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.** * * @class * @extends OO.ui.SelectWidget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) { // Parent constructor - OO.ui.OutlineSelectWidget.super.call( this, config ); + OO.ui.OutlineSelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, config ); // Events this.$element.on( { @@ -16018,26 +19126,26 @@ OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget ); -OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement ); /** * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options} * - * ####Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.#### + * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.** * * @class * @extends OO.ui.SelectWidget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) { // Parent constructor - OO.ui.TabSelectWidget.super.call( this, config ); + OO.ui.TabSelectWidget.parent.call( this, config ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, config ); // Events this.$element.on( { @@ -16052,7 +19160,364 @@ OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget ); -OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement ); + +/** + * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value + * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets} + * (to adjust the value in increments) to allow the user to enter a number. + * + * @example + * // Example: A NumberInputWidget. + * var numberInput = new OO.ui.NumberInputWidget( { + * label: 'NumberInputWidget', + * input: { value: 5, min: 1, max: 10 } + * } ); + * $( 'body' ).append( numberInput.$element ); + * + * @class + * @extends OO.ui.Widget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}. + * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}. + * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}. + * @cfg {boolean} [isInteger=false] Whether the field accepts only integer values. + * @cfg {number} [min=-Infinity] Minimum allowed value + * @cfg {number} [max=Infinity] Maximum allowed value + * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys + * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step. + */ +OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) { + // Configuration initialization + config = $.extend( { + isInteger: false, + min: -Infinity, + max: Infinity, + step: 1, + pageStep: null + }, config ); + + // Parent constructor + OO.ui.NumberInputWidget.parent.call( this, config ); + + // Properties + this.input = new OO.ui.TextInputWidget( $.extend( + { + disabled: this.isDisabled() + }, + config.input + ) ); + this.minusButton = new OO.ui.ButtonWidget( $.extend( + { + disabled: this.isDisabled(), + tabIndex: -1 + }, + config.minusButton, + { + classes: [ 'oo-ui-numberInputWidget-minusButton' ], + label: '−' + } + ) ); + this.plusButton = new OO.ui.ButtonWidget( $.extend( + { + disabled: this.isDisabled(), + tabIndex: -1 + }, + config.plusButton, + { + classes: [ 'oo-ui-numberInputWidget-plusButton' ], + label: '+' + } + ) ); + + // Events + this.input.connect( this, { + change: this.emit.bind( this, 'change' ), + enter: this.emit.bind( this, 'enter' ) + } ); + this.input.$input.on( { + keydown: this.onKeyDown.bind( this ), + 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this ) + } ); + this.plusButton.connect( this, { + click: [ 'onButtonClick', +1 ] + } ); + this.minusButton.connect( this, { + click: [ 'onButtonClick', -1 ] + } ); + + // Initialization + this.setIsInteger( !!config.isInteger ); + this.setRange( config.min, config.max ); + this.setStep( config.step, config.pageStep ); + + this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' ) + .append( + this.minusButton.$element, + this.input.$element, + this.plusButton.$element + ); + this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field ); + this.input.setValidation( this.validateNumber.bind( this ) ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget ); + +/* Events */ + +/** + * A `change` event is emitted when the value of the input changes. + * + * @event change + */ + +/** + * An `enter` event is emitted when the user presses 'enter' inside the text box. + * + * @event enter + */ + +/* Methods */ + +/** + * Set whether only integers are allowed + * @param {boolean} flag + */ +OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) { + this.isInteger = !!flag; + this.input.setValidityFlag(); +}; + +/** + * Get whether only integers are allowed + * @return {boolean} Flag value + */ +OO.ui.NumberInputWidget.prototype.getIsInteger = function () { + return this.isInteger; +}; + +/** + * Set the range of allowed values + * @param {number} min Minimum allowed value + * @param {number} max Maximum allowed value + */ +OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) { + if ( min > max ) { + throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' ); + } + this.min = min; + this.max = max; + this.input.setValidityFlag(); +}; + +/** + * Get the current range + * @return {number[]} Minimum and maximum values + */ +OO.ui.NumberInputWidget.prototype.getRange = function () { + return [ this.min, this.max ]; +}; + +/** + * Set the stepping deltas + * @param {number} step Normal step + * @param {number|null} pageStep Page step. If null, 10 * step will be used. + */ +OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) { + if ( step <= 0 ) { + throw new Error( 'Step value must be positive' ); + } + if ( pageStep === null ) { + pageStep = step * 10; + } else if ( pageStep <= 0 ) { + throw new Error( 'Page step value must be positive' ); + } + this.step = step; + this.pageStep = pageStep; +}; + +/** + * Get the current stepping values + * @return {number[]} Step and page step + */ +OO.ui.NumberInputWidget.prototype.getStep = function () { + return [ this.step, this.pageStep ]; +}; + +/** + * Get the current value of the widget + * @return {string} + */ +OO.ui.NumberInputWidget.prototype.getValue = function () { + return this.input.getValue(); +}; + +/** + * Get the current value of the widget as a number + * @return {number} May be NaN, or an invalid number + */ +OO.ui.NumberInputWidget.prototype.getNumericValue = function () { + return +this.input.getValue(); +}; + +/** + * Set the value of the widget + * @param {string} value Invalid values are allowed + */ +OO.ui.NumberInputWidget.prototype.setValue = function ( value ) { + this.input.setValue( value ); +}; + +/** + * Adjust the value of the widget + * @param {number} delta Adjustment amount + */ +OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) { + var n, v = this.getNumericValue(); + + delta = +delta; + if ( isNaN( delta ) || !isFinite( delta ) ) { + throw new Error( 'Delta must be a finite number' ); + } + + if ( isNaN( v ) ) { + n = 0; + } else { + n = v + delta; + n = Math.max( Math.min( n, this.max ), this.min ); + if ( this.isInteger ) { + n = Math.round( n ); + } + } + + if ( n !== v ) { + this.setValue( n ); + } +}; + +/** + * Validate input + * @private + * @param {string} value Field value + * @return {boolean} + */ +OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) { + var n = +value; + if ( isNaN( n ) || !isFinite( n ) ) { + return false; + } + + /*jshint bitwise: false */ + if ( this.isInteger && ( n | 0 ) !== n ) { + return false; + } + /*jshint bitwise: true */ + + if ( n < this.min || n > this.max ) { + return false; + } + + return true; +}; + +/** + * Handle mouse click events. + * + * @private + * @param {number} dir +1 or -1 + */ +OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) { + this.adjustValue( dir * this.step ); +}; + +/** + * Handle mouse wheel events. + * + * @private + * @param {jQuery.Event} event + */ +OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) { + var delta = 0; + + // Standard 'wheel' event + if ( event.originalEvent.deltaMode !== undefined ) { + this.sawWheelEvent = true; + } + if ( event.originalEvent.deltaY ) { + delta = -event.originalEvent.deltaY; + } else if ( event.originalEvent.deltaX ) { + delta = event.originalEvent.deltaX; + } + + // Non-standard events + if ( !this.sawWheelEvent ) { + if ( event.originalEvent.wheelDeltaX ) { + delta = -event.originalEvent.wheelDeltaX; + } else if ( event.originalEvent.wheelDeltaY ) { + delta = event.originalEvent.wheelDeltaY; + } else if ( event.originalEvent.wheelDelta ) { + delta = event.originalEvent.wheelDelta; + } else if ( event.originalEvent.detail ) { + delta = -event.originalEvent.detail; + } + } + + if ( delta ) { + delta = delta < 0 ? -1 : 1; + this.adjustValue( delta * this.step ); + } + + return false; +}; + +/** + * Handle key down events. + * + * @private + * @param {jQuery.Event} e Key down event + */ +OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) { + if ( !this.isDisabled() ) { + switch ( e.which ) { + case OO.ui.Keys.UP: + this.adjustValue( this.step ); + return false; + case OO.ui.Keys.DOWN: + this.adjustValue( -this.step ); + return false; + case OO.ui.Keys.PAGEUP: + this.adjustValue( this.pageStep ); + return false; + case OO.ui.Keys.PAGEDOWN: + this.adjustValue( -this.pageStep ); + return false; + } + } +}; + +/** + * @inheritdoc + */ +OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) { + // Parent method + OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled ); + + if ( this.input ) { + this.input.setDisabled( this.isDisabled() ); + } + if ( this.minusButton ) { + this.minusButton.setDisabled( this.isDisabled() ); + } + if ( this.plusButton ) { + this.plusButton.setDisabled( this.isDisabled() ); + } + + return this; +}; /** * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean @@ -16078,7 +19543,7 @@ OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.TabIndexedElement ); * * @class * @extends OO.ui.ToggleWidget - * @mixins OO.ui.TabIndexedElement + * @mixins OO.ui.mixin.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -16087,10 +19552,10 @@ OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.TabIndexedElement ); */ OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) { // Parent constructor - OO.ui.ToggleSwitchWidget.super.call( this, config ); + OO.ui.ToggleSwitchWidget.parent.call( this, config ); // Mixin constructors - OO.ui.TabIndexedElement.call( this, config ); + OO.ui.mixin.TabIndexedElement.call( this, config ); // Properties this.dragging = false; @@ -16117,7 +19582,7 @@ OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) { /* Setup */ OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget ); -OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.TabIndexedElement ); +OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement ); /* Methods */ @@ -16147,4 +19612,104 @@ OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) { } }; +/*! + * Deprecated aliases for classes in the `OO.ui.mixin` namespace. + */ + +/** + * @inheritdoc OO.ui.mixin.ButtonElement + * @deprecated Use {@link OO.ui.mixin.ButtonElement} instead. + */ +OO.ui.ButtonElement = OO.ui.mixin.ButtonElement; + +/** + * @inheritdoc OO.ui.mixin.ClippableElement + * @deprecated Use {@link OO.ui.mixin.ClippableElement} instead. + */ +OO.ui.ClippableElement = OO.ui.mixin.ClippableElement; + +/** + * @inheritdoc OO.ui.mixin.DraggableElement + * @deprecated Use {@link OO.ui.mixin.DraggableElement} instead. + */ +OO.ui.DraggableElement = OO.ui.mixin.DraggableElement; + +/** + * @inheritdoc OO.ui.mixin.DraggableGroupElement + * @deprecated Use {@link OO.ui.mixin.DraggableGroupElement} instead. + */ +OO.ui.DraggableGroupElement = OO.ui.mixin.DraggableGroupElement; + +/** + * @inheritdoc OO.ui.mixin.FlaggedElement + * @deprecated Use {@link OO.ui.mixin.FlaggedElement} instead. + */ +OO.ui.FlaggedElement = OO.ui.mixin.FlaggedElement; + +/** + * @inheritdoc OO.ui.mixin.GroupElement + * @deprecated Use {@link OO.ui.mixin.GroupElement} instead. + */ +OO.ui.GroupElement = OO.ui.mixin.GroupElement; + +/** + * @inheritdoc OO.ui.mixin.GroupWidget + * @deprecated Use {@link OO.ui.mixin.GroupWidget} instead. + */ +OO.ui.GroupWidget = OO.ui.mixin.GroupWidget; + +/** + * @inheritdoc OO.ui.mixin.IconElement + * @deprecated Use {@link OO.ui.mixin.IconElement} instead. + */ +OO.ui.IconElement = OO.ui.mixin.IconElement; + +/** + * @inheritdoc OO.ui.mixin.IndicatorElement + * @deprecated Use {@link OO.ui.mixin.IndicatorElement} instead. + */ +OO.ui.IndicatorElement = OO.ui.mixin.IndicatorElement; + +/** + * @inheritdoc OO.ui.mixin.ItemWidget + * @deprecated Use {@link OO.ui.mixin.ItemWidget} instead. + */ +OO.ui.ItemWidget = OO.ui.mixin.ItemWidget; + +/** + * @inheritdoc OO.ui.mixin.LabelElement + * @deprecated Use {@link OO.ui.mixin.LabelElement} instead. + */ +OO.ui.LabelElement = OO.ui.mixin.LabelElement; + +/** + * @inheritdoc OO.ui.mixin.LookupElement + * @deprecated Use {@link OO.ui.mixin.LookupElement} instead. + */ +OO.ui.LookupElement = OO.ui.mixin.LookupElement; + +/** + * @inheritdoc OO.ui.mixin.PendingElement + * @deprecated Use {@link OO.ui.mixin.PendingElement} instead. + */ +OO.ui.PendingElement = OO.ui.mixin.PendingElement; + +/** + * @inheritdoc OO.ui.mixin.PopupElement + * @deprecated Use {@link OO.ui.mixin.PopupElement} instead. + */ +OO.ui.PopupElement = OO.ui.mixin.PopupElement; + +/** + * @inheritdoc OO.ui.mixin.TabIndexedElement + * @deprecated Use {@link OO.ui.mixin.TabIndexedElement} instead. + */ +OO.ui.TabIndexedElement = OO.ui.mixin.TabIndexedElement; + +/** + * @inheritdoc OO.ui.mixin.TitledElement + * @deprecated Use {@link OO.ui.mixin.TitledElement} instead. + */ +OO.ui.TitledElement = OO.ui.mixin.TitledElement; + }( OO ) ); |