X-Git-Url: https://git.cyclocoop.org/?a=blobdiff_plain;f=resources%2Flib%2Foojs-ui%2Foojs-ui.js;h=0ad88fe19f95aa2c5699f8ec820d41ac981499d3;hb=d50f6c0e22b980703dc96a4c2937419ec9b02436;hp=5f758950fa7cfde8f69f253bf88199f47e1fde99;hpb=0d8e9eacae1f47c60518f871dbb73f4539413d39;p=lhc%2Fweb%2Fwiklou.git diff --git a/resources/lib/oojs-ui/oojs-ui.js b/resources/lib/oojs-ui/oojs-ui.js index 5f758950fa..0ad88fe19f 100644 --- a/resources/lib/oojs-ui/oojs-ui.js +++ b/resources/lib/oojs-ui/oojs-ui.js @@ -1,12 +1,12 @@ /*! - * OOjs UI v0.6.0 + * OOjs UI v0.8.0 * https://www.mediawiki.org/wiki/OOjs_UI * - * Copyright 2011–2014 OOjs Team and other contributors. + * Copyright 2011–2015 OOjs Team and other contributors. * Released under the MIT license * http://oojs.mit-license.org * - * Date: 2014-12-16T21:00:55Z + * Date: 2015-02-19T01:33:11Z */ ( function ( OO ) { @@ -70,24 +70,24 @@ OO.ui.getLocalValue = function ( obj, lang, fallback ) { var i, len, langs; // Requested language - if ( obj[lang] ) { - return obj[lang]; + if ( obj[ lang ] ) { + return obj[ lang ]; } // Known user language langs = OO.ui.getUserLanguages(); for ( i = 0, len = langs.length; i < len; i++ ) { - lang = langs[i]; - if ( obj[lang] ) { - return obj[lang]; + lang = langs[ i ]; + if ( obj[ lang ] ) { + return obj[ lang ]; } } // Fallback language - if ( obj[fallback] ) { - return obj[fallback]; + if ( obj[ fallback ] ) { + return obj[ fallback ]; } // First existing language for ( lang in obj ) { - return obj[lang]; + return obj[ lang ]; } return undefined; @@ -110,7 +110,7 @@ OO.ui.contains = function ( containers, contained, matchContainers ) { containers = [ containers ]; } for ( i = containers.length - 1; i >= 0; i-- ) { - if ( ( matchContainers && contained === containers[i] ) || $.contains( containers[i], contained ) ) { + if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) { return true; } } @@ -171,12 +171,13 @@ OO.ui.contains = function ( containers, contained, matchContainers ) { * @return {string} Translated message with parameters substituted */ OO.ui.msg = function ( key ) { - var message = messages[key], params = Array.prototype.slice.call( arguments, 1 ); + var message = messages[ key ], + params = Array.prototype.slice.call( arguments, 1 ); if ( typeof message === 'string' ) { // Perform $1 substitution message = message.replace( /\$(\d+)/g, function ( unused, n ) { var i = parseInt( n, 10 ); - return params[i - 1] !== undefined ? params[i - 1] : '$' + n; + return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n; } ); } else { // Return placeholder if message not found @@ -304,7 +305,68 @@ OO.ui.PendingElement.prototype.popPending = function () { }; /** - * List of actions. + * ActionSets manage the behavior of the {@link OO.ui.ActionWidget Action widgets} that comprise them. + * Actions can be made available for specific contexts (modes) and circumstances + * (abilities). Please see the [OOjs UI documentation on MediaWiki][1] for more information. + * + * @example + * // Example: An action set used in a process dialog + * function ProcessDialog( config ) { + * ProcessDialog.super.call( this, config ); + * } + * OO.inheritClass( ProcessDialog, OO.ui.ProcessDialog ); + * ProcessDialog.static.title = 'An action set in a process dialog'; + * // An action set that uses modes ('edit' and 'help' mode, in this example). + * ProcessDialog.static.actions = [ + * { action: 'continue', modes: 'edit', label: 'Continue', flags: [ 'primary', 'constructive' ] }, + * { action: 'help', modes: 'edit', label: 'Help' }, + * { modes: 'edit', label: 'Cancel', flags: 'safe' }, + * { action: 'back', modes: 'help', label: 'Back', flags: 'safe' } + * ]; + * + * ProcessDialog.prototype.initialize = function () { + * ProcessDialog.super.prototype.initialize.apply( this, arguments ); + * this.panel1 = new OO.ui.PanelLayout( { $: this.$, padded: true, expanded: false } ); + * this.panel1.$element.append( '

This dialog uses an action set (continue, help, cancel, back) configured with modes. This is edit mode. Click \'help\' to see help mode.

' ); + * this.panel2 = new OO.ui.PanelLayout( { $: this.$, padded: true, expanded: false } ); + * this.panel2.$element.append( '

This is help mode. Only the \'back\' action widget is configured to be visible here. Click \'back\' to return to \'edit\' mode

' ); + * this.stackLayout= new OO.ui.StackLayout( { + * items: [ this.panel1, this.panel2 ] + * }); + * this.$body.append( this.stackLayout.$element ); + * }; + * ProcessDialog.prototype.getSetupProcess = function ( data ) { + * return ProcessDialog.super.prototype.getSetupProcess.call( this, data ) + * .next( function () { + * this.actions.setMode('edit'); + * }, this ); + * }; + * ProcessDialog.prototype.getActionProcess = function ( action ) { + * if ( action === 'help' ) { + * this.actions.setMode( 'help' ); + * this.stackLayout.setItem( this.panel2 ); + * } else if ( action === 'back' ) { + * this.actions.setMode( 'edit' ); + * this.stackLayout.setItem( this.panel1 ); + * } else if ( action === 'continue' ) { + * var dialog = this; + * return new OO.ui.Process( function () { + * dialog.close(); + * } ); + * } + * return ProcessDialog.super.prototype.getActionProcess.call( this, action ); + * }; + * ProcessDialog.prototype.getBodyHeight = function () { + * return this.panel1.$element.outerHeight( true ); + * }; + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * var processDialog = new ProcessDialog({ + * size: 'medium'}); + * windowManager.addWindows( [ processDialog ] ); + * windowManager.openWindow( processDialog ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets * * @abstract * @class @@ -342,7 +404,11 @@ OO.mixinClass( OO.ui.ActionSet, OO.EventEmitter ); /* Static Properties */ /** - * Symbolic name of dialog. + * Symbolic name of the flags used to identify special actions. Special actions are displayed in the + * header of a {@link OO.ui.ProcessDialog process dialog}. + * See the [OOjs UI documentation on MediaWiki][2] for more information and examples. + * + * [2]:https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs * * @abstract * @static @@ -382,6 +448,7 @@ OO.ui.ActionSet.static.specialFlags = [ 'safe', 'primary' ]; /** * Handle action change events. * + * @private * @fires change */ OO.ui.ActionSet.prototype.onActionChange = function () { @@ -403,7 +470,7 @@ OO.ui.ActionSet.prototype.isSpecial = function ( action ) { var flag; for ( flag in this.special ) { - if ( action === this.special[flag] ) { + if ( action === this.special[ flag ] ) { return true; } } @@ -431,13 +498,13 @@ OO.ui.ActionSet.prototype.get = function ( filters ) { // Collect category candidates matches = []; for ( category in this.categorized ) { - list = filters[category]; + list = filters[ category ]; if ( list ) { if ( !Array.isArray( list ) ) { list = [ list ]; } for ( i = 0, len = list.length; i < len; i++ ) { - actions = this.categorized[category][list[i]]; + actions = this.categorized[ category ][ list[ i ] ]; if ( Array.isArray( actions ) ) { matches.push.apply( matches, actions ); } @@ -446,7 +513,7 @@ OO.ui.ActionSet.prototype.get = function ( filters ) { } // Remove by boolean filters for ( i = 0, len = matches.length; i < len; i++ ) { - match = matches[i]; + match = matches[ i ]; if ( ( filters.visible !== undefined && match.isVisible() !== filters.visible ) || ( filters.disabled !== undefined && match.isDisabled() !== filters.disabled ) @@ -458,7 +525,7 @@ OO.ui.ActionSet.prototype.get = function ( filters ) { } // Remove duplicates for ( i = 0, len = matches.length; i < len; i++ ) { - match = matches[i]; + match = matches[ i ]; index = matches.lastIndexOf( match ); while ( index !== i ) { matches.splice( index, 1 ); @@ -512,7 +579,7 @@ OO.ui.ActionSet.prototype.setMode = function ( mode ) { this.changing = true; for ( i = 0, len = this.list.length; i < len; i++ ) { - action = this.list[i]; + action = this.list[ i ]; action.toggle( action.hasMode( mode ) ); } @@ -536,10 +603,10 @@ OO.ui.ActionSet.prototype.setAbilities = function ( actions ) { var i, len, action, item; for ( i = 0, len = this.list.length; i < len; i++ ) { - item = this.list[i]; + item = this.list[ i ]; action = item.getAction(); - if ( actions[action] !== undefined ) { - item.setDisabled( !actions[action] ); + if ( actions[ action ] !== undefined ) { + item.setDisabled( !actions[ action ] ); } } @@ -582,7 +649,7 @@ OO.ui.ActionSet.prototype.add = function ( actions ) { this.changing = true; for ( i = 0, len = actions.length; i < len; i++ ) { - action = actions[i]; + action = actions[ i ]; action.connect( this, { click: [ 'emit', 'click', action ], resize: [ 'emit', 'resize', action ], @@ -611,7 +678,7 @@ OO.ui.ActionSet.prototype.remove = function ( actions ) { this.changing = true; for ( i = 0, len = actions.length; i < len; i++ ) { - action = actions[i]; + action = actions[ i ]; index = this.list.indexOf( action ); if ( index !== -1 ) { action.disconnect( this ); @@ -639,7 +706,7 @@ OO.ui.ActionSet.prototype.clear = function () { this.changing = true; for ( i = 0, len = this.list.length; i < len; i++ ) { - action = this.list[i]; + action = this.list[ i ]; action.disconnect( this ); } @@ -671,31 +738,31 @@ OO.ui.ActionSet.prototype.organize = function () { this.special = {}; this.others = []; for ( i = 0, iLen = this.list.length; i < iLen; i++ ) { - action = this.list[i]; + action = this.list[ i ]; if ( action.isVisible() ) { // Populate categories for ( category in this.categories ) { - if ( !this.categorized[category] ) { - this.categorized[category] = {}; + if ( !this.categorized[ category ] ) { + this.categorized[ category ] = {}; } - list = action[this.categories[category]](); + list = action[ this.categories[ category ] ](); if ( !Array.isArray( list ) ) { list = [ list ]; } for ( j = 0, jLen = list.length; j < jLen; j++ ) { - item = list[j]; - if ( !this.categorized[category][item] ) { - this.categorized[category][item] = []; + item = list[ j ]; + if ( !this.categorized[ category ][ item ] ) { + this.categorized[ category ][ item ] = []; } - this.categorized[category][item].push( action ); + this.categorized[ category ][ item ].push( action ); } } // Populate special/others special = false; for ( j = 0, jLen = specialFlags.length; j < jLen; j++ ) { - flag = specialFlags[j]; - if ( !this.special[flag] && action.hasFlag( flag ) ) { - this.special[flag] = action; + flag = specialFlags[ j ]; + if ( !this.special[ flag ] && action.hasFlag( flag ) ) { + this.special[ flag ] = action; special = true; break; } @@ -712,15 +779,17 @@ OO.ui.ActionSet.prototype.organize = function () { }; /** - * DOM element abstraction. + * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything + * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events + * connected to them and can't be interacted with. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {Function} [$] jQuery for the frame the widget is in * @cfg {string[]} [classes] CSS class names to add + * @cfg {string} [id] HTML id attribute * @cfg {string} [text] Text to insert * @cfg {jQuery} [$content] Content elements to append (after text) * @cfg {Mixed} [data] Element data @@ -730,17 +799,20 @@ OO.ui.Element = function OoUiElement( config ) { config = config || {}; // Properties - this.$ = config.$ || OO.ui.Element.static.getJQuery( document ); + this.$ = $; this.data = config.data; - this.$element = this.$( this.$.context.createElement( this.getTagName() ) ); + this.$element = $( document.createElement( this.getTagName() ) ); this.elementGroup = null; this.debouncedUpdateThemeClassesHandler = this.debouncedUpdateThemeClasses.bind( this ); this.updateThemeClassesPending = false; // Initialization - if ( $.isArray( config.classes ) ) { + if ( Array.isArray( config.classes ) ) { this.$element.addClass( config.classes.join( ' ' ) ); } + if ( config.id ) { + this.$element.attr( 'id', config.id ); + } if ( config.text ) { this.$element.text( config.text ); } @@ -800,7 +872,7 @@ OO.ui.Element.static.getJQuery = function ( context, $iframe ) { */ OO.ui.Element.static.getDocument = function ( obj ) { // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable - return ( obj[0] && obj[0].ownerDocument ) || + return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) || // Empty jQuery selections might have a context obj.context || // HTMLElement @@ -835,7 +907,7 @@ OO.ui.Element.static.getDir = function ( obj ) { var isDoc, isWin; if ( obj instanceof jQuery ) { - obj = obj[0]; + obj = obj[ 0 ]; } isDoc = obj.nodeType === 9; isWin = obj.document !== undefined; @@ -875,8 +947,8 @@ OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) { // Get iframe element frames = from.parent.document.getElementsByTagName( 'iframe' ); for ( i = 0, len = frames.length; i < len; i++ ) { - if ( frames[i].contentWindow === from ) { - frame = frames[i]; + if ( frames[ i ].contentWindow === from ) { + frame = frames[ i ]; break; } } @@ -947,10 +1019,10 @@ OO.ui.Element.static.getBorders = function ( el ) { right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0; return { - top: Math.round( top ), - left: Math.round( left ), - bottom: Math.round( bottom ), - right: Math.round( right ) + top: top, + left: left, + bottom: bottom, + right: right }; }; @@ -1052,14 +1124,14 @@ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) } while ( $parent.length ) { - if ( $parent[0] === this.getRootScrollableElement( el ) ) { - return $parent[0]; + if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) { + return $parent[ 0 ]; } i = props.length; while ( i-- ) { - val = $parent.css( props[i] ); + val = $parent.css( props[ i ] ); if ( val === 'auto' || val === 'scroll' ) { - return $parent[0]; + return $parent[ 0 ]; } } $parent = $parent.parent(); @@ -1138,6 +1210,33 @@ OO.ui.Element.static.scrollIntoView = function ( el, config ) { } }; +/** + * Force the browser to reconsider whether it really needs to render scrollbars inside the element + * and reserve space for them, because it probably doesn't. + * + * Workaround primarily for , but also + * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need + * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow, + * and then reattach (or show) them back. + * + * @static + * @param {HTMLElement} el Element to reconsider the scrollbars on + */ +OO.ui.Element.static.reconsiderScrollbars = function ( el ) { + var i, len, nodes = []; + // Detach all children + while ( el.firstChild ) { + nodes.push( el.firstChild ); + el.removeChild( el.firstChild ); + } + // Force reflow + void el.offsetHeight; + // Reattach all children + for ( i = 0, len = nodes.length; i < len; i++ ) { + el.appendChild( nodes[ i ] ); + } +}; + /* Methods */ /** @@ -1170,9 +1269,9 @@ OO.ui.Element.prototype.supports = function ( methods ) { var i, len, support = 0; - methods = $.isArray( methods ) ? methods : [ methods ]; + methods = Array.isArray( methods ) ? methods : [ methods ]; for ( i = 0, len = methods.length; i < len; i++ ) { - if ( $.isFunction( this[methods[i]] ) ) { + if ( $.isFunction( this[ methods[ i ] ] ) ) { support++; } } @@ -1218,7 +1317,7 @@ OO.ui.Element.prototype.getTagName = function () { * @return {boolean} The element is attached to the DOM */ OO.ui.Element.prototype.isElementAttached = function () { - return $.contains( this.getElementDocument(), this.$element[0] ); + return $.contains( this.getElementDocument(), this.$element[ 0 ] ); }; /** @@ -1227,7 +1326,6 @@ OO.ui.Element.prototype.isElementAttached = function () { * @return {HTMLDocument} Document object */ OO.ui.Element.prototype.getElementDocument = function () { - // Don't use this.$.context because subclasses can rebind this.$ // Don't cache this in other ways either because subclasses could can change this.$element return OO.ui.Element.static.getDocument( this.$element ); }; @@ -1245,7 +1343,7 @@ OO.ui.Element.prototype.getElementWindow = function () { * Get closest scrollable container. */ OO.ui.Element.prototype.getClosestScrollableElementContainer = function () { - return OO.ui.Element.static.getClosestScrollableContainer( this.$element[0] ); + return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] ); }; /** @@ -1274,7 +1372,7 @@ OO.ui.Element.prototype.setElementGroup = function ( group ) { * @param {Object} [config] Configuration options */ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) { - return OO.ui.Element.static.scrollIntoView( this.$element[0], config ); + return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config ); }; /** @@ -1308,7 +1406,9 @@ OO.inheritClass( OO.ui.Layout, OO.ui.Element ); OO.mixinClass( OO.ui.Layout, OO.EventEmitter ); /** - * User interface control. + * Widgets are compositions of one or more OOjs UI elements that users can both view + * and interact with. All widgets can be configured and modified via a standard API, + * and their state can change dynamically according to a model. * * @abstract * @class @@ -1392,6 +1492,7 @@ OO.ui.Widget.prototype.setDisabled = function ( disabled ) { if ( isDisabled !== this.wasDisabled ) { this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled ); this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled ); + this.$element.attr( 'aria-disabled', isDisabled.toString() ); this.emit( 'disable', isDisabled ); this.updateThemeClasses(); } @@ -1412,7 +1513,7 @@ OO.ui.Widget.prototype.toggle = function ( show ) { if ( show !== this.isVisible() ) { this.visible = show; - this.$element.toggle( show ); + this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible ); this.emit( 'toggle', show ); } @@ -1430,44 +1531,49 @@ OO.ui.Widget.prototype.updateDisabled = function () { }; /** - * Container for elements in a child frame. - * - * Use together with OO.ui.WindowManager. + * A window is a container for elements that are in a child frame. They are used with + * a window manager (OO.ui.WindowManager), which is used to open and close the window and control + * its presentation. The size of a window is specified using a symbolic name (e.g., ‘small’, ‘medium’, + * ‘large’), which is interpreted by the window manager. If the requested size is not recognized, + * the window manager will choose a sensible fallback. * - * @abstract - * @class - * @extends OO.ui.Element - * @mixins OO.EventEmitter + * The lifecycle of a window has three primary stages (opening, opened, and closing) in which + * different processes are executed: * - * When a window is opened, the setup and ready processes are executed. Similarly, the hold and - * teardown processes are executed when the window is closed. + * **opening**: The opening stage begins when the window manager's {@link OO.ui.WindowManager#openWindow + * openWindow} or the window's {@link #open open} methods are used, and the window manager begins to open + * the window. * - * - {@link OO.ui.WindowManager#openWindow} or {@link #open} methods are used to start opening - * - Window manager begins opening window * - {@link #getSetupProcess} method is called and its result executed * - {@link #getReadyProcess} method is called and its result executed - * - Window is now open * - * - {@link OO.ui.WindowManager#closeWindow} or {@link #close} methods are used to start closing - * - Window manager begins closing window + * **opened**: The window is now open + * + * **closing**: The closing stage begins when the window manager's + * {@link OO.ui.WindowManager#closeWindow closeWindow} + * or the window's {@link #close} methods are used, and the window manager begins to close the window. + * * - {@link #getHoldProcess} method is called and its result executed - * - {@link #getTeardownProcess} method is called and its result executed - * - Window is now closed + * - {@link #getTeardownProcess} method is called and its result executed. The window is now closed * - * Each process (setup, ready, hold and teardown) can be extended in subclasses by overriding - * {@link #getSetupProcess}, {@link #getReadyProcess}, {@link #getHoldProcess} and - * {@link #getTeardownProcess} respectively. Each process is executed in series, so asynchronous - * processing can complete. Always assume window processes are executed asynchronously. See - * OO.ui.Process for more details about how to work with processes. Some events, as well as the - * #open and #close methods, provide promises which are resolved when the window enters a new state. + * Each of the window's processes (setup, ready, hold, and teardown) can be extended in subclasses + * by overriding the window's #getSetupProcess, #getReadyProcess, #getHoldProcess and #getTeardownProcess + * methods. Note that each {@link OO.ui.Process process} is executed in series, so asynchronous + * processing can complete. Always assume window processes are executed asynchronously. * - * Sizing of windows is specified using symbolic names which are interpreted by the window manager. - * If the requested size is not recognized, the window manager will choose a sensible fallback. + * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows + * + * @abstract + * @class + * @extends OO.ui.Element + * @mixins OO.EventEmitter * * @constructor * @param {Object} [config] Configuration options - * @cfg {string} [size] Symbolic name of dialog size, `small`, `medium`, `large` or `full`; omit to - * use #static-size + * @cfg {string} [size] Symbolic name of dialog size, `small`, `medium`, `large`, `larger` or + * `full`; omit to use #static-size */ OO.ui.Window = function OoUiWindow( config ) { // Configuration initialization @@ -1481,25 +1587,29 @@ OO.ui.Window = function OoUiWindow( config ) { // Properties this.manager = null; - this.initialized = false; - this.visible = false; - this.opening = null; - this.closing = null; - this.opened = null; - this.timing = null; - this.loading = null; this.size = config.size || this.constructor.static.size; - this.$frame = this.$( '
' ); - this.$overlay = this.$( '
' ); + this.$frame = $( '
' ); + this.$overlay = $( '
' ); + this.$content = $( '
' ); // Initialization + this.$overlay.addClass( 'oo-ui-window-overlay' ); + this.$content + .addClass( 'oo-ui-window-content' ) + .attr( 'tabIndex', 0 ); + this.$frame + .addClass( 'oo-ui-window-frame' ) + .append( this.$content ); + this.$element .addClass( 'oo-ui-window' ) .append( this.$frame, this.$overlay ); - this.$frame.addClass( 'oo-ui-window-frame' ); - this.$overlay.addClass( 'oo-ui-window-overlay' ); - // NOTE: Additional initialization will occur when #setManager is called + // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods + // that reference properties not initialized at that time of parent class construction + // TODO: Find a better way to handle post-constructor setup + this.visible = false; + this.$element.addClass( 'oo-ui-element-hidden' ); }; /* Setup */ @@ -1520,118 +1630,6 @@ OO.mixinClass( OO.ui.Window, OO.EventEmitter ); */ OO.ui.Window.static.size = 'medium'; -/* Static Methods */ - -/** - * Transplant the CSS styles from as parent document to a frame's document. - * - * This loops over the style sheets in the parent document, and copies their nodes to the - * frame's document. It then polls the document to see when all styles have loaded, and once they - * have, resolves the promise. - * - * If the styles still haven't loaded after a long time (5 seconds by default), we give up waiting - * and resolve the promise anyway. This protects against cases like a display: none; iframe in - * Firefox, where the styles won't load until the iframe becomes visible. - * - * For details of how we arrived at the strategy used in this function, see #load. - * - * @static - * @inheritable - * @param {HTMLDocument} parentDoc Document to transplant styles from - * @param {HTMLDocument} frameDoc Document to transplant styles to - * @param {number} [timeout=5000] How long to wait before giving up (in ms). If 0, never give up. - * @return {jQuery.Promise} Promise resolved when styles have loaded - */ -OO.ui.Window.static.transplantStyles = function ( parentDoc, frameDoc, timeout ) { - var i, numSheets, styleNode, styleText, newNode, timeoutID, pollNodeId, $pendingPollNodes, - $pollNodes = $( [] ), - // Fake font-family value - fontFamily = 'oo-ui-frame-transplantStyles-loaded', - nextIndex = parentDoc.oouiFrameTransplantStylesNextIndex || 0, - deferred = $.Deferred(); - - for ( i = 0, numSheets = parentDoc.styleSheets.length; i < numSheets; i++ ) { - styleNode = parentDoc.styleSheets[i].ownerNode; - if ( styleNode.disabled ) { - continue; - } - - if ( styleNode.nodeName.toLowerCase() === 'link' ) { - // External stylesheet; use @import - styleText = '@import url(' + styleNode.href + ');'; - } else { - // Internal stylesheet; just copy the text - // For IE10 we need to fall back to .cssText, BUT that's undefined in - // other browsers, so fall back to '' rather than 'undefined' - styleText = styleNode.textContent || parentDoc.styleSheets[i].cssText || ''; - } - - // Create a node with a unique ID that we're going to monitor to see when the CSS - // has loaded - if ( styleNode.oouiFrameTransplantStylesId ) { - // If we're nesting transplantStyles operations and this node already has - // a CSS rule to wait for loading, reuse it - pollNodeId = styleNode.oouiFrameTransplantStylesId; - } else { - // Otherwise, create a new ID - pollNodeId = 'oo-ui-frame-transplantStyles-loaded-' + nextIndex; - nextIndex++; - - // Add #pollNodeId { font-family: ... } to the end of the stylesheet / after the @import - // The font-family rule will only take effect once the @import finishes - styleText += '\n' + '#' + pollNodeId + ' { font-family: ' + fontFamily + '; }'; - } - - // Create a node with id=pollNodeId - $pollNodes = $pollNodes.add( $( '
', frameDoc ) - .attr( 'id', pollNodeId ) - .appendTo( frameDoc.body ) - ); - - // Add our modified CSS as a ` - * tags. Because `@import` is blocking, Chrome won't add the stylesheet to document.styleSheets - * until the `@import` has finished, and Firefox won't allow .cssRules to be accessed until the - * `@import` has finished. And because the contents of the ``, then create `
` - * and wait for its font-family to change to someValue. Because `@import` is blocking, the - * font-family rule is not applied until after the `@import` finishes. - * - * All this stylesheet injection and polling magic is in #transplantStyles. - * - * @return {jQuery.Promise} Promise resolved when loading is complete - */ -OO.ui.Window.prototype.load = function () { - var sub, doc, loading, - win = this; - - this.$element.addClass( 'oo-ui-window-load' ); - - // Non-isolated windows are already "loaded" - if ( !this.loading && !this.isolated ) { - this.loading = $.Deferred().resolve(); - this.initialize(); - // Set initialized state after so sub-classes aren't confused by it being set by calling - // their parent initialize method - this.initialized = true; - } - - // Return existing promise if already loading or loaded - if ( this.loading ) { - return this.loading.promise(); - } - - // Load the frame - loading = this.loading = $.Deferred(); - sub = this.$iframe.prop( 'contentWindow' ); - doc = sub.document; - - // Initialize contents - doc.open(); - doc.write( - '' + - '' + - '' + - '
' + - '' + - '' - ); - doc.close(); - - // Properties - this.$ = OO.ui.Element.static.getJQuery( doc, this.$iframe ); - this.$content = this.$( '.oo-ui-window-content' ).attr( 'tabIndex', 0 ); - this.$document = this.$( doc ); - - // Initialization - this.constructor.static.transplantStyles( this.getElementDocument(), this.$document[0] ) - .always( function () { - // Initialize isolated windows - win.initialize(); - // Set initialized state after so sub-classes aren't confused by it being set by calling - // their parent initialize method - win.initialized = true; - // Undo the visibility: hidden; hack and apply display: none; - // We can do this safely now that the iframe has initialized - // (don't do this from within #initialize because it has to happen - // after the all subclasses have been handled as well). - win.toggle( win.isVisible() ); - - loading.resolve(); + var win = this; + + return this.getTeardownProcess( data ).execute() + .done( function () { + // 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.toggle( false ); } ); - - return loading.promise(); }; /** - * Base class for all dialogs. - * - * Logic: - * - Manage the window (open and close, etc.). - * - Store the internal name and display title. - * - A stack to track one or more pending actions. - * - Manage a set of actions that can be performed. - * - Configure and create action widgets. - * - * User interface: - * - Close the dialog with Escape key. - * - Visually lock the dialog while an action is in - * progress (aka "pending"). - * - * Subclass responsibilities: - * - Display the title somewhere. - * - Add content to the dialog. - * - Provide a UI to close the dialog. - * - Display the action widgets somewhere. + * The Dialog class serves as the base class for the other types of dialogs. + * Unless extended to include controls, the rendered dialog box is a simple window + * that users can close by hitting the ‘Esc’ key. Dialog windows are used with OO.ui.WindowManager, + * which opens, closes, and controls the presentation of the window. See the + * [OOjs UI documentation on MediaWiki] [1] for more information. + * + * @example + * // A simple dialog window. + * function MyDialog( config ) { + * MyDialog.super.call( this, config ); + * } + * OO.inheritClass( MyDialog, OO.ui.Dialog ); + * MyDialog.prototype.initialize = function () { + * MyDialog.super.prototype.initialize.call( this ); + * this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); + * this.content.$element.append( '

A simple dialog window. Press \'Esc\' to close.

' ); + * this.$body.append( this.content.$element ); + * }; + * MyDialog.prototype.getBodyHeight = function () { + * return this.content.$element.outerHeight( true ); + * }; + * var myDialog = new MyDialog( { + * size: 'medium' + * } ); + * // Create and append a window manager, which opens and closes the window. + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * windowManager.addWindows( [ myDialog ] ); + * // Open the window! + * windowManager.openWindow( myDialog ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Dialogs * * @abstract * @class @@ -2467,7 +2357,7 @@ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { ); for ( i = 0, len = actions.length; i < len; i++ ) { items.push( - new OO.ui.ActionWidget( $.extend( { $: this.$ }, actions[i] ) ) + new OO.ui.ActionWidget( actions[ i ] ) ); } this.actions.add( items ); @@ -2502,7 +2392,7 @@ OO.ui.Dialog.prototype.initialize = function () { OO.ui.Dialog.super.prototype.initialize.call( this ); // Properties - this.title = new OO.ui.LabelWidget( { $: this.$ } ); + this.title = new OO.ui.LabelWidget(); // Initialization this.$content.addClass( 'oo-ui-dialog-content' ); @@ -2527,7 +2417,7 @@ OO.ui.Dialog.prototype.detachActions = function () { // Detach all actions that may have been previously attached for ( i = 0, len = this.attachedActions.length; i < len; i++ ) { - this.attachedActions[i].$element.detach(); + this.attachedActions[ i ].$element.detach(); } this.attachedActions = []; }; @@ -2545,46 +2435,56 @@ OO.ui.Dialog.prototype.executeAction = function ( action ) { }; /** - * Collection of windows. + * Window managers are used to open and close {@link OO.ui.Window windows} and control their presentation. + * Managed windows are mutually exclusive. If a new window is opened while a current window is opening + * or is opened, the current window will be closed and any ongoing {@link OO.ui.Process process} will be cancelled. Windows + * themselves are persistent and—rather than being torn down when closed—can be repopulated with the + * pertinent data and reused. + * + * Over the lifecycle of a window, the window manager makes available three promises: `opening`, + * `opened`, and `closing`, which represent the primary stages of the cycle: + * + * **Opening**: the opening stage begins when the window manager’s #openWindow or a window’s + * {@link OO.ui.Window#open open} method is used, and the window manager begins to open the window. + * + * - an `opening` event is emitted with an `opening` promise + * - the #getSetupDelay method is called and the returned value is used to time a pause in execution before + * the window’s {@link OO.ui.Window#getSetupProcess getSetupProcess} method is called on the + * window and its result executed + * - a `setup` progress notification is emitted from the `opening` promise + * - the #getReadyDelay method is called the returned value is used to time a pause in execution before + * the window’s {@link OO.ui.Window#getReadyProcess getReadyProcess} method is called on the + * window and its result executed + * - a `ready` progress notification is emitted from the `opening` promise + * - the `opening` promise is resolved with an `opened` promise + * + * **Opened**: the window is now open. + * + * **Closing**: the closing stage begins when the window manager's #closeWindow or the + * window's {@link OO.ui.Window#close close} methods is used, and the window manager begins + * to close the window. + * + * - the `opened` promise is resolved with `closing` promise and a `closing` event is emitted + * - the #getHoldDelay method is called and the returned value is used to time a pause in execution before + * the window's {@link OO.ui.Window#getHoldProcess getHoldProces} method is called on the + * window and its result executed + * - a `hold` progress notification is emitted from the `closing` promise + * - the #getTeardownDelay() method is called and the returned value is used to time a pause in execution before + * the window's {@link OO.ui.Window#getTeardownProcess getTeardownProcess} method is called on the + * window and its result executed + * - a `teardown` progress notification is emitted from the `closing` promise + * - the `closing` promise is resolved. The window is now closed + * + * See the [OOjs UI documentation on MediaWiki][1] for more information. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers * * @class * @extends OO.ui.Element * @mixins OO.EventEmitter * - * Managed windows are mutually exclusive. If a window is opened while there is a current window - * already opening or opened, the current window will be closed without data. Empty closing data - * should always result in the window being closed without causing constructive or destructive - * action. - * - * As a window is opened and closed, it passes through several stages and the manager emits several - * corresponding events. - * - * - {@link #openWindow} or {@link OO.ui.Window#open} methods are used to start opening - * - {@link #event-opening} is emitted with `opening` promise - * - {@link #getSetupDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getSetupProcess} method is called on the window and its result executed - * - `setup` progress notification is emitted from opening promise - * - {@link #getReadyDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getReadyProcess} method is called on the window and its result executed - * - `ready` progress notification is emitted from opening promise - * - `opening` promise is resolved with `opened` promise - * - Window is now open - * - * - {@link #closeWindow} or {@link OO.ui.Window#close} methods are used to start closing - * - `opened` promise is resolved with `closing` promise - * - {@link #event-closing} is emitted with `closing` promise - * - {@link #getHoldDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getHoldProcess} method is called on the window and its result executed - * - `hold` progress notification is emitted from opening promise - * - {@link #getTeardownDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getTeardownProcess} method is called on the window and its result executed - * - `teardown` progress notification is emitted from opening promise - * - Closing promise is resolved - * - Window is now closed - * * @constructor * @param {Object} [config] Configuration options - * @cfg {boolean} [isolate] Configure managed windows to isolate their content using inline frames * @cfg {OO.Factory} [factory] Window factory to use for automatic instantiation * @cfg {boolean} [modal=true] Prevent interaction outside the dialog */ @@ -2601,22 +2501,17 @@ OO.ui.WindowManager = function OoUiWindowManager( config ) { // Properties this.factory = config.factory; this.modal = config.modal === undefined || !!config.modal; - this.isolate = !!config.isolate; this.windows = {}; this.opening = null; this.opened = null; this.closing = null; this.preparingToOpen = null; this.preparingToClose = null; - this.size = null; this.currentWindow = null; this.$ariaHidden = null; - this.requestedSize = null; this.onWindowResizeTimeout = null; this.onWindowResizeHandler = this.onWindowResize.bind( this ); this.afterWindowResizeHandler = this.afterWindowResize.bind( this ); - this.onWindowMouseWheelHandler = this.onWindowMouseWheel.bind( this ); - this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this ); // Initialization this.$element @@ -2684,6 +2579,9 @@ OO.ui.WindowManager.static.sizes = { large: { width: 700 }, + larger: { + width: 900 + }, full: { // These can be non-numeric because they are never used in calculations width: '100%', @@ -2725,36 +2623,6 @@ OO.ui.WindowManager.prototype.afterWindowResize = function () { } }; -/** - * Handle window mouse wheel events. - * - * @param {jQuery.Event} e Mouse wheel event - */ -OO.ui.WindowManager.prototype.onWindowMouseWheel = function () { - // Kill all events in the parent window if the child window is isolated - return !this.shouldIsolate(); -}; - -/** - * Handle document key down events. - * - * @param {jQuery.Event} e Key down event - */ -OO.ui.WindowManager.prototype.onDocumentKeyDown = function ( e ) { - switch ( e.which ) { - case OO.ui.Keys.PAGEUP: - case OO.ui.Keys.PAGEDOWN: - case OO.ui.Keys.END: - case OO.ui.Keys.HOME: - case OO.ui.Keys.LEFT: - case OO.ui.Keys.UP: - case OO.ui.Keys.RIGHT: - case OO.ui.Keys.DOWN: - // Kill all events in the parent window if the child window is isolated - return !this.shouldIsolate(); - } -}; - /** * Check if window is opening. * @@ -2782,17 +2650,6 @@ OO.ui.WindowManager.prototype.isOpened = function ( win ) { return win === this.currentWindow && !!this.opened && this.opened.state() === 'pending'; }; -/** - * Check if window contents should be isolated. - * - * Window content isolation is done using inline frames. - * - * @return {boolean} Window contents should be isolated - */ -OO.ui.WindowManager.prototype.shouldIsolate = function () { - return this.isolate; -}; - /** * Check if a window is being managed. * @@ -2803,7 +2660,7 @@ OO.ui.WindowManager.prototype.hasWindow = function ( win ) { var name; for ( name in this.windows ) { - if ( this.windows[name] === win ) { + if ( this.windows[ name ] === win ) { return true; } } @@ -2867,7 +2724,7 @@ OO.ui.WindowManager.prototype.getTeardownDelay = function () { */ OO.ui.WindowManager.prototype.getWindow = function ( name ) { var deferred = $.Deferred(), - win = this.windows[name]; + win = this.windows[ name ]; if ( !( win instanceof OO.ui.Window ) ) { if ( this.factory ) { @@ -2876,7 +2733,7 @@ OO.ui.WindowManager.prototype.getWindow = function ( name ) { 'Cannot auto-instantiate window: symbolic name is unrecognized by the factory' ) ); } else { - win = this.factory.create( name, this, { $: this.$ } ); + win = this.factory.create( name, this ); this.addWindows( [ win ] ); deferred.resolve( win ); } @@ -2912,7 +2769,6 @@ OO.ui.WindowManager.prototype.getCurrentWindow = function () { */ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { var manager = this, - preparing = [], opening = $.Deferred(); // Argument handling @@ -2935,17 +2791,8 @@ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { // Window opening if ( opening.state() !== 'rejected' ) { - if ( !win.getManager() ) { - win.setManager( this ); - } - preparing.push( win.load() ); - - if ( this.closing ) { - // If a window is currently closing, wait for it to complete - preparing.push( this.closing ); - } - - this.preparingToOpen = $.when.apply( $, preparing ); + // If a window is currently closing, wait for it to complete + this.preparingToOpen = $.when( this.closing ); // Ensure handlers get called after preparingToOpen is set this.preparingToOpen.done( function () { if ( manager.modal ) { @@ -2988,13 +2835,12 @@ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { */ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { var manager = this, - preparing = [], closing = $.Deferred(), opened; // Argument handling if ( typeof win === 'string' ) { - win = this.windows[win]; + win = this.windows[ win ]; } else if ( !this.hasWindow( win ) ) { win = null; } @@ -3016,12 +2862,8 @@ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { // Window closing if ( closing.state() !== 'rejected' ) { - if ( this.opening ) { - // If the window is currently opening, close it when it's done - preparing.push( this.opening ); - } - - this.preparingToClose = $.when.apply( $, preparing ); + // If the window is currently opening, close it when it's done + this.preparingToClose = $.when( this.opening ); // Ensure handlers get called after preparingToClose is set this.preparingToClose.done( function () { manager.closing = closing; @@ -3063,15 +2905,15 @@ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { OO.ui.WindowManager.prototype.addWindows = function ( windows ) { var i, len, win, name, list; - if ( $.isArray( windows ) ) { + if ( Array.isArray( windows ) ) { // Convert to map of windows by looking up symbolic names from static configuration list = {}; for ( i = 0, len = windows.length; i < len; i++ ) { - name = windows[i].constructor.static.name; + name = windows[ i ].constructor.static.name; if ( typeof name !== 'string' ) { throw new Error( 'Cannot add window' ); } - list[name] = windows[i]; + list[ name ] = windows[ i ]; } } else if ( $.isPlainObject( windows ) ) { list = windows; @@ -3079,9 +2921,10 @@ OO.ui.WindowManager.prototype.addWindows = function ( windows ) { // Add windows for ( name in list ) { - win = list[name]; - this.windows[name] = win; + win = list[ name ]; + this.windows[ name ] = win.toggle( false ); this.$element.append( win.$element ); + win.setManager( this ); } }; @@ -3090,7 +2933,7 @@ OO.ui.WindowManager.prototype.addWindows = function ( windows ) { * * Windows will be closed before they are removed. * - * @param {string} name Symbolic name of window to remove + * @param {string[]} names Symbolic names of windows to remove * @return {jQuery.Promise} Promise resolved when window is closed and removed * @throws {Error} If windows being removed are not being managed */ @@ -3099,13 +2942,13 @@ OO.ui.WindowManager.prototype.removeWindows = function ( names ) { manager = this, promises = [], cleanup = function ( name, win ) { - delete manager.windows[name]; + delete manager.windows[ name ]; win.$element.detach(); }; for ( i = 0, len = names.length; i < len; i++ ) { - name = names[i]; - win = this.windows[name]; + name = names[ i ]; + win = this.windows[ name ]; if ( !win ) { throw new Error( 'Cannot remove window' ); } @@ -3144,16 +2987,16 @@ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) { sizes = this.constructor.static.sizes, size = win.getSize(); - if ( !sizes[size] ) { + if ( !sizes[ size ] ) { size = this.constructor.static.defaultSize; } - if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[size].width ) { + if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) { size = 'full'; } this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', size === 'full' ); this.$element.toggleClass( 'oo-ui-windowManager-floating', size !== 'full' ); - win.setDimensions( sizes[size] ); + win.setDimensions( sizes[ size ] ); this.emit( 'resize', win ); @@ -3171,37 +3014,19 @@ OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) { if ( on ) { if ( !this.globalEvents ) { - this.$( this.getElementDocument() ).on( { - // Prevent scrolling by keys in top-level window - keydown: this.onDocumentKeyDownHandler - } ); - this.$( this.getElementWindow() ).on( { - // Prevent scrolling by wheel in top-level window - mousewheel: this.onWindowMouseWheelHandler, + $( this.getElementWindow() ).on( { // Start listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); - // Disable window scrolling in isolated windows - if ( !this.shouldIsolate() ) { - $( this.getElementDocument().body ).css( 'overflow', 'hidden' ); - } + $( this.getElementDocument().body ).css( 'overflow', 'hidden' ); this.globalEvents = true; } } else if ( this.globalEvents ) { - // Unbind global events - this.$( this.getElementDocument() ).off( { - // Allow scrolling by keys in top-level window - keydown: this.onDocumentKeyDownHandler - } ); - this.$( this.getElementWindow() ).off( { - // Allow scrolling by wheel in top-level window - mousewheel: this.onWindowMouseWheelHandler, + $( this.getElementWindow() ).off( { // Stop listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); - if ( !this.shouldIsolate() ) { - $( this.getElementDocument().body ).css( 'overflow', '' ); - } + $( this.getElementDocument().body ).css( 'overflow', '' ); this.globalEvents = false; } @@ -3236,17 +3061,15 @@ OO.ui.WindowManager.prototype.toggleAriaIsolation = function ( isolate ) { /** * Destroy window manager. - * - * Windows will not be closed, only removed from the DOM. */ OO.ui.WindowManager.prototype.destroy = function () { this.toggleGlobalEvents( false ); this.toggleAriaIsolation( false ); + this.clearWindows(); this.$element.remove(); }; /** - * @abstract * @class * * @constructor @@ -3381,7 +3204,7 @@ OO.ui.Process.prototype.execute = function () { // Use rejected promise for error return $.Deferred().reject( [ result ] ).promise(); } - if ( $.isArray( result ) && result.length && result[0] instanceof OO.ui.Error ) { + if ( Array.isArray( result ) && result.length && result[ 0 ] instanceof OO.ui.Error ) { // Use rejected promise for list of errors return $.Deferred().reject( result ).promise(); } @@ -3397,9 +3220,9 @@ OO.ui.Process.prototype.execute = function () { if ( this.steps.length ) { // Generate a chain reaction of promises - promise = proceed( this.steps[0] )(); + promise = proceed( this.steps[ 0 ] )(); for ( i = 1, len = this.steps.length; i < len; i++ ) { - promise = promise.then( proceed( this.steps[i] ) ); + promise = promise.then( proceed( this.steps[ i ] ) ); } } else { promise = $.Deferred().resolve().promise(); @@ -3509,8 +3332,8 @@ OO.ui.ToolFactory.prototype.getTools = function ( include, exclude, promote, dem // Auto for ( i = 0, len = included.length; i < len; i++ ) { - if ( !used[included[i]] ) { - auto.push( included[i] ); + if ( !used[ included[ i ] ] ) { + auto.push( included[ i ] ); } } @@ -3538,22 +3361,22 @@ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { if ( collection === '*' ) { for ( name in this.registry ) { - tool = this.registry[name]; + tool = this.registry[ name ]; if ( // Only add tools by group name when auto-add is enabled tool.static.autoAddToCatchall && // Exclude already used tools - ( !used || !used[name] ) + ( !used || !used[ name ] ) ) { names.push( name ); if ( used ) { - used[name] = true; + used[ name ] = true; } } } - } else if ( $.isArray( collection ) ) { + } else if ( Array.isArray( collection ) ) { for ( i = 0, len = collection.length; i < len; i++ ) { - item = collection[i]; + item = collection[ i ]; // Allow plain strings as shorthand for named tools if ( typeof item === 'string' ) { item = { name: item }; @@ -3561,26 +3384,26 @@ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { if ( OO.isPlainObject( item ) ) { if ( item.group ) { for ( name in this.registry ) { - tool = this.registry[name]; + tool = this.registry[ name ]; if ( // Include tools with matching group tool.static.group === item.group && // Only add tools by group name when auto-add is enabled tool.static.autoAddToGroup && // Exclude already used tools - ( !used || !used[name] ) + ( !used || !used[ name ] ) ) { names.push( name ); if ( used ) { - used[name] = true; + used[ name ] = true; } } } // Include tools with matching name and exclude already used tools - } else if ( item.name && ( !used || !used[item.name] ) ) { + } else if ( item.name && ( !used || !used[ item.name ] ) ) { names.push( item.name ); if ( used ) { - used[item.name] = true; + used[ item.name ] = true; } } } @@ -3605,7 +3428,7 @@ OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() { // Register default toolgroups for ( i = 0, l = defaultClasses.length; i < l; i++ ) { - this.register( defaultClasses[i] ); + this.register( defaultClasses[ i ] ); } }; @@ -3678,95 +3501,219 @@ OO.ui.Theme.prototype.updateElementClasses = function ( element ) { }; /** - * Element with a button. - * - * Buttons are used for controls which can be clicked. They can be configured to use tab indexing - * and access keys for accessibility purposes. + * Element supporting "sequential focus navigation" using the 'tabindex' attribute. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {jQuery} [$button] Button node, assigned to #$button, omit to use a generated `` - * @cfg {boolean} [framed=true] Render button with a frame - * @cfg {number} [tabIndex=0] Button's tab index. Use 0 to use default ordering, use -1 to prevent - * tab focusing. - * @cfg {string} [accessKey] Button's access key + * @cfg {jQuery} [$tabIndexed] tabIndexed node, assigned to #$tabIndexed, omit to use #$element + * @cfg {number|null} [tabIndex=0] Tab index value. Use 0 to use default ordering, use -1 to + * prevent tab focusing, use null to suppress the `tabindex` attribute. */ -OO.ui.ButtonElement = function OoUiButtonElement( config ) { +OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { // Configuration initialization - config = config || {}; + config = $.extend( { tabIndex: 0 }, config ); // Properties - this.$button = null; - this.framed = null; + this.$tabIndexed = null; this.tabIndex = null; - this.accessKey = null; - this.active = false; - this.onMouseUpHandler = this.onMouseUp.bind( this ); - this.onMouseDownHandler = this.onMouseDown.bind( this ); + + // Events + this.connect( this, { disable: 'onDisable' } ); // Initialization - this.$element.addClass( 'oo-ui-buttonElement' ); - this.toggleFramed( config.framed === undefined || config.framed ); - this.setTabIndex( config.tabIndex || 0 ); - this.setAccessKey( config.accessKey ); - this.setButtonElement( config.$button || this.$( '' ) ); + this.setTabIndex( config.tabIndex ); + this.setTabIndexedElement( config.$tabIndexed || this.$element ); }; /* Setup */ -OO.initClass( OO.ui.ButtonElement ); +OO.initClass( OO.ui.TabIndexedElement ); -/* Static Properties */ +/* Methods */ /** - * Cancel mouse down events. + * Set the element with `tabindex` attribute. * - * @static - * @inheritable - * @property {boolean} + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $tabIndexed Element to set tab index on + * @chainable */ -OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; - -/* Methods */ +OO.ui.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) { + var tabIndex = this.tabIndex; + // Remove attributes from old $tabIndexed + this.setTabIndex( null ); + // Force update of new $tabIndexed + this.$tabIndexed = $tabIndexed; + this.tabIndex = tabIndex; + return this.updateTabIndex(); +}; /** - * Set the button element. - * - * If an element is already set, it will be cleaned up before setting up the new element. + * Set tab index value. * - * @param {jQuery} $button Element to use as button + * @param {number|null} tabIndex Tab index value or null for no tab index + * @chainable */ -OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { - if ( this.$button ) { - this.$button - .removeClass( 'oo-ui-buttonElement-button' ) - .removeAttr( 'role accesskey tabindex' ) - .off( 'mousedown', this.onMouseDownHandler ); +OO.ui.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { + tabIndex = typeof tabIndex === 'number' ? tabIndex : null; + + if ( this.tabIndex !== tabIndex ) { + this.tabIndex = tabIndex; + this.updateTabIndex(); } - this.$button = $button - .addClass( 'oo-ui-buttonElement-button' ) - .attr( { role: 'button', accesskey: this.accessKey, tabindex: this.tabIndex } ) - .on( 'mousedown', this.onMouseDownHandler ); + return this; }; /** - * Handles mouse down events. + * Update the `tabindex` attribute, in case of changes to tab index or + * disabled state. * - * @param {jQuery.Event} e Mouse down event + * @chainable */ -OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { - if ( this.isDisabled() || e.which !== 1 ) { - return false; +OO.ui.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 + 'aria-disabled': this.isDisabled().toString() + } ); + } else { + this.$tabIndexed.removeAttr( 'tabindex aria-disabled' ); + } } - // Remove the tab-index while the button is down to prevent the button from stealing focus - this.$button.removeAttr( 'tabindex' ); - 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 reapply the tabindex and remove the pressed class + return this; +}; + +/** + * Handle disable events. + * + * @param {boolean} disabled Element is disabled + */ +OO.ui.TabIndexedElement.prototype.onDisable = function () { + this.updateTabIndex(); +}; + +/** + * Get tab index value. + * + * @return {number|null} Tab index value + */ +OO.ui.TabIndexedElement.prototype.getTabIndex = function () { + return this.tabIndex; +}; + +/** + * ButtonElement is often mixed into other classes to generate a button, which is a clickable + * interface element that can be configured with access keys for accessibility. + * See the [OOjs UI documentation on MediaWiki] [1] for examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {jQuery} [$button] Button node, assigned to #$button, omit to use a generated `` + * @cfg {boolean} [framed=true] Render button with a frame + * @cfg {string} [accessKey] Button's access key + */ +OO.ui.ButtonElement = function OoUiButtonElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$button = config.$button || $( '' ); + this.framed = null; + this.accessKey = null; + this.active = false; + this.onMouseUpHandler = this.onMouseUp.bind( this ); + this.onMouseDownHandler = this.onMouseDown.bind( this ); + this.onKeyDownHandler = this.onKeyDown.bind( this ); + this.onKeyUpHandler = this.onKeyUp.bind( this ); + this.onClickHandler = this.onClick.bind( this ); + this.onKeyPressHandler = this.onKeyPress.bind( this ); + + // Initialization + this.$element.addClass( 'oo-ui-buttonElement' ); + this.toggleFramed( config.framed === undefined || config.framed ); + this.setAccessKey( config.accessKey ); + this.setButtonElement( this.$button ); +}; + +/* Setup */ + +OO.initClass( OO.ui.ButtonElement ); + +/* Static Properties */ + +/** + * Cancel mouse down events. + * + * @static + * @inheritable + * @property {boolean} + */ +OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; + +/* Events */ + +/** + * @event click + */ + +/* Methods */ + +/** + * Set the button element. + * + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $button Element to use as button + */ +OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { + if ( this.$button ) { + this.$button + .removeClass( 'oo-ui-buttonElement-button' ) + .removeAttr( 'role accesskey' ) + .off( { + mousedown: this.onMouseDownHandler, + keydown: this.onKeyDownHandler, + click: this.onClickHandler, + keypress: this.onKeyPressHandler + } ); + } + + this.$button = $button + .addClass( 'oo-ui-buttonElement-button' ) + .attr( { role: 'button', accesskey: this.accessKey } ) + .on( { + mousedown: this.onMouseDownHandler, + keydown: this.onKeyDownHandler, + click: this.onClickHandler, + keypress: this.onKeyPressHandler + } ); +}; + +/** + * Handles mouse down events. + * + * @protected + * @param {jQuery.Event} e Mouse down event + */ +OO.ui.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 ); // Prevent change of focus unless specifically configured otherwise if ( this.constructor.static.cancelButtonMouseDownEvents ) { @@ -3777,19 +3724,77 @@ OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { /** * Handles mouse up events. * + * @protected * @param {jQuery.Event} e Mouse up event */ OO.ui.ButtonElement.prototype.onMouseUp = function ( e ) { if ( this.isDisabled() || e.which !== 1 ) { - return false; + return; } - // Restore the tab-index after the button is up to restore the button's accessibility - this.$button.attr( 'tabindex', this.tabIndex ); this.$element.removeClass( 'oo-ui-buttonElement-pressed' ); // Stop listening for mouseup, since we only needed this once this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true ); }; +/** + * Handles mouse click events. + * + * @protected + * @param {jQuery.Event} e Mouse click event + * @fires click + */ +OO.ui.ButtonElement.prototype.onClick = function ( e ) { + if ( !this.isDisabled() && e.which === 1 ) { + this.emit( 'click' ); + } + return false; +}; + +/** + * Handles key down events. + * + * @protected + * @param {jQuery.Event} e Key down event + */ +OO.ui.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 ); +}; + +/** + * Handles key up events. + * + * @protected + * @param {jQuery.Event} e Key up event + */ +OO.ui.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 ); +}; + +/** + * Handles key press events. + * + * @protected + * @param {jQuery.Event} e Key press event + * @fires click + */ +OO.ui.ButtonElement.prototype.onKeyPress = function ( e ) { + if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { + this.emit( 'click' ); + } + return false; +}; + /** * Check if button has a frame. * @@ -3818,29 +3823,6 @@ OO.ui.ButtonElement.prototype.toggleFramed = function ( framed ) { return this; }; -/** - * Set tab index. - * - * @param {number|null} tabIndex Button's tab index, use null to remove - * @chainable - */ -OO.ui.ButtonElement.prototype.setTabIndex = function ( tabIndex ) { - tabIndex = typeof tabIndex === 'number' && tabIndex >= 0 ? tabIndex : null; - - if ( this.tabIndex !== tabIndex ) { - if ( this.$button ) { - if ( tabIndex !== null ) { - this.$button.attr( 'tabindex', tabIndex ); - } else { - this.$button.removeAttr( 'tabindex' ); - } - } - this.tabIndex = tabIndex; - } - - return this; -}; - /** * Set access key. * @@ -3876,7 +3858,12 @@ OO.ui.ButtonElement.prototype.setActive = function ( value ) { }; /** - * Element containing a sequence of child elements. + * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or + * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing + * items from the group is done through the interface the class provides. + * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups * * @abstract * @class @@ -3895,7 +3882,7 @@ OO.ui.GroupElement = function OoUiGroupElement( config ) { this.aggregateItemEvents = {}; // Initialization - this.setGroupElement( config.$group || this.$( '
' ) ); + this.setGroupElement( config.$group || $( '
' ) ); }; /* Methods */ @@ -3912,7 +3899,7 @@ OO.ui.GroupElement.prototype.setGroupElement = function ( $group ) { this.$group = $group; for ( i = 0, len = this.items.length; i < len; i++ ) { - this.$group.append( this.items[i].$element ); + this.$group.append( this.items[ i ].$element ); } }; @@ -3947,7 +3934,7 @@ OO.ui.GroupElement.prototype.getItemFromData = function ( data ) { hash = OO.getHash( data ); for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { return item; } @@ -3970,7 +3957,7 @@ OO.ui.GroupElement.prototype.getItemsFromData = function ( data ) { items = []; for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { items.push( item ); } @@ -3994,7 +3981,7 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { var i, len, item, add, remove, itemEvent, groupEvent; for ( itemEvent in events ) { - groupEvent = events[itemEvent]; + groupEvent = events[ itemEvent ]; // Remove existing aggregated event if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { @@ -4004,27 +3991,27 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { } // Remove event aggregation from existing items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect ) { remove = {}; - remove[itemEvent] = [ 'emit', groupEvent, item ]; + remove[ itemEvent ] = [ 'emit', groupEvent, item ]; item.disconnect( this, remove ); } } // Prevent future items from aggregating event - delete this.aggregateItemEvents[itemEvent]; + delete this.aggregateItemEvents[ itemEvent ]; } // Add new aggregate event if ( groupEvent ) { // Make future items aggregate event - this.aggregateItemEvents[itemEvent] = groupEvent; + this.aggregateItemEvents[ itemEvent ] = groupEvent; // Add event aggregation to existing items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect ) { add = {}; - add[itemEvent] = [ 'emit', groupEvent, item ]; + add[ itemEvent ] = [ 'emit', groupEvent, item ]; item.connect( this, add ); } } @@ -4046,7 +4033,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { itemElements = []; for ( i = 0, len = items.length; i < len; i++ ) { - item = items[i]; + item = items[ i ]; // Check if item exists then remove it first, effectively "moving" it currentIndex = $.inArray( item, this.items ); @@ -4061,7 +4048,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { events = {}; for ( event in this.aggregateItemEvents ) { - events[event] = [ 'emit', this.aggregateItemEvents[event], item ]; + events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ]; } item.connect( this, events ); } @@ -4076,7 +4063,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { this.$group.prepend( itemElements ); this.items.unshift.apply( this.items, items ); } else { - this.items[index].$element.before( itemElements ); + this.items[ index ].$element.before( itemElements ); this.items.splice.apply( this.items, [ index, 0 ].concat( items ) ); } @@ -4096,7 +4083,7 @@ OO.ui.GroupElement.prototype.removeItems = function ( items ) { // Remove specific items for ( i = 0, len = items.length; i < len; i++ ) { - item = items[i]; + item = items[ i ]; index = $.inArray( item, this.items ); if ( index !== -1 ) { if ( @@ -4105,7 +4092,7 @@ OO.ui.GroupElement.prototype.removeItems = function ( items ) { ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { - remove[itemEvent] = [ 'emit', this.aggregateItemEvents[itemEvent], item ]; + remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } @@ -4130,14 +4117,14 @@ OO.ui.GroupElement.prototype.clearItems = function () { // Remove all items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { - remove[itemEvent] = [ 'emit', this.aggregateItemEvents[itemEvent], item ]; + remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } @@ -4150,8 +4137,10 @@ OO.ui.GroupElement.prototype.clearItems = function () { }; /** - * A mixin for an element that can be dragged and dropped. - * Use in conjunction with DragGroupWidget + * 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 + * the draggable elements. * * @abstract * @class @@ -4174,6 +4163,8 @@ OO.ui.DraggableElement = function OoUiDraggableElement() { } ); }; +OO.initClass( OO.ui.DraggableElement ); + /* Events */ /** @@ -4189,6 +4180,13 @@ OO.ui.DraggableElement = function OoUiDraggableElement() { * @event drop */ +/* Static Properties */ + +/** + * @inheritdoc OO.ui.ButtonElement + */ +OO.ui.DraggableElement.static.cancelButtonMouseDownEvents = false; + /* Methods */ /** @@ -4264,8 +4262,9 @@ OO.ui.DraggableElement.prototype.getIndex = function () { }; /** - * Element containing a sequence of child elements that can be dragged - * and dropped. + * 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. * * @abstract * @class @@ -4306,7 +4305,7 @@ OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { } ); // Initialize - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } this.$placeholder = $( '
' ) @@ -4340,7 +4339,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { // Map the index of each object for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].setIndex( i ); + this.items[ i ].setIndex( i ); } if ( this.orientation === 'horizontal' ) { @@ -4386,6 +4385,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDrop = function ( item ) { // Emit change event this.emit( 'reorder', this.getDragItem(), toIndex ); } + this.unsetDragItem(); // Return false to prevent propogation return false; }; @@ -4397,7 +4397,7 @@ OO.ui.DraggableGroupElement.prototype.onDragLeave = function () { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) - .hide(); + .addClass( 'oo-ui-element-hidden' ); }; /** @@ -4413,9 +4413,9 @@ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { // Get the OptionWidget item we are dragging over dragOverObj = this.getElementDocument().elementFromPoint( clientX, clientY ); $optionWidget = $( dragOverObj ).closest( '.oo-ui-draggableElement' ); - if ( $optionWidget[0] ) { + if ( $optionWidget[ 0 ] ) { itemOffset = $optionWidget.offset(); - itemBoundingRect = $optionWidget[0].getBoundingClientRect(); + itemBoundingRect = $optionWidget[ 0 ].getBoundingClientRect(); itemPosition = $optionWidget.position(); itemIndex = $optionWidget.data( 'index' ); } @@ -4458,23 +4458,14 @@ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { this.sideInsertion = dragPosition < itemMidpoint ? 'before' : 'after'; } // Add drop indicator between objects - if ( this.sideInsertion ) { - this.$placeholder - .css( cssOutput ) - .show(); - } else { - this.$placeholder - .css( { - left: 0, - top: 0 - } ) - .hide(); - } + this.$placeholder + .css( cssOutput ) + .removeClass( 'oo-ui-element-hidden' ); } else { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) - .hide(); + .addClass( 'oo-ui-element-hidden' ); } // Prevent default e.preventDefault(); @@ -4494,7 +4485,7 @@ OO.ui.DraggableGroupElement.prototype.setDragItem = function ( item ) { OO.ui.DraggableGroupElement.prototype.unsetDragItem = function () { this.dragItem = null; this.itemDragOver = null; - this.$placeholder.hide(); + this.$placeholder.addClass( 'oo-ui-element-hidden' ); this.sideInsertion = ''; }; @@ -4515,12 +4506,13 @@ OO.ui.DraggableGroupElement.prototype.isDragging = function () { }; /** - * Element containing an icon. + * IconElement is often mixed into other classes to generate an icon. + * Icons are graphics, about the size of normal text. They are used to aid the user + * in locating a control or to convey information in a space-efficient way. See the + * [OOjs UI documentation on MediaWiki] [1] for a list of icons + * included in the library. * - * Icons are graphics, about the size of normal text. They can be used to aid the user in locating - * a control or convey information in a more space efficient way. Icons should rarely be used - * without labels; such as in a toolbar where space is at a premium or within a context where the - * meaning is very clear to the user. + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @abstract * @class @@ -4545,7 +4537,7 @@ OO.ui.IconElement = function OoUiIconElement( config ) { // Initialization this.setIcon( config.icon || this.constructor.static.icon ); this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle ); - this.setIconElement( config.$icon || this.$( '' ) ); + this.setIconElement( config.$icon || $( '' ) ); }; /* Setup */ @@ -4555,31 +4547,31 @@ OO.initClass( OO.ui.IconElement ); /* Static Properties */ /** - * Icon. + * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used + * for i18n purposes and contains a `default` icon name and additional names keyed by + * language code. The `default` name is used when no icon is keyed by the user's language. * - * Value should be the unique portion of an icon CSS class name, such as 'up' for 'oo-ui-icon-up'. + * Example of an i18n map: * - * For i18n purposes, this property can be an object containing a `default` icon name property and - * additional icon names keyed by language code. - * - * Example of i18n icon definition: * { default: 'bold-a', en: 'bold-b', de: 'bold-f' } * + * Note: the static property will be overridden if the #icon configuration is used. + * * @static * @inheritable - * @property {Object|string} Symbolic icon name, or map of icon names keyed by language ID; - * use the 'default' key to specify the icon to be used when there is no icon in the user's - * language + * @property {Object|string} */ OO.ui.IconElement.static.icon = null; /** - * Icon title. + * The icon title, displayed when users move the mouse over the icon. The value can be text, a + * function that returns title text, or `null` for no title. + * + * The static property will be overridden if the #iconTitle configuration is used. * * @static * @inheritable - * @property {string|Function|null} Icon title text, a function that returns text or null for no - * icon title + * @property {string|Function|null} */ OO.ui.IconElement.static.iconTitle = null; @@ -4682,12 +4674,18 @@ OO.ui.IconElement.prototype.getIconTitle = function () { }; /** - * Element containing an indicator. + * IndicatorElement is often mixed into other classes to generate an indicator. + * Indicators are small graphics that are generally used in two ways: + * + * - To draw attention to the status of an item. For example, an indicator might be + * used to show that an item in a list has errors that need to be resolved. + * - To clarify the function of a control that acts in an exceptional way (a button + * that opens a menu instead of performing an action directly, for example). * - * Indicators are graphics, smaller than normal text. They can be used to describe unique status or - * behavior. Indicators should only be used in exceptional cases; such as a button that opens a menu - * instead of performing an action directly, or an item in a list which has errors that need to be - * resolved. + * For a list of indicators included in the library, please see the + * [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators * * @abstract * @class @@ -4711,7 +4709,7 @@ OO.ui.IndicatorElement = function OoUiIndicatorElement( config ) { // Initialization this.setIndicator( config.indicator || this.constructor.static.indicator ); this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle ); - this.setIndicatorElement( config.$indicator || this.$( '' ) ); + this.setIndicatorElement( config.$indicator || $( '' ) ); }; /* Setup */ @@ -4759,7 +4757,7 @@ OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { .addClass( 'oo-ui-indicatorElement-indicator' ) .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator ); if ( this.indicatorTitle !== null ) { - this.$indicatorTitle.attr( 'title', this.indicatorTitle ); + this.$indicator.attr( 'title', this.indicatorTitle ); } }; @@ -4857,13 +4855,20 @@ OO.ui.LabelElement = function OoUiLabelElement( config ) { // Initialization this.setLabel( config.label || this.constructor.static.label ); - this.setLabelElement( config.$label || this.$( '' ) ); + this.setLabelElement( config.$label || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.LabelElement ); +/* Events */ + +/** + * @event labelChange + * @param {string} value + */ + /* Static Properties */ /** @@ -4908,15 +4913,16 @@ OO.ui.LabelElement.prototype.setLabel = function ( label ) { label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label; label = ( typeof label === 'string' && label.length ) || label instanceof jQuery ? label : null; + this.$element.toggleClass( 'oo-ui-labelElement', !!label ); + if ( this.label !== label ) { if ( this.$label ) { this.setLabelContent( label ); } this.label = label; + this.emit( 'labelChange' ); } - this.$element.toggleClass( 'oo-ui-labelElement', !!this.label ); - return this; }; @@ -4968,234 +4974,573 @@ OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { }; /** - * Element containing an OO.ui.PopupWidget object. + * Mixin that adds a menu showing suggested values for a OO.ui.TextInputWidget. + * + * Subclasses that set the value of #lookupInput from #onLookupMenuItemChoose should + * be aware that this will cause new suggestions to be looked up for the new value. If this is + * not desired, disable lookups with #setLookupsDisabled, then set the value, then re-enable lookups. * - * @abstract * @class + * @abstract * * @constructor * @param {Object} [config] Configuration options - * @cfg {Object} [popup] Configuration to pass to popup - * @cfg {boolean} [autoClose=true] Popup auto-closes when it loses focus + * @cfg {jQuery} [$overlay] Overlay for dropdown; defaults to relative positioning + * @cfg {jQuery} [$container=this.$element] Element to render menu under */ -OO.ui.PopupElement = function OoUiPopupElement( config ) { +OO.ui.LookupElement = function OoUiLookupElement( config ) { // Configuration initialization config = config || {}; // Properties - this.popup = new OO.ui.PopupWidget( $.extend( - { autoClose: true }, - config.popup, - { $: this.$, $autoCloseIgnore: this.$element } - ) ); + this.$overlay = config.$overlay || this.$element; + this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { + widget: this, + input: this, + $container: config.$container + } ); + this.lookupCache = {}; + this.lookupQuery = null; + this.lookupRequest = null; + this.lookupsDisabled = false; + this.lookupInputFocused = false; + + // Events + this.$input.on( { + focus: this.onLookupInputFocus.bind( this ), + blur: this.onLookupInputBlur.bind( this ), + mousedown: this.onLookupInputMouseDown.bind( this ) + } ); + this.connect( this, { change: 'onLookupInputChange' } ); + this.lookupMenu.connect( this, { + toggle: 'onLookupMenuToggle', + choose: 'onLookupMenuItemChoose' + } ); + + // Initialization + this.$element.addClass( 'oo-ui-lookupElement' ); + this.lookupMenu.$element.addClass( 'oo-ui-lookupElement-menu' ); + this.$overlay.append( this.lookupMenu.$element ); }; /* Methods */ /** - * Get popup. + * Handle input focus event. * - * @return {OO.ui.PopupWidget} Popup widget + * @param {jQuery.Event} e Input focus event */ -OO.ui.PopupElement.prototype.getPopup = function () { - return this.popup; +OO.ui.LookupElement.prototype.onLookupInputFocus = function () { + this.lookupInputFocused = true; + this.populateLookupMenu(); }; /** - * Element with named flags that can be added, removed, listed and checked. - * - * A flag, when set, adds a CSS class on the `$element` by combining `oo-ui-flaggedElement-` with - * the flag name. Flags are primarily useful for styling. - * - * @abstract - * @class + * Handle input blur event. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {string|string[]} [flags] Flags describing importance and functionality, e.g. 'primary', - * 'safe', 'progressive', 'destructive' or 'constructive' - * @cfg {jQuery} [$flagged] Flagged node, assigned to #$flagged, omit to use #$element + * @param {jQuery.Event} e Input blur event */ -OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { - // Configuration initialization - config = config || {}; - - // Properties - this.flags = {}; - this.$flagged = null; - - // Initialization - this.setFlags( config.flags ); - this.setFlaggedElement( config.$flagged || this.$element ); +OO.ui.LookupElement.prototype.onLookupInputBlur = function () { + this.closeLookupMenu(); + this.lookupInputFocused = false; }; -/* Events */ - /** - * @event flag - * @param {Object.} changes Object keyed by flag name containing boolean - * added/removed properties + * Handle input mouse down event. + * + * @param {jQuery.Event} e Input mouse down event */ - -/* Methods */ +OO.ui.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 + // clicking into the input is handled by the focus handler. + if ( this.lookupInputFocused && !this.lookupMenu.isVisible() ) { + this.populateLookupMenu(); + } +}; /** - * Set the flagged element. + * Handle input change event. * - * If an element is already set, it will be cleaned up before setting up the new element. + * @param {string} value New input value + */ +OO.ui.LookupElement.prototype.onLookupInputChange = function () { + if ( this.lookupInputFocused ) { + this.populateLookupMenu(); + } +}; + +/** + * Handle the lookup menu being shown/hidden. * - * @param {jQuery} $flagged Element to add flags to + * @param {boolean} visible Whether the lookup menu is now visible. */ -OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { - var classNames = Object.keys( this.flags ).map( function ( flag ) { - return 'oo-ui-flaggedElement-' + flag; - } ).join( ' ' ); +OO.ui.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 + // MenuSelectWidget will close itself when the user presses Esc. + this.abortLookupRequest(); + this.lookupMenu.clearItems(); + } +}; - if ( this.$flagged ) { - this.$flagged.removeClass( classNames ); +/** + * Handle menu item 'choose' event, updating the text input value to the value of the clicked item. + * + * @param {OO.ui.MenuOptionWidget|null} item Selected item + */ +OO.ui.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { + if ( item ) { + this.setValue( item.getData() ); } +}; - this.$flagged = $flagged.addClass( classNames ); +/** + * Get lookup menu. + * + * @return {OO.ui.TextInputMenuSelectWidget} + */ +OO.ui.LookupElement.prototype.getLookupMenu = function () { + return this.lookupMenu; }; /** - * Check if a flag is set. + * Disable or re-enable lookups. * - * @param {string} flag Name of flag - * @return {boolean} Has flag + * When lookups are disabled, calls to #populateLookupMenu will be ignored. + * + * @param {boolean} disabled Disable lookups */ -OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { - return flag in this.flags; +OO.ui.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { + this.lookupsDisabled = !!disabled; }; /** - * Get the names of all flags set. + * Open the menu. If there are no entries in the menu, this does nothing. * - * @return {string[]} Flag names + * @chainable */ -OO.ui.FlaggedElement.prototype.getFlags = function () { - return Object.keys( this.flags ); +OO.ui.LookupElement.prototype.openLookupMenu = function () { + if ( !this.lookupMenu.isEmpty() ) { + this.lookupMenu.toggle( true ); + } + return this; }; /** - * Clear all flags. + * Close the menu, empty it, and abort any pending request. * * @chainable - * @fires flag */ -OO.ui.FlaggedElement.prototype.clearFlags = function () { - var flag, className, - changes = {}, - remove = [], - classPrefix = 'oo-ui-flaggedElement-'; +OO.ui.LookupElement.prototype.closeLookupMenu = function () { + this.lookupMenu.toggle( false ); + this.abortLookupRequest(); + this.lookupMenu.clearItems(); + return this; +}; - for ( flag in this.flags ) { - className = classPrefix + flag; - changes[flag] = false; - delete this.flags[flag]; - remove.push( className ); - } +/** + * Request menu items based on the input's current value, and when they arrive, + * populate the menu with these items and show the menu. + * + * If lookups have been disabled with #setLookupsDisabled, this function does nothing. + * + * @chainable + */ +OO.ui.LookupElement.prototype.populateLookupMenu = function () { + var widget = this, + value = this.getValue(); - if ( this.$flagged ) { - this.$flagged.removeClass( remove.join( ' ' ) ); + if ( this.lookupsDisabled ) { + return; } - this.updateThemeClasses(); - this.emit( 'flag', changes ); + // If the input is empty, clear the menu + if ( value === '' ) { + this.closeLookupMenu(); + // Skip population if there is already a request pending for the current value + } else if ( value !== this.lookupQuery ) { + this.getLookupMenuItems() + .done( function ( items ) { + widget.lookupMenu.clearItems(); + if ( items.length ) { + widget.lookupMenu + .addItems( items ) + .toggle( true ); + widget.initializeLookupMenuSelection(); + } else { + widget.lookupMenu.toggle( false ); + } + } ) + .fail( function () { + widget.lookupMenu.clearItems(); + } ); + } return this; }; /** - * Add one or more flags. + * Select and highlight the first selectable item in the menu. * - * @param {string|string[]|Object.} flags One or more flags to add, or an object - * keyed by flag name containing boolean set/remove instructions. * @chainable - * @fires flag */ -OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { - var i, len, flag, className, - changes = {}, - add = [], - remove = [], - classPrefix = 'oo-ui-flaggedElement-'; +OO.ui.LookupElement.prototype.initializeLookupMenuSelection = function () { + if ( !this.lookupMenu.getSelectedItem() ) { + this.lookupMenu.selectItem( this.lookupMenu.getFirstSelectableItem() ); + } + this.lookupMenu.highlightItem( this.lookupMenu.getSelectedItem() ); +}; - if ( typeof flags === 'string' ) { - className = classPrefix + flags; - // Set - if ( !this.flags[flags] ) { - this.flags[flags] = true; - add.push( className ); - } - } else if ( $.isArray( flags ) ) { - for ( i = 0, len = flags.length; i < len; i++ ) { - flag = flags[i]; - className = classPrefix + flag; - // Set - if ( !this.flags[flag] ) { - changes[flag] = true; - this.flags[flag] = true; - add.push( className ); - } - } - } else if ( OO.isPlainObject( flags ) ) { - for ( flag in flags ) { - className = classPrefix + flag; - if ( flags[flag] ) { - // Set - if ( !this.flags[flag] ) { - changes[flag] = true; - this.flags[flag] = true; - add.push( className ); +/** + * Get lookup menu items for the current query. + * + * @return {jQuery.Promise} Promise object which will be passed menu items as the first argument of + * 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 () { + var widget = this, + value = this.getValue(), + deferred = $.Deferred(), + ourRequest; + + this.abortLookupRequest(); + if ( Object.prototype.hasOwnProperty.call( this.lookupCache, value ) ) { + deferred.resolve( this.getLookupMenuOptionsFromData( this.lookupCache[ value ] ) ); + } else { + this.pushPending(); + this.lookupQuery = value; + ourRequest = this.lookupRequest = this.getLookupRequest(); + ourRequest + .always( function () { + // We need to pop pending even if this is an old request, otherwise + // the widget will remain pending forever. + // TODO: this assumes that an aborted request will fail or succeed soon after + // being aborted, or at least eventually. It would be nice if we could popPending() + // at abort time, but only if we knew that we hadn't already called popPending() + // for that request. + widget.popPending(); + } ) + .done( function ( data ) { + // If this is an old request (and aborting it somehow caused it to still succeed), + // ignore its success completely + if ( ourRequest === widget.lookupRequest ) { + widget.lookupQuery = null; + widget.lookupRequest = null; + widget.lookupCache[ value ] = widget.getLookupCacheDataFromResponse( data ); + deferred.resolve( widget.getLookupMenuOptionsFromData( widget.lookupCache[ value ] ) ); } - } else { - // Remove - if ( this.flags[flag] ) { - changes[flag] = false; - delete this.flags[flag]; - remove.push( className ); + } ) + .fail( function () { + // If this is an old request (or a request failing because it's being aborted), + // ignore its failure completely + if ( ourRequest === widget.lookupRequest ) { + widget.lookupQuery = null; + widget.lookupRequest = null; + deferred.reject(); } - } - } + } ); } + return deferred.promise(); +}; - if ( this.$flagged ) { - this.$flagged - .addClass( add.join( ' ' ) ) - .removeClass( remove.join( ' ' ) ); +/** + * Abort the currently pending lookup request, if any. + */ +OO.ui.LookupElement.prototype.abortLookupRequest = function () { + var oldRequest = this.lookupRequest; + if ( oldRequest ) { + // First unset this.lookupRequest to the fail handler will notice + // that the request is no longer current + this.lookupRequest = null; + this.lookupQuery = null; + oldRequest.abort(); } +}; - this.updateThemeClasses(); - this.emit( 'flag', changes ); +/** + * Get a new request object of the current lookup query value. + * + * @abstract + * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method + */ +OO.ui.LookupElement.prototype.getLookupRequest = function () { + // Stub, implemented in subclass + return null; +}; - return this; +/** + * Pre-process data returned by the request from #getLookupRequest. + * + * The return value of this function will be cached, and any further queries for the given value + * will use the cache rather than doing API requests. + * + * @abstract + * @param {Mixed} data Response from server + * @return {Mixed} Cached result data + */ +OO.ui.LookupElement.prototype.getLookupCacheDataFromResponse = function () { + // Stub, implemented in subclass + return []; }; /** - * Element with a title. + * Get a list of menu option widgets from the (possibly cached) data returned by + * #getLookupCacheDataFromResponse. * - * Titles are rendered by the browser and are made visible when hovering the element. Titles are - * not visible on touch devices. + * @abstract + * @param {Mixed} data Cached result data, usually an array + * @return {OO.ui.MenuOptionWidget[]} Menu items + */ +OO.ui.LookupElement.prototype.getLookupMenuOptionsFromData = function () { + // Stub, implemented in subclass + return []; +}; + +/** + * Element containing an OO.ui.PopupWidget object. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {jQuery} [$titled] Titled node, assigned to #$titled, omit to use #$element - * @cfg {string|Function} [title] Title text or a function that returns text. If not provided, the - * static property 'title' is used. + * @cfg {Object} [popup] Configuration to pass to popup + * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus */ -OO.ui.TitledElement = function OoUiTitledElement( config ) { +OO.ui.PopupElement = function OoUiPopupElement( config ) { // Configuration initialization config = config || {}; // Properties - this.$titled = null; - this.title = null; - - // Initialization + this.popup = new OO.ui.PopupWidget( $.extend( + { autoClose: true }, + config.popup, + { $autoCloseIgnore: this.$element } + ) ); +}; + +/* Methods */ + +/** + * Get popup. + * + * @return {OO.ui.PopupWidget} Popup widget + */ +OO.ui.PopupElement.prototype.getPopup = function () { + return this.popup; +}; + +/** + * The FlaggedElement class is an attribute mixin, meaning that it is used to add + * additional functionality to an element created by another class. The class provides + * a ‘flags’ property assigned the name (or an array of names) of styling flags, + * which are used to customize the look and feel of a widget to better describe its + * importance and functionality. + * + * The library currently contains the following styling flags for general use: + * + * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process. + * - **destructive**: Destructive styling is applied to convey that the widget will remove something. + * - **constructive**: Constructive styling is applied to convey that the widget will create something. + * + * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string|string[]} [flags] Flags describing importance and functionality, e.g. 'primary', + * 'safe', 'progressive', 'destructive' or 'constructive' + * @cfg {jQuery} [$flagged] Flagged node, assigned to #$flagged, omit to use #$element + */ +OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.flags = {}; + this.$flagged = null; + + // Initialization + this.setFlags( config.flags ); + this.setFlaggedElement( config.$flagged || this.$element ); +}; + +/* Events */ + +/** + * @event flag + * @param {Object.} changes Object keyed by flag name containing boolean + * added/removed properties + */ + +/* Methods */ + +/** + * Set the flagged element. + * + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $flagged Element to add flags to + */ +OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { + var classNames = Object.keys( this.flags ).map( function ( flag ) { + return 'oo-ui-flaggedElement-' + flag; + } ).join( ' ' ); + + if ( this.$flagged ) { + this.$flagged.removeClass( classNames ); + } + + this.$flagged = $flagged.addClass( classNames ); +}; + +/** + * Check if a flag is set. + * + * @param {string} flag Name of flag + * @return {boolean} Has flag + */ +OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { + return flag in this.flags; +}; + +/** + * Get the names of all flags set. + * + * @return {string[]} Flag names + */ +OO.ui.FlaggedElement.prototype.getFlags = function () { + return Object.keys( this.flags ); +}; + +/** + * Clear all flags. + * + * @chainable + * @fires flag + */ +OO.ui.FlaggedElement.prototype.clearFlags = function () { + var flag, className, + changes = {}, + remove = [], + classPrefix = 'oo-ui-flaggedElement-'; + + for ( flag in this.flags ) { + className = classPrefix + flag; + changes[ flag ] = false; + delete this.flags[ flag ]; + remove.push( className ); + } + + if ( this.$flagged ) { + this.$flagged.removeClass( remove.join( ' ' ) ); + } + + this.updateThemeClasses(); + this.emit( 'flag', changes ); + + return this; +}; + +/** + * Add one or more flags. + * + * @param {string|string[]|Object.} flags One or more flags to add, or an object + * keyed by flag name containing boolean set/remove instructions. + * @chainable + * @fires flag + */ +OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { + var i, len, flag, className, + changes = {}, + add = [], + remove = [], + classPrefix = 'oo-ui-flaggedElement-'; + + if ( typeof flags === 'string' ) { + className = classPrefix + flags; + // Set + if ( !this.flags[ flags ] ) { + this.flags[ flags ] = true; + add.push( className ); + } + } else if ( Array.isArray( flags ) ) { + for ( i = 0, len = flags.length; i < len; i++ ) { + flag = flags[ i ]; + className = classPrefix + flag; + // Set + if ( !this.flags[ flag ] ) { + changes[ flag ] = true; + this.flags[ flag ] = true; + add.push( className ); + } + } + } else if ( OO.isPlainObject( flags ) ) { + for ( flag in flags ) { + className = classPrefix + flag; + if ( flags[ flag ] ) { + // Set + if ( !this.flags[ flag ] ) { + changes[ flag ] = true; + this.flags[ flag ] = true; + add.push( className ); + } + } else { + // Remove + if ( this.flags[ flag ] ) { + changes[ flag ] = false; + delete this.flags[ flag ]; + remove.push( className ); + } + } + } + } + + if ( this.$flagged ) { + this.$flagged + .addClass( add.join( ' ' ) ) + .removeClass( remove.join( ' ' ) ); + } + + this.updateThemeClasses(); + this.emit( 'flag', changes ); + + return this; +}; + +/** + * Element with a title. + * + * Titles are rendered by the browser and are made visible when hovering the element. Titles are + * not visible on touch devices. + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {jQuery} [$titled] Titled node, assigned to #$titled, omit to use #$element + * @cfg {string|Function} [title] Title text or a function that returns text. If not provided, the + * static property 'title' is used. + */ +OO.ui.TitledElement = function OoUiTitledElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$titled = null; + this.title = null; + + // Initialization this.setTitle( config.title || this.constructor.static.title ); this.setTitledElement( config.$titled || this.$element ); }; @@ -5313,9 +5658,8 @@ OO.ui.ClippableElement = function OoUiClippableElement( config ) { OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { if ( this.$clippable ) { this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' ); - this.$clippable.css( { width: '', height: '' } ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( { overflowX: '', overflowY: '' } ); + this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' ); @@ -5336,21 +5680,20 @@ OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { if ( this.clipping !== clipping ) { this.clipping = clipping; if ( clipping ) { - this.$clippableContainer = this.$( this.getClosestScrollableElementContainer() ); + this.$clippableContainer = $( 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' ) ? - this.$( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : + $( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : this.$clippableContainer; this.$clippableScroller.on( 'scroll', this.onClippableContainerScrollHandler ); - this.$clippableWindow = this.$( this.getElementWindow() ) + this.$clippableWindow = $( this.getElementWindow() ) .on( 'resize', this.onClippableWindowResizeHandler ); // Initial clip after visible this.clip(); } else { - this.$clippable.css( { width: '', height: '' } ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( { overflowX: '', overflowY: '' } ); + this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); this.$clippableContainer = null; this.$clippableScroller.off( 'scroll', this.onClippableContainerScrollHandler ); @@ -5456,16 +5799,17 @@ OO.ui.ClippableElement.prototype.clip = function () { if ( clipWidth ) { this.$clippable.css( { overflowX: 'scroll', width: desiredWidth } ); } else { - this.$clippable.css( 'width', this.idealWidth || '' ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( 'overflowX', '' ); + this.$clippable.css( { width: this.idealWidth || '', overflowX: '' } ); } if ( clipHeight ) { this.$clippable.css( { overflowY: 'scroll', height: desiredHeight } ); } else { - this.$clippable.css( 'height', this.idealHeight || '' ); - this.$clippable.height(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( 'overflowY', '' ); + this.$clippable.css( { height: this.idealHeight || '', overflowY: '' } ); + } + + // If we stopped clipping in at least one of the dimensions + if ( !clipWidth || !clipHeight ) { + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } this.clippedHorizontally = clipWidth; @@ -5503,9 +5847,9 @@ OO.ui.Tool = function OoUiTool( toolGroup, config ) { this.toolGroup = toolGroup; this.toolbar = this.toolGroup.getToolbar(); this.active = false; - this.$title = this.$( '' ); - this.$accel = this.$( '' ); - this.$link = this.$( '' ); + this.$title = $( '' ); + this.$accel = $( '' ); + this.$link = $( '' ); this.title = null; // Events @@ -5766,8 +6110,8 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { this.toolGroupFactory = toolGroupFactory; this.groups = []; this.tools = {}; - this.$bar = this.$( '
' ); - this.$actions = this.$( '
' ); + this.$bar = $( '
' ); + this.$actions = $( '
' ); this.initialized = false; // Events @@ -5821,16 +6165,16 @@ OO.ui.Toolbar.prototype.getToolGroupFactory = function () { * @param {jQuery.Event} e Mouse down event */ OO.ui.Toolbar.prototype.onPointerDown = function ( e ) { - var $closestWidgetToEvent = this.$( e.target ).closest( '.oo-ui-widget' ), + var $closestWidgetToEvent = $( e.target ).closest( '.oo-ui-widget' ), $closestWidgetToToolbar = this.$element.closest( '.oo-ui-widget' ); - if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[0] === $closestWidgetToToolbar[0] ) { + if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[ 0 ] === $closestWidgetToToolbar[ 0 ] ) { return false; } }; /** * Sets up handles and preloads required information for the toolbar to work. - * This must be called immediately after it is attached to a visible document. + * 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; @@ -5861,7 +6205,7 @@ OO.ui.Toolbar.prototype.setup = function ( groups ) { // Build out new groups for ( i = 0, len = groups.length; i < len; i++ ) { - group = groups[i]; + group = groups[ i ]; if ( group.include === '*' ) { // Apply defaults to catch-all groups if ( group.type === undefined ) { @@ -5874,7 +6218,7 @@ OO.ui.Toolbar.prototype.setup = function ( groups ) { // Check type has been registered type = this.getToolGroupFactory().lookup( group.type ) ? group.type : defaultType; items.push( - this.getToolGroupFactory().create( type, this, $.extend( { $: this.$ }, group ) ) + this.getToolGroupFactory().create( type, this, group ) ); } this.addItems( items ); @@ -5889,7 +6233,7 @@ OO.ui.Toolbar.prototype.reset = function () { this.groups = []; this.tools = {}; for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].destroy(); + this.items[ i ].destroy(); } this.clearItems(); }; @@ -5911,7 +6255,7 @@ OO.ui.Toolbar.prototype.destroy = function () { * @return {boolean} Tool is available */ OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) { - return !this.tools[name]; + return !this.tools[ name ]; }; /** @@ -5920,7 +6264,7 @@ OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) { * @param {OO.ui.Tool} tool Tool to reserve */ OO.ui.Toolbar.prototype.reserveTool = function ( tool ) { - this.tools[tool.getName()] = tool; + this.tools[ tool.getName() ] = tool; }; /** @@ -5929,7 +6273,7 @@ OO.ui.Toolbar.prototype.reserveTool = function ( tool ) { * @param {OO.ui.Tool} tool Tool to release */ OO.ui.Toolbar.prototype.releaseTool = function ( tool ) { - delete this.tools[tool.getName()]; + delete this.tools[ tool.getName() ]; }; /** @@ -6063,7 +6407,7 @@ OO.ui.ToolGroup.prototype.updateDisabled = function () { if ( this.constructor.static.autoDisable ) { for ( i = this.items.length - 1; i >= 0; i-- ) { - item = this.items[i]; + item = this.items[ i ]; if ( !item.isDisabled() ) { allDisabled = false; break; @@ -6160,7 +6504,7 @@ OO.ui.ToolGroup.prototype.onMouseOut = function ( e ) { */ OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) { var tool, - $item = this.$( e.target ).closest( '.oo-ui-tool-link' ); + $item = $( e.target ).closest( '.oo-ui-tool-link' ); if ( $item.length ) { tool = $item.parent().data( 'oo-ui-tool' ); @@ -6207,31 +6551,31 @@ OO.ui.ToolGroup.prototype.populate = function () { // Build a list of needed tools for ( i = 0, len = list.length; i < len; i++ ) { - name = list[i]; + name = list[ i ]; if ( // Tool exists toolFactory.lookup( name ) && // Tool is available or is already in this group - ( this.toolbar.isToolAvailable( name ) || this.tools[name] ) + ( this.toolbar.isToolAvailable( name ) || this.tools[ name ] ) ) { - tool = this.tools[name]; + tool = this.tools[ name ]; if ( !tool ) { // Auto-initialize tools on first use - this.tools[name] = tool = toolFactory.create( name, this ); + this.tools[ name ] = tool = toolFactory.create( name, this ); tool.updateTitle(); } this.toolbar.reserveTool( tool ); add.push( tool ); - names[name] = true; + names[ name ] = true; } } // Remove tools that are no longer needed for ( name in this.tools ) { - if ( !names[name] ) { - this.tools[name].destroy(); - this.toolbar.releaseTool( this.tools[name] ); - remove.push( this.tools[name] ); - delete this.tools[name]; + if ( !names[ name ] ) { + this.tools[ name ].destroy(); + this.toolbar.releaseTool( this.tools[ name ] ); + remove.push( this.tools[ name ] ); + delete this.tools[ name ]; } } if ( remove.length ) { @@ -6258,9 +6602,9 @@ OO.ui.ToolGroup.prototype.destroy = function () { this.clearItems(); this.toolbar.getToolFactory().disconnect( this ); for ( name in this.tools ) { - this.toolbar.releaseTool( this.tools[name] ); - this.tools[name].disconnect( this ).destroy(); - delete this.tools[name]; + this.toolbar.releaseTool( this.tools[ name ] ); + this.tools[ name ].disconnect( this ).destroy(); + delete this.tools[ name ]; } this.$element.remove(); }; @@ -6431,16 +6775,13 @@ OO.ui.MessageDialog.prototype.getBodyHeight = function () { var bodyHeight, oldOverflow, $scrollable = this.container.$element; - oldOverflow = $scrollable[0].style.overflow; - $scrollable[0].style.overflow = 'hidden'; + oldOverflow = $scrollable[ 0 ].style.overflow; + $scrollable[ 0 ].style.overflow = 'hidden'; - // Force… ugh… something to happen - $scrollable.contents().hide(); - $scrollable.height(); - $scrollable.contents().show(); + OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); - bodyHeight = Math.round( this.text.$element.outerHeight( true ) ); - $scrollable[0].style.overflow = oldOverflow; + bodyHeight = this.text.$element.outerHeight( true ); + $scrollable[ 0 ].style.overflow = oldOverflow; return bodyHeight; }; @@ -6455,15 +6796,12 @@ OO.ui.MessageDialog.prototype.setDimensions = function ( 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. setTimeout( function () { - var oldOverflow = $scrollable[0].style.overflow; - $scrollable[0].style.overflow = 'hidden'; + var oldOverflow = $scrollable[ 0 ].style.overflow; + $scrollable[ 0 ].style.overflow = 'hidden'; - // Force… ugh… something to happen - $scrollable.contents().hide(); - $scrollable.height(); - $scrollable.contents().show(); + OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); - $scrollable[0].style.overflow = oldOverflow; + $scrollable[ 0 ].style.overflow = oldOverflow; }, 300 ); return this; @@ -6477,15 +6815,15 @@ OO.ui.MessageDialog.prototype.initialize = function () { OO.ui.MessageDialog.super.prototype.initialize.call( this ); // Properties - this.$actions = this.$( '
' ); + this.$actions = $( '
' ); this.container = new OO.ui.PanelLayout( { - $: this.$, scrollable: true, classes: [ 'oo-ui-messageDialog-container' ] + scrollable: true, classes: [ 'oo-ui-messageDialog-container' ] } ); this.text = new OO.ui.PanelLayout( { - $: this.$, padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ] + padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ] } ); this.message = new OO.ui.LabelWidget( { - $: this.$, classes: [ 'oo-ui-messageDialog-message' ] + classes: [ 'oo-ui-messageDialog-message' ] } ); // Initialization @@ -6515,7 +6853,7 @@ OO.ui.MessageDialog.prototype.attachActions = function () { } if ( others.length ) { for ( i = 0, len = others.length; i < len; i++ ) { - other = others[i]; + other = others[ i ]; this.$actions.append( other.$element ); other.toggleFramed( false ); } @@ -6528,7 +6866,7 @@ OO.ui.MessageDialog.prototype.attachActions = function () { if ( !this.isOpening() ) { // If the dialog is currently opening, this will be called automatically soon. // This also calls #fitActions. - this.manager.updateWindowSize( this ); + this.updateSize(); } }; @@ -6545,17 +6883,19 @@ OO.ui.MessageDialog.prototype.fitActions = function () { // Detect clipping this.toggleVerticalActionLayout( false ); for ( i = 0, len = actions.length; i < len; i++ ) { - action = actions[i]; + action = actions[ i ]; if ( action.$element.innerWidth() < action.$label.outerWidth( true ) ) { this.toggleVerticalActionLayout( true ); break; } } + // Move the body out of the way of the foot + this.$body.css( 'bottom', this.$foot.outerHeight( true ) ); + if ( this.verticalActionLayout !== previous ) { - this.$body.css( 'bottom', this.$foot.outerHeight( true ) ); // We changed the layout, window height might need to be updated. - this.manager.updateWindowSize( this ); + this.updateSize(); } }; @@ -6636,18 +6976,17 @@ OO.ui.ProcessDialog.prototype.initialize = function () { OO.ui.ProcessDialog.super.prototype.initialize.call( this ); // Properties - this.$navigation = this.$( '
' ); - this.$location = this.$( '
' ); - this.$safeActions = this.$( '
' ); - this.$primaryActions = this.$( '
' ); - this.$otherActions = this.$( '
' ); + this.$navigation = $( '
' ); + this.$location = $( '
' ); + this.$safeActions = $( '
' ); + this.$primaryActions = $( '
' ); + this.$otherActions = $( '
' ); this.dismissButton = new OO.ui.ButtonWidget( { - $: this.$, label: OO.ui.msg( 'ooui-dialog-process-dismiss' ) } ); - this.retryButton = new OO.ui.ButtonWidget( { $: this.$ } ); - this.$errors = this.$( '
' ); - this.$errorsTitle = this.$( '
' ); + this.retryButton = new OO.ui.ButtonWidget(); + this.$errors = $( '
' ); + this.$errorsTitle = $( '
' ); // Events this.dismissButton.connect( this, { click: 'onDismissErrorButtonClick' } ); @@ -6665,7 +7004,7 @@ OO.ui.ProcessDialog.prototype.initialize = function () { .addClass( 'oo-ui-processDialog-errors-title' ) .text( OO.ui.msg( 'ooui-dialog-process-error' ) ); this.$errors - .addClass( 'oo-ui-processDialog-errors' ) + .addClass( 'oo-ui-processDialog-errors oo-ui-element-hidden' ) .append( this.$errorsTitle, this.dismissButton.$element, this.retryButton.$element ); this.$content .addClass( 'oo-ui-processDialog-content' ) @@ -6694,7 +7033,7 @@ OO.ui.ProcessDialog.prototype.attachActions = function () { } if ( others.length ) { for ( i = 0, len = others.length; i < len; i++ ) { - other = others[i]; + other = others[ i ]; this.$otherActions.append( other.$element ); other.toggleFramed( true ); } @@ -6743,18 +7082,18 @@ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { warning = false; for ( i = 0, len = errors.length; i < len; i++ ) { - if ( !errors[i].isRecoverable() ) { + if ( !errors[ i ].isRecoverable() ) { recoverable = false; } - if ( errors[i].isWarning() ) { + if ( errors[ i ].isWarning() ) { warning = true; } - $item = this.$( '
' ) + $item = $( '
' ) .addClass( 'oo-ui-processDialog-error' ) - .append( errors[i].getMessage() ); - items.push( $item[0] ); + .append( errors[ i ].getMessage() ); + items.push( $item[ 0 ] ); } - this.$errorItems = this.$( items ); + this.$errorItems = $( items ); if ( recoverable ) { this.retryButton.clearFlags().setFlags( this.currentAction.getFlags() ); } else { @@ -6767,867 +7106,1131 @@ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { } this.retryButton.toggle( recoverable ); this.$errorsTitle.after( this.$errorItems ); - this.$errors.show().scrollTop( 0 ); + this.$errors.removeClass( 'oo-ui-element-hidden' ).scrollTop( 0 ); }; /** * Hide errors. */ OO.ui.ProcessDialog.prototype.hideErrors = function () { - this.$errors.hide(); + this.$errors.addClass( 'oo-ui-element-hidden' ); this.$errorItems.remove(); this.$errorItems = null; }; /** - * Layout containing a series of pages. + * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget, + * which is a widget that is specified by reference before any optional configuration settings. + * + * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways: + * + * - **left**: The label is placed before the field-widget and aligned with the left margin. + * A left-alignment is used for forms with many fields. + * - **right**: The label is placed before the field-widget and aligned to the right margin. + * A right-alignment is used for long but familiar forms which users tab through, + * verifying the current field with a quick glance at the label. + * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms + * that users fill out from top to bottom. + * - **inline**: The label is placed after the field-widget and aligned to the left. + An inline-alignment is best used with checkboxes or radio buttons. + * + * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout. + * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information. * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets * @class * @extends OO.ui.Layout + * @mixins OO.ui.LabelElement * * @constructor + * @param {OO.ui.Widget} fieldWidget Field widget * @param {Object} [config] Configuration options - * @cfg {boolean} [continuous=false] Show all pages, one after another - * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when changing to a page - * @cfg {boolean} [outlined=false] Show an outline - * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages + * @cfg {string} [align='left'] Alignment mode, either 'left', 'right', 'top' or 'inline' + * @cfg {string} [help] Explanatory text shown as a '?' icon. */ -OO.ui.BookletLayout = function OoUiBookletLayout( config ) { +OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { + var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; + // Configuration initialization - config = config || {}; + config = $.extend( { align: 'left' }, config ); // Parent constructor - OO.ui.BookletLayout.super.call( this, config ); + OO.ui.FieldLayout.super.call( this, config ); + + // Mixin constructors + OO.ui.LabelElement.call( this, config ); // Properties - this.currentPageName = null; - this.pages = {}; - this.ignoreFocus = false; - this.stackLayout = new OO.ui.StackLayout( { $: this.$, continuous: !!config.continuous } ); - this.autoFocus = config.autoFocus === undefined || !!config.autoFocus; - this.outlineVisible = false; - this.outlined = !!config.outlined; - if ( this.outlined ) { - this.editable = !!config.editable; - this.outlineControlsWidget = null; - this.outlineSelectWidget = new OO.ui.OutlineSelectWidget( { $: this.$ } ); - this.outlinePanel = new OO.ui.PanelLayout( { $: this.$, scrollable: true } ); - this.gridLayout = new OO.ui.GridLayout( - [ this.outlinePanel, this.stackLayout ], - { $: this.$, widths: [ 1, 2 ] } + this.fieldWidget = fieldWidget; + this.$field = $( '
' ); + this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' ); + this.align = null; + if ( config.help ) { + this.popupButtonWidget = new OO.ui.PopupButtonWidget( { + classes: [ 'oo-ui-fieldLayout-help' ], + framed: false, + icon: 'info' + } ); + + this.popupButtonWidget.getPopup().$body.append( + $( '
' ) + .text( config.help ) + .addClass( 'oo-ui-fieldLayout-help-content' ) ); - this.outlineVisible = true; - if ( this.editable ) { - this.outlineControlsWidget = new OO.ui.OutlineControlsWidget( - this.outlineSelectWidget, { $: this.$ } - ); - } + this.$help = this.popupButtonWidget.$element; + } else { + this.$help = $( [] ); } // Events - this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); - if ( this.outlined ) { - this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } ); - } - if ( this.autoFocus ) { - // Event 'focus' does not bubble, but 'focusin' does - this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); + if ( hasInputWidget ) { + this.$label.on( 'click', this.onLabelClick.bind( this ) ); } + this.fieldWidget.connect( this, { disable: 'onFieldDisable' } ); // Initialization - this.$element.addClass( 'oo-ui-bookletLayout' ); - this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' ); - if ( this.outlined ) { - this.outlinePanel.$element - .addClass( 'oo-ui-bookletLayout-outlinePanel' ) - .append( this.outlineSelectWidget.$element ); - if ( this.editable ) { - this.outlinePanel.$element - .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' ) - .append( this.outlineControlsWidget.$element ); - } - this.$element.append( this.gridLayout.$element ); - } else { - this.$element.append( this.stackLayout.$element ); - } + this.$element + .addClass( 'oo-ui-fieldLayout' ) + .append( this.$help, this.$body ); + this.$body.addClass( 'oo-ui-fieldLayout-body' ); + this.$field + .addClass( 'oo-ui-fieldLayout-field' ) + .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() ) + .append( this.fieldWidget.$element ); + + this.setAlignment( config.align ); }; /* Setup */ -OO.inheritClass( OO.ui.BookletLayout, OO.ui.Layout ); +OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); +OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); -/* Events */ +/* Methods */ /** - * @event set - * @param {OO.ui.PageLayout} page Current page + * Handle field disable events. + * + * @param {boolean} value Field is disabled */ +OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) { + this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value ); +}; /** - * @event add - * @param {OO.ui.PageLayout[]} page Added pages - * @param {number} index Index pages were added at + * Handle label mouse click events. + * + * @param {jQuery.Event} e Mouse click event */ +OO.ui.FieldLayout.prototype.onLabelClick = function () { + this.fieldWidget.simulateLabelClick(); + return false; +}; /** - * @event remove - * @param {OO.ui.PageLayout[]} pages Removed pages + * Get the field. + * + * @return {OO.ui.Widget} Field widget */ - -/* Methods */ +OO.ui.FieldLayout.prototype.getField = function () { + return this.fieldWidget; +}; /** - * Handle stack layout focus. + * Set the field alignment mode. * - * @param {jQuery.Event} e Focusin event + * @private + * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline' + * @chainable */ -OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) { - var name, $target; - - // Find the page that an element was focused within - $target = $( e.target ).closest( '.oo-ui-pageLayout' ); - for ( name in this.pages ) { - // Check for page match, exclude current page to find only page changes - if ( this.pages[name].$element[0] === $target[0] && name !== this.currentPageName ) { - this.setPage( name ); - break; +OO.ui.FieldLayout.prototype.setAlignment = function ( value ) { + if ( value !== this.align ) { + // Default to 'left' + if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) { + value = 'left'; } + // Reorder elements + if ( value === 'inline' ) { + this.$body.append( this.$field, this.$label ); + } else { + this.$body.append( this.$label, this.$field ); + } + // Set classes. The following classes can be used here: + // * oo-ui-fieldLayout-align-left + // * oo-ui-fieldLayout-align-right + // * oo-ui-fieldLayout-align-top + // * oo-ui-fieldLayout-align-inline + if ( this.align ) { + this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align ); + } + this.$element.addClass( 'oo-ui-fieldLayout-align-' + value ); + this.align = value; } + + return this; }; /** - * Handle stack layout set events. + * Layout made of a field, a button, and an optional label. * - * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel + * @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 mode, either 'left', 'right', 'top' or 'inline' + * @cfg {string} [help] Explanatory text shown as a '?' icon. */ -OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { - var layout = this; - if ( page ) { - page.scrollElementIntoView( { complete: function () { - if ( layout.autoFocus ) { - layout.focus(); - } - } } ); - } +OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) { + // Configuration initialization + config = $.extend( { align: 'left' }, config ); + + // Parent constructor + OO.ui.ActionFieldLayout.super.call( this, fieldWidget, config ); + + // Mixin constructors + OO.ui.LabelElement.call( this, config ); + + // Properties + this.fieldWidget = fieldWidget; + this.buttonWidget = buttonWidget; + this.$button = $( '
' ) + .addClass( 'oo-ui-actionFieldLayout-button' ) + .append( this.buttonWidget.$element ); + this.$input = $( '
' ) + .addClass( 'oo-ui-actionFieldLayout-input' ) + .append( this.fieldWidget.$element ); + this.$field + .addClass( 'oo-ui-actionFieldLayout' ) + .append( this.$input, this.$button ); }; +/* Setup */ + +OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout ); + /** - * Focus the first input in the current page. + * Layout made of a fieldset and optional legend. * - * If no page is selected, the first selectable page will be selected. - * If the focus is already in an element on the current page, nothing will happen. + * Just add OO.ui.FieldLayout items. + * + * @class + * @extends OO.ui.Layout + * @mixins OO.ui.IconElement + * @mixins OO.ui.LabelElement + * @mixins OO.ui.GroupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {OO.ui.FieldLayout[]} [items] Items to add */ -OO.ui.BookletLayout.prototype.focus = function () { - var $input, page = this.stackLayout.getCurrentItem(); - if ( !page && this.outlined ) { - this.selectFirstSelectablePage(); - page = this.stackLayout.getCurrentItem(); - if ( !page ) { - return; - } +OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.FieldsetLayout.super.call( this, config ); + + // Mixin constructors + OO.ui.IconElement.call( this, config ); + OO.ui.LabelElement.call( this, config ); + OO.ui.GroupElement.call( this, config ); + + if ( config.help ) { + this.popupButtonWidget = new OO.ui.PopupButtonWidget( { + classes: [ 'oo-ui-fieldsetLayout-help' ], + framed: false, + icon: 'info' + } ); + + this.popupButtonWidget.getPopup().$body.append( + $( '
' ) + .text( config.help ) + .addClass( 'oo-ui-fieldsetLayout-help-content' ) + ); + this.$help = this.popupButtonWidget.$element; + } else { + this.$help = $( [] ); } - // 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(); - } + + // Initialization + this.$element + .addClass( 'oo-ui-fieldsetLayout' ) + .prepend( this.$help, this.$icon, this.$label, this.$group ); + if ( Array.isArray( config.items ) ) { + this.addItems( config.items ); } }; +/* 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 ); + /** - * Handle outline widget select events. + * Layout with an HTML form. * - * @param {OO.ui.OptionWidget|null} item Selected item + * @class + * @extends OO.ui.Layout + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [method] HTML form `method` attribute + * @cfg {string} [action] HTML form `action` attribute + * @cfg {string} [enctype] HTML form `enctype` attribute */ -OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { - if ( item ) { - this.setPage( item.getData() ); - } +OO.ui.FormLayout = function OoUiFormLayout( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.FormLayout.super.call( this, config ); + + // Events + this.$element.on( 'submit', this.onFormSubmit.bind( this ) ); + + // Initialization + this.$element + .addClass( 'oo-ui-formLayout' ) + .attr( { + method: config.method, + action: config.action, + enctype: config.enctype + } ); }; +/* Setup */ + +OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); + +/* Events */ + /** - * Check if booklet has an outline. - * - * @return {boolean} + * @event submit */ -OO.ui.BookletLayout.prototype.isOutlined = function () { - return this.outlined; -}; + +/* Static Properties */ + +OO.ui.FormLayout.static.tagName = 'form'; + +/* Methods */ /** - * Check if booklet has editing controls. + * Handle form submit events. * - * @return {boolean} + * @param {jQuery.Event} e Submit event + * @fires submit */ -OO.ui.BookletLayout.prototype.isEditable = function () { - return this.editable; +OO.ui.FormLayout.prototype.onFormSubmit = function () { + this.emit( 'submit' ); + return false; }; /** - * Check if booklet has a visible outline. + * Layout made of proportionally sized columns and rows. * - * @return {boolean} + * @class + * @extends OO.ui.Layout + * @deprecated Use OO.ui.MenuLayout or plain CSS instead. + * + * @constructor + * @param {OO.ui.PanelLayout[]} panels Panels in the grid + * @param {Object} [config] Configuration options + * @cfg {number[]} [widths] Widths of columns as ratios + * @cfg {number[]} [heights] Heights of rows as ratios */ -OO.ui.BookletLayout.prototype.isOutlineVisible = function () { - return this.outlined && this.outlineVisible; +OO.ui.GridLayout = function OoUiGridLayout( panels, config ) { + var i, len, widths; + + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.GridLayout.super.call( this, config ); + + // Properties + this.panels = []; + this.widths = []; + this.heights = []; + + // Initialization + this.$element.addClass( 'oo-ui-gridLayout' ); + for ( i = 0, len = panels.length; i < len; i++ ) { + this.panels.push( panels[ i ] ); + this.$element.append( panels[ i ].$element ); + } + if ( config.widths || config.heights ) { + this.layout( config.widths || [ 1 ], config.heights || [ 1 ] ); + } else { + // Arrange in columns by default + widths = this.panels.map( function () { return 1; } ); + this.layout( widths, [ 1 ] ); + } }; +/* Setup */ + +OO.inheritClass( OO.ui.GridLayout, OO.ui.Layout ); + +/* Events */ + /** - * Hide or show the outline. + * @event layout + */ + +/** + * @event update + */ + +/* Methods */ + +/** + * Set grid dimensions. * - * @param {boolean} [show] Show outline, omit to invert current state - * @chainable + * @param {number[]} widths Widths of columns as ratios + * @param {number[]} heights Heights of rows as ratios + * @fires layout + * @throws {Error} If grid is not large enough to fit all panels */ -OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { - if ( this.outlined ) { - show = show === undefined ? !this.outlineVisible : !!show; - this.outlineVisible = show; - this.gridLayout.layout( show ? [ 1, 2 ] : [ 0, 1 ], [ 1 ] ); +OO.ui.GridLayout.prototype.layout = function ( widths, heights ) { + var x, y, + xd = 0, + yd = 0, + cols = widths.length, + rows = heights.length; + + // Verify grid is big enough to fit panels + if ( cols * rows < this.panels.length ) { + throw new Error( 'Grid is not large enough to fit ' + this.panels.length + 'panels' ); } - return this; + // Sum up denominators + for ( x = 0; x < cols; x++ ) { + xd += widths[ x ]; + } + for ( y = 0; y < rows; y++ ) { + yd += heights[ y ]; + } + // Store factors + this.widths = []; + this.heights = []; + for ( x = 0; x < cols; x++ ) { + this.widths[ x ] = widths[ x ] / xd; + } + for ( y = 0; y < rows; y++ ) { + this.heights[ y ] = heights[ y ] / yd; + } + // Synchronize view + this.update(); + this.emit( 'layout' ); }; /** - * Get the outline widget. + * Update panel positions and sizes. * - * @param {OO.ui.PageLayout} page Page to be selected - * @return {OO.ui.PageLayout|null} Closest page to another + * @fires update */ -OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { - var next, prev, level, - pages = this.stackLayout.getItems(), - index = $.inArray( page, pages ); +OO.ui.GridLayout.prototype.update = function () { + var x, y, panel, width, height, dimensions, + i = 0, + top = 0, + left = 0, + cols = this.widths.length, + rows = this.heights.length; - if ( index !== -1 ) { - next = pages[index + 1]; - prev = pages[index - 1]; - // Prefer adjacent pages at the same level - if ( this.outlined ) { - level = this.outlineSelectWidget.getItemFromData( page.getName() ).getLevel(); - if ( - prev && - level === this.outlineSelectWidget.getItemFromData( prev.getName() ).getLevel() - ) { - return prev; + for ( y = 0; y < rows; y++ ) { + height = this.heights[ y ]; + for ( x = 0; x < cols; x++ ) { + width = this.widths[ x ]; + panel = this.panels[ i ]; + dimensions = { + width: ( width * 100 ) + '%', + height: ( height * 100 ) + '%', + top: ( top * 100 ) + '%' + }; + // If RTL, reverse: + if ( OO.ui.Element.static.getDir( document ) === 'rtl' ) { + dimensions.right = ( left * 100 ) + '%'; + } else { + dimensions.left = ( left * 100 ) + '%'; } - if ( - next && - level === this.outlineSelectWidget.getItemFromData( next.getName() ).getLevel() - ) { - return next; + // HACK: Work around IE bug by setting visibility: hidden; if width or height is zero + if ( width === 0 || height === 0 ) { + dimensions.visibility = 'hidden'; + } else { + dimensions.visibility = ''; } + panel.$element.css( dimensions ); + i++; + left += width; } + top += height; + left = 0; } - return prev || next || null; + + this.emit( 'update' ); }; /** - * Get the outline widget. + * Get a panel at a given position. * - * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if booklet has no outline + * The x and y position is affected by the current grid layout. + * + * @param {number} x Horizontal position + * @param {number} y Vertical position + * @return {OO.ui.PanelLayout} The panel at the given position */ -OO.ui.BookletLayout.prototype.getOutline = function () { - return this.outlineSelectWidget; +OO.ui.GridLayout.prototype.getPanel = function ( x, y ) { + return this.panels[ ( x * this.widths.length ) + y ]; }; /** - * Get the outline controls widget. If the outline is not editable, null is returned. + * Layout with a content and menu area. * - * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. + * The menu area can be positioned at the top, after, bottom or before. The content area will fill + * all remaining space. + * + * @class + * @extends OO.ui.Layout + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {number|string} [menuSize='18em'] Size of menu in pixels or any CSS unit + * @cfg {boolean} [showMenu=true] Show menu + * @cfg {string} [position='before'] Position of menu, either `top`, `after`, `bottom` or `before` + * @cfg {boolean} [collapse] Collapse the menu out of view */ -OO.ui.BookletLayout.prototype.getOutlineControls = function () { - return this.outlineControlsWidget; +OO.ui.MenuLayout = function OoUiMenuLayout( config ) { + var positions = this.constructor.static.menuPositions; + + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.MenuLayout.super.call( this, config ); + + // Properties + this.showMenu = config.showMenu !== false; + this.menuSize = config.menuSize || '18em'; + this.menuPosition = positions[ config.menuPosition ] || positions.before; + + /** + * Menu DOM node + * + * @property {jQuery} + */ + this.$menu = $( '
' ); + /** + * Content DOM node + * + * @property {jQuery} + */ + this.$content = $( '
' ); + + // Initialization + this.toggleMenu( this.showMenu ); + this.updateSizes(); + this.$menu + .addClass( 'oo-ui-menuLayout-menu' ) + .css( this.menuPosition.sizeProperty, this.menuSize ); + this.$content.addClass( 'oo-ui-menuLayout-content' ); + this.$element + .addClass( 'oo-ui-menuLayout ' + this.menuPosition.className ) + .append( this.$content, this.$menu ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout ); + +/* Static Properties */ + +OO.ui.MenuLayout.static.menuPositions = { + top: { + sizeProperty: 'height', + className: 'oo-ui-menuLayout-top' + }, + after: { + sizeProperty: 'width', + className: 'oo-ui-menuLayout-after' + }, + bottom: { + sizeProperty: 'height', + className: 'oo-ui-menuLayout-bottom' + }, + before: { + sizeProperty: 'width', + className: 'oo-ui-menuLayout-before' + } }; +/* Methods */ + /** - * Get a page by name. + * Toggle menu. * - * @param {string} name Symbolic name of page - * @return {OO.ui.PageLayout|undefined} Page, if found + * @param {boolean} showMenu Show menu, omit to toggle + * @chainable */ -OO.ui.BookletLayout.prototype.getPage = function ( name ) { - return this.pages[name]; +OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) { + showMenu = showMenu === undefined ? !this.showMenu : !!showMenu; + + if ( this.showMenu !== showMenu ) { + this.showMenu = showMenu; + this.updateSizes(); + } + + return this; }; /** - * Get the current page name. + * Check if menu is visible * - * @return {string|null} Current page name + * @return {boolean} Menu is visible */ -OO.ui.BookletLayout.prototype.getCurrentPageName = function () { - return this.currentPageName; +OO.ui.MenuLayout.prototype.isMenuVisible = function () { + return this.showMenu; }; /** - * Add a page to the layout. - * - * When pages are added with the same names as existing pages, the existing pages will be - * automatically removed before the new pages are added. + * Set menu size. * - * @param {OO.ui.PageLayout[]} pages Pages to add - * @param {number} index Index to insert pages after - * @fires add + * @param {number|string} size Size of menu in pixels or any CSS unit * @chainable */ -OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) { - var i, len, name, page, item, currentIndex, - stackLayoutPages = this.stackLayout.getItems(), - remove = [], - items = []; - - // Remove pages with same names - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - - if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { - // Correct the insertion index - currentIndex = $.inArray( this.pages[name], stackLayoutPages ); - if ( currentIndex !== -1 && currentIndex + 1 < index ) { - index--; - } - remove.push( this.pages[name] ); - } - } - if ( remove.length ) { - this.removePages( remove ); - } - - // Add new pages - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - this.pages[page.getName()] = page; - if ( this.outlined ) { - item = new OO.ui.OutlineOptionWidget( { $: this.$, data: name } ); - page.setOutlineItem( item ); - items.push( item ); - } - } - - if ( this.outlined && items.length ) { - this.outlineSelectWidget.addItems( items, index ); - this.selectFirstSelectablePage(); - } - this.stackLayout.addItems( pages, index ); - this.emit( 'add', pages, index ); +OO.ui.MenuLayout.prototype.setMenuSize = function ( size ) { + this.menuSize = size; + this.updateSizes(); return this; }; /** - * Remove a page from the layout. + * Update menu and content CSS based on current menu size and visibility * - * @fires remove - * @chainable + * This method is called internally when size or position is changed. */ -OO.ui.BookletLayout.prototype.removePages = function ( pages ) { - var i, len, name, page, - items = []; - - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - delete this.pages[name]; - if ( this.outlined ) { - items.push( this.outlineSelectWidget.getItemFromData( name ) ); - page.setOutlineItem( null ); - } - } - if ( this.outlined && items.length ) { - this.outlineSelectWidget.removeItems( items ); - this.selectFirstSelectablePage(); +OO.ui.MenuLayout.prototype.updateSizes = function () { + if ( this.showMenu ) { + this.$menu + .css( this.menuPosition.sizeProperty, this.menuSize ) + .css( 'overflow', '' ); + // Set offsets on all sides. CSS resets all but one with + // 'important' rules so directionality flips are supported + this.$content.css( { + top: this.menuSize, + right: this.menuSize, + bottom: this.menuSize, + left: this.menuSize + } ); + } else { + this.$menu + .css( this.menuPosition.sizeProperty, 0 ) + .css( 'overflow', 'hidden' ); + this.$content.css( { + top: 0, + right: 0, + bottom: 0, + left: 0 + } ); } - this.stackLayout.removeItems( pages ); - this.emit( 'remove', pages ); +}; - return this; +/** + * Get menu size. + * + * @return {number|string} Menu size + */ +OO.ui.MenuLayout.prototype.getMenuSize = function () { + return this.menuSize; }; /** - * Clear all pages from the layout. + * Set menu position. * - * @fires remove + * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before` + * @throws {Error} If position value is not supported * @chainable */ -OO.ui.BookletLayout.prototype.clearPages = function () { - var i, len, - pages = this.stackLayout.getItems(); +OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) { + var positions = this.constructor.static.menuPositions; - this.pages = {}; - this.currentPageName = null; - if ( this.outlined ) { - this.outlineSelectWidget.clearItems(); - for ( i = 0, len = pages.length; i < len; i++ ) { - pages[i].setOutlineItem( null ); - } + if ( !positions[ position ] ) { + throw new Error( 'Cannot set position; unsupported position value: ' + position ); } - this.stackLayout.clearItems(); - this.emit( 'remove', pages ); + this.$menu.css( this.menuPosition.sizeProperty, '' ); + this.$element.removeClass( this.menuPosition.className ); - return this; -}; + this.menuPosition = positions[ position ]; -/** - * Set the current page by name. - * - * @fires set - * @param {string} name Symbolic name of page - */ -OO.ui.BookletLayout.prototype.setPage = function ( name ) { - var selectedItem, - $focused, - page = this.pages[name]; + this.updateSizes(); + this.$element.addClass( this.menuPosition.className ); - if ( name !== this.currentPageName ) { - if ( this.outlined ) { - selectedItem = this.outlineSelectWidget.getSelectedItem(); - if ( selectedItem && selectedItem.getData() !== name ) { - this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getItemFromData( 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 ( $focused.length ) { - $focused[0].blur(); - } - } - } - this.currentPageName = name; - this.stackLayout.setItem( page ); - page.setActive( true ); - this.emit( 'set', page ); - } - } + return this; }; /** - * Select the first selectable page. + * Get menu position. * - * @chainable + * @return {string} Menu position */ -OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { - if ( !this.outlineSelectWidget.getSelectedItem() ) { - this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() ); - } - - return this; +OO.ui.MenuLayout.prototype.getMenuPosition = function () { + return this.menuPosition; }; /** - * Layout made of a field and optional label. - * - * Available label alignment modes include: - * - left: Label is before the field and aligned away from it, best for when the user will be - * scanning for a specific label in a form with many fields - * - right: Label is before the field and aligned toward it, best for forms the user is very - * familiar with and will tab through field checking quickly to verify which field they are in - * - top: Label is before the field and above it, best for when the user will need to fill out all - * fields from top to bottom in a form with few fields - * - inline: Label is after the field and aligned toward it, best for small boolean fields like - * checkboxes or radio buttons + * Layout containing a series of pages. * * @class - * @extends OO.ui.Layout - * @mixins OO.ui.LabelElement + * @extends OO.ui.MenuLayout * * @constructor - * @param {OO.ui.Widget} fieldWidget Field widget * @param {Object} [config] Configuration options - * @cfg {string} [align='left'] Alignment mode, either 'left', 'right', 'top' or 'inline' - * @cfg {string} [help] Explanatory text shown as a '?' icon. + * @cfg {boolean} [continuous=false] Show all pages, one after another + * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when changing to a page + * @cfg {boolean} [outlined=false] Show an outline + * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages */ -OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { - var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; - +OO.ui.BookletLayout = function OoUiBookletLayout( config ) { // Configuration initialization - config = $.extend( { align: 'left' }, config ); - - // Properties (must be set before parent constructor, which calls #getTagName) - this.fieldWidget = fieldWidget; + config = config || {}; // Parent constructor - OO.ui.FieldLayout.super.call( this, config ); - - // Mixin constructors - OO.ui.LabelElement.call( this, config ); + OO.ui.BookletLayout.super.call( this, config ); // Properties - this.$field = this.$( '
' ); - this.$body = this.$( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' ); - this.align = null; - if ( config.help ) { - this.popupButtonWidget = new OO.ui.PopupButtonWidget( { - $: this.$, - classes: [ 'oo-ui-fieldLayout-help' ], - framed: false, - icon: 'info' - } ); - - this.popupButtonWidget.getPopup().$body.append( - this.$( '
' ) - .text( config.help ) - .addClass( 'oo-ui-fieldLayout-help-content' ) - ); - this.$help = this.popupButtonWidget.$element; - } else { - this.$help = this.$( [] ); + this.currentPageName = null; + this.pages = {}; + this.ignoreFocus = false; + this.stackLayout = new OO.ui.StackLayout( { continuous: !!config.continuous } ); + this.$content.append( this.stackLayout.$element ); + this.autoFocus = config.autoFocus === undefined || !!config.autoFocus; + this.outlineVisible = false; + this.outlined = !!config.outlined; + if ( this.outlined ) { + this.editable = !!config.editable; + this.outlineControlsWidget = null; + this.outlineSelectWidget = new OO.ui.OutlineSelectWidget(); + this.outlinePanel = new OO.ui.PanelLayout( { scrollable: true } ); + this.$menu.append( this.outlinePanel.$element ); + this.outlineVisible = true; + if ( this.editable ) { + this.outlineControlsWidget = new OO.ui.OutlineControlsWidget( + this.outlineSelectWidget + ); + } } + this.toggleMenu( this.outlined ); // Events - if ( hasInputWidget ) { - this.$label.on( 'click', this.onLabelClick.bind( this ) ); + this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); + if ( this.outlined ) { + this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } ); + } + if ( this.autoFocus ) { + // Event 'focus' does not bubble, but 'focusin' does + this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); } - this.fieldWidget.connect( this, { disable: 'onFieldDisable' } ); // Initialization - this.$element - .addClass( 'oo-ui-fieldLayout' ) - .append( this.$help, this.$body ); - this.$body.addClass( 'oo-ui-fieldLayout-body' ); - this.$field - .addClass( 'oo-ui-fieldLayout-field' ) - .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() ) - .append( this.fieldWidget.$element ); - - this.setAlignment( config.align ); + this.$element.addClass( 'oo-ui-bookletLayout' ); + this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' ); + if ( this.outlined ) { + this.outlinePanel.$element + .addClass( 'oo-ui-bookletLayout-outlinePanel' ) + .append( this.outlineSelectWidget.$element ); + if ( this.editable ) { + this.outlinePanel.$element + .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' ) + .append( this.outlineControlsWidget.$element ); + } + } }; /* Setup */ -OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); +OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout ); + +/* Events */ + +/** + * @event set + * @param {OO.ui.PageLayout} page Current page + */ + +/** + * @event add + * @param {OO.ui.PageLayout[]} page Added pages + * @param {number} index Index pages were added at + */ + +/** + * @event remove + * @param {OO.ui.PageLayout[]} pages Removed pages + */ /* Methods */ /** - * Handle field disable events. + * Handle stack layout focus. * - * @param {boolean} value Field is disabled + * @param {jQuery.Event} e Focusin event */ -OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) { - this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value ); +OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) { + var name, $target; + + // Find the page that an element was focused within + $target = $( e.target ).closest( '.oo-ui-pageLayout' ); + for ( name in this.pages ) { + // Check for page match, exclude current page to find only page changes + if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) { + this.setPage( name ); + break; + } + } }; /** - * Handle label mouse click events. + * Handle stack layout set events. * - * @param {jQuery.Event} e Mouse click event + * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel */ -OO.ui.FieldLayout.prototype.onLabelClick = function () { - this.fieldWidget.simulateLabelClick(); - return false; +OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { + var layout = this; + if ( page ) { + page.scrollElementIntoView( { complete: function () { + if ( layout.autoFocus ) { + layout.focus(); + } + } } ); + } }; /** - * Get the field. + * Focus the first input in the current page. * - * @return {OO.ui.Widget} Field widget + * If no page is selected, the first selectable page will be selected. + * If the focus is already in an element on the current page, nothing will happen. */ -OO.ui.FieldLayout.prototype.getField = function () { - return this.fieldWidget; +OO.ui.BookletLayout.prototype.focus = function () { + var $input, page = this.stackLayout.getCurrentItem(); + if ( !page && this.outlined ) { + this.selectFirstSelectablePage(); + page = this.stackLayout.getCurrentItem(); + } + if ( !page ) { + 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(); + } + } }; /** - * Set the field alignment mode. + * Handle outline widget select events. * - * @private - * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline' - * @chainable + * @param {OO.ui.OptionWidget|null} item Selected item */ -OO.ui.FieldLayout.prototype.setAlignment = function ( value ) { - if ( value !== this.align ) { - // Default to 'left' - if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) { - value = 'left'; - } - // Reorder elements - if ( value === 'inline' ) { - this.$body.append( this.$field, this.$label ); - } else { - this.$body.append( this.$label, this.$field ); - } - // Set classes. The following classes can be used here: - // * oo-ui-fieldLayout-align-left - // * oo-ui-fieldLayout-align-right - // * oo-ui-fieldLayout-align-top - // * oo-ui-fieldLayout-align-inline - if ( this.align ) { - this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align ); - } - this.$element.addClass( 'oo-ui-fieldLayout-align-' + value ); - this.align = value; +OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { + if ( item ) { + this.setPage( item.getData() ); } +}; - return this; +/** + * Check if booklet has an outline. + * + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isOutlined = function () { + return this.outlined; }; /** - * Layout made of a fieldset and optional legend. + * Check if booklet has editing controls. * - * Just add OO.ui.FieldLayout items. + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isEditable = function () { + return this.editable; +}; + +/** + * Check if booklet has a visible outline. * - * @class - * @extends OO.ui.Layout - * @mixins OO.ui.IconElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.GroupElement + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isOutlineVisible = function () { + return this.outlined && this.outlineVisible; +}; + +/** + * Hide or show the outline. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {OO.ui.FieldLayout[]} [items] Items to add + * @param {boolean} [show] Show outline, omit to invert current state + * @chainable */ -OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { - // Configuration initialization - config = config || {}; +OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { + if ( this.outlined ) { + show = show === undefined ? !this.outlineVisible : !!show; + this.outlineVisible = show; + this.toggleMenu( show ); + } - // Parent constructor - OO.ui.FieldsetLayout.super.call( this, config ); + return this; +}; - // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.GroupElement.call( this, config ); +/** + * Get the outline widget. + * + * @param {OO.ui.PageLayout} page Page to be selected + * @return {OO.ui.PageLayout|null} Closest page to another + */ +OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { + var next, prev, level, + pages = this.stackLayout.getItems(), + index = $.inArray( page, pages ); - // Initialization - this.$element - .addClass( 'oo-ui-fieldsetLayout' ) - .prepend( this.$icon, this.$label, this.$group ); - if ( $.isArray( config.items ) ) { - this.addItems( config.items ); + if ( index !== -1 ) { + next = pages[ index + 1 ]; + prev = pages[ index - 1 ]; + // Prefer adjacent pages at the same level + if ( this.outlined ) { + level = this.outlineSelectWidget.getItemFromData( page.getName() ).getLevel(); + if ( + prev && + level === this.outlineSelectWidget.getItemFromData( prev.getName() ).getLevel() + ) { + return prev; + } + if ( + next && + level === this.outlineSelectWidget.getItemFromData( next.getName() ).getLevel() + ) { + return next; + } + } } + return prev || next || null; }; -/* 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 ); - /** - * Layout with an HTML form. - * - * @class - * @extends OO.ui.Layout + * Get the outline widget. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {string} [method] HTML form `method` attribute - * @cfg {string} [action] HTML form `action` attribute - * @cfg {string} [enctype] HTML form `enctype` attribute + * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if booklet has no outline */ -OO.ui.FormLayout = function OoUiFormLayout( config ) { - // Configuration initialization - config = config || {}; - - // Parent constructor - OO.ui.FormLayout.super.call( this, config ); - - // Events - this.$element.on( 'submit', this.onFormSubmit.bind( this ) ); - - // Initialization - this.$element - .addClass( 'oo-ui-formLayout' ) - .attr( { - method: config.method, - action: config.action, - enctype: config.enctype - } ); +OO.ui.BookletLayout.prototype.getOutline = function () { + return this.outlineSelectWidget; }; -/* Setup */ - -OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); +/** + * Get the outline controls widget. If the outline is not editable, null is returned. + * + * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. + */ +OO.ui.BookletLayout.prototype.getOutlineControls = function () { + return this.outlineControlsWidget; +}; -/* Events */ +/** + * Get a page by name. + * + * @param {string} name Symbolic name of page + * @return {OO.ui.PageLayout|undefined} Page, if found + */ +OO.ui.BookletLayout.prototype.getPage = function ( name ) { + return this.pages[ name ]; +}; /** - * @event submit + * Get the current page + * + * @return {OO.ui.PageLayout|undefined} Current page, if found */ - -/* Static Properties */ - -OO.ui.FormLayout.static.tagName = 'form'; - -/* Methods */ +OO.ui.BookletLayout.prototype.getCurrentPage = function () { + var name = this.getCurrentPageName(); + return name ? this.getPage( name ) : undefined; +}; /** - * Handle form submit events. + * Get the current page name. * - * @param {jQuery.Event} e Submit event - * @fires submit + * @return {string|null} Current page name */ -OO.ui.FormLayout.prototype.onFormSubmit = function () { - this.emit( 'submit' ); - return false; +OO.ui.BookletLayout.prototype.getCurrentPageName = function () { + return this.currentPageName; }; /** - * Layout made of proportionally sized columns and rows. + * Add a page to the layout. * - * @class - * @extends OO.ui.Layout + * When pages are added with the same names as existing pages, the existing pages will be + * automatically removed before the new pages are added. * - * @constructor - * @param {OO.ui.PanelLayout[]} panels Panels in the grid - * @param {Object} [config] Configuration options - * @cfg {number[]} [widths] Widths of columns as ratios - * @cfg {number[]} [heights] Heights of rows as ratios + * @param {OO.ui.PageLayout[]} pages Pages to add + * @param {number} index Index to insert pages after + * @fires add + * @chainable */ -OO.ui.GridLayout = function OoUiGridLayout( panels, config ) { - var i, len, widths; - - // Configuration initialization - config = config || {}; - - // Parent constructor - OO.ui.GridLayout.super.call( this, config ); +OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) { + var i, len, name, page, item, currentIndex, + stackLayoutPages = this.stackLayout.getItems(), + remove = [], + items = []; - // Properties - this.panels = []; - this.widths = []; - this.heights = []; + // Remove pages with same names + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); - // Initialization - this.$element.addClass( 'oo-ui-gridLayout' ); - for ( i = 0, len = panels.length; i < len; i++ ) { - this.panels.push( panels[i] ); - this.$element.append( panels[i].$element ); + if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { + // Correct the insertion index + currentIndex = $.inArray( this.pages[ name ], stackLayoutPages ); + if ( currentIndex !== -1 && currentIndex + 1 < index ) { + index--; + } + remove.push( this.pages[ name ] ); + } } - if ( config.widths || config.heights ) { - this.layout( config.widths || [ 1 ], config.heights || [ 1 ] ); - } else { - // Arrange in columns by default - widths = this.panels.map( function () { return 1; } ); - this.layout( widths, [ 1 ] ); + if ( remove.length ) { + this.removePages( remove ); } -}; -/* Setup */ + // Add new pages + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); + this.pages[ page.getName() ] = page; + if ( this.outlined ) { + item = new OO.ui.OutlineOptionWidget( { data: name } ); + page.setOutlineItem( item ); + items.push( item ); + } + } -OO.inheritClass( OO.ui.GridLayout, OO.ui.Layout ); + if ( this.outlined && items.length ) { + this.outlineSelectWidget.addItems( items, index ); + this.selectFirstSelectablePage(); + } + this.stackLayout.addItems( pages, index ); + this.emit( 'add', pages, index ); -/* Events */ + return this; +}; /** - * @event layout + * Remove a page from the layout. + * + * @fires remove + * @chainable */ +OO.ui.BookletLayout.prototype.removePages = function ( pages ) { + var i, len, name, page, + items = []; -/** - * @event update - */ + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); + delete this.pages[ name ]; + if ( this.outlined ) { + items.push( this.outlineSelectWidget.getItemFromData( name ) ); + page.setOutlineItem( null ); + } + } + if ( this.outlined && items.length ) { + this.outlineSelectWidget.removeItems( items ); + this.selectFirstSelectablePage(); + } + this.stackLayout.removeItems( pages ); + this.emit( 'remove', pages ); -/* Methods */ + return this; +}; /** - * Set grid dimensions. + * Clear all pages from the layout. * - * @param {number[]} widths Widths of columns as ratios - * @param {number[]} heights Heights of rows as ratios - * @fires layout - * @throws {Error} If grid is not large enough to fit all panels + * @fires remove + * @chainable */ -OO.ui.GridLayout.prototype.layout = function ( widths, heights ) { - var x, y, - xd = 0, - yd = 0, - cols = widths.length, - rows = heights.length; +OO.ui.BookletLayout.prototype.clearPages = function () { + var i, len, + pages = this.stackLayout.getItems(); - // Verify grid is big enough to fit panels - if ( cols * rows < this.panels.length ) { - throw new Error( 'Grid is not large enough to fit ' + this.panels.length + 'panels' ); + this.pages = {}; + this.currentPageName = null; + if ( this.outlined ) { + this.outlineSelectWidget.clearItems(); + for ( i = 0, len = pages.length; i < len; i++ ) { + pages[ i ].setOutlineItem( null ); + } } + this.stackLayout.clearItems(); - // Sum up denominators - for ( x = 0; x < cols; x++ ) { - xd += widths[x]; - } - for ( y = 0; y < rows; y++ ) { - yd += heights[y]; - } - // Store factors - this.widths = []; - this.heights = []; - for ( x = 0; x < cols; x++ ) { - this.widths[x] = widths[x] / xd; - } - for ( y = 0; y < rows; y++ ) { - this.heights[y] = heights[y] / yd; - } - // Synchronize view - this.update(); - this.emit( 'layout' ); + this.emit( 'remove', pages ); + + return this; }; /** - * Update panel positions and sizes. + * Set the current page by name. * - * @fires update + * @fires set + * @param {string} name Symbolic name of page */ -OO.ui.GridLayout.prototype.update = function () { - var x, y, panel, width, height, dimensions, - i = 0, - top = 0, - left = 0, - cols = this.widths.length, - rows = this.heights.length; +OO.ui.BookletLayout.prototype.setPage = function ( name ) { + var selectedItem, + $focused, + page = this.pages[ name ]; - for ( y = 0; y < rows; y++ ) { - height = this.heights[y]; - for ( x = 0; x < cols; x++ ) { - width = this.widths[x]; - panel = this.panels[i]; - dimensions = { - width: ( width * 100 ) + '%', - height: ( height * 100 ) + '%', - top: ( top * 100 ) + '%' - }; - // If RTL, reverse: - if ( OO.ui.Element.static.getDir( this.$.context ) === 'rtl' ) { - dimensions.right = ( left * 100 ) + '%'; - } else { - dimensions.left = ( left * 100 ) + '%'; + if ( name !== this.currentPageName ) { + if ( this.outlined ) { + selectedItem = this.outlineSelectWidget.getSelectedItem(); + if ( selectedItem && selectedItem.getData() !== name ) { + this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getItemFromData( name ) ); } - // HACK: Work around IE bug by setting visibility: hidden; if width or height is zero - if ( width === 0 || height === 0 ) { - dimensions.visibility = 'hidden'; - } else { - dimensions.visibility = ''; + } + 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 ( $focused.length ) { + $focused[ 0 ].blur(); + } + } } - panel.$element.css( dimensions ); - i++; - left += width; + this.currentPageName = name; + this.stackLayout.setItem( page ); + page.setActive( true ); + this.emit( 'set', page ); } - top += height; - left = 0; } - - this.emit( 'update' ); }; /** - * Get a panel at a given position. - * - * The x and y position is affected by the current grid layout. + * Select the first selectable page. * - * @param {number} x Horizontal position - * @param {number} y Vertical position - * @return {OO.ui.PanelLayout} The panel at the given position + * @chainable */ -OO.ui.GridLayout.prototype.getPanel = function ( x, y ) { - return this.panels[ ( x * this.widths.length ) + y ]; +OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { + if ( !this.outlineSelectWidget.getSelectedItem() ) { + this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() ); + } + + return this; }; /** @@ -7814,7 +8417,7 @@ OO.ui.StackLayout = function OoUiStackLayout( config ) { if ( this.continuous ) { this.$element.addClass( 'oo-ui-stackLayout-continuous' ); } - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; @@ -7869,11 +8472,14 @@ OO.ui.StackLayout.prototype.unsetCurrentItem = function () { * @chainable */ OO.ui.StackLayout.prototype.addItems = function ( items, index ) { + // Update the visibility + this.updateHiddenState( items, this.currentItem ); + // Mixin method OO.ui.GroupElement.prototype.addItems.call( this, items, index ); if ( !this.currentItem && items.length ) { - this.setItem( items[0] ); + this.setItem( items[ 0 ] ); } return this; @@ -7894,7 +8500,7 @@ OO.ui.StackLayout.prototype.removeItems = function ( items ) { if ( $.inArray( this.currentItem, items ) !== -1 ) { if ( this.items.length ) { - this.setItem( this.items[0] ); + this.setItem( this.items[ 0 ] ); } else { this.unsetCurrentItem(); } @@ -7931,18 +8537,10 @@ OO.ui.StackLayout.prototype.clearItems = function () { * @fires set */ OO.ui.StackLayout.prototype.setItem = function ( item ) { - var i, len; - if ( item !== this.currentItem ) { - if ( !this.continuous ) { - for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].$element.css( 'display', '' ); - } - } + this.updateHiddenState( this.items, item ); + if ( $.inArray( item, this.items ) !== -1 ) { - if ( !this.continuous ) { - item.$element.css( 'display', 'block' ); - } this.currentItem = item; this.emit( 'set', item ); } else { @@ -7953,6 +8551,30 @@ OO.ui.StackLayout.prototype.setItem = function ( item ) { return this; }; +/** + * Update the visibility of all items in case of non-continuous view. + * + * Ensure all items are hidden except for the selected one. + * This method does nothing when the stack is continuous. + * + * @param {OO.ui.Layout[]} items Item list iterate over + * @param {OO.ui.Layout} [selectedItem] Selected item to show + */ +OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) { + var i, len; + + if ( !this.continuous ) { + for ( i = 0, len = items.length; i < len; i++ ) { + if ( !selectedItem || selectedItem !== items[ i ] ) { + items[ i ].$element.addClass( 'oo-ui-element-hidden' ); + } + } + if ( selectedItem ) { + selectedItem.$element.removeClass( 'oo-ui-element-hidden' ); + } + } +}; + /** * Horizontal bar layout of tools as icon buttons. * @@ -8018,7 +8640,7 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { this.active = false; this.dragging = false; this.onBlurHandler = this.onBlur.bind( this ); - this.$handle = this.$( '' ); + this.$handle = $( '' ); // Events this.$handle.on( { @@ -8035,7 +8657,7 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { // OO.ui.HeaderedElement mixin constructor. if ( config.header !== undefined ) { this.$group - .prepend( this.$( '' ) + .prepend( $( '' ) .addClass( 'oo-ui-popupToolGroup-header' ) .text( config.header ) ); @@ -8079,7 +8701,7 @@ OO.ui.PopupToolGroup.prototype.setDisabled = function () { */ OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) { // Only deactivate when clicking outside the dropdown element - if ( this.$( e.target ).closest( '.oo-ui-popupToolGroup' )[0] !== this.$element[0] ) { + if ( $( e.target ).closest( '.oo-ui-popupToolGroup' )[ 0 ] !== this.$element[ 0 ] ) { this.setActive( false ); } }; @@ -8217,8 +8839,8 @@ OO.ui.ListToolGroup.prototype.populate = function () { this.collapsibleTools = []; for ( i = 0, len = allowCollapse.length; i < len; i++ ) { - if ( this.tools[ allowCollapse[i] ] !== undefined ) { - this.collapsibleTools.push( this.tools[ allowCollapse[i] ] ); + if ( this.tools[ allowCollapse[ i ] ] !== undefined ) { + this.collapsibleTools.push( this.tools[ allowCollapse[ i ] ] ); } } @@ -8226,14 +8848,6 @@ OO.ui.ListToolGroup.prototype.populate = function () { this.$group.append( this.getExpandCollapseTool().$element ); this.getExpandCollapseTool().toggle( this.collapsibleTools.length !== 0 ); - - // Calling jQuery's .hide() and then .show() on a detached element caches the default value of its - // 'display' attribute and restores it, and the tool uses a and can be hidden and re-shown. - // Is this a jQuery bug? http://jsfiddle.net/gtj4hu3h/ - if ( this.getExpandCollapseTool().$element.css( 'display' ) === 'inline' ) { - this.getExpandCollapseTool().$element.css( 'display', 'block' ); - } - this.updateCollapsibleState(); }; @@ -8268,7 +8882,7 @@ OO.ui.ListToolGroup.prototype.onPointerUp = function ( e ) { var ret = OO.ui.ListToolGroup.super.prototype.onPointerUp.call( this, e ); // Do not close the popup when the user wants to show more/fewer tools - if ( this.$( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length ) { + if ( $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length ) { // Prevent the popup list from being hidden this.setActive( true ); } @@ -8284,7 +8898,7 @@ OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () { .setTitle( OO.ui.msg( this.expanded ? 'ooui-toolgroup-collapse' : 'ooui-toolgroup-expand' ) ); for ( i = 0, len = this.collapsibleTools.length; i < len; i++ ) { - this.collapsibleTools[i].toggle( this.expanded ); + this.collapsibleTools[ i ].toggle( this.expanded ); } }; @@ -8335,8 +8949,8 @@ OO.ui.MenuToolGroup.prototype.onUpdateState = function () { labelTexts = []; for ( name in this.tools ) { - if ( this.tools[name].isActive() ) { - labelTexts.push( this.tools[name].getTitle() ); + if ( this.tools[ name ].isActive() ) { + labelTexts.push( this.tools[ name ].getTitle() ); } } @@ -8438,7 +9052,7 @@ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { // During construction, #setDisabled is called before the OO.ui.GroupElement constructor if ( this.items ) { for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].updateDisabled(); + this.items[ i ].updateDisabled(); } } @@ -8504,6 +9118,7 @@ OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) { * * @class * @abstract + * @deprecated Use OO.ui.LookupElement instead. * * @constructor * @param {OO.ui.TextInputWidget} input Input widget @@ -8519,7 +9134,6 @@ OO.ui.LookupInputWidget = function OoUiLookupInputWidget( input, config ) { this.lookupInput = input; this.$overlay = config.$overlay || this.$element; this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { - $: OO.ui.Element.static.getJQuery( this.$overlay ), input: this.lookupInput, $container: config.$container } ); @@ -8718,7 +9332,7 @@ OO.ui.LookupInputWidget.prototype.getLookupMenuItems = function () { this.abortLookupRequest(); if ( Object.prototype.hasOwnProperty.call( this.lookupCache, value ) ) { - deferred.resolve( this.getLookupMenuItemsFromData( this.lookupCache[value] ) ); + deferred.resolve( this.getLookupMenuItemsFromData( this.lookupCache[ value ] ) ); } else { this.lookupInput.pushPending(); this.lookupQuery = value; @@ -8739,8 +9353,8 @@ OO.ui.LookupInputWidget.prototype.getLookupMenuItems = function () { if ( ourRequest === widget.lookupRequest ) { widget.lookupQuery = null; widget.lookupRequest = null; - widget.lookupCache[value] = widget.getLookupCacheItemFromData( data ); - deferred.resolve( widget.getLookupMenuItemsFromData( widget.lookupCache[value] ) ); + widget.lookupCache[ value ] = widget.getLookupCacheItemFromData( data ); + deferred.resolve( widget.getLookupMenuItemsFromData( widget.lookupCache[ value ] ) ); } } ) .fail( function () { @@ -8832,21 +9446,18 @@ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, confi // Properties this.outline = outline; - this.$movers = this.$( '
' ); + this.$movers = $( '
' ); this.upButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'collapse', title: OO.ui.msg( 'ooui-outline-control-move-up' ) } ); this.downButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'expand', title: OO.ui.msg( 'ooui-outline-control-move-down' ) } ); this.removeButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'remove', title: OO.ui.msg( 'ooui-outline-control-remove' ) @@ -8904,15 +9515,15 @@ OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () { i = -1; len = items.length; while ( ++i < len ) { - if ( items[i].isMovable() ) { - firstMovable = items[i]; + if ( items[ i ].isMovable() ) { + firstMovable = items[ i ]; break; } } i = len; while ( i-- ) { - if ( items[i].isMovable() ) { - lastMovable = items[i]; + if ( items[ i ].isMovable() ) { + lastMovable = items[ i ]; break; } } @@ -8976,14 +9587,34 @@ OO.ui.ToggleWidget.prototype.setValue = function ( value ) { this.emit( 'change', value ); this.$element.toggleClass( 'oo-ui-toggleWidget-on', value ); this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value ); + this.$element.attr( 'aria-checked', value.toString() ); } return this; }; /** - * Group widget for multiple related buttons. - * - * Use together with OO.ui.ButtonWidget. + * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and + * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added, + * removed, and cleared from the group. + * + * @example + * // Example: A ButtonGroupWidget with two buttons + * var button1 = new OO.ui.PopupButtonWidget( { + * label : 'Select a category', + * icon : 'menu', + * popup : { + * $content: $( '

List of categories...

' ), + * padded: true, + * align: 'left' + * } + * } ); + * var button2 = new OO.ui.ButtonWidget( { + * label : 'Add item' + * }); + * var buttonGroup = new OO.ui.ButtonGroupWidget( { + * items: [button1, button2] + * } ); + * $('body').append(buttonGroup.$element); * * @class * @extends OO.ui.Widget @@ -9005,7 +9636,7 @@ OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) { // Initialization this.$element.addClass( 'oo-ui-buttonGroupWidget' ); - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; @@ -9016,7 +9647,23 @@ OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); /** - * Generic widget for buttons. + * ButtonWidget is a generic widget for buttons. A wide variety of looks, + * feels, and functionality can be customized via the class’s configuration options + * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information + * and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches + * + * @example + * // A button widget + * var button = new OO.ui.ButtonWidget( { + * label : 'Button with Icon', + * icon : 'remove', + * iconTitle : 'Remove' + * } ); + * $( 'body' ).append( button.$element ); + * + * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class. * * @class * @extends OO.ui.Widget @@ -9026,15 +9673,18 @@ OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options - * @cfg {string} [href] Hyperlink to visit when clicked - * @cfg {string} [target] Target to open hyperlink in + * @cfg {string} [href] Hyperlink to visit when the button is clicked. + * @cfg {string} [target] The frame or window in which to open the hyperlink. + * @cfg {boolean} [noFollow] Search engine traversal hint (default: true) */ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { // Configuration initialization - config = config || {}; + // FIXME: The `nofollow` alias is deprecated and will be removed (T89767) + config = $.extend( { noFollow: config && config.nofollow }, config ); // Parent constructor OO.ui.ButtonWidget.super.call( this, config ); @@ -9046,18 +9696,14 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( 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 } ) ); // Properties this.href = null; this.target = null; + this.noFollow = false; this.isHyperlink = false; - // Events - this.$button.on( { - click: this.onClick.bind( this ), - keypress: this.onKeyPress.bind( this ) - } ); - // Initialization this.$button.append( this.$icon, this.$label, this.$indicator ); this.$element @@ -9065,6 +9711,7 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { .append( this.$button ); this.setHref( config.href ); this.setTarget( config.target ); + this.setNoFollow( config.noFollow ); }; /* Setup */ @@ -9076,45 +9723,54 @@ 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 ); -/* Events */ +/* Methods */ /** - * @event click + * @inheritdoc */ +OO.ui.ButtonWidget.prototype.onMouseDown = function ( e ) { + if ( !this.isDisabled() ) { + // Remove the tab-index while the button is down to prevent the button from stealing focus + this.$button.removeAttr( 'tabindex' ); + } -/* Methods */ + return OO.ui.ButtonElement.prototype.onMouseDown.call( this, e ); +}; /** - * Handles mouse click events. - * - * @param {jQuery.Event} e Mouse click event - * @fires click + * @inheritdoc */ -OO.ui.ButtonWidget.prototype.onClick = function () { +OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) { if ( !this.isDisabled() ) { - this.emit( 'click' ); - if ( this.isHyperlink ) { - return true; - } + // Restore the tab-index after the button is up to restore the button's accessibility + this.$button.attr( 'tabindex', this.tabIndex ); } - return false; + + return OO.ui.ButtonElement.prototype.onMouseUp.call( this, e ); }; /** - * Handles keypress events. - * - * @param {jQuery.Event} e Keypress event - * @fires click + * @inheritdoc + */ +OO.ui.ButtonWidget.prototype.onClick = function ( e ) { + var ret = OO.ui.ButtonElement.prototype.onClick.call( this, e ); + if ( this.isHyperlink ) { + return true; + } + return ret; +}; + +/** + * @inheritdoc */ OO.ui.ButtonWidget.prototype.onKeyPress = function ( e ) { - if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { - this.emit( 'click' ); - if ( this.isHyperlink ) { - return true; - } + var ret = OO.ui.ButtonElement.prototype.onKeyPress.call( this, e ); + if ( this.isHyperlink ) { + return true; } - return false; + return ret; }; /** @@ -9135,6 +9791,15 @@ OO.ui.ButtonWidget.prototype.getTarget = function () { return this.target; }; +/** + * Get search engine traversal hint. + * + * @return {boolean} Whether search engines should avoid traversing this hyperlink + */ +OO.ui.ButtonWidget.prototype.getNoFollow = function () { + return this.noFollow; +}; + /** * Set hyperlink location. * @@ -9178,7 +9843,32 @@ OO.ui.ButtonWidget.prototype.setTarget = function ( target ) { }; /** - * Button widget that executes an action and is managed by an OO.ui.ActionSet. + * Set search engine traversal hint. + * + * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink + */ +OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) { + noFollow = typeof noFollow === 'boolean' ? noFollow : true; + + if ( noFollow !== this.noFollow ) { + this.noFollow = noFollow; + if ( noFollow ) { + this.$button.attr( 'rel', 'nofollow' ); + } else { + this.$button.removeAttr( 'rel' ); + } + } + + return this; +}; + +/** + * An ActionWidget is a {@link OO.ui.ButtonWidget button widget} that executes an action. + * Action widgets are used with OO.ui.ActionSet, which manages the behavior and availability + * of the actions. Please see the [OOjs UI documentation on MediaWiki] [1] for more information + * and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets * * @class * @extends OO.ui.ButtonWidget @@ -9348,9 +10038,13 @@ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) { // Mixin constructors OO.ui.PopupElement.call( this, config ); + // Events + this.connect( this, { click: 'onAction' } ); + // Initialization this.$element .addClass( 'oo-ui-popupButtonWidget' ) + .attr( 'aria-haspopup', 'true' ) .append( this.popup.$element ); }; @@ -9362,22 +10056,10 @@ OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.PopupElement ); /* Methods */ /** - * Handles mouse click events. - * - * @param {jQuery.Event} e Mouse click event + * Handle the button action being triggered. */ -OO.ui.PopupButtonWidget.prototype.onClick = function ( e ) { - // Skip clicks within the popup - if ( $.contains( this.popup.$element[0], e.target ) ) { - return; - } - - if ( !this.isDisabled() ) { - this.popup.toggle(); - // Parent method - OO.ui.PopupButtonWidget.super.prototype.onClick.call( this ); - } - return false; +OO.ui.PopupButtonWidget.prototype.onAction = function () { + this.popup.toggle(); }; /** @@ -9401,6 +10083,9 @@ OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) { // Mixin constructors OO.ui.ToggleWidget.call( this, config ); + // Events + this.connect( this, { click: 'onAction' } ); + // Initialization this.$element.addClass( 'oo-ui-toggleButtonWidget' ); }; @@ -9413,15 +10098,10 @@ OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget ); /* Methods */ /** - * @inheritdoc + * Handle the button action being triggered. */ -OO.ui.ToggleButtonWidget.prototype.onClick = function () { - if ( !this.isDisabled() ) { - this.setValue( !this.value ); - } - - // Parent method - return OO.ui.ToggleButtonWidget.super.prototype.onClick.call( this ); +OO.ui.ToggleButtonWidget.prototype.onAction = function () { + this.setValue( !this.value ); }; /** @@ -9430,6 +10110,7 @@ OO.ui.ToggleButtonWidget.prototype.onClick = function () { OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { value = !!value; if ( value !== this.value ) { + this.$button.attr( 'aria-pressed', value.toString() ); this.setActive( value ); } @@ -9440,12 +10121,37 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { }; /** - * Dropdown menu of options. + * 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. + * + * @example + * // Example: A DropdownWidget with a menu that contains three options + * var dropDown=new OO.ui.DropdownWidget( { + * label: 'Dropdown menu: Select a menu option', + * menu: { + * items: [ + * new OO.ui.MenuOptionWidget( { + * data: 'a', + * label: 'First' + * } ), + * new OO.ui.MenuOptionWidget( { + * data: 'b', + * label: 'Second' + * } ), + * new OO.ui.MenuOptionWidget( { + * data: 'c', + * label: 'Third' + * } ) + * ] + * } + * } ); * - * Dropdown menus provide a control for accessing a menu and compose a menu within the widget, which - * can be accessed using the #getMenu method. + * $('body').append(dropDown.$element); * - * Use with OO.ui.MenuOptionWidget. + * 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 @@ -9453,6 +10159,7 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -9465,18 +10172,24 @@ OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) { // Parent constructor OO.ui.DropdownWidget.super.call( this, config ); + // Properties (must be set before TabIndexedElement constructor call) + this.$handle = this.$( '' ); + // 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 } ) ); // Properties - this.menu = new OO.ui.MenuSelectWidget( $.extend( { $: this.$, widget: this }, config.menu ) ); - this.$handle = this.$( '' ); + this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) ); // Events - this.$element.on( { click: this.onClick.bind( this ) } ); + this.$handle.on( { + click: this.onClick.bind( this ), + keypress: this.onKeyPress.bind( this ) + } ); this.menu.connect( this, { select: 'onMenuSelect' } ); // Initialization @@ -9495,6 +10208,7 @@ 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 ); /* Methods */ @@ -9510,6 +10224,7 @@ OO.ui.DropdownWidget.prototype.getMenu = function () { /** * Handles menu select events. * + * @private * @param {OO.ui.MenuOptionWidget} item Selected menu item */ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { @@ -9530,30 +10245,49 @@ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { }; /** - * Handles mouse click events. + * Handle mouse click events. * + * @private * @param {jQuery.Event} e Mouse click event */ OO.ui.DropdownWidget.prototype.onClick = function ( e ) { - // Skip clicks within the menu - if ( $.contains( this.menu.$element[0], e.target ) ) { - return; + if ( !this.isDisabled() && e.which === 1 ) { + this.menu.toggle(); } + return false; +}; - if ( !this.isDisabled() ) { - if ( this.menu.isVisible() ) { - this.menu.toggle( false ); - } else { - this.menu.toggle( true ); - } +/** + * Handle key press events. + * + * @private + * @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 ) ) { + this.menu.toggle(); } return false; }; /** - * Icon widget. + * IconWidget is a generic widget for {@link OO.ui.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. + * + * @example + * // An icon widget with a label + * var myIcon = new OO.ui.IconWidget({ + * icon: 'help', + * iconTitle: 'Help' + * }); + * // Create a label. + * var iconLabel = new OO.ui.LabelWidget({ + * label: 'Help' + * }); + * $('body').append(myIcon.$element, iconLabel.$element); * - * See OO.ui.IconElement for more information. + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @class * @extends OO.ui.Widget @@ -9627,12 +10361,18 @@ OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.TitledElement ); OO.ui.IndicatorWidget.static.tagName = 'span'; /** - * Base class for input widgets. + * InputWidget is the base class for all input widgets, which + * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs}, + * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}. + * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs * * @abstract * @class * @extends OO.ui.Widget * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -9647,14 +10387,15 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { // Parent constructor OO.ui.InputWidget.super.call( this, config ); - // Mixin constructors - OO.ui.FlaggedElement.call( this, config ); - // Properties this.$input = this.getInputElement( config ); this.value = ''; this.inputFilter = config.inputFilter; + // Mixin constructors + OO.ui.FlaggedElement.call( this, config ); + OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) ); + // Events this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) ); @@ -9670,6 +10411,7 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.TabIndexedElement ); /* Events */ @@ -9683,12 +10425,15 @@ OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); /** * Get input element. * + * 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 - * @param {Object} [config] Configuration options + * @param {Object} config Configuration options * @return {jQuery} Input element */ OO.ui.InputWidget.prototype.getInputElement = function () { - return this.$( '' ); + return $( '' ); }; /** @@ -9712,6 +10457,12 @@ OO.ui.InputWidget.prototype.onEdit = function () { * @return {string} Input value */ OO.ui.InputWidget.prototype.getValue = function () { + // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify + // it, and we won't know unless they're kind enough to trigger a 'change' event. + var value = this.$input.val(); + if ( this.value !== value ) { + this.setValue( value ); + } return this.value; }; @@ -9721,13 +10472,7 @@ OO.ui.InputWidget.prototype.getValue = function () { * @param {boolean} isRTL */ OO.ui.InputWidget.prototype.setRTL = function ( isRTL ) { - if ( isRTL ) { - this.$input.removeClass( 'oo-ui-ltr' ); - this.$input.addClass( 'oo-ui-rtl' ); - } else { - this.$input.removeClass( 'oo-ui-rtl' ); - this.$input.addClass( 'oo-ui-ltr' ); - } + this.$input.prop( 'dir', isRTL ? 'rtl' : 'ltr' ); }; /** @@ -9778,7 +10523,7 @@ OO.ui.InputWidget.prototype.simulateLabelClick = function () { if ( this.$input.is( ':checkbox,:radio' ) ) { this.$input.click(); } else if ( this.$input.is( ':input' ) ) { - this.$input[0].focus(); + this.$input[ 0 ].focus(); } } }; @@ -9800,7 +10545,7 @@ OO.ui.InputWidget.prototype.setDisabled = function ( state ) { * @chainable */ OO.ui.InputWidget.prototype.focus = function () { - this.$input[0].focus(); + this.$input[ 0 ].focus(); return this; }; @@ -9810,12 +10555,27 @@ OO.ui.InputWidget.prototype.focus = function () { * @chainable */ OO.ui.InputWidget.prototype.blur = function () { - this.$input[0].blur(); + this.$input[ 0 ].blur(); return this; }; /** - * A button that is an input widget. Intended to be used within a OO.ui.FormLayout. + * 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 + * HTML `