/*! * OOjs UI v0.11.3 * https://www.mediawiki.org/wiki/OOjs_UI * * Copyright 2011–2015 OOjs Team and other contributors. * Released under the MIT license * http://oojs.mit-license.org * * Date: 2015-05-12T12:15:37Z */ ( function ( OO ) { 'use strict'; /** * Namespace for all classes, static methods and static properties. * * @class * @singleton */ OO.ui = {}; OO.ui.bind = $.proxy; /** * @property {Object} */ OO.ui.Keys = { UNDEFINED: 0, BACKSPACE: 8, DELETE: 46, LEFT: 37, RIGHT: 39, UP: 38, DOWN: 40, ENTER: 13, END: 35, HOME: 36, TAB: 9, PAGEUP: 33, PAGEDOWN: 34, ESCAPE: 27, SHIFT: 16, SPACE: 32 }; /** * Check if an element is focusable. * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14 * * @param {jQuery} element Element to test * @return {Boolean} [description] */ OO.ui.isFocusableElement = function ( $element ) { var node = $element[0], nodeName = node.nodeName.toLowerCase(), // Check if the element have tabindex set isInElementGroup = /^(input|select|textarea|button|object)$/.test( nodeName ), // Check if the element is a link with href or if it has tabindex isOtherElement = ( ( nodeName === 'a' && node.href ) || !isNaN( $element.attr( 'tabindex' ) ) ), // Check if the element is visible isVisible = ( // This is quicker than calling $element.is( ':visible' ) $.expr.filters.visible( node ) && // Check that all parents are visible !$element.parents().addBack().filter( function () { return $.css( this, 'visibility' ) === 'hidden'; } ).length ); return ( ( isInElementGroup ? !node.disabled : isOtherElement ) && isVisible ); }; /** * Get the user's language and any fallback languages. * * These language codes are used to localize user interface elements in the user's language. * * In environments that provide a localization system, this function should be overridden to * return the user's language(s). The default implementation returns English (en) only. * * @return {string[]} Language codes, in descending order of priority */ OO.ui.getUserLanguages = function () { return [ 'en' ]; }; /** * Get a value in an object keyed by language code. * * @param {Object.} obj Object keyed by language code * @param {string|null} [lang] Language code, if omitted or null defaults to any user language * @param {string} [fallback] Fallback code, used if no matching language can be found * @return {Mixed} Local value */ OO.ui.getLocalValue = function ( obj, lang, fallback ) { var i, len, langs; // Requested language 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 ]; } } // Fallback language if ( obj[ fallback ] ) { return obj[ fallback ]; } // First existing language for ( lang in obj ) { return obj[ lang ]; } return undefined; }; /** * Check if a node is contained within another node * * Similar to jQuery#contains except a list of containers can be supplied * and a boolean argument allows you to include the container in the match list * * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in * @param {HTMLElement} contained Node to find * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants * @return {boolean} The node is in the list of target nodes */ OO.ui.contains = function ( containers, contained, matchContainers ) { var i; if ( !Array.isArray( containers ) ) { containers = [ containers ]; } for ( i = containers.length - 1; i >= 0; i-- ) { if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) { return true; } } return false; }; /** * Return a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. If `immediate` is passed, trigger the function on the * leading edge, instead of the trailing. * * Ported from: http://underscorejs.org/underscore.js * * @param {Function} func * @param {number} wait * @param {boolean} immediate * @return {Function} */ OO.ui.debounce = function ( func, wait, immediate ) { var timeout; return function () { var context = this, args = arguments, later = function () { timeout = null; if ( !immediate ) { func.apply( context, args ); } }; if ( immediate && !timeout ) { func.apply( context, args ); } clearTimeout( timeout ); timeout = setTimeout( later, wait ); }; }; /** * Reconstitute a JavaScript object corresponding to a widget created by * the PHP implementation. * * This is an alias for `OO.ui.Element.static.infuse()`. * * @param {string|HTMLElement|jQuery} idOrNode * A DOM id (if a string) or node for the widget to infuse. * @return {OO.ui.Element} * The `OO.ui.Element` corresponding to this (infusable) document node. */ OO.ui.infuse = function ( idOrNode ) { return OO.ui.Element.static.infuse( idOrNode ); }; ( function () { /** * Message store for the default implementation of OO.ui.msg * * Environments that provide a localization system should not use this, but should override * OO.ui.msg altogether. * * @private */ var messages = { // Tool tip for a button that moves items in a list down one place 'ooui-outline-control-move-down': 'Move item down', // Tool tip for a button that moves items in a list up one place 'ooui-outline-control-move-up': 'Move item up', // Tool tip for a button that removes items from a list 'ooui-outline-control-remove': 'Remove item', // Label for the toolbar group that contains a list of all other available tools 'ooui-toolbar-more': 'More', // Label for the fake tool that expands the full list of tools in a toolbar group 'ooui-toolgroup-expand': 'More', // Label for the fake tool that collapses the full list of tools in a toolbar group 'ooui-toolgroup-collapse': 'Fewer', // Default label for the accept button of a confirmation dialog 'ooui-dialog-message-accept': 'OK', // Default label for the reject button of a confirmation dialog 'ooui-dialog-message-reject': 'Cancel', // Title for process dialog error description 'ooui-dialog-process-error': 'Something went wrong', // Label for process dialog dismiss error button, visible when describing errors 'ooui-dialog-process-dismiss': 'Dismiss', // Label for process dialog retry action button, visible when describing only recoverable errors 'ooui-dialog-process-retry': 'Try again', // Label for process dialog retry action button, visible when describing only warnings 'ooui-dialog-process-continue': 'Continue' }; /** * Get a localized message. * * In environments that provide a localization system, this function should be overridden to * return the message translated in the user's language. The default implementation always returns * English messages. * * After the message key, message parameters may optionally be passed. In the default implementation, * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc. * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as * they support unnamed, ordered message parameters. * * @abstract * @param {string} key Message key * @param {Mixed...} [params] Message parameters * @return {string} Translated message with parameters substituted */ OO.ui.msg = function ( key ) { 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; } ); } else { // Return placeholder if message not found message = '[' + key + ']'; } return message; }; /** * Package a message and arguments for deferred resolution. * * Use this when you are statically specifying a message and the message may not yet be present. * * @param {string} key Message key * @param {Mixed...} [params] Message parameters * @return {Function} Function that returns the resolved message when executed */ OO.ui.deferMsg = function () { var args = arguments; return function () { return OO.ui.msg.apply( OO.ui, args ); }; }; /** * Resolve a message. * * If the message is a function it will be executed, otherwise it will pass through directly. * * @param {Function|string} msg Deferred message, or message text * @return {string} Resolved message */ OO.ui.resolveMsg = function ( msg ) { if ( $.isFunction( msg ) ) { return msg(); } return msg; }; } )(); /** * Element that can be marked as pending. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element */ OO.ui.PendingElement = function OoUiPendingElement( config ) { // Configuration initialization config = config || {}; // Properties this.pending = 0; this.$pending = null; // Initialisation this.setPendingElement( config.$pending || this.$element ); }; /* Setup */ OO.initClass( OO.ui.PendingElement ); /* Methods */ /** * Set the pending element (and clean up any existing one). * * @param {jQuery} $pending The element to set to pending. */ OO.ui.PendingElement.prototype.setPendingElement = function ( $pending ) { if ( this.$pending ) { this.$pending.removeClass( 'oo-ui-pendingElement-pending' ); } this.$pending = $pending; if ( this.pending > 0 ) { this.$pending.addClass( 'oo-ui-pendingElement-pending' ); } }; /** * Check if input is pending. * * @return {boolean} */ OO.ui.PendingElement.prototype.isPending = function () { return !!this.pending; }; /** * Increase the pending stack. * * @chainable */ OO.ui.PendingElement.prototype.pushPending = function () { if ( this.pending === 0 ) { this.$pending.addClass( 'oo-ui-pendingElement-pending' ); this.updateThemeClasses(); } this.pending++; return this; }; /** * Reduce the pending stack. * * Clamped at zero. * * @chainable */ OO.ui.PendingElement.prototype.popPending = function () { if ( this.pending === 1 ) { this.$pending.removeClass( 'oo-ui-pendingElement-pending' ); this.updateThemeClasses(); } this.pending = Math.max( 0, this.pending - 1 ); return this; }; /** * 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). Action sets are primarily used with {@link OO.ui.Dialog Dialogs}. * * ActionSets contain two types of actions: * * - Special: Special actions are the first visible actions with special flags, such as 'safe' and 'primary', the default special flags. Additional special flags can be configured in subclasses with the static #specialFlags property. * - Other: Other actions include all non-special visible actions. * * Please see the [OOjs UI documentation on MediaWiki][1] for more information. * * @example * // Example: An action set used in a process dialog * function MyProcessDialog( config ) { * MyProcessDialog.super.call( this, config ); * } * OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog ); * MyProcessDialog.static.title = 'An action set in a process dialog'; * // An action set that uses modes ('edit' and 'help' mode, in this example). * MyProcessDialog.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' } * ]; * * MyProcessDialog.prototype.initialize = function () { * MyProcessDialog.super.prototype.initialize.apply( this, arguments ); * this.panel1 = new OO.ui.PanelLayout( { 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( { 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 ); * }; * MyProcessDialog.prototype.getSetupProcess = function ( data ) { * return MyProcessDialog.super.prototype.getSetupProcess.call( this, data ) * .next( function () { * this.actions.setMode( 'edit' ); * }, this ); * }; * MyProcessDialog.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 MyProcessDialog.super.prototype.getActionProcess.call( this, action ); * }; * MyProcessDialog.prototype.getBodyHeight = function () { * return this.panel1.$element.outerHeight( true ); * }; * var windowManager = new OO.ui.WindowManager(); * $( 'body' ).append( windowManager.$element ); * var dialog = new MyProcessDialog( { * size: 'medium' * } ); * windowManager.addWindows( [ dialog ] ); * windowManager.openWindow( dialog ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets * * @abstract * @class * @mixins OO.EventEmitter * * @constructor * @param {Object} [config] Configuration options */ OO.ui.ActionSet = function OoUiActionSet( config ) { // Configuration initialization config = config || {}; // Mixin constructors OO.EventEmitter.call( this ); // Properties this.list = []; this.categories = { actions: 'getAction', flags: 'getFlags', modes: 'getModes' }; this.categorized = {}; this.special = {}; this.others = []; this.organized = false; this.changing = false; this.changed = false; }; /* Setup */ OO.mixinClass( OO.ui.ActionSet, OO.EventEmitter ); /* Static Properties */ /** * 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 * @inheritable * @property {string} */ OO.ui.ActionSet.static.specialFlags = [ 'safe', 'primary' ]; /* Events */ /** * @event click * * A 'click' event is emitted when an action is clicked. * * @param {OO.ui.ActionWidget} action Action that was clicked */ /** * @event resize * * A 'resize' event is emitted when an action widget is resized. * * @param {OO.ui.ActionWidget} action Action that was resized */ /** * @event add * * An 'add' event is emitted when actions are {@link #method-add added} to the action set. * * @param {OO.ui.ActionWidget[]} added Actions added */ /** * @event remove * * A 'remove' event is emitted when actions are {@link #method-remove removed} * or {@link #clear cleared}. * * @param {OO.ui.ActionWidget[]} added Actions removed */ /** * @event change * * A 'change' event is emitted when actions are {@link #method-add added}, {@link #clear cleared}, * or {@link #method-remove removed} from the action set or when the {@link #setMode mode} is changed. * */ /* Methods */ /** * Handle action change events. * * @private * @fires change */ OO.ui.ActionSet.prototype.onActionChange = function () { this.organized = false; if ( this.changing ) { this.changed = true; } else { this.emit( 'change' ); } }; /** * Check if an action is one of the special actions. * * @param {OO.ui.ActionWidget} action Action to check * @return {boolean} Action is special */ OO.ui.ActionSet.prototype.isSpecial = function ( action ) { var flag; for ( flag in this.special ) { if ( action === this.special[ flag ] ) { return true; } } return false; }; /** * Get action widgets based on the specified filter: ‘actions’, ‘flags’, ‘modes’, ‘visible’, * or ‘disabled’. * * @param {Object} [filters] Filters to use, omit to get all actions * @param {string|string[]} [filters.actions] Actions that action widgets must have * @param {string|string[]} [filters.flags] Flags that action widgets must have (e.g., 'safe') * @param {string|string[]} [filters.modes] Modes that action widgets must have * @param {boolean} [filters.visible] Action widgets must be visible * @param {boolean} [filters.disabled] Action widgets must be disabled * @return {OO.ui.ActionWidget[]} Action widgets matching all criteria */ OO.ui.ActionSet.prototype.get = function ( filters ) { var i, len, list, category, actions, index, match, matches; if ( filters ) { this.organize(); // Collect category candidates matches = []; for ( category in this.categorized ) { 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 ] ]; if ( Array.isArray( actions ) ) { matches.push.apply( matches, actions ); } } } } // Remove by boolean filters for ( i = 0, len = matches.length; i < len; i++ ) { match = matches[ i ]; if ( ( filters.visible !== undefined && match.isVisible() !== filters.visible ) || ( filters.disabled !== undefined && match.isDisabled() !== filters.disabled ) ) { matches.splice( i, 1 ); len--; i--; } } // Remove duplicates for ( i = 0, len = matches.length; i < len; i++ ) { match = matches[ i ]; index = matches.lastIndexOf( match ); while ( index !== i ) { matches.splice( index, 1 ); len--; index = matches.lastIndexOf( match ); } } return matches; } return this.list.slice(); }; /** * Get 'special' actions. * * Special actions are the first visible action widgets with special flags, such as 'safe' and 'primary'. * Special flags can be configured in subclasses by changing the static #specialFlags property. * * @return {OO.ui.ActionWidget[]|null} 'Special' action widgets. */ OO.ui.ActionSet.prototype.getSpecial = function () { this.organize(); return $.extend( {}, this.special ); }; /** * Get 'other' actions. * * Other actions include all non-special visible action widgets. * * @return {OO.ui.ActionWidget[]} 'Other' action widgets */ OO.ui.ActionSet.prototype.getOthers = function () { this.organize(); return this.others.slice(); }; /** * Set the mode (e.g., ‘edit’ or ‘view’). Only {@link OO.ui.ActionWidget#modes actions} configured * to be available in the specified mode will be made visible. All other actions will be hidden. * * @param {string} mode The mode. Only actions configured to be available in the specified * mode will be made visible. * @chainable * @fires toggle * @fires change */ OO.ui.ActionSet.prototype.setMode = function ( mode ) { var i, len, action; this.changing = true; for ( i = 0, len = this.list.length; i < len; i++ ) { action = this.list[ i ]; action.toggle( action.hasMode( mode ) ); } this.organized = false; this.changing = false; this.emit( 'change' ); return this; }; /** * Set the abilities of the specified actions. * * Action widgets that are configured with the specified actions will be enabled * or disabled based on the boolean values specified in the `actions` * parameter. * * @param {Object.} actions A list keyed by action name with boolean * values that indicate whether or not the action should be enabled. * @chainable */ 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 ]; action = item.getAction(); if ( actions[ action ] !== undefined ) { item.setDisabled( !actions[ action ] ); } } return this; }; /** * Executes a function once per action. * * When making changes to multiple actions, use this method instead of iterating over the actions * manually to defer emitting a #change event until after all actions have been changed. * * @param {Object|null} actions Filters to use to determine which actions to iterate over; see #get * @param {Function} callback Callback to run for each action; callback is invoked with three * arguments: the action, the action's index, the list of actions being iterated over * @chainable */ OO.ui.ActionSet.prototype.forEach = function ( filter, callback ) { this.changed = false; this.changing = true; this.get( filter ).forEach( callback ); this.changing = false; if ( this.changed ) { this.emit( 'change' ); } return this; }; /** * Add action widgets to the action set. * * @param {OO.ui.ActionWidget[]} actions Action widgets to add * @chainable * @fires add * @fires change */ OO.ui.ActionSet.prototype.add = function ( actions ) { var i, len, action; this.changing = true; for ( i = 0, len = actions.length; i < len; i++ ) { action = actions[ i ]; action.connect( this, { click: [ 'emit', 'click', action ], resize: [ 'emit', 'resize', action ], toggle: [ 'onActionChange' ] } ); this.list.push( action ); } this.organized = false; this.emit( 'add', actions ); this.changing = false; this.emit( 'change' ); return this; }; /** * Remove action widgets from the set. * * To remove all actions, you may wish to use the #clear method instead. * * @param {OO.ui.ActionWidget[]} actions Action widgets to remove * @chainable * @fires remove * @fires change */ OO.ui.ActionSet.prototype.remove = function ( actions ) { var i, len, index, action; this.changing = true; for ( i = 0, len = actions.length; i < len; i++ ) { action = actions[ i ]; index = this.list.indexOf( action ); if ( index !== -1 ) { action.disconnect( this ); this.list.splice( index, 1 ); } } this.organized = false; this.emit( 'remove', actions ); this.changing = false; this.emit( 'change' ); return this; }; /** * Remove all action widets from the set. * * To remove only specified actions, use the {@link #method-remove remove} method instead. * * @chainable * @fires remove * @fires change */ OO.ui.ActionSet.prototype.clear = function () { var i, len, action, removed = this.list.slice(); this.changing = true; for ( i = 0, len = this.list.length; i < len; i++ ) { action = this.list[ i ]; action.disconnect( this ); } this.list = []; this.organized = false; this.emit( 'remove', removed ); this.changing = false; this.emit( 'change' ); return this; }; /** * Organize actions. * * This is called whenever organized information is requested. It will only reorganize the actions * if something has changed since the last time it ran. * * @private * @chainable */ OO.ui.ActionSet.prototype.organize = function () { var i, iLen, j, jLen, flag, action, category, list, item, special, specialFlags = this.constructor.static.specialFlags; if ( !this.organized ) { this.categorized = {}; this.special = {}; this.others = []; for ( i = 0, iLen = this.list.length; i < iLen; i++ ) { action = this.list[ i ]; if ( action.isVisible() ) { // Populate categories for ( category in this.categories ) { if ( !this.categorized[ category ] ) { this.categorized[ 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 ] = []; } 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; special = true; break; } } if ( !special ) { this.others.push( action ); } } } this.organized = true; } return this; }; /** * 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 {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2] * for an example. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample * @cfg {string} [id] The HTML id attribute used in the rendered tag. * @cfg {string} [text] Text to insert * @cfg {Array} [content] An array of content elements to append (after #text). * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML. * Instances of OO.ui.Element will have their $element appended. * @cfg {jQuery} [$content] Content elements to append (after #text) * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object). * Data can also be specified with the #setData method. */ OO.ui.Element = function OoUiElement( config ) { // Configuration initialization config = config || {}; // Properties this.$ = $; this.visible = true; this.data = config.data; this.$element = config.$element || $( document.createElement( this.getTagName() ) ); this.elementGroup = null; this.debouncedUpdateThemeClassesHandler = this.debouncedUpdateThemeClasses.bind( this ); this.updateThemeClassesPending = false; // Initialization 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 ); } if ( config.content ) { // The `content` property treats plain strings as text; use an // HtmlSnippet to append HTML content. `OO.ui.Element`s get their // appropriate $element appended. this.$element.append( config.content.map( function ( v ) { if ( typeof v === 'string' ) { // Escape string so it is properly represented in HTML. return document.createTextNode( v ); } else if ( v instanceof OO.ui.HtmlSnippet ) { // Bypass escaping. return v.toString(); } else if ( v instanceof OO.ui.Element ) { return v.$element; } return v; } ) ); } if ( config.$content ) { // The `$content` property treats plain strings as HTML. this.$element.append( config.$content ); } }; /* Setup */ OO.initClass( OO.ui.Element ); /* Static Properties */ /** * The name of the HTML tag used by the element. * * The static value may be ignored if the #getTagName method is overridden. * * @static * @inheritable * @property {string} */ OO.ui.Element.static.tagName = 'div'; /* Static Methods */ /** * Reconstitute a JavaScript object corresponding to a widget created * by the PHP implementation. * * @param {string|HTMLElement|jQuery} idOrNode * A DOM id (if a string) or node for the widget to infuse. * @return {OO.ui.Element} * The `OO.ui.Element` corresponding to this (infusable) document node. * For `Tag` objects emitted on the HTML side (used occasionally for content) * the value returned is a newly-created Element wrapping around the existing * DOM node. */ OO.ui.Element.static.infuse = function ( idOrNode ) { var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, true ); // Verify that the type matches up. // FIXME: uncomment after T89721 is fixed (see T90929) /* if ( !( obj instanceof this['class'] ) ) { throw new Error( 'Infusion type mismatch!' ); } */ return obj; }; /** * Implementation helper for `infuse`; skips the type check and has an * extra property so that only the top-level invocation touches the DOM. * @private * @param {string|HTMLElement|jQuery} idOrNode * @param {boolean} top True only for top-level invocation. * @return {OO.ui.Element} */ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { // look for a cached result of a previous infusion. var id, $elem, data, cls, obj; if ( typeof idOrNode === 'string' ) { id = idOrNode; $elem = $( document.getElementById( id ) ); } else { $elem = $( idOrNode ); id = $elem.attr( 'id' ); } data = $elem.data( 'ooui-infused' ); if ( data ) { // cached! if ( data === true ) { throw new Error( 'Circular dependency! ' + id ); } return data; } if ( !$elem.length ) { throw new Error( 'Widget not found: ' + id ); } data = $elem.attr( 'data-ooui' ); if ( !data ) { throw new Error( 'No infusion data found: ' + id ); } try { data = $.parseJSON( data ); } catch ( _ ) { data = null; } if ( !( data && data._ ) ) { throw new Error( 'No valid infusion data found: ' + id ); } if ( data._ === 'Tag' ) { // Special case: this is a raw Tag; wrap existing node, don't rebuild. return new OO.ui.Element( { $element: $elem } ); } cls = OO.ui[data._]; if ( !cls ) { throw new Error( 'Unknown widget type: ' + id ); } $elem.data( 'ooui-infused', true ); // prevent loops data.id = id; // implicit data = OO.copy( data, null, function deserialize( value ) { if ( OO.isPlainObject( value ) ) { if ( value.tag ) { return OO.ui.Element.static.unsafeInfuse( value.tag, false ); } if ( value.html ) { return new OO.ui.HtmlSnippet( value.html ); } } } ); // jscs:disable requireCapitalizedConstructors obj = new cls( data ); // rebuild widget // now replace old DOM with this new DOM. if ( top ) { $elem.replaceWith( obj.$element ); } obj.$element.data( 'ooui-infused', obj ); // set the 'data-ooui' attribute so we can identify infused widgets obj.$element.attr( 'data-ooui', '' ); return obj; }; /** * Get a jQuery function within a specific document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is * not in an iframe * @return {Function} Bound jQuery function */ OO.ui.Element.static.getJQuery = function ( context, $iframe ) { function wrapper( selector ) { return $( selector, wrapper.context ); } wrapper.context = this.getDocument( context ); if ( $iframe ) { wrapper.$iframe = $iframe; } return wrapper; }; /** * Get the document of an element. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for * @return {HTMLDocument|null} Document object */ 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 ) || // Empty jQuery selections might have a context obj.context || // HTMLElement obj.ownerDocument || // Window obj.document || // HTMLDocument ( obj.nodeType === 9 && obj ) || null; }; /** * Get the window of an element or document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for * @return {Window} Window object */ OO.ui.Element.static.getWindow = function ( obj ) { var doc = this.getDocument( obj ); return doc.parentWindow || doc.defaultView; }; /** * Get the direction of an element or document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for * @return {string} Text direction, either 'ltr' or 'rtl' */ OO.ui.Element.static.getDir = function ( obj ) { var isDoc, isWin; if ( obj instanceof jQuery ) { obj = obj[ 0 ]; } isDoc = obj.nodeType === 9; isWin = obj.document !== undefined; if ( isDoc || isWin ) { if ( isWin ) { obj = obj.document; } obj = obj.body; } return $( obj ).css( 'direction' ); }; /** * Get the offset between two frames. * * TODO: Make this function not use recursion. * * @static * @param {Window} from Window of the child frame * @param {Window} [to=window] Window of the parent frame * @param {Object} [offset] Offset to start with, used internally * @return {Object} Offset object, containing left and top properties */ OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) { var i, len, frames, frame, rect; if ( !to ) { to = window; } if ( !offset ) { offset = { top: 0, left: 0 }; } if ( from.parent === from ) { return 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 ]; break; } } // Recursively accumulate offset values if ( frame ) { rect = frame.getBoundingClientRect(); offset.left += rect.left; offset.top += rect.top; if ( from !== to ) { this.getFrameOffset( from.parent, offset ); } } return offset; }; /** * Get the offset between two elements. * * The two elements may be in a different frame, but in that case the frame $element is in must * be contained in the frame $anchor is in. * * @static * @param {jQuery} $element Element whose position to get * @param {jQuery} $anchor Element to get $element's position relative to * @return {Object} Translated position coordinates, containing top and left properties */ OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) { var iframe, iframePos, pos = $element.offset(), anchorPos = $anchor.offset(), elementDocument = this.getDocument( $element ), anchorDocument = this.getDocument( $anchor ); // If $element isn't in the same document as $anchor, traverse up while ( elementDocument !== anchorDocument ) { iframe = elementDocument.defaultView.frameElement; if ( !iframe ) { throw new Error( '$element frame is not contained in $anchor frame' ); } iframePos = $( iframe ).offset(); pos.left += iframePos.left; pos.top += iframePos.top; elementDocument = iframe.ownerDocument; } pos.left -= anchorPos.left; pos.top -= anchorPos.top; return pos; }; /** * Get element border sizes. * * @static * @param {HTMLElement} el Element to measure * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties */ OO.ui.Element.static.getBorders = function ( el ) { var doc = el.ownerDocument, win = doc.parentWindow || doc.defaultView, style = win && win.getComputedStyle ? win.getComputedStyle( el, null ) : el.currentStyle, $el = $( el ), top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0, left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0, bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0, right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0; return { top: top, left: left, bottom: bottom, right: right }; }; /** * Get dimensions of an element or window. * * @static * @param {HTMLElement|Window} el Element to measure * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties */ OO.ui.Element.static.getDimensions = function ( el ) { var $el, $win, doc = el.ownerDocument || el.document, win = doc.parentWindow || doc.defaultView; if ( win === el || el === doc.documentElement ) { $win = $( win ); return { borders: { top: 0, left: 0, bottom: 0, right: 0 }, scroll: { top: $win.scrollTop(), left: $win.scrollLeft() }, scrollbar: { right: 0, bottom: 0 }, rect: { top: 0, left: 0, bottom: $win.innerHeight(), right: $win.innerWidth() } }; } else { $el = $( el ); return { borders: this.getBorders( el ), scroll: { top: $el.scrollTop(), left: $el.scrollLeft() }, scrollbar: { right: $el.innerWidth() - el.clientWidth, bottom: $el.innerHeight() - el.clientHeight }, rect: el.getBoundingClientRect() }; } }; /** * Get scrollable object parent * * documentElement can't be used to get or set the scrollTop * property on Blink. Changing and testing its value lets us * use 'body' or 'documentElement' based on what is working. * * https://code.google.com/p/chromium/issues/detail?id=303131 * * @static * @param {HTMLElement} el Element to find scrollable parent for * @return {HTMLElement} Scrollable parent */ OO.ui.Element.static.getRootScrollableElement = function ( el ) { var scrollTop, body; if ( OO.ui.scrollableElement === undefined ) { body = el.ownerDocument.body; scrollTop = body.scrollTop; body.scrollTop = 1; if ( body.scrollTop === 1 ) { body.scrollTop = scrollTop; OO.ui.scrollableElement = 'body'; } else { OO.ui.scrollableElement = 'documentElement'; } } return el.ownerDocument[ OO.ui.scrollableElement ]; }; /** * Get closest scrollable container. * * Traverses up until either a scrollable element or the root is reached, in which case the window * will be returned. * * @static * @param {HTMLElement} el Element to find scrollable container for * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either * @return {HTMLElement} Closest scrollable container */ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) { var i, val, props = [ 'overflow' ], $parent = $( el ).parent(); if ( dimension === 'x' || dimension === 'y' ) { props.push( 'overflow-' + dimension ); } while ( $parent.length ) { if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) { return $parent[ 0 ]; } i = props.length; while ( i-- ) { val = $parent.css( props[ i ] ); if ( val === 'auto' || val === 'scroll' ) { return $parent[ 0 ]; } } $parent = $parent.parent(); } return this.getDocument( el ).body; }; /** * Scroll element into view. * * @static * @param {HTMLElement} el Element to scroll into view * @param {Object} [config] Configuration options * @param {string} [config.duration] jQuery animation duration value * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit * to scroll in both directions * @param {Function} [config.complete] Function to call when scrolling completes */ OO.ui.Element.static.scrollIntoView = function ( el, config ) { // Configuration initialization config = config || {}; var rel, anim = {}, callback = typeof config.complete === 'function' && config.complete, sc = this.getClosestScrollableContainer( el, config.direction ), $sc = $( sc ), eld = this.getDimensions( el ), scd = this.getDimensions( sc ), $win = $( this.getWindow( el ) ); // Compute the distances between the edges of el and the edges of the scroll viewport if ( $sc.is( 'html, body' ) ) { // If the scrollable container is the root, this is easy rel = { top: eld.rect.top, bottom: $win.innerHeight() - eld.rect.bottom, left: eld.rect.left, right: $win.innerWidth() - eld.rect.right }; } else { // Otherwise, we have to subtract el's coordinates from sc's coordinates rel = { top: eld.rect.top - ( scd.rect.top + scd.borders.top ), bottom: scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom, left: eld.rect.left - ( scd.rect.left + scd.borders.left ), right: scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right }; } if ( !config.direction || config.direction === 'y' ) { if ( rel.top < 0 ) { anim.scrollTop = scd.scroll.top + rel.top; } else if ( rel.top > 0 && rel.bottom < 0 ) { anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom ); } } if ( !config.direction || config.direction === 'x' ) { if ( rel.left < 0 ) { anim.scrollLeft = scd.scroll.left + rel.left; } else if ( rel.left > 0 && rel.right < 0 ) { anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right ); } } if ( !$.isEmptyObject( anim ) ) { $sc.stop( true ).animate( anim, config.duration || 'fast' ); if ( callback ) { $sc.queue( function ( next ) { callback(); next(); } ); } } else { if ( callback ) { callback(); } } }; /** * 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 */ /** * Toggle visibility of an element. * * @param {boolean} [show] Make element visible, omit to toggle visibility * @fires visible * @chainable */ OO.ui.Element.prototype.toggle = function ( show ) { show = show === undefined ? !this.visible : !!show; if ( show !== this.isVisible() ) { this.visible = show; this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible ); this.emit( 'toggle', show ); } return this; }; /** * Check if element is visible. * * @return {boolean} element is visible */ OO.ui.Element.prototype.isVisible = function () { return this.visible; }; /** * Get element data. * * @return {Mixed} Element data */ OO.ui.Element.prototype.getData = function () { return this.data; }; /** * Set element data. * * @param {Mixed} Element data * @chainable */ OO.ui.Element.prototype.setData = function ( data ) { this.data = data; return this; }; /** * Check if element supports one or more methods. * * @param {string|string[]} methods Method or list of methods to check * @return {boolean} All methods are supported */ OO.ui.Element.prototype.supports = function ( methods ) { var i, len, support = 0; methods = Array.isArray( methods ) ? methods : [ methods ]; for ( i = 0, len = methods.length; i < len; i++ ) { if ( $.isFunction( this[ methods[ i ] ] ) ) { support++; } } return methods.length === support; }; /** * Update the theme-provided classes. * * @localdoc This is called in element mixins and widget classes any time state changes. * Updating is debounced, minimizing overhead of changing multiple attributes and * guaranteeing that theme updates do not occur within an element's constructor */ OO.ui.Element.prototype.updateThemeClasses = function () { if ( !this.updateThemeClassesPending ) { this.updateThemeClassesPending = true; setTimeout( this.debouncedUpdateThemeClassesHandler ); } }; /** * @private */ OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () { OO.ui.theme.updateElementClasses( this ); this.updateThemeClassesPending = false; }; /** * Get the HTML tag name. * * Override this method to base the result on instance information. * * @return {string} HTML tag name */ OO.ui.Element.prototype.getTagName = function () { return this.constructor.static.tagName; }; /** * Check if the element is attached to the DOM * @return {boolean} The element is attached to the DOM */ OO.ui.Element.prototype.isElementAttached = function () { return $.contains( this.getElementDocument(), this.$element[ 0 ] ); }; /** * Get the DOM document. * * @return {HTMLDocument} Document object */ OO.ui.Element.prototype.getElementDocument = function () { // Don't cache this in other ways either because subclasses could can change this.$element return OO.ui.Element.static.getDocument( this.$element ); }; /** * Get the DOM window. * * @return {Window} Window object */ OO.ui.Element.prototype.getElementWindow = function () { return OO.ui.Element.static.getWindow( this.$element ); }; /** * Get closest scrollable container. */ OO.ui.Element.prototype.getClosestScrollableElementContainer = function () { return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] ); }; /** * Get group element is in. * * @return {OO.ui.GroupElement|null} Group element, null if none */ OO.ui.Element.prototype.getElementGroup = function () { return this.elementGroup; }; /** * Set group element is in. * * @param {OO.ui.GroupElement|null} group Group element, null if none * @chainable */ OO.ui.Element.prototype.setElementGroup = function ( group ) { this.elementGroup = group; return this; }; /** * Scroll element into view. * * @param {Object} [config] Configuration options */ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) { return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config ); }; /** * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined. * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout}, * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout}, * and {@link OO.ui.BookletLayout BookletLayout} for more information and examples. * * @abstract * @class * @extends OO.ui.Element * @mixins OO.EventEmitter * * @constructor * @param {Object} [config] Configuration options */ OO.ui.Layout = function OoUiLayout( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.Layout.super.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); // Initialization this.$element.addClass( 'oo-ui-layout' ); }; /* Setup */ OO.inheritClass( OO.ui.Layout, OO.ui.Element ); OO.mixinClass( OO.ui.Layout, OO.EventEmitter ); /** * 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 * @extends OO.ui.Element * @mixins OO.EventEmitter * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their * appearance reflects this state. */ OO.ui.Widget = function OoUiWidget( config ) { // Initialize config config = $.extend( { disabled: false }, config ); // Parent constructor OO.ui.Widget.super.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); // Properties this.disabled = null; this.wasDisabled = null; // Initialization this.$element.addClass( 'oo-ui-widget' ); this.setDisabled( !!config.disabled ); }; /* Setup */ OO.inheritClass( OO.ui.Widget, OO.ui.Element ); OO.mixinClass( OO.ui.Widget, OO.EventEmitter ); /* Events */ /** * @event disable * * A 'disable' event is emitted when a widget is disabled. * * @param {boolean} disabled Widget is disabled */ /** * @event toggle * * A 'toggle' event is emitted when the visibility of the widget changes. * * @param {boolean} visible Widget is visible */ /* Methods */ /** * Check if the widget is disabled. * * @return {boolean} Widget is disabled */ OO.ui.Widget.prototype.isDisabled = function () { return this.disabled; }; /** * Set the 'disabled' state of the widget. * * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state. * * @param {boolean} disabled Disable widget * @chainable */ OO.ui.Widget.prototype.setDisabled = function ( disabled ) { var isDisabled; this.disabled = !!disabled; isDisabled = this.isDisabled(); 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(); } this.wasDisabled = isDisabled; return this; }; /** * Update the disabled state, in case of changes in parent widget. * * @chainable */ OO.ui.Widget.prototype.updateDisabled = function () { this.setDisabled( this.disabled ); return this; }; /** * 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. * * The lifecycle of a window has three primary stages (opening, opened, and closing) in which * different processes are executed: * * **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 #getSetupProcess} method is called and its result executed * - {@link #getReadyProcess} method is called and its result executed * * **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. The window is now closed * * 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. * * 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 the dialog size: `small`, `medium`, `large`, `larger` or * `full`. If omitted, the value of the {@link #static-size static size} property will be used. */ OO.ui.Window = function OoUiWindow( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.Window.super.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); // Properties this.manager = null; this.size = config.size || this.constructor.static.size; 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 ); // 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 */ OO.inheritClass( OO.ui.Window, OO.ui.Element ); OO.mixinClass( OO.ui.Window, OO.EventEmitter ); /* Static Properties */ /** * Symbolic name of the window size: `small`, `medium`, `large`, `larger` or `full`. * * The static size is used if no #size is configured during construction. * * @static * @inheritable * @property {string} */ OO.ui.Window.static.size = 'medium'; /* Methods */ /** * Handle mouse down events. * * @private * @param {jQuery.Event} e Mouse down event */ OO.ui.Window.prototype.onMouseDown = function ( e ) { // Prevent clicking on the click-block from stealing focus if ( e.target === this.$element[ 0 ] ) { return false; } }; /** * Check if the window has been initialized. * * Initialization occurs when a window is added to a manager. * * @return {boolean} Window has been initialized */ OO.ui.Window.prototype.isInitialized = function () { return !!this.manager; }; /** * Check if the window is visible. * * @return {boolean} Window is visible */ OO.ui.Window.prototype.isVisible = function () { return this.visible; }; /** * Check if the window is opening. * * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpening isOpening} * method. * * @return {boolean} Window is opening */ OO.ui.Window.prototype.isOpening = function () { return this.manager.isOpening( this ); }; /** * Check if the window is closing. * * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isClosing isClosing} method. * * @return {boolean} Window is closing */ OO.ui.Window.prototype.isClosing = function () { return this.manager.isClosing( this ); }; /** * Check if the window is opened. * * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpened isOpened} method. * * @return {boolean} Window is opened */ OO.ui.Window.prototype.isOpened = function () { return this.manager.isOpened( this ); }; /** * Get the window manager. * * All windows must be attached to a window manager, which is used to open * and close the window and control its presentation. * * @return {OO.ui.WindowManager} Manager of window */ OO.ui.Window.prototype.getManager = function () { return this.manager; }; /** * Get the symbolic name of the window size (e.g., `small` or `medium`). * * @return {string} Symbolic name of the size: `small`, `medium`, `large`, `larger`, `full` */ OO.ui.Window.prototype.getSize = function () { return this.size; }; /** * Disable transitions on window's frame for the duration of the callback function, then enable them * back. * * @private * @param {Function} callback Function to call while transitions are disabled */ OO.ui.Window.prototype.withoutSizeTransitions = function ( callback ) { // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements. // Disable transitions first, otherwise we'll get values from when the window was animating. var oldTransition, styleObj = this.$frame[ 0 ].style; oldTransition = styleObj.transition || styleObj.OTransition || styleObj.MsTransition || styleObj.MozTransition || styleObj.WebkitTransition; styleObj.transition = styleObj.OTransition = styleObj.MsTransition = styleObj.MozTransition = styleObj.WebkitTransition = 'none'; callback(); // Force reflow to make sure the style changes done inside callback really are not transitioned this.$frame.height(); styleObj.transition = styleObj.OTransition = styleObj.MsTransition = styleObj.MozTransition = styleObj.WebkitTransition = oldTransition; }; /** * Get the height of the full window contents (i.e., the window head, body and foot together). * * What consistitutes the head, body, and foot varies depending on the window type. * A {@link OO.ui.MessageDialog message dialog} displays a title and message in its body, * and any actions in the foot. A {@link OO.ui.ProcessDialog process dialog} displays a title * and special actions in the head, and dialog content in the body. * * To get just the height of the dialog body, use the #getBodyHeight method. * * @return {number} The height of the window contents (the dialog head, body and foot) in pixels */ OO.ui.Window.prototype.getContentHeight = function () { var bodyHeight, win = this, bodyStyleObj = this.$body[ 0 ].style, frameStyleObj = this.$frame[ 0 ].style; // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements. // Disable transitions first, otherwise we'll get values from when the window was animating. this.withoutSizeTransitions( function () { var oldHeight = frameStyleObj.height, oldPosition = bodyStyleObj.position; frameStyleObj.height = '1px'; // Force body to resize to new width bodyStyleObj.position = 'relative'; bodyHeight = win.getBodyHeight(); frameStyleObj.height = oldHeight; bodyStyleObj.position = oldPosition; } ); return ( // Add buffer for border ( this.$frame.outerHeight() - this.$frame.innerHeight() ) + // Use combined heights of children ( this.$head.outerHeight( true ) + bodyHeight + this.$foot.outerHeight( true ) ) ); }; /** * Get the height of the window body. * * To get the height of the full window contents (the window body, head, and foot together), * use #getContentHeight. * * When this function is called, the window will temporarily have been resized * to height=1px, so .scrollHeight measurements can be taken accurately. * * @return {number} Height of the window body in pixels */ OO.ui.Window.prototype.getBodyHeight = function () { return this.$body[ 0 ].scrollHeight; }; /** * Get the directionality of the frame (right-to-left or left-to-right). * * @return {string} Directionality: `'ltr'` or `'rtl'` */ OO.ui.Window.prototype.getDir = function () { return this.dir; }; /** * Get the 'setup' process. * * The setup process is used to set up a window for use in a particular context, * based on the `data` argument. This method is called during the opening phase of the window’s * lifecycle. * * Override this method to add additional steps to the ‘setup’ process the parent method provides * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods * of OO.ui.Process. * * To add window content that persists between openings, you may wish to use the #initialize method * instead. * * @abstract * @param {Object} [data] Window opening data * @return {OO.ui.Process} Setup process */ OO.ui.Window.prototype.getSetupProcess = function () { return new OO.ui.Process(); }; /** * Get the ‘ready’ process. * * The ready process is used to ready a window for use in a particular * context, based on the `data` argument. This method is called during the opening phase of * the window’s lifecycle, after the window has been {@link #getSetupProcess setup}. * * Override this method to add additional steps to the ‘ready’ process the parent method * provides using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} * methods of OO.ui.Process. * * @abstract * @param {Object} [data] Window opening data * @return {OO.ui.Process} Ready process */ OO.ui.Window.prototype.getReadyProcess = function () { return new OO.ui.Process(); }; /** * Get the 'hold' process. * * The hold proccess is used to keep a window from being used in a particular context, * based on the `data` argument. This method is called during the closing phase of the window’s * lifecycle. * * Override this method to add additional steps to the 'hold' process the parent method provides * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods * of OO.ui.Process. * * @abstract * @param {Object} [data] Window closing data * @return {OO.ui.Process} Hold process */ OO.ui.Window.prototype.getHoldProcess = function () { return new OO.ui.Process(); }; /** * Get the ‘teardown’ process. * * The teardown process is used to teardown a window after use. During teardown, * user interactions within the window are conveyed and the window is closed, based on the `data` * argument. This method is called during the closing phase of the window’s lifecycle. * * Override this method to add additional steps to the ‘teardown’ process the parent method provides * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods * of OO.ui.Process. * * @abstract * @param {Object} [data] Window closing data * @return {OO.ui.Process} Teardown process */ OO.ui.Window.prototype.getTeardownProcess = function () { return new OO.ui.Process(); }; /** * Set the window manager. * * This will cause the window to initialize. Calling it more than once will cause an error. * * @param {OO.ui.WindowManager} manager Manager for this window * @throws {Error} An error is thrown if the method is called more than once * @chainable */ OO.ui.Window.prototype.setManager = function ( manager ) { if ( this.manager ) { throw new Error( 'Cannot set window manager, window already has a manager' ); } this.manager = manager; this.initialize(); return this; }; /** * Set the window size by symbolic name (e.g., 'small' or 'medium') * * @param {string} size Symbolic name of size: `small`, `medium`, `large`, `larger` or * `full` * @chainable */ OO.ui.Window.prototype.setSize = function ( size ) { this.size = size; this.updateSize(); return this; }; /** * Update the window size. * * @throws {Error} An error is thrown if the window is not attached to a window manager * @chainable */ OO.ui.Window.prototype.updateSize = function () { if ( !this.manager ) { throw new Error( 'Cannot update window size, must be attached to a manager' ); } this.manager.updateWindowSize( this ); return this; }; /** * Set window dimensions. This method is called by the {@link OO.ui.WindowManager window manager} * when the window is opening. In general, setDimensions should not be called directly. * * To set the size of the window, use the #setSize method. * * @param {Object} dim CSS dimension properties * @param {string|number} [dim.width] Width * @param {string|number} [dim.minWidth] Minimum width * @param {string|number} [dim.maxWidth] Maximum width * @param {string|number} [dim.width] Height, omit to set based on height of contents * @param {string|number} [dim.minWidth] Minimum height * @param {string|number} [dim.maxWidth] Maximum height * @chainable */ OO.ui.Window.prototype.setDimensions = function ( dim ) { var height, win = this, styleObj = this.$frame[ 0 ].style; // Calculate the height we need to set using the correct width if ( dim.height === undefined ) { this.withoutSizeTransitions( function () { var oldWidth = styleObj.width; win.$frame.css( 'width', dim.width || '' ); height = win.getContentHeight(); styleObj.width = oldWidth; } ); } else { height = dim.height; } this.$frame.css( { width: dim.width || '', minWidth: dim.minWidth || '', maxWidth: dim.maxWidth || '', height: height || '', minHeight: dim.minHeight || '', maxHeight: dim.maxHeight || '' } ); return this; }; /** * Initialize window contents. * * Before the window is opened for the first time, #initialize is called so that content that * persists between openings can be added to the window. * * To set up a window with new content each time the window opens, use #getSetupProcess. * * @throws {Error} An error is thrown if the window is not attached to a window manager * @chainable */ OO.ui.Window.prototype.initialize = function () { if ( !this.manager ) { throw new Error( 'Cannot initialize window, must be attached to a manager' ); } // Properties this.$head = $( '
' ); this.$body = $( '
' ); this.$foot = $( '
' ); this.dir = OO.ui.Element.static.getDir( this.$content ) || 'ltr'; this.$document = $( this.getElementDocument() ); // Events this.$element.on( 'mousedown', this.onMouseDown.bind( this ) ); // Initialization this.$head.addClass( 'oo-ui-window-head' ); this.$body.addClass( 'oo-ui-window-body' ); this.$foot.addClass( 'oo-ui-window-foot' ); this.$content.append( this.$head, this.$body, this.$foot ); return this; }; /** * Open the window. * * This method is a wrapper around a call to the window manager’s {@link OO.ui.WindowManager#openWindow openWindow} * method, which returns a promise resolved when the window is done opening. * * To customize the window each time it opens, use #getSetupProcess or #getReadyProcess. * * @param {Object} [data] Window opening data * @return {jQuery.Promise} Promise resolved with a value when the window is opened, or rejected * if the window fails to open. When the promise is resolved successfully, the first argument of the * value is a new promise, which is resolved when the window begins closing. * @throws {Error} An error is thrown if the window is not attached to a window manager */ OO.ui.Window.prototype.open = function ( data ) { if ( !this.manager ) { throw new Error( 'Cannot open window, must be attached to a manager' ); } return this.manager.openWindow( this, data ); }; /** * Close the window. * * This method is a wrapper around a call to the window * manager’s {@link OO.ui.WindowManager#closeWindow closeWindow} method, * which returns a closing promise resolved when the window is done closing. * * The window's #getHoldProcess and #getTeardownProcess methods are called during the closing * phase of the window’s lifecycle and can be used to specify closing behavior each time * the window closes. * * @param {Object} [data] Window closing data * @return {jQuery.Promise} Promise resolved when window is closed * @throws {Error} An error is thrown if the window is not attached to a window manager */ OO.ui.Window.prototype.close = function ( data ) { if ( !this.manager ) { throw new Error( 'Cannot close window, must be attached to a manager' ); } return this.manager.closeWindow( this, data ); }; /** * Setup window. * * This is called by OO.ui.WindowManager during window opening, and should not be called directly * by other systems. * * @param {Object} [data] Window opening data * @return {jQuery.Promise} Promise resolved when window is setup */ OO.ui.Window.prototype.setup = function ( data ) { var win = this, deferred = $.Deferred(); this.toggle( true ); this.getSetupProcess( data ).execute().done( function () { // Force redraw by asking the browser to measure the elements' widths win.$element.addClass( 'oo-ui-window-active oo-ui-window-setup' ).width(); win.$content.addClass( 'oo-ui-window-content-setup' ).width(); deferred.resolve(); } ); return deferred.promise(); }; /** * Ready window. * * This is called by OO.ui.WindowManager during window opening, and should not be called directly * by other systems. * * @param {Object} [data] Window opening data * @return {jQuery.Promise} Promise resolved when window is ready */ OO.ui.Window.prototype.ready = function ( data ) { var win = this, deferred = $.Deferred(); this.$content.focus(); this.getReadyProcess( data ).execute().done( function () { // Force redraw by asking the browser to measure the elements' widths win.$element.addClass( 'oo-ui-window-ready' ).width(); win.$content.addClass( 'oo-ui-window-content-ready' ).width(); deferred.resolve(); } ); return deferred.promise(); }; /** * Hold window. * * This is called by OO.ui.WindowManager during window closing, and should not be called directly * by other systems. * * @param {Object} [data] Window closing data * @return {jQuery.Promise} Promise resolved when window is held */ OO.ui.Window.prototype.hold = function ( data ) { var win = this, deferred = $.Deferred(); this.getHoldProcess( data ).execute().done( function () { // Get the focused element within the window's content var $focus = win.$content.find( OO.ui.Element.static.getDocument( win.$content ).activeElement ); // Blur the focused element if ( $focus.length ) { $focus[ 0 ].blur(); } // Force redraw by asking the browser to measure the elements' widths win.$element.removeClass( 'oo-ui-window-ready' ).width(); win.$content.removeClass( 'oo-ui-window-content-ready' ).width(); deferred.resolve(); } ); return deferred.promise(); }; /** * Teardown window. * * This is called by OO.ui.WindowManager during window closing, and should not be called directly * by other systems. * * @param {Object} [data] Window closing data * @return {jQuery.Promise} Promise resolved when window is torn down */ OO.ui.Window.prototype.teardown = function ( data ) { 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 ); } ); }; /** * 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 * @extends OO.ui.Window * @mixins OO.ui.PendingElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.Dialog = function OoUiDialog( config ) { // Parent constructor OO.ui.Dialog.super.call( this, config ); // Mixin constructors OO.ui.PendingElement.call( this ); // Properties this.actions = new OO.ui.ActionSet(); this.attachedActions = []; this.currentAction = null; this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this ); // Events this.actions.connect( this, { click: 'onActionClick', resize: 'onActionResize', change: 'onActionsChange' } ); // Initialization this.$element .addClass( 'oo-ui-dialog' ) .attr( 'role', 'dialog' ); }; /* Setup */ OO.inheritClass( OO.ui.Dialog, OO.ui.Window ); OO.mixinClass( OO.ui.Dialog, OO.ui.PendingElement ); /* Static Properties */ /** * Symbolic name of dialog. * * The dialog class must have a symbolic name in order to be registered with OO.Factory. * Please see the [OOjs UI documentation on MediaWiki] [3] for more information. * * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers * * @abstract * @static * @inheritable * @property {string} */ OO.ui.Dialog.static.name = ''; /** * The dialog title. * * The title can be specified as a plaintext string, a {@link OO.ui.LabelElement Label} node, or a function * that will produce a Label node or string. The title can also be specified with data passed to the * constructor (see #getSetupProcess). In this case, the static value will be overriden. * * @abstract * @static * @inheritable * @property {jQuery|string|Function} */ OO.ui.Dialog.static.title = ''; /** * An array of configured {@link OO.ui.ActionWidget action widgets}. * * Actions can also be specified with data passed to the constructor (see #getSetupProcess). In this case, the static * value will be overriden. * * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets * * @static * @inheritable * @property {Object[]} */ OO.ui.Dialog.static.actions = []; /** * Close the dialog when the 'Esc' key is pressed. * * @static * @abstract * @inheritable * @property {boolean} */ OO.ui.Dialog.static.escapable = true; /* Methods */ /** * Handle frame document key down events. * * @private * @param {jQuery.Event} e Key down event */ OO.ui.Dialog.prototype.onDocumentKeyDown = function ( e ) { if ( e.which === OO.ui.Keys.ESCAPE ) { this.close(); e.preventDefault(); e.stopPropagation(); } }; /** * Handle action resized events. * * @private * @param {OO.ui.ActionWidget} action Action that was resized */ OO.ui.Dialog.prototype.onActionResize = function () { // Override in subclass }; /** * Handle action click events. * * @private * @param {OO.ui.ActionWidget} action Action that was clicked */ OO.ui.Dialog.prototype.onActionClick = function ( action ) { if ( !this.isPending() ) { this.executeAction( action.getAction() ); } }; /** * Handle actions change event. * * @private */ OO.ui.Dialog.prototype.onActionsChange = function () { this.detachActions(); if ( !this.isClosing() ) { this.attachActions(); } }; /** * Get the set of actions used by the dialog. * * @return {OO.ui.ActionSet} */ OO.ui.Dialog.prototype.getActions = function () { return this.actions; }; /** * Get a process for taking action. * * When you override this method, you can create a new OO.ui.Process and return it, or add additional * accept steps to the process the parent method provides using the {@link OO.ui.Process#first 'first'} * and {@link OO.ui.Process#next 'next'} methods of OO.ui.Process. * * @abstract * @param {string} [action] Symbolic name of action * @return {OO.ui.Process} Action process */ OO.ui.Dialog.prototype.getActionProcess = function ( action ) { return new OO.ui.Process() .next( function () { if ( !action ) { // An empty action always closes the dialog without data, which should always be // safe and make no changes this.close(); } }, this ); }; /** * @inheritdoc * * @param {Object} [data] Dialog opening data * @param {jQuery|string|Function|null} [data.title] Dialog title, omit to use * the {@link #static-title static title} * @param {Object[]} [data.actions] List of configuration options for each * {@link OO.ui.ActionWidget action widget}, omit to use {@link #static-actions static actions}. */ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { data = data || {}; // Parent method return OO.ui.Dialog.super.prototype.getSetupProcess.call( this, data ) .next( function () { var config = this.constructor.static, actions = data.actions !== undefined ? data.actions : config.actions; this.title.setLabel( data.title !== undefined ? data.title : this.constructor.static.title ); this.actions.add( this.getActionWidgets( actions ) ); if ( this.constructor.static.escapable ) { this.$document.on( 'keydown', this.onDocumentKeyDownHandler ); } }, this ); }; /** * @inheritdoc */ OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) { // Parent method return OO.ui.Dialog.super.prototype.getTeardownProcess.call( this, data ) .first( function () { if ( this.constructor.static.escapable ) { this.$document.off( 'keydown', this.onDocumentKeyDownHandler ); } this.actions.clear(); this.currentAction = null; }, this ); }; /** * @inheritdoc */ OO.ui.Dialog.prototype.initialize = function () { // Parent method OO.ui.Dialog.super.prototype.initialize.call( this ); // Properties this.title = new OO.ui.LabelWidget(); // Initialization this.$content.addClass( 'oo-ui-dialog-content' ); this.setPendingElement( this.$head ); }; /** * Get action widgets from a list of configs * * @param {Object[]} actions Action widget configs * @return {OO.ui.ActionWidget[]} Action widgets */ OO.ui.Dialog.prototype.getActionWidgets = function ( actions ) { var i, len, widgets = []; for ( i = 0, len = actions.length; i < len; i++ ) { widgets.push( new OO.ui.ActionWidget( actions[ i ] ) ); } return widgets; }; /** * Attach action actions. * * @protected */ OO.ui.Dialog.prototype.attachActions = function () { // Remember the list of potentially attached actions this.attachedActions = this.actions.get(); }; /** * Detach action actions. * * @protected * @chainable */ OO.ui.Dialog.prototype.detachActions = function () { var i, len; // 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 = []; }; /** * Execute an action. * * @param {string} action Symbolic name of action to execute * @return {jQuery.Promise} Promise resolved when action completes, rejected if it fails */ OO.ui.Dialog.prototype.executeAction = function ( action ) { this.pushPending(); this.currentAction = action; return this.getActionProcess( action ).execute() .always( this.popPending.bind( this ) ); }; /** * 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 * * @constructor * @param {Object} [config] Configuration options * @cfg {OO.Factory} [factory] Window factory to use for automatic instantiation * Note that window classes that are instantiated with a factory must have * a {@link OO.ui.Dialog#static-name static name} property that specifies a symbolic name. * @cfg {boolean} [modal=true] Prevent interaction outside the dialog */ OO.ui.WindowManager = function OoUiWindowManager( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.WindowManager.super.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); // Properties this.factory = config.factory; this.modal = config.modal === undefined || !!config.modal; this.windows = {}; this.opening = null; this.opened = null; this.closing = null; this.preparingToOpen = null; this.preparingToClose = null; this.currentWindow = null; this.globalEvents = false; this.$ariaHidden = null; this.onWindowResizeTimeout = null; this.onWindowResizeHandler = this.onWindowResize.bind( this ); this.afterWindowResizeHandler = this.afterWindowResize.bind( this ); // Initialization this.$element .addClass( 'oo-ui-windowManager' ) .toggleClass( 'oo-ui-windowManager-modal', this.modal ); }; /* Setup */ OO.inheritClass( OO.ui.WindowManager, OO.ui.Element ); OO.mixinClass( OO.ui.WindowManager, OO.EventEmitter ); /* Events */ /** * An 'opening' event is emitted when the window begins to be opened. * * @event opening * @param {OO.ui.Window} win Window that's being opened * @param {jQuery.Promise} opening An `opening` promise resolved with a value when the window is opened successfully. * When the `opening` promise is resolved, the first argument of the value is an 'opened' promise, the second argument * is the opening data. The `opening` promise emits `setup` and `ready` notifications when those processes are complete. * @param {Object} data Window opening data */ /** * A 'closing' event is emitted when the window begins to be closed. * * @event closing * @param {OO.ui.Window} win Window that's being closed * @param {jQuery.Promise} closing A `closing` promise is resolved with a value when the window * is closed successfully. The promise emits `hold` and `teardown` notifications when those * processes are complete. When the `closing` promise is resolved, the first argument of its value * is the closing data. * @param {Object} data Window closing data */ /** * A 'resize' event is emitted when a window is resized. * * @event resize * @param {OO.ui.Window} win Window that was resized */ /* Static Properties */ /** * Map of the symbolic name of each window size and its CSS properties. * * @static * @inheritable * @property {Object} */ OO.ui.WindowManager.static.sizes = { small: { width: 300 }, medium: { width: 500 }, large: { width: 700 }, larger: { width: 900 }, full: { // These can be non-numeric because they are never used in calculations width: '100%', height: '100%' } }; /** * Symbolic name of the default window size. * * The default size is used if the window's requested size is not recognized. * * @static * @inheritable * @property {string} */ OO.ui.WindowManager.static.defaultSize = 'medium'; /* Methods */ /** * Handle window resize events. * * @private * @param {jQuery.Event} e Window resize event */ OO.ui.WindowManager.prototype.onWindowResize = function () { clearTimeout( this.onWindowResizeTimeout ); this.onWindowResizeTimeout = setTimeout( this.afterWindowResizeHandler, 200 ); }; /** * Handle window resize events. * * @private * @param {jQuery.Event} e Window resize event */ OO.ui.WindowManager.prototype.afterWindowResize = function () { if ( this.currentWindow ) { this.updateWindowSize( this.currentWindow ); } }; /** * Check if window is opening. * * @return {boolean} Window is opening */ OO.ui.WindowManager.prototype.isOpening = function ( win ) { return win === this.currentWindow && !!this.opening && this.opening.state() === 'pending'; }; /** * Check if window is closing. * * @return {boolean} Window is closing */ OO.ui.WindowManager.prototype.isClosing = function ( win ) { return win === this.currentWindow && !!this.closing && this.closing.state() === 'pending'; }; /** * Check if window is opened. * * @return {boolean} Window is opened */ OO.ui.WindowManager.prototype.isOpened = function ( win ) { return win === this.currentWindow && !!this.opened && this.opened.state() === 'pending'; }; /** * Check if a window is being managed. * * @param {OO.ui.Window} win Window to check * @return {boolean} Window is being managed */ OO.ui.WindowManager.prototype.hasWindow = function ( win ) { var name; for ( name in this.windows ) { if ( this.windows[ name ] === win ) { return true; } } return false; }; /** * Get the number of milliseconds to wait after opening begins before executing the ‘setup’ process. * * @param {OO.ui.Window} win Window being opened * @param {Object} [data] Window opening data * @return {number} Milliseconds to wait */ OO.ui.WindowManager.prototype.getSetupDelay = function () { return 0; }; /** * Get the number of milliseconds to wait after setup has finished before executing the ‘ready’ process. * * @param {OO.ui.Window} win Window being opened * @param {Object} [data] Window opening data * @return {number} Milliseconds to wait */ OO.ui.WindowManager.prototype.getReadyDelay = function () { return 0; }; /** * Get the number of milliseconds to wait after closing has begun before executing the 'hold' process. * * @param {OO.ui.Window} win Window being closed * @param {Object} [data] Window closing data * @return {number} Milliseconds to wait */ OO.ui.WindowManager.prototype.getHoldDelay = function () { return 0; }; /** * Get the number of milliseconds to wait after the ‘hold’ process has finished before * executing the ‘teardown’ process. * * @param {OO.ui.Window} win Window being closed * @param {Object} [data] Window closing data * @return {number} Milliseconds to wait */ OO.ui.WindowManager.prototype.getTeardownDelay = function () { return this.modal ? 250 : 0; }; /** * Get a window by its symbolic name. * * If the window is not yet instantiated and its symbolic name is recognized by a factory, it will be * instantiated and added to the window manager automatically. Please see the [OOjs UI documentation on MediaWiki][3] * for more information about using factories. * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers * * @param {string} name Symbolic name of the window * @return {jQuery.Promise} Promise resolved with matching window, or rejected with an OO.ui.Error * @throws {Error} An error is thrown if the symbolic name is not recognized by the factory. * @throws {Error} An error is thrown if the named window is not recognized as a managed window. */ OO.ui.WindowManager.prototype.getWindow = function ( name ) { var deferred = $.Deferred(), win = this.windows[ name ]; if ( !( win instanceof OO.ui.Window ) ) { if ( this.factory ) { if ( !this.factory.lookup( name ) ) { deferred.reject( new OO.ui.Error( 'Cannot auto-instantiate window: symbolic name is unrecognized by the factory' ) ); } else { win = this.factory.create( name ); this.addWindows( [ win ] ); deferred.resolve( win ); } } else { deferred.reject( new OO.ui.Error( 'Cannot get unmanaged window: symbolic name unrecognized as a managed window' ) ); } } else { deferred.resolve( win ); } return deferred.promise(); }; /** * Get current window. * * @return {OO.ui.Window|null} Currently opening/opened/closing window */ OO.ui.WindowManager.prototype.getCurrentWindow = function () { return this.currentWindow; }; /** * Open a window. * * @param {OO.ui.Window|string} win Window object or symbolic name of window to open * @param {Object} [data] Window opening data * @return {jQuery.Promise} An `opening` promise resolved when the window is done opening. * See {@link #event-opening 'opening' event} for more information about `opening` promises. * @fires opening */ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { var manager = this, opening = $.Deferred(); // Argument handling if ( typeof win === 'string' ) { return this.getWindow( win ).then( function ( win ) { return manager.openWindow( win, data ); } ); } // Error handling if ( !this.hasWindow( win ) ) { opening.reject( new OO.ui.Error( 'Cannot open window: window is not attached to manager' ) ); } else if ( this.preparingToOpen || this.opening || this.opened ) { opening.reject( new OO.ui.Error( 'Cannot open window: another window is opening or open' ) ); } // Window opening if ( opening.state() !== 'rejected' ) { // 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 ) { manager.toggleGlobalEvents( true ); manager.toggleAriaIsolation( true ); } manager.currentWindow = win; manager.opening = opening; manager.preparingToOpen = null; manager.emit( 'opening', win, opening, data ); setTimeout( function () { win.setup( data ).then( function () { manager.updateWindowSize( win ); manager.opening.notify( { state: 'setup' } ); setTimeout( function () { win.ready( data ).then( function () { manager.opening.notify( { state: 'ready' } ); manager.opening = null; manager.opened = $.Deferred(); opening.resolve( manager.opened.promise(), data ); } ); }, manager.getReadyDelay() ); } ); }, manager.getSetupDelay() ); } ); } return opening.promise(); }; /** * Close a window. * * @param {OO.ui.Window|string} win Window object or symbolic name of window to close * @param {Object} [data] Window closing data * @return {jQuery.Promise} A `closing` promise resolved when the window is done closing. * See {@link #event-closing 'closing' event} for more information about closing promises. * @throws {Error} An error is thrown if the window is not managed by the window manager. * @fires closing */ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { var manager = this, closing = $.Deferred(), opened; // Argument handling if ( typeof win === 'string' ) { win = this.windows[ win ]; } else if ( !this.hasWindow( win ) ) { win = null; } // Error handling if ( !win ) { closing.reject( new OO.ui.Error( 'Cannot close window: window is not attached to manager' ) ); } else if ( win !== this.currentWindow ) { closing.reject( new OO.ui.Error( 'Cannot close window: window already closed with different data' ) ); } else if ( this.preparingToClose || this.closing ) { closing.reject( new OO.ui.Error( 'Cannot close window: window already closing with different data' ) ); } // Window closing if ( closing.state() !== 'rejected' ) { // 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; manager.preparingToClose = null; manager.emit( 'closing', win, closing, data ); opened = manager.opened; manager.opened = null; opened.resolve( closing.promise(), data ); setTimeout( function () { win.hold( data ).then( function () { closing.notify( { state: 'hold' } ); setTimeout( function () { win.teardown( data ).then( function () { closing.notify( { state: 'teardown' } ); if ( manager.modal ) { manager.toggleGlobalEvents( false ); manager.toggleAriaIsolation( false ); } manager.closing = null; manager.currentWindow = null; closing.resolve( data ); } ); }, manager.getTeardownDelay() ); } ); }, manager.getHoldDelay() ); } ); } return closing.promise(); }; /** * Add windows to the window manager. * * Windows can be added by reference, symbolic name, or explicitly defined symbolic names. * See the [OOjs ui documentation on MediaWiki] [2] for examples. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers * * @param {Object.|OO.ui.Window[]} windows An array of window objects specified * by reference, symbolic name, or explicitly defined symbolic names. * @throws {Error} An error is thrown if a window is added by symbolic name, but has neither an * explicit nor a statically configured symbolic name. */ OO.ui.WindowManager.prototype.addWindows = function ( windows ) { var i, len, win, name, list; 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; if ( typeof name !== 'string' ) { throw new Error( 'Cannot add window' ); } list[ name ] = windows[ i ]; } } else if ( OO.isPlainObject( windows ) ) { list = windows; } // Add windows for ( name in list ) { win = list[ name ]; this.windows[ name ] = win.toggle( false ); this.$element.append( win.$element ); win.setManager( this ); } }; /** * Remove the specified windows from the windows manager. * * Windows will be closed before they are removed. If you wish to remove all windows, you may wish to use * the #clearWindows method instead. If you no longer need the window manager and want to ensure that it no * longer listens to events, use the #destroy method. * * @param {string[]} names Symbolic names of windows to remove * @return {jQuery.Promise} Promise resolved when window is closed and removed * @throws {Error} An error is thrown if the named windows are not managed by the window manager. */ OO.ui.WindowManager.prototype.removeWindows = function ( names ) { var i, len, win, name, cleanupWindow, manager = this, promises = [], cleanup = function ( name, win ) { delete manager.windows[ name ]; win.$element.detach(); }; for ( i = 0, len = names.length; i < len; i++ ) { name = names[ i ]; win = this.windows[ name ]; if ( !win ) { throw new Error( 'Cannot remove window' ); } cleanupWindow = cleanup.bind( null, name, win ); promises.push( this.closeWindow( name ).then( cleanupWindow, cleanupWindow ) ); } return $.when.apply( $, promises ); }; /** * Remove all windows from the window manager. * * Windows will be closed before they are removed. Note that the window manager, though not in use, will still * listen to events. If the window manager will not be used again, you may wish to use the #destroy method instead. * To remove just a subset of windows, use the #removeWindows method. * * @return {jQuery.Promise} Promise resolved when all windows are closed and removed */ OO.ui.WindowManager.prototype.clearWindows = function () { return this.removeWindows( Object.keys( this.windows ) ); }; /** * Set dialog size. In general, this method should not be called directly. * * Fullscreen mode will be used if the dialog is too wide to fit in the screen. * * @chainable */ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) { // Bypass for non-current, and thus invisible, windows if ( win !== this.currentWindow ) { return; } var viewport = OO.ui.Element.static.getDimensions( win.getElementWindow() ), sizes = this.constructor.static.sizes, size = win.getSize(); if ( !sizes[ size ] ) { size = this.constructor.static.defaultSize; } if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) { size = 'full'; } this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', size === 'full' ); this.$element.toggleClass( 'oo-ui-windowManager-floating', size !== 'full' ); win.setDimensions( sizes[ size ] ); this.emit( 'resize', win ); return this; }; /** * Bind or unbind global events for scrolling. * * @private * @param {boolean} [on] Bind global events * @chainable */ OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) { on = on === undefined ? !!this.globalEvents : !!on; var scrollWidth, bodyMargin, $body = $( this.getElementDocument().body ), // We could have multiple window managers open so only modify // the body css at the bottom of the stack stackDepth = $body.data( 'windowManagerGlobalEvents' ) || 0 ; if ( on ) { if ( !this.globalEvents ) { $( this.getElementWindow() ).on( { // Start listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); if ( stackDepth === 0 ) { scrollWidth = window.innerWidth - document.documentElement.clientWidth; bodyMargin = parseFloat( $body.css( 'margin-right' ) ) || 0; $body.css( { overflow: 'hidden', 'margin-right': bodyMargin + scrollWidth } ); } stackDepth++; this.globalEvents = true; } } else if ( this.globalEvents ) { $( this.getElementWindow() ).off( { // Stop listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); stackDepth--; if ( stackDepth === 0 ) { $body.css( { overflow: '', 'margin-right': '' } ); } this.globalEvents = false; } $body.data( 'windowManagerGlobalEvents', stackDepth ); return this; }; /** * Toggle screen reader visibility of content other than the window manager. * * @private * @param {boolean} [isolate] Make only the window manager visible to screen readers * @chainable */ OO.ui.WindowManager.prototype.toggleAriaIsolation = function ( isolate ) { isolate = isolate === undefined ? !this.$ariaHidden : !!isolate; if ( isolate ) { if ( !this.$ariaHidden ) { // Hide everything other than the window manager from screen readers this.$ariaHidden = $( 'body' ) .children() .not( this.$element.parentsUntil( 'body' ).last() ) .attr( 'aria-hidden', '' ); } } else if ( this.$ariaHidden ) { // Restore screen reader visibility this.$ariaHidden.removeAttr( 'aria-hidden' ); this.$ariaHidden = null; } return this; }; /** * Destroy the window manager. * * Destroying the window manager ensures that it will no longer listen to events. If you would like to * continue using the window manager, but wish to remove all windows from it, use the #clearWindows method * instead. */ OO.ui.WindowManager.prototype.destroy = function () { this.toggleGlobalEvents( false ); this.toggleAriaIsolation( false ); this.clearWindows(); this.$element.remove(); }; /** * Errors contain a required message (either a string or jQuery selection) that is used to describe what went wrong * in a {@link OO.ui.Process process}. The error's #recoverable and #warning configurations are used to customize the * appearance and functionality of the error interface. * * The basic error interface contains a formatted error message as well as two buttons: 'Dismiss' and 'Try again' (i.e., the error * is 'recoverable' by default). If the error is not recoverable, the 'Try again' button will not be rendered and the widget * that initiated the failed process will be disabled. * * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button, which will try the * process again. * * For an example of error interfaces, please see the [OOjs UI documentation on MediaWiki][1]. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Processes_and_errors * * @class * * @constructor * @param {string|jQuery} message Description of error * @param {Object} [config] Configuration options * @cfg {boolean} [recoverable=true] Error is recoverable. * By default, errors are recoverable, and users can try the process again. * @cfg {boolean} [warning=false] Error is a warning. * If the error is a warning, the error interface will include a * 'Dismiss' and a 'Continue' button. It is the responsibility of the developer to ensure that the warning * is not triggered a second time if the user chooses to continue. */ OO.ui.Error = function OoUiError( message, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( message ) && config === undefined ) { config = message; message = config.message; } // Configuration initialization config = config || {}; // Properties this.message = message instanceof jQuery ? message : String( message ); this.recoverable = config.recoverable === undefined || !!config.recoverable; this.warning = !!config.warning; }; /* Setup */ OO.initClass( OO.ui.Error ); /* Methods */ /** * Check if the error is recoverable. * * If the error is recoverable, users are able to try the process again. * * @return {boolean} Error is recoverable */ OO.ui.Error.prototype.isRecoverable = function () { return this.recoverable; }; /** * Check if the error is a warning. * * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button. * * @return {boolean} Error is warning */ OO.ui.Error.prototype.isWarning = function () { return this.warning; }; /** * Get error message as DOM nodes. * * @return {jQuery} Error message in DOM nodes */ OO.ui.Error.prototype.getMessage = function () { return this.message instanceof jQuery ? this.message.clone() : $( '
' ).text( this.message ).contents(); }; /** * Get the error message text. * * @return {string} Error message */ OO.ui.Error.prototype.getMessageText = function () { return this.message instanceof jQuery ? this.message.text() : this.message; }; /** * Wraps an HTML snippet for use with configuration values which default * to strings. This bypasses the default html-escaping done to string * values. * * @class * * @constructor * @param {string} [content] HTML content */ OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) { // Properties this.content = content; }; /* Setup */ OO.initClass( OO.ui.HtmlSnippet ); /* Methods */ /** * Render into HTML. * * @return {string} Unchanged HTML snippet. */ OO.ui.HtmlSnippet.prototype.toString = function () { return this.content; }; /** * A Process is a list of steps that are called in sequence. The step can be a number, a jQuery promise, * or a function: * * - **number**: the process will wait for the specified number of milliseconds before proceeding. * - **promise**: the process will continue to the next step when the promise is successfully resolved * or stop if the promise is rejected. * - **function**: the process will execute the function. The process will stop if the function returns * either a boolean `false` or a promise that is rejected; if the function returns a number, the process * will wait for that number of milliseconds before proceeding. * * If the process fails, an {@link OO.ui.Error error} is generated. Depending on how the error is * configured, users can dismiss the error and try the process again, or not. If a process is stopped, * its remaining steps will not be performed. * * @class * * @constructor * @param {number|jQuery.Promise|Function} step Number of miliseconds to wait before proceeding, promise * that must be resolved before proceeding, or a function to execute. See #createStep for more information. see #createStep for more information * @param {Object} [context=null] Execution context of the function. The context is ignored if the step is * a number or promise. * @return {Object} Step object, with `callback` and `context` properties */ OO.ui.Process = function ( step, context ) { // Properties this.steps = []; // Initialization if ( step !== undefined ) { this.next( step, context ); } }; /* Setup */ OO.initClass( OO.ui.Process ); /* Methods */ /** * Start the process. * * @return {jQuery.Promise} Promise that is resolved when all steps have successfully completed. * If any of the steps return a promise that is rejected or a boolean false, this promise is rejected * and any remaining steps are not performed. */ OO.ui.Process.prototype.execute = function () { var i, len, promise; /** * Continue execution. * * @ignore * @param {Array} step A function and the context it should be called in * @return {Function} Function that continues the process */ function proceed( step ) { return function () { // Execute step in the correct context var deferred, result = step.callback.call( step.context ); if ( result === false ) { // Use rejected promise for boolean false results return $.Deferred().reject( [] ).promise(); } if ( typeof result === 'number' ) { if ( result < 0 ) { throw new Error( 'Cannot go back in time: flux capacitor is out of service' ); } // Use a delayed promise for numbers, expecting them to be in milliseconds deferred = $.Deferred(); setTimeout( deferred.resolve, result ); return deferred.promise(); } if ( result instanceof OO.ui.Error ) { // Use rejected promise for error return $.Deferred().reject( [ result ] ).promise(); } if ( Array.isArray( result ) && result.length && result[ 0 ] instanceof OO.ui.Error ) { // Use rejected promise for list of errors return $.Deferred().reject( result ).promise(); } // Duck-type the object to see if it can produce a promise if ( result && $.isFunction( result.promise ) ) { // Use a promise generated from the result return result.promise(); } // Use resolved promise for other results return $.Deferred().resolve().promise(); }; } if ( this.steps.length ) { // Generate a chain reaction of promises promise = proceed( this.steps[ 0 ] )(); for ( i = 1, len = this.steps.length; i < len; i++ ) { promise = promise.then( proceed( this.steps[ i ] ) ); } } else { promise = $.Deferred().resolve().promise(); } return promise; }; /** * Create a process step. * * @private * @param {number|jQuery.Promise|Function} step * * - Number of milliseconds to wait before proceeding * - Promise that must be resolved before proceeding * - Function to execute * - If the function returns a boolean false the process will stop * - If the function returns a promise, the process will continue to the next * step when the promise is resolved or stop if the promise is rejected * - If the function returns a number, the process will wait for that number of * milliseconds before proceeding * @param {Object} [context=null] Execution context of the function. The context is * ignored if the step is a number or promise. * @return {Object} Step object, with `callback` and `context` properties */ OO.ui.Process.prototype.createStep = function ( step, context ) { if ( typeof step === 'number' || $.isFunction( step.promise ) ) { return { callback: function () { return step; }, context: null }; } if ( $.isFunction( step ) ) { return { callback: step, context: context }; } throw new Error( 'Cannot create process step: number, promise or function expected' ); }; /** * Add step to the beginning of the process. * * @inheritdoc #createStep * @return {OO.ui.Process} this * @chainable */ OO.ui.Process.prototype.first = function ( step, context ) { this.steps.unshift( this.createStep( step, context ) ); return this; }; /** * Add step to the end of the process. * * @inheritdoc #createStep * @return {OO.ui.Process} this * @chainable */ OO.ui.Process.prototype.next = function ( step, context ) { this.steps.push( this.createStep( step, context ) ); return this; }; /** * Factory for tools. * * @class * @extends OO.Factory * @constructor */ OO.ui.ToolFactory = function OoUiToolFactory() { // Parent constructor OO.ui.ToolFactory.super.call( this ); }; /* Setup */ OO.inheritClass( OO.ui.ToolFactory, OO.Factory ); /* Methods */ /** * Get tools from the factory * * @param {Array} include Included tools * @param {Array} exclude Excluded tools * @param {Array} promote Promoted tools * @param {Array} demote Demoted tools * @return {string[]} List of tools */ OO.ui.ToolFactory.prototype.getTools = function ( include, exclude, promote, demote ) { var i, len, included, promoted, demoted, auto = [], used = {}; // Collect included and not excluded tools included = OO.simpleArrayDifference( this.extract( include ), this.extract( exclude ) ); // Promotion promoted = this.extract( promote, used ); demoted = this.extract( demote, used ); // Auto for ( i = 0, len = included.length; i < len; i++ ) { if ( !used[ included[ i ] ] ) { auto.push( included[ i ] ); } } return promoted.concat( auto ).concat( demoted ); }; /** * Get a flat list of names from a list of names or groups. * * Tools can be specified in the following ways: * * - A specific tool: `{ name: 'tool-name' }` or `'tool-name'` * - All tools in a group: `{ group: 'group-name' }` * - All tools: `'*'` * * @private * @param {Array|string} collection List of tools * @param {Object} [used] Object with names that should be skipped as properties; extracted * names will be added as properties * @return {string[]} List of extracted names */ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { var i, len, item, name, tool, names = []; if ( collection === '*' ) { for ( name in this.registry ) { 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 ] ) ) { names.push( name ); if ( used ) { used[ name ] = true; } } } } else if ( Array.isArray( collection ) ) { for ( i = 0, len = collection.length; i < len; i++ ) { item = collection[ i ]; // Allow plain strings as shorthand for named tools if ( typeof item === 'string' ) { item = { name: item }; } if ( OO.isPlainObject( item ) ) { if ( item.group ) { for ( name in this.registry ) { 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 ] ) ) { names.push( name ); if ( used ) { used[ name ] = true; } } } // Include tools with matching name and exclude already used tools } else if ( item.name && ( !used || !used[ item.name ] ) ) { names.push( item.name ); if ( used ) { used[ item.name ] = true; } } } } } return names; }; /** * Factory for tool groups. * * @class * @extends OO.Factory * @constructor */ OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() { // Parent constructor OO.Factory.call( this ); var i, l, defaultClasses = this.constructor.static.getDefaultClasses(); // Register default toolgroups for ( i = 0, l = defaultClasses.length; i < l; i++ ) { this.register( defaultClasses[ i ] ); } }; /* Setup */ OO.inheritClass( OO.ui.ToolGroupFactory, OO.Factory ); /* Static Methods */ /** * Get a default set of classes to be registered on construction * * @return {Function[]} Default classes */ OO.ui.ToolGroupFactory.static.getDefaultClasses = function () { return [ OO.ui.BarToolGroup, OO.ui.ListToolGroup, OO.ui.MenuToolGroup ]; }; /** * Theme logic. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options */ OO.ui.Theme = function OoUiTheme( config ) { // Configuration initialization config = config || {}; }; /* Setup */ OO.initClass( OO.ui.Theme ); /* Methods */ /** * Get a list of classes to be applied to a widget. * * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes, * otherwise state transitions will not work properly. * * @param {OO.ui.Element} element Element for which to get classes * @return {Object.} Categorized class names with `on` and `off` lists */ OO.ui.Theme.prototype.getElementClasses = function ( /* element */ ) { return { on: [], off: [] }; }; /** * Update CSS classes provided by the theme. * * For elements with theme logic hooks, this should be called any time there's a state change. * * @param {OO.ui.Element} element Element for which to update classes * @return {Object.} Categorized class names with `on` and `off` lists */ OO.ui.Theme.prototype.updateElementClasses = function ( element ) { var classes = this.getElementClasses( element ); element.$element .removeClass( classes.off.join( ' ' ) ) .addClass( classes.on.join( ' ' ) ); }; /** * The TabIndexedElement class is an attribute mixin used to add additional functionality to an * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the * order in which users will navigate through the focusable elements via the "tab" key. * * @example * // TabIndexedElement is mixed into the ButtonWidget class * // to provide a tabIndex property. * var button1 = new OO.ui.ButtonWidget( { * label: 'fourth', * tabIndex: 4 * } ); * var button2 = new OO.ui.ButtonWidget( { * label: 'second', * tabIndex: 2 * } ); * var button3 = new OO.ui.ButtonWidget( { * label: 'third', * tabIndex: 3 * } ); * var button4 = new OO.ui.ButtonWidget( { * label: 'first', * tabIndex: 1 * } ); * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element ); * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default, * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex * functionality will be applied to it instead. * @cfg {number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1 * to remove the element from the tab-navigation flow. */ OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { // Configuration initialization config = $.extend( { tabIndex: 0 }, config ); // Properties this.$tabIndexed = null; this.tabIndex = null; // Events this.connect( this, { disable: 'onDisable' } ); // Initialization this.setTabIndex( config.tabIndex ); this.setTabIndexedElement( config.$tabIndexed || this.$element ); }; /* Setup */ OO.initClass( OO.ui.TabIndexedElement ); /* Methods */ /** * Set the element that should use the tabindex functionality. * * This method is used to retarget a tabindex mixin so that its functionality applies * to the specified element. If an element is currently using the functionality, the mixin’s * effect on that element is removed before the new element is set up. * * @param {jQuery} $tabIndexed Element that should use the tabindex functionality * @chainable */ 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 value of the tabindex. * * @param {number|null} tabIndex Tabindex value, or `null` for no tabindex * @chainable */ OO.ui.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { tabIndex = typeof tabIndex === 'number' ? tabIndex : null; if ( this.tabIndex !== tabIndex ) { this.tabIndex = tabIndex; this.updateTabIndex(); } return this; }; /** * Update the `tabindex` attribute, in case of changes to tab index or * disabled state. * * @private * @chainable */ 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' ); } } return this; }; /** * Handle disable events. * * @private * @param {boolean} disabled Element is disabled */ OO.ui.TabIndexedElement.prototype.onDisable = function () { this.updateTabIndex(); }; /** * Get the value of the tabindex. * * @return {number|null} Tabindex 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] The button element created by the class. * If this configuration is omitted, the button element will use a generated ``. * @cfg {boolean} [framed=true] Render the button with a frame * @cfg {string} [accessKey] Button's access key */ OO.ui.ButtonElement = function OoUiButtonElement( config ) { // Configuration initialization config = config || {}; // Properties this.$button = null; this.framed = null; this.accessKey = null; this.active = false; this.onMouseUpHandler = this.onMouseUp.bind( this ); this.onMouseDownHandler = this.onMouseDown.bind( this ); 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( config.$button || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.ButtonElement ); /* Static Properties */ /** * Cancel mouse down events. * * This property is usually set to `true` to prevent the focus from changing when the button is clicked. * Classes such as {@link OO.ui.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a * parent widget. * * @static * @inheritable * @property {boolean} */ OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; /* Events */ /** * A 'click' event is emitted when the button element is clicked. * * @event click */ /* Methods */ /** * Set the button element. * * This method is used to retarget a button mixin so that its functionality applies to * the specified button element instead of the one created by the class. If a button element * is already set, the method will remove the mixin’s effect on that 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 ) { return false; } }; /** * 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; } 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 ) { if ( 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 ) ) { if ( this.emit( 'click' ) ) { return false; } } }; /** * Check if button has a frame. * * @return {boolean} Button is framed */ OO.ui.ButtonElement.prototype.isFramed = function () { return this.framed; }; /** * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off. * * @param {boolean} [framed] Make button framed, omit to toggle * @chainable */ OO.ui.ButtonElement.prototype.toggleFramed = function ( framed ) { framed = framed === undefined ? !this.framed : !!framed; if ( framed !== this.framed ) { this.framed = framed; this.$element .toggleClass( 'oo-ui-buttonElement-frameless', !framed ) .toggleClass( 'oo-ui-buttonElement-framed', framed ); this.updateThemeClasses(); } return this; }; /** * Set the button's access key. * * @param {string} accessKey Button's access key, use empty string to remove * @chainable */ OO.ui.ButtonElement.prototype.setAccessKey = function ( accessKey ) { accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null; if ( this.accessKey !== accessKey ) { if ( this.$button ) { if ( accessKey !== null ) { this.$button.attr( 'accesskey', accessKey ); } else { this.$button.removeAttr( 'accesskey' ); } } this.accessKey = accessKey; } return this; }; /** * Set the button to its 'active' state. * * The active state occurs when a {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} or * a {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} is pressed. This method does nothing * for other button types. * * @param {boolean} [value] Make button active * @chainable */ OO.ui.ButtonElement.prototype.setActive = function ( value ) { this.$element.toggleClass( 'oo-ui-buttonElement-active', !!value ); return this; }; /** * 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 * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$group] The container element created by the class. If this configuration * is omitted, the group element will use a generated `
`. */ OO.ui.GroupElement = function OoUiGroupElement( config ) { // Configuration initialization config = config || {}; // Properties this.$group = null; this.items = []; this.aggregateItemEvents = {}; // Initialization this.setGroupElement( config.$group || $( '
' ) ); }; /* Methods */ /** * Set the group element. * * If an element is already set, items will be moved to the new element. * * @param {jQuery} $group Element to use as group */ OO.ui.GroupElement.prototype.setGroupElement = function ( $group ) { var i, len; this.$group = $group; for ( i = 0, len = this.items.length; i < len; i++ ) { this.$group.append( this.items[ i ].$element ); } }; /** * Check if a group contains no items. * * @return {boolean} Group is empty */ OO.ui.GroupElement.prototype.isEmpty = function () { return !this.items.length; }; /** * Get all items in the group. * * The method returns an array of item references (e.g., [button1, button2, button3]) and is useful * when synchronizing groups of items, or whenever the references are required (e.g., when removing items * from a group). * * @return {OO.ui.Element[]} An array of items. */ OO.ui.GroupElement.prototype.getItems = function () { return this.items.slice( 0 ); }; /** * Get an item by its data. * * Only the first item with matching data will be returned. To return all matching items, * use the #getItemsFromData method. * * @param {Object} data Item data to search for * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists */ OO.ui.GroupElement.prototype.getItemFromData = function ( data ) { var i, len, item, hash = OO.getHash( data ); for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { return item; } } return null; }; /** * Get items by their data. * * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead. * * @param {Object} data Item data to search for * @return {OO.ui.Element[]} Items with equivalent data */ OO.ui.GroupElement.prototype.getItemsFromData = function ( data ) { var i, len, item, hash = OO.getHash( data ), items = []; for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { items.push( item ); } } return items; }; /** * Aggregate the events emitted by the group. * * When events are aggregated, the group will listen to all contained items for the event, * and then emit the event under a new name. The new event will contain an additional leading * parameter containing the item that emitted the original event. Other arguments emitted from * the original event are passed through. * * @param {Object.} events An object keyed by the name of the event that should be * aggregated (e.g., ‘click’) and the value of the new name to use (e.g., ‘groupClick’). * A `null` value will remove aggregated events. * @throws {Error} An error is thrown if aggregation already exists. */ OO.ui.GroupElement.prototype.aggregate = function ( events ) { var i, len, item, add, remove, itemEvent, groupEvent; for ( itemEvent in events ) { groupEvent = events[ itemEvent ]; // Remove existing aggregated event if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { // Don't allow duplicate aggregations if ( groupEvent ) { throw new Error( 'Duplicate item event aggregation for ' + itemEvent ); } // Remove event aggregation from existing items for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( item.connect && item.disconnect ) { remove = {}; remove[ itemEvent ] = [ 'emit', groupEvent, item ]; item.disconnect( this, remove ); } } // Prevent future items from aggregating event delete this.aggregateItemEvents[ itemEvent ]; } // Add new aggregate event if ( groupEvent ) { // Make future items aggregate event this.aggregateItemEvents[ itemEvent ] = groupEvent; // Add event aggregation to existing items for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( item.connect && item.disconnect ) { add = {}; add[ itemEvent ] = [ 'emit', groupEvent, item ]; item.connect( this, add ); } } } } }; /** * Add items to the group. * * Items will be added to the end of the group array unless the optional `index` parameter specifies * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`. * * @param {OO.ui.Element[]} items An array of items to add to the group * @param {number} [index] Index of the insertion point * @chainable */ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { var i, len, item, event, events, currentIndex, itemElements = []; for ( i = 0, len = items.length; i < len; i++ ) { item = items[ i ]; // Check if item exists then remove it first, effectively "moving" it currentIndex = $.inArray( item, this.items ); if ( currentIndex >= 0 ) { this.removeItems( [ item ] ); // Adjust index to compensate for removal if ( currentIndex < index ) { index--; } } // Add the item if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { events = {}; for ( event in this.aggregateItemEvents ) { events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ]; } item.connect( this, events ); } item.setElementGroup( this ); itemElements.push( item.$element.get( 0 ) ); } if ( index === undefined || index < 0 || index >= this.items.length ) { this.$group.append( itemElements ); this.items.push.apply( this.items, items ); } else if ( index === 0 ) { this.$group.prepend( itemElements ); this.items.unshift.apply( this.items, items ); } else { this.items[ index ].$element.before( itemElements ); this.items.splice.apply( this.items, [ index, 0 ].concat( items ) ); } return this; }; /** * Remove the specified items from a group. * * Removed items are detached (not removed) from the DOM so that they may be reused. * To remove all items from a group, you may wish to use the #clearItems method instead. * * @param {OO.ui.Element[]} items An array of items to remove * @chainable */ OO.ui.GroupElement.prototype.removeItems = function ( items ) { var i, len, item, index, remove, itemEvent; // Remove specific items for ( i = 0, len = items.length; i < len; i++ ) { item = items[ i ]; index = $.inArray( item, this.items ); if ( index !== -1 ) { if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } item.setElementGroup( null ); this.items.splice( index, 1 ); item.$element.detach(); } } return this; }; /** * Clear all items from the group. * * Cleared items are detached from the DOM, not removed, so that they may be reused. * To remove only a subset of items from a group, use the #removeItems method. * * @chainable */ OO.ui.GroupElement.prototype.clearItems = function () { var i, len, item, remove, itemEvent; // Remove all items for ( i = 0, len = this.items.length; i < len; i++ ) { item = this.items[ i ]; if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } item.setElementGroup( null ); item.$element.detach(); } this.items = []; return this; }; /** * 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 * * @constructor */ OO.ui.DraggableElement = function OoUiDraggableElement() { // Properties this.index = null; // Initialize and events this.$element .attr( 'draggable', true ) .addClass( 'oo-ui-draggableElement' ) .on( { dragstart: this.onDragStart.bind( this ), dragover: this.onDragOver.bind( this ), dragend: this.onDragEnd.bind( this ), drop: this.onDrop.bind( this ) } ); }; OO.initClass( OO.ui.DraggableElement ); /* Events */ /** * @event dragstart * * A dragstart event is emitted when the user clicks and begins dragging an item. * @param {OO.ui.DraggableElement} item The item the user has clicked and is dragging with the mouse. */ /** * @event dragend * A dragend event is emitted when the user drags an item and releases the mouse, * thus terminating the drag operation. */ /** * @event drop * A drop event is emitted when the user drags an item and then releases the mouse button * over a valid target. */ /* Static Properties */ /** * @inheritdoc OO.ui.ButtonElement */ OO.ui.DraggableElement.static.cancelButtonMouseDownEvents = false; /* Methods */ /** * Respond to dragstart event. * * @private * @param {jQuery.Event} event jQuery event * @fires dragstart */ OO.ui.DraggableElement.prototype.onDragStart = function ( e ) { var dataTransfer = e.originalEvent.dataTransfer; // Define drop effect dataTransfer.dropEffect = 'none'; dataTransfer.effectAllowed = 'move'; // We must set up a dataTransfer data property or Firefox seems to // ignore the fact the element is draggable. try { dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() ); } catch ( err ) { // The above is only for firefox. No need to set a catch clause // if it fails, move on. } // Add dragging class this.$element.addClass( 'oo-ui-draggableElement-dragging' ); // Emit event this.emit( 'dragstart', this ); return true; }; /** * Respond to dragend event. * * @private * @fires dragend */ OO.ui.DraggableElement.prototype.onDragEnd = function () { this.$element.removeClass( 'oo-ui-draggableElement-dragging' ); this.emit( 'dragend' ); }; /** * Handle drop event. * * @private * @param {jQuery.Event} event jQuery event * @fires drop */ OO.ui.DraggableElement.prototype.onDrop = function ( e ) { e.preventDefault(); this.emit( 'drop', e ); }; /** * In order for drag/drop to work, the dragover event must * return false and stop propogation. * * @private */ OO.ui.DraggableElement.prototype.onDragOver = function ( e ) { e.preventDefault(); }; /** * Set item index. * Store it in the DOM so we can access from the widget drag event * * @private * @param {number} Item index */ OO.ui.DraggableElement.prototype.setIndex = function ( index ) { if ( this.index !== index ) { this.index = index; this.$element.data( 'index', index ); } }; /** * Get item index * * @private * @return {number} Item index */ OO.ui.DraggableElement.prototype.getIndex = function () { return this.index; }; /** * DraggableGroupElement is a mixin class used to create a group element to * contain draggable elements, which are items that can be clicked and dragged by a mouse. * The class is used with OO.ui.DraggableElement. * * @abstract * @class * @mixins OO.ui.GroupElement * * @constructor * @param {Object} [config] Configuration options * @cfg {string} [orientation] Item orientation: 'horizontal' or 'vertical'. The orientation * should match the layout of the items. Items displayed in a single row * or in several rows should use horizontal orientation. The vertical orientation should only be * used when the items are displayed in a single column. Defaults to 'vertical' */ OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.GroupElement.call( this, config ); // Properties this.orientation = config.orientation || 'vertical'; this.dragItem = null; this.itemDragOver = null; this.itemKeys = {}; this.sideInsertion = ''; // Events this.aggregate( { dragstart: 'itemDragStart', dragend: 'itemDragEnd', drop: 'itemDrop' } ); this.connect( this, { itemDragStart: 'onItemDragStart', itemDrop: 'onItemDrop', itemDragEnd: 'onItemDragEnd' } ); this.$element.on( { dragover: $.proxy( this.onDragOver, this ), dragleave: $.proxy( this.onDragLeave, this ) } ); // Initialize if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } this.$placeholder = $( '
' ) .addClass( 'oo-ui-draggableGroupElement-placeholder' ); this.$element .addClass( 'oo-ui-draggableGroupElement' ) .append( this.$status ) .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' ) .prepend( this.$placeholder ); }; /* Setup */ OO.mixinClass( OO.ui.DraggableGroupElement, OO.ui.GroupElement ); /* Events */ /** * A 'reorder' event is emitted when the order of items in the group changes. * * @event reorder * @param {OO.ui.DraggableElement} item Reordered item * @param {number} [newIndex] New index for the item */ /* Methods */ /** * Respond to item drag start event * * @private * @param {OO.ui.DraggableElement} item Dragged item */ OO.ui.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { var i, len; // Map the index of each object for ( i = 0, len = this.items.length; i < len; i++ ) { this.items[ i ].setIndex( i ); } if ( this.orientation === 'horizontal' ) { // Set the height of the indicator this.$placeholder.css( { height: item.$element.outerHeight(), width: 2 } ); } else { // Set the width of the indicator this.$placeholder.css( { height: 2, width: item.$element.outerWidth() } ); } this.setDragItem( item ); }; /** * Respond to item drag end event * * @private */ OO.ui.DraggableGroupElement.prototype.onItemDragEnd = function () { this.unsetDragItem(); return false; }; /** * Handle drop event and switch the order of the items accordingly * * @private * @param {OO.ui.DraggableElement} item Dropped item * @fires reorder */ OO.ui.DraggableGroupElement.prototype.onItemDrop = function ( item ) { var toIndex = item.getIndex(); // Check if the dropped item is from the current group // TODO: Figure out a way to configure a list of legally droppable // elements even if they are not yet in the list if ( this.getDragItem() ) { // If the insertion point is 'after', the insertion index // is shifted to the right (or to the left in RTL, hence 'after') if ( this.sideInsertion === 'after' ) { toIndex++; } // Emit change event this.emit( 'reorder', this.getDragItem(), toIndex ); } this.unsetDragItem(); // Return false to prevent propogation return false; }; /** * Handle dragleave event. * * @private */ OO.ui.DraggableGroupElement.prototype.onDragLeave = function () { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) .addClass( 'oo-ui-element-hidden' ); }; /** * Respond to dragover event * * @private * @param {jQuery.Event} event Event details */ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { var dragOverObj, $optionWidget, itemOffset, itemMidpoint, itemBoundingRect, itemSize, cssOutput, dragPosition, itemIndex, itemPosition, clientX = e.originalEvent.clientX, clientY = e.originalEvent.clientY; // Get the OptionWidget item we are dragging over dragOverObj = this.getElementDocument().elementFromPoint( clientX, clientY ); $optionWidget = $( dragOverObj ).closest( '.oo-ui-draggableElement' ); if ( $optionWidget[ 0 ] ) { itemOffset = $optionWidget.offset(); itemBoundingRect = $optionWidget[ 0 ].getBoundingClientRect(); itemPosition = $optionWidget.position(); itemIndex = $optionWidget.data( 'index' ); } if ( itemOffset && this.isDragging() && itemIndex !== this.getDragItem().getIndex() ) { if ( this.orientation === 'horizontal' ) { // Calculate where the mouse is relative to the item width itemSize = itemBoundingRect.width; itemMidpoint = itemBoundingRect.left + itemSize / 2; dragPosition = clientX; // Which side of the item we hover over will dictate // where the placeholder will appear, on the left or // on the right cssOutput = { left: dragPosition < itemMidpoint ? itemPosition.left : itemPosition.left + itemSize, top: itemPosition.top }; } else { // Calculate where the mouse is relative to the item height itemSize = itemBoundingRect.height; itemMidpoint = itemBoundingRect.top + itemSize / 2; dragPosition = clientY; // Which side of the item we hover over will dictate // where the placeholder will appear, on the top or // on the bottom cssOutput = { top: dragPosition < itemMidpoint ? itemPosition.top : itemPosition.top + itemSize, left: itemPosition.left }; } // Store whether we are before or after an item to rearrange // For horizontal layout, we need to account for RTL, as this is flipped if ( this.orientation === 'horizontal' && this.$element.css( 'direction' ) === 'rtl' ) { this.sideInsertion = dragPosition < itemMidpoint ? 'after' : 'before'; } else { this.sideInsertion = dragPosition < itemMidpoint ? 'before' : 'after'; } // Add drop indicator between objects this.$placeholder .css( cssOutput ) .removeClass( 'oo-ui-element-hidden' ); } else { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) .addClass( 'oo-ui-element-hidden' ); } // Prevent default e.preventDefault(); }; /** * Set a dragged item * * @param {OO.ui.DraggableElement} item Dragged item */ OO.ui.DraggableGroupElement.prototype.setDragItem = function ( item ) { this.dragItem = item; }; /** * Unset the current dragged item */ OO.ui.DraggableGroupElement.prototype.unsetDragItem = function () { this.dragItem = null; this.itemDragOver = null; this.$placeholder.addClass( 'oo-ui-element-hidden' ); this.sideInsertion = ''; }; /** * Get the item that is currently being dragged. * * @return {OO.ui.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged */ OO.ui.DraggableGroupElement.prototype.getDragItem = function () { return this.dragItem; }; /** * Check if an item in the group is currently being dragged. * * @return {Boolean} Item is being dragged */ OO.ui.DraggableGroupElement.prototype.isDragging = function () { return this.getDragItem() !== null; }; /** * 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. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted, * the icon element will use a generated ``. To use a different HTML tag, or to specify that * the icon element be set to an existing icon instead of the one generated by this class, set a * value using a jQuery selection. For example: * * // Use a
tag instead of a * $icon: $("
") * // Use an existing icon element instead of the one generated by the class * $icon: this.$element * // Use an icon element from a child widget * $icon: this.childwidget.$element * @cfg {Object|string} [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. * * Example of an i18n map: * * { default: 'bold-a', en: 'bold-b', de: 'bold-f' } * See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title * text. The icon title is displayed when users move the mouse over the icon. */ OO.ui.IconElement = function OoUiIconElement( config ) { // Configuration initialization config = config || {}; // Properties this.$icon = null; this.icon = null; this.iconTitle = null; // Initialization this.setIcon( config.icon || this.constructor.static.icon ); this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle ); this.setIconElement( config.$icon || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.IconElement ); /* Static Properties */ /** * 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. * * Example of an i18n map: * * { 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} */ OO.ui.IconElement.static.icon = null; /** * 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} */ OO.ui.IconElement.static.iconTitle = null; /* Methods */ /** * Set the icon element. This method is used to retarget an icon mixin so that its functionality * applies to the specified icon element instead of the one created by the class. If an icon * element is already set, the mixin’s effect on that element is removed. Generated CSS classes * and mixin methods will no longer affect the element. * * @param {jQuery} $icon Element to use as icon */ OO.ui.IconElement.prototype.setIconElement = function ( $icon ) { if ( this.$icon ) { this.$icon .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon ) .removeAttr( 'title' ); } this.$icon = $icon .addClass( 'oo-ui-iconElement-icon' ) .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon ); if ( this.iconTitle !== null ) { this.$icon.attr( 'title', this.iconTitle ); } }; /** * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon. * The icon parameter can also be set to a map of icon names. See the #icon config setting * for an example. * * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed * by language code, or `null` to remove the icon. * @chainable */ OO.ui.IconElement.prototype.setIcon = function ( icon ) { icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon; icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null; if ( this.icon !== icon ) { if ( this.$icon ) { if ( this.icon !== null ) { this.$icon.removeClass( 'oo-ui-icon-' + this.icon ); } if ( icon !== null ) { this.$icon.addClass( 'oo-ui-icon-' + icon ); } } this.icon = icon; } this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon ); this.updateThemeClasses(); return this; }; /** * Set the icon title. Use `null` to remove the title. * * @param {string|Function|null} iconTitle A text string used as the icon title, * a function that returns title text, or `null` for no title. * @chainable */ OO.ui.IconElement.prototype.setIconTitle = function ( iconTitle ) { iconTitle = typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ? OO.ui.resolveMsg( iconTitle ) : null; if ( this.iconTitle !== iconTitle ) { this.iconTitle = iconTitle; if ( this.$icon ) { if ( this.iconTitle !== null ) { this.$icon.attr( 'title', iconTitle ); } else { this.$icon.removeAttr( 'title' ); } } } return this; }; /** * Get the symbolic name of the icon. * * @return {string} Icon name */ OO.ui.IconElement.prototype.getIcon = function () { return this.icon; }; /** * Get the icon title. The title text is displayed when a user moves the mouse over the icon. * * @return {string} Icon title text */ OO.ui.IconElement.prototype.getIconTitle = function () { return this.iconTitle; }; /** * 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). * * 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 * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$indicator] The indicator element created by the class. If this * configuration is omitted, the indicator element will use a generated ``. * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or ‘down’). * See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included * in the library. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title, * or a function that returns title text. The indicator title is displayed when users move * the mouse over the indicator. */ OO.ui.IndicatorElement = function OoUiIndicatorElement( config ) { // Configuration initialization config = config || {}; // Properties this.$indicator = null; this.indicator = null; this.indicatorTitle = null; // Initialization this.setIndicator( config.indicator || this.constructor.static.indicator ); this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle ); this.setIndicatorElement( config.$indicator || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.IndicatorElement ); /* Static Properties */ /** * Symbolic name of the indicator (e.g., ‘alert’ or ‘down’). * The static property will be overridden if the #indicator configuration is used. * * @static * @inheritable * @property {string|null} */ OO.ui.IndicatorElement.static.indicator = null; /** * A text string used as the indicator title, a function that returns title text, or `null` * for no title. The static property will be overridden if the #indicatorTitle configuration is used. * * @static * @inheritable * @property {string|Function|null} */ OO.ui.IndicatorElement.static.indicatorTitle = null; /* Methods */ /** * Set the indicator element. * * If an element is already set, it will be cleaned up before setting up the new element. * * @param {jQuery} $indicator Element to use as indicator */ OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { if ( this.$indicator ) { this.$indicator .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator ) .removeAttr( 'title' ); } this.$indicator = $indicator .addClass( 'oo-ui-indicatorElement-indicator' ) .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator ); if ( this.indicatorTitle !== null ) { this.$indicator.attr( 'title', this.indicatorTitle ); } }; /** * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator. * * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator * @chainable */ OO.ui.IndicatorElement.prototype.setIndicator = function ( indicator ) { indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null; if ( this.indicator !== indicator ) { if ( this.$indicator ) { if ( this.indicator !== null ) { this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator ); } if ( indicator !== null ) { this.$indicator.addClass( 'oo-ui-indicator-' + indicator ); } } this.indicator = indicator; } this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator ); this.updateThemeClasses(); return this; }; /** * Set the indicator title. * * The title is displayed when a user moves the mouse over the indicator. * * @param {string|Function|null} indicator Indicator title text, a function that returns text, or * `null` for no indicator title * @chainable */ OO.ui.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) { indicatorTitle = typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ? OO.ui.resolveMsg( indicatorTitle ) : null; if ( this.indicatorTitle !== indicatorTitle ) { this.indicatorTitle = indicatorTitle; if ( this.$indicator ) { if ( this.indicatorTitle !== null ) { this.$indicator.attr( 'title', indicatorTitle ); } else { this.$indicator.removeAttr( 'title' ); } } } return this; }; /** * Get the symbolic name of the indicator (e.g., ‘alert’ or ‘down’). * * @return {string} Symbolic name of indicator */ OO.ui.IndicatorElement.prototype.getIndicator = function () { return this.indicator; }; /** * Get the indicator title. * * The title is displayed when a user moves the mouse over the indicator. * * @return {string} Indicator title text */ OO.ui.IndicatorElement.prototype.getIndicatorTitle = function () { return this.indicatorTitle; }; /** * LabelElement is often mixed into other classes to generate a label, which * helps identify the function of an interface element. * See the [OOjs UI documentation on MediaWiki] [1] for more information. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$label] The label element created by the class. If this * configuration is omitted, the label element will use a generated ``. * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified * as a plaintext string, a jQuery selection of elements, or a function that will produce a string * in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels * @cfg {boolean} [autoFitLabel=true] Fit the label to the width of the parent element. * The label will be truncated to fit if necessary. */ OO.ui.LabelElement = function OoUiLabelElement( config ) { // Configuration initialization config = config || {}; // Properties this.$label = null; this.label = null; this.autoFitLabel = config.autoFitLabel === undefined || !!config.autoFitLabel; // Initialization this.setLabel( config.label || this.constructor.static.label ); this.setLabelElement( config.$label || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.LabelElement ); /* Events */ /** * @event labelChange * @param {string} value */ /* Static Properties */ /** * The label text. The label can be specified as a plaintext string, a function that will * produce a string in the future, or `null` for no label. The static value will * be overridden if a label is specified with the #label config option. * * @static * @inheritable * @property {string|Function|null} */ OO.ui.LabelElement.static.label = null; /* Methods */ /** * Set the label element. * * If an element is already set, it will be cleaned up before setting up the new element. * * @param {jQuery} $label Element to use as label */ OO.ui.LabelElement.prototype.setLabelElement = function ( $label ) { if ( this.$label ) { this.$label.removeClass( 'oo-ui-labelElement-label' ).empty(); } this.$label = $label.addClass( 'oo-ui-labelElement-label' ); this.setLabelContent( this.label ); }; /** * Set the label. * * An empty string will result in the label being hidden. A string containing only whitespace will * be converted to a single ` `. * * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or * text; or null for no label * @chainable */ 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 instanceof OO.ui.HtmlSnippet ) ? 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' ); } return this; }; /** * Get the label. * * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or * text; or null for no label */ OO.ui.LabelElement.prototype.getLabel = function () { return this.label; }; /** * Fit the label. * * @chainable */ OO.ui.LabelElement.prototype.fitLabel = function () { if ( this.$label && this.$label.autoEllipsis && this.autoFitLabel ) { this.$label.autoEllipsis( { hasSpan: false, tooltip: true } ); } return this; }; /** * Set the content of the label. * * Do not call this method until after the label element has been set by #setLabelElement. * * @private * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or * text; or null for no label */ OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { if ( typeof label === 'string' ) { if ( label.match( /^\s*$/ ) ) { // Convert whitespace only string to a single non-breaking space this.$label.html( ' ' ); } else { this.$label.text( label ); } } else if ( label instanceof OO.ui.HtmlSnippet ) { this.$label.html( label.toString() ); } else if ( label instanceof jQuery ) { this.$label.empty().append( label ); } else { this.$label.empty(); } }; /** * LookupElement is a mixin that creates a {@link OO.ui.TextInputMenuSelectWidget menu} of suggested values for * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen * from the lookup menu, that value becomes the value of the input field. * * Note that a new menu of suggested items is displayed when a value is chosen from the lookup menu. If this is * not the desired behavior, disable lookup menus with the #setLookupsDisabled method, then set the value, then * re-enable lookups. * * See the [OOjs UI demos][1] for an example. * * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/index.html#widgets-apex-vector-ltr * * @class * @abstract * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered beneath the specified element. * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty. * By default, the lookup menu is not generated and displayed until the user begins to type. */ OO.ui.LookupElement = function OoUiLookupElement( config ) { // Configuration initialization config = config || {}; // Properties this.$overlay = config.$overlay || this.$element; this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { widget: this, input: this, $container: config.$container } ); this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false; 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 */ /** * Handle input focus event. * * @protected * @param {jQuery.Event} e Input focus event */ OO.ui.LookupElement.prototype.onLookupInputFocus = function () { this.lookupInputFocused = true; this.populateLookupMenu(); }; /** * Handle input blur event. * * @protected * @param {jQuery.Event} e Input blur event */ OO.ui.LookupElement.prototype.onLookupInputBlur = function () { this.closeLookupMenu(); this.lookupInputFocused = false; }; /** * Handle input mouse down event. * * @protected * @param {jQuery.Event} e Input mouse down event */ 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(); } }; /** * Handle input change event. * * @protected * @param {string} value New input value */ OO.ui.LookupElement.prototype.onLookupInputChange = function () { if ( this.lookupInputFocused ) { this.populateLookupMenu(); } }; /** * Handle the lookup menu being shown/hidden. * * @protected * @param {boolean} visible Whether the lookup menu is now visible. */ 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(); } }; /** * Handle menu item 'choose' event, updating the text input value to the value of the clicked item. * * @protected * @param {OO.ui.MenuOptionWidget} item Selected item */ OO.ui.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { this.setValue( item.getData() ); }; /** * Get lookup menu. * * @private * @return {OO.ui.TextInputMenuSelectWidget} */ OO.ui.LookupElement.prototype.getLookupMenu = function () { return this.lookupMenu; }; /** * Disable or re-enable lookups. * * When lookups are disabled, calls to #populateLookupMenu will be ignored. * * @param {boolean} disabled Disable lookups */ OO.ui.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { this.lookupsDisabled = !!disabled; }; /** * Open the menu. If there are no entries in the menu, this does nothing. * * @private * @chainable */ OO.ui.LookupElement.prototype.openLookupMenu = function () { if ( !this.lookupMenu.isEmpty() ) { this.lookupMenu.toggle( true ); } return this; }; /** * Close the menu, empty it, and abort any pending request. * * @private * @chainable */ OO.ui.LookupElement.prototype.closeLookupMenu = function () { this.lookupMenu.toggle( false ); this.abortLookupRequest(); this.lookupMenu.clearItems(); return this; }; /** * 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. * * @private * @chainable */ OO.ui.LookupElement.prototype.populateLookupMenu = function () { var widget = this, value = this.getValue(); if ( this.lookupsDisabled ) { return; } // If the input is empty, clear the menu, unless suggestions when empty are allowed. if ( !this.allowSuggestionsWhenEmpty && 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; }; /** * Highlight the first selectable item in the menu. * * @private * @chainable */ OO.ui.LookupElement.prototype.initializeLookupMenuSelection = function () { if ( !this.lookupMenu.getSelectedItem() ) { this.lookupMenu.highlightItem( this.lookupMenu.getFirstSelectableItem() ); } }; /** * Get lookup menu items for the current query. * * @private * @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 ( response ) { // 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( response ); deferred.resolve( widget.getLookupMenuOptionsFromData( widget.lookupCache[ value ] ) ); } } ) .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(); }; /** * Abort the currently pending lookup request, if any. * * @private */ 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(); } }; /** * Get a new request object of the current lookup query value. * * @protected * @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; }; /** * 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. * * @protected * @abstract * @param {Mixed} response Response from server * @return {Mixed} Cached result data */ OO.ui.LookupElement.prototype.getLookupCacheDataFromResponse = function () { // Stub, implemented in subclass return []; }; /** * Get a list of menu option widgets from the (possibly cached) data returned by * #getLookupCacheDataFromResponse. * * @protected * @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 []; }; /** * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}. * A popup is a container for content. It is overlaid and positioned absolutely. By default, each * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin. * See {@link OO.ui.PopupWidget PopupWidget} for an example. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {Object} [popup] Configuration to pass to popup * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus */ OO.ui.PopupElement = function OoUiPopupElement( config ) { // Configuration initialization config = config || {}; // Properties 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. * * The flags affect the appearance of the buttons: * * @example * // FlaggedElement is mixed into ButtonWidget to provide styling flags * var button1 = new OO.ui.ButtonWidget( { * label: 'Constructive', * flags: 'constructive' * } ); * var button2 = new OO.ui.ButtonWidget( { * label: 'Destructive', * flags: 'destructive' * } ); * var button3 = new OO.ui.ButtonWidget( { * label: 'Progressive', * flags: 'progressive' * } ); * $( 'body' ).append( button1.$element, button2.$element, button3.$element ); * * {@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] The name or names of the flags (e.g., 'constructive' or 'primary') to apply. * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged * @cfg {jQuery} [$flagged] The flagged element. By default, * the flagged functionality is applied to the element created by the class ($element). * If a different element is specified, the flagged functionality will be applied to it instead. */ OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { // Configuration initialization config = config || {}; // Properties this.flags = {}; this.$flagged = null; // Initialization this.setFlags( config.flags ); this.setFlaggedElement( config.$flagged || this.$element ); }; /* Events */ /** * @event flag * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes` * parameter contains the name of each modified flag and indicates whether it was * added or removed. * * @param {Object.} changes Object keyed by flag name. A Boolean `true` indicates * that the flag was added, `false` that the flag was removed. */ /* Methods */ /** * Set the flagged element. * * This method is used to retarget a flagged mixin so that its functionality applies to the specified element. * If an element is already set, the method will remove the mixin’s effect on that element. * * @param {jQuery} $flagged Element that should be flagged */ 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 the specified flag is set. * * @param {string} flag Name of flag * @return {boolean} The flag is set */ 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 A flag name, an array of flag names, * or an object keyed by flag name with a boolean value that indicates whether the flag should * be added (`true`) or removed (`false`). * @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; }; /** * TitledElement is mixed into other classes to provide a `title` attribute. * Titles are rendered by the browser and are made visible when the user moves * the mouse over the element. Titles are not visible on touch devices. * * @example * // TitledElement provides a 'title' attribute to the * // ButtonWidget class * var button = new OO.ui.ButtonWidget( { * label: 'Button with Title', * title: 'I am a button' * } ); * $( 'body' ).append( button.$element ); * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied. * If this config is omitted, the title functionality is applied to $element, the * element created by the class. * @cfg {string|Function} [title] The title text or a function that returns text. If * this config is omitted, the value of the {@link #static-title static title} property is used. */ OO.ui.TitledElement = function OoUiTitledElement( config ) { // 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 ); }; /* Setup */ OO.initClass( OO.ui.TitledElement ); /* Static Properties */ /** * The title text, a function that returns text, or `null` for no title. The value of the static property * is overridden if the #title config option is used. * * @static * @inheritable * @property {string|Function|null} */ OO.ui.TitledElement.static.title = null; /* Methods */ /** * Set the titled element. * * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element. * If an element is already set, the mixin’s effect on that element is removed before the new element is set up. * * @param {jQuery} $titled Element that should use the 'titled' functionality */ OO.ui.TitledElement.prototype.setTitledElement = function ( $titled ) { if ( this.$titled ) { this.$titled.removeAttr( 'title' ); } this.$titled = $titled; if ( this.title ) { this.$titled.attr( 'title', this.title ); } }; /** * Set title. * * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title * @chainable */ OO.ui.TitledElement.prototype.setTitle = function ( title ) { title = typeof title === 'string' ? OO.ui.resolveMsg( title ) : null; if ( this.title !== title ) { if ( this.$titled ) { if ( title !== null ) { this.$titled.attr( 'title', title ); } else { this.$titled.removeAttr( 'title' ); } } this.title = title; } return this; }; /** * Get title. * * @return {string} Title string */ OO.ui.TitledElement.prototype.getTitle = function () { return this.title; }; /** * Element that can be automatically clipped to visible boundaries. * * Whenever the element's natural height changes, you have to call * #clip to make sure it's still clipping correctly. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$clippable] Nodes to clip, assigned to #$clippable, omit to use #$element */ OO.ui.ClippableElement = function OoUiClippableElement( config ) { // Configuration initialization config = config || {}; // Properties this.$clippable = null; this.clipping = false; this.clippedHorizontally = false; this.clippedVertically = false; this.$clippableContainer = null; this.$clippableScroller = null; this.$clippableWindow = null; this.idealWidth = null; this.idealHeight = null; this.onClippableContainerScrollHandler = this.clip.bind( this ); this.onClippableWindowResizeHandler = this.clip.bind( this ); // Initialization this.setClippableElement( config.$clippable || this.$element ); }; /* Methods */ /** * Set clippable element. * * If an element is already set, it will be cleaned up before setting up the new element. * * @param {jQuery} $clippable Element to make clippable */ OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { if ( this.$clippable ) { this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' ); this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' ); this.clip(); }; /** * Toggle clipping. * * Do not turn clipping on until after the element is attached to the DOM and visible. * * @param {boolean} [clipping] Enable clipping, omit to toggle * @chainable */ OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { clipping = clipping === undefined ? !this.clipping : !!clipping; if ( this.clipping !== clipping ) { this.clipping = clipping; if ( clipping ) { 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' ) ? $( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : this.$clippableContainer; this.$clippableScroller.on( 'scroll', this.onClippableContainerScrollHandler ); this.$clippableWindow = $( this.getElementWindow() ) .on( 'resize', this.onClippableWindowResizeHandler ); // Initial clip after visible this.clip(); } else { this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); this.$clippableContainer = null; this.$clippableScroller.off( 'scroll', this.onClippableContainerScrollHandler ); this.$clippableScroller = null; this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler ); this.$clippableWindow = null; } } return this; }; /** * Check if the element will be clipped to fit the visible area of the nearest scrollable container. * * @return {boolean} Element will be clipped to the visible area */ OO.ui.ClippableElement.prototype.isClipping = function () { return this.clipping; }; /** * Check if the bottom or right of the element is being clipped by the nearest scrollable container. * * @return {boolean} Part of the element is being clipped */ OO.ui.ClippableElement.prototype.isClipped = function () { return this.clippedHorizontally || this.clippedVertically; }; /** * Check if the right of the element is being clipped by the nearest scrollable container. * * @return {boolean} Part of the element is being clipped */ OO.ui.ClippableElement.prototype.isClippedHorizontally = function () { return this.clippedHorizontally; }; /** * Check if the bottom of the element is being clipped by the nearest scrollable container. * * @return {boolean} Part of the element is being clipped */ OO.ui.ClippableElement.prototype.isClippedVertically = function () { return this.clippedVertically; }; /** * Set the ideal size. These are the dimensions the element will have when it's not being clipped. * * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix */ OO.ui.ClippableElement.prototype.setIdealSize = function ( width, height ) { this.idealWidth = width; this.idealHeight = height; if ( !this.clipping ) { // Update dimensions this.$clippable.css( { width: width, height: height } ); } // While clipping, idealWidth and idealHeight are not considered }; /** * Clip element to visible boundaries and allow scrolling when needed. Call this method when * the element's natural height changes. * * Element will be clipped the bottom or right of the element is within 10px of the edge of, or * overlapped by, the visible area of the nearest scrollable container. * * @chainable */ OO.ui.ClippableElement.prototype.clip = function () { if ( !this.clipping ) { // this.$clippableContainer and this.$clippableWindow are null, so the below will fail return this; } var buffer = 7, // Chosen by fair dice roll cOffset = this.$clippable.offset(), $container = this.$clippableContainer.is( 'html, body' ) ? this.$clippableWindow : this.$clippableContainer, ccOffset = $container.offset() || { top: 0, left: 0 }, ccHeight = $container.innerHeight() - buffer, ccWidth = $container.innerWidth() - buffer, cHeight = this.$clippable.outerHeight() + buffer, cWidth = this.$clippable.outerWidth() + buffer, scrollTop = this.$clippableScroller.scrollTop(), scrollLeft = this.$clippableScroller.scrollLeft(), desiredWidth = cOffset.left < 0 ? cWidth + cOffset.left : ( ccOffset.left + scrollLeft + ccWidth ) - cOffset.left, desiredHeight = cOffset.top < 0 ? cHeight + cOffset.top : ( ccOffset.top + scrollTop + ccHeight ) - cOffset.top, naturalWidth = this.$clippable.prop( 'scrollWidth' ), naturalHeight = this.$clippable.prop( 'scrollHeight' ), clipWidth = desiredWidth < naturalWidth, clipHeight = desiredHeight < naturalHeight; if ( clipWidth ) { this.$clippable.css( { overflowX: 'scroll', width: desiredWidth } ); } else { this.$clippable.css( { width: this.idealWidth || '', overflowX: '' } ); } if ( clipHeight ) { this.$clippable.css( { overflowY: 'scroll', height: desiredHeight } ); } else { 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; this.clippedVertically = clipHeight; return this; }; /** * Generic toolbar tool. * * @abstract * @class * @extends OO.ui.Widget * @mixins OO.ui.IconElement * @mixins OO.ui.FlaggedElement * @mixins OO.ui.TabIndexedElement * * @constructor * @param {OO.ui.ToolGroup} toolGroup * @param {Object} [config] Configuration options * @cfg {string|Function} [title] Title text or a function that returns text */ OO.ui.Tool = function OoUiTool( toolGroup, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolGroup ) && config === undefined ) { config = toolGroup; toolGroup = config.toolGroup; } // Configuration initialization config = config || {}; // Parent constructor OO.ui.Tool.super.call( this, config ); // Properties this.toolGroup = toolGroup; this.toolbar = this.toolGroup.getToolbar(); this.active = false; this.$title = $( '' ); this.$accel = $( '' ); this.$link = $( '' ); this.title = null; // Mixin constructors OO.ui.IconElement.call( this, config ); OO.ui.FlaggedElement.call( this, config ); OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) ); // Events this.toolbar.connect( this, { updateState: 'onUpdateState' } ); // Initialization this.$title.addClass( 'oo-ui-tool-title' ); this.$accel .addClass( 'oo-ui-tool-accel' ) .prop( { // This may need to be changed if the key names are ever localized, // but for now they are essentially written in English dir: 'ltr', lang: 'en' } ); this.$link .addClass( 'oo-ui-tool-link' ) .append( this.$icon, this.$title, this.$accel ) .attr( 'role', 'button' ); this.$element .data( 'oo-ui-tool', this ) .addClass( 'oo-ui-tool ' + 'oo-ui-tool-name-' + this.constructor.static.name.replace( /^([^\/]+)\/([^\/]+).*$/, '$1-$2' ) ) .toggleClass( 'oo-ui-tool-with-label', this.constructor.static.displayBothIconAndLabel ) .append( this.$link ); this.setTitle( config.title || this.constructor.static.title ); }; /* Setup */ OO.inheritClass( OO.ui.Tool, OO.ui.Widget ); OO.mixinClass( OO.ui.Tool, OO.ui.IconElement ); OO.mixinClass( OO.ui.Tool, OO.ui.FlaggedElement ); OO.mixinClass( OO.ui.Tool, OO.ui.TabIndexedElement ); /* Events */ /** * @event select */ /* Static Properties */ /** * @static * @inheritdoc */ OO.ui.Tool.static.tagName = 'span'; /** * Symbolic name of tool. * * @abstract * @static * @inheritable * @property {string} */ OO.ui.Tool.static.name = ''; /** * Tool group. * * @abstract * @static * @inheritable * @property {string} */ OO.ui.Tool.static.group = ''; /** * Tool title. * * Title is used as a tooltip when the tool is part of a bar tool group, or a label when the tool * is part of a list or menu tool group. If a trigger is associated with an action by the same name * as the tool, a description of its keyboard shortcut for the appropriate platform will be * appended to the title if the tool is part of a bar tool group. * * @abstract * @static * @inheritable * @property {string|Function} Title text or a function that returns text */ OO.ui.Tool.static.title = ''; /** * Whether this tool should be displayed with both title and label when used in a bar tool group. * Normally only the icon is displayed, or only the label if no icon is given. * * @static * @inheritable * @property {boolean} */ OO.ui.Tool.static.displayBothIconAndLabel = false; /** * Tool can be automatically added to catch-all groups. * * @static * @inheritable * @property {boolean} */ OO.ui.Tool.static.autoAddToCatchall = true; /** * Tool can be automatically added to named groups. * * @static * @property {boolean} * @inheritable */ OO.ui.Tool.static.autoAddToGroup = true; /** * Check if this tool is compatible with given data. * * @static * @inheritable * @param {Mixed} data Data to check * @return {boolean} Tool can be used with data */ OO.ui.Tool.static.isCompatibleWith = function () { return false; }; /* Methods */ /** * Handle the toolbar state being updated. * * This is an abstract method that must be overridden in a concrete subclass. * * @abstract */ OO.ui.Tool.prototype.onUpdateState = function () { throw new Error( 'OO.ui.Tool.onUpdateState not implemented in this subclass:' + this.constructor ); }; /** * Handle the tool being selected. * * This is an abstract method that must be overridden in a concrete subclass. * * @abstract */ OO.ui.Tool.prototype.onSelect = function () { throw new Error( 'OO.ui.Tool.onSelect not implemented in this subclass:' + this.constructor ); }; /** * Check if the button is active. * * @return {boolean} Button is active */ OO.ui.Tool.prototype.isActive = function () { return this.active; }; /** * Make the button appear active or inactive. * * @param {boolean} state Make button appear active */ OO.ui.Tool.prototype.setActive = function ( state ) { this.active = !!state; if ( this.active ) { this.$element.addClass( 'oo-ui-tool-active' ); } else { this.$element.removeClass( 'oo-ui-tool-active' ); } }; /** * Get the tool title. * * @param {string|Function} title Title text or a function that returns text * @chainable */ OO.ui.Tool.prototype.setTitle = function ( title ) { this.title = OO.ui.resolveMsg( title ); this.updateTitle(); return this; }; /** * Get the tool title. * * @return {string} Title text */ OO.ui.Tool.prototype.getTitle = function () { return this.title; }; /** * Get the tool's symbolic name. * * @return {string} Symbolic name of tool */ OO.ui.Tool.prototype.getName = function () { return this.constructor.static.name; }; /** * Update the title. */ OO.ui.Tool.prototype.updateTitle = function () { var titleTooltips = this.toolGroup.constructor.static.titleTooltips, accelTooltips = this.toolGroup.constructor.static.accelTooltips, accel = this.toolbar.getToolAccelerator( this.constructor.static.name ), tooltipParts = []; this.$title.text( this.title ); this.$accel.text( accel ); if ( titleTooltips && typeof this.title === 'string' && this.title.length ) { tooltipParts.push( this.title ); } if ( accelTooltips && typeof accel === 'string' && accel.length ) { tooltipParts.push( accel ); } if ( tooltipParts.length ) { this.$link.attr( 'title', tooltipParts.join( ' ' ) ); } else { this.$link.removeAttr( 'title' ); } }; /** * Destroy tool. */ OO.ui.Tool.prototype.destroy = function () { this.toolbar.disconnect( this ); this.$element.remove(); }; /** * Collection of tool groups. * * The following is a minimal example using several tools and tool groups. * * @example * // Create the toolbar * var toolFactory = new OO.ui.ToolFactory(); * var toolGroupFactory = new OO.ui.ToolGroupFactory(); * var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory ); * * // We will be placing status text in this element when tools are used * var $area = $( '

' ).text( 'Toolbar example' ); * * // Define the tools that we're going to place in our toolbar * * // Create a class inheriting from OO.ui.Tool * function PictureTool() { * PictureTool.super.apply( this, arguments ); * } * OO.inheritClass( PictureTool, OO.ui.Tool ); * // Each tool must have a 'name' (used as an internal identifier, see later) and at least one * // of 'icon' and 'title' (displayed icon and text). * PictureTool.static.name = 'picture'; * PictureTool.static.icon = 'picture'; * PictureTool.static.title = 'Insert picture'; * // Defines the action that will happen when this tool is selected (clicked). * PictureTool.prototype.onSelect = function () { * $area.text( 'Picture tool clicked!' ); * // Never display this tool as "active" (selected). * this.setActive( false ); * }; * // Make this tool available in our toolFactory and thus our toolbar * toolFactory.register( PictureTool ); * * // Register two more tools, nothing interesting here * function SettingsTool() { * SettingsTool.super.apply( this, arguments ); * } * OO.inheritClass( SettingsTool, OO.ui.Tool ); * SettingsTool.static.name = 'settings'; * SettingsTool.static.icon = 'settings'; * SettingsTool.static.title = 'Change settings'; * SettingsTool.prototype.onSelect = function () { * $area.text( 'Settings tool clicked!' ); * this.setActive( false ); * }; * toolFactory.register( SettingsTool ); * * // Register two more tools, nothing interesting here * function StuffTool() { * StuffTool.super.apply( this, arguments ); * } * OO.inheritClass( StuffTool, OO.ui.Tool ); * StuffTool.static.name = 'stuff'; * StuffTool.static.icon = 'ellipsis'; * StuffTool.static.title = 'More stuff'; * StuffTool.prototype.onSelect = function () { * $area.text( 'More stuff tool clicked!' ); * this.setActive( false ); * }; * toolFactory.register( StuffTool ); * * // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a * // little popup window (a PopupWidget). * function HelpTool( toolGroup, config ) { * OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: { * padded: true, * label: 'Help', * head: true * } }, config ) ); * this.popup.$body.append( '

I am helpful!

' ); * } * OO.inheritClass( HelpTool, OO.ui.PopupTool ); * HelpTool.static.name = 'help'; * HelpTool.static.icon = 'help'; * HelpTool.static.title = 'Help'; * toolFactory.register( HelpTool ); * * // Finally define which tools and in what order appear in the toolbar. Each tool may only be * // used once (but not all defined tools must be used). * toolbar.setup( [ * { * // 'bar' tool groups display tools' icons only, side-by-side. * type: 'bar', * include: [ 'picture', 'help' ] * }, * { * // 'list' tool groups display both the titles and icons, in a dropdown list. * type: 'list', * indicator: 'down', * label: 'More', * include: [ 'settings', 'stuff' ] * } * // Note how the tools themselves are toolgroup-agnostic - the same tool can be displayed * // either in a 'list' or a 'bar'. There is a 'menu' tool group too, not showcased here, * // since it's more complicated to use. (See the next example snippet on this page.) * ] ); * * // Create some UI around the toolbar and place it in the document * var frame = new OO.ui.PanelLayout( { * expanded: false, * framed: true * } ); * var contentFrame = new OO.ui.PanelLayout( { * expanded: false, * padded: true * } ); * frame.$element.append( * toolbar.$element, * contentFrame.$element.append( $area ) * ); * $( 'body' ).append( frame.$element ); * * // Here is where the toolbar is actually built. This must be done after inserting it into the * // document. * toolbar.initialize(); * * The following example extends the previous one to illustrate 'menu' tool groups and the usage of * 'updateState' event. * * @example * // Create the toolbar * var toolFactory = new OO.ui.ToolFactory(); * var toolGroupFactory = new OO.ui.ToolGroupFactory(); * var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory ); * * // We will be placing status text in this element when tools are used * var $area = $( '

' ).text( 'Toolbar example' ); * * // Define the tools that we're going to place in our toolbar * * // Create a class inheriting from OO.ui.Tool * function PictureTool() { * PictureTool.super.apply( this, arguments ); * } * OO.inheritClass( PictureTool, OO.ui.Tool ); * // Each tool must have a 'name' (used as an internal identifier, see later) and at least one * // of 'icon' and 'title' (displayed icon and text). * PictureTool.static.name = 'picture'; * PictureTool.static.icon = 'picture'; * PictureTool.static.title = 'Insert picture'; * // Defines the action that will happen when this tool is selected (clicked). * PictureTool.prototype.onSelect = function () { * $area.text( 'Picture tool clicked!' ); * // Never display this tool as "active" (selected). * this.setActive( false ); * }; * // The toolbar can be synchronized with the state of some external stuff, like a text * // editor's editing area, highlighting the tools (e.g. a 'bold' tool would be shown as active * // when the text cursor was inside bolded text). Here we simply disable this feature. * PictureTool.prototype.onUpdateState = function () { * }; * // Make this tool available in our toolFactory and thus our toolbar * toolFactory.register( PictureTool ); * * // Register two more tools, nothing interesting here * function SettingsTool() { * SettingsTool.super.apply( this, arguments ); * this.reallyActive = false; * } * OO.inheritClass( SettingsTool, OO.ui.Tool ); * SettingsTool.static.name = 'settings'; * SettingsTool.static.icon = 'settings'; * SettingsTool.static.title = 'Change settings'; * SettingsTool.prototype.onSelect = function () { * $area.text( 'Settings tool clicked!' ); * // Toggle the active state on each click * this.reallyActive = !this.reallyActive; * this.setActive( this.reallyActive ); * // To update the menu label * this.toolbar.emit( 'updateState' ); * }; * SettingsTool.prototype.onUpdateState = function () { * }; * toolFactory.register( SettingsTool ); * * // Register two more tools, nothing interesting here * function StuffTool() { * StuffTool.super.apply( this, arguments ); * this.reallyActive = false; * } * OO.inheritClass( StuffTool, OO.ui.Tool ); * StuffTool.static.name = 'stuff'; * StuffTool.static.icon = 'ellipsis'; * StuffTool.static.title = 'More stuff'; * StuffTool.prototype.onSelect = function () { * $area.text( 'More stuff tool clicked!' ); * // Toggle the active state on each click * this.reallyActive = !this.reallyActive; * this.setActive( this.reallyActive ); * // To update the menu label * this.toolbar.emit( 'updateState' ); * }; * StuffTool.prototype.onUpdateState = function () { * }; * toolFactory.register( StuffTool ); * * // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a * // little popup window (a PopupWidget). 'onUpdateState' is also already implemented. * function HelpTool( toolGroup, config ) { * OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: { * padded: true, * label: 'Help', * head: true * } }, config ) ); * this.popup.$body.append( '

I am helpful!

' ); * } * OO.inheritClass( HelpTool, OO.ui.PopupTool ); * HelpTool.static.name = 'help'; * HelpTool.static.icon = 'help'; * HelpTool.static.title = 'Help'; * toolFactory.register( HelpTool ); * * // Finally define which tools and in what order appear in the toolbar. Each tool may only be * // used once (but not all defined tools must be used). * toolbar.setup( [ * { * // 'bar' tool groups display tools' icons only, side-by-side. * type: 'bar', * include: [ 'picture', 'help' ] * }, * { * // 'menu' tool groups display both the titles and icons, in a dropdown menu. * // Menu label indicates which items are selected. * type: 'menu', * indicator: 'down', * include: [ 'settings', 'stuff' ] * } * ] ); * * // Create some UI around the toolbar and place it in the document * var frame = new OO.ui.PanelLayout( { * expanded: false, * framed: true * } ); * var contentFrame = new OO.ui.PanelLayout( { * expanded: false, * padded: true * } ); * frame.$element.append( * toolbar.$element, * contentFrame.$element.append( $area ) * ); * $( 'body' ).append( frame.$element ); * * // Here is where the toolbar is actually built. This must be done after inserting it into the * // document. * toolbar.initialize(); * toolbar.emit( 'updateState' ); * * @class * @extends OO.ui.Element * @mixins OO.EventEmitter * @mixins OO.ui.GroupElement * * @constructor * @param {OO.ui.ToolFactory} toolFactory Factory for creating tools * @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating tool groups * @param {Object} [config] Configuration options * @cfg {boolean} [actions] Add an actions section opposite to the tools * @cfg {boolean} [shadow] Add a shadow below the toolbar */ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolFactory ) && config === undefined ) { config = toolFactory; toolFactory = config.toolFactory; toolGroupFactory = config.toolGroupFactory; } // Configuration initialization config = config || {}; // Parent constructor OO.ui.Toolbar.super.call( this, config ); // Mixin constructors OO.EventEmitter.call( this ); OO.ui.GroupElement.call( this, config ); // Properties this.toolFactory = toolFactory; this.toolGroupFactory = toolGroupFactory; this.groups = []; this.tools = {}; this.$bar = $( '
' ); this.$actions = $( '
' ); this.initialized = false; this.onWindowResizeHandler = this.onWindowResize.bind( this ); // Events this.$element .add( this.$bar ).add( this.$group ).add( this.$actions ) .on( 'mousedown keydown', this.onPointerDown.bind( this ) ); // Initialization this.$group.addClass( 'oo-ui-toolbar-tools' ); if ( config.actions ) { this.$bar.append( this.$actions.addClass( 'oo-ui-toolbar-actions' ) ); } this.$bar .addClass( 'oo-ui-toolbar-bar' ) .append( this.$group, '
' ); if ( config.shadow ) { this.$bar.append( '
' ); } this.$element.addClass( 'oo-ui-toolbar' ).append( this.$bar ); }; /* Setup */ OO.inheritClass( OO.ui.Toolbar, OO.ui.Element ); OO.mixinClass( OO.ui.Toolbar, OO.EventEmitter ); OO.mixinClass( OO.ui.Toolbar, OO.ui.GroupElement ); /* Methods */ /** * Get the tool factory. * * @return {OO.ui.ToolFactory} Tool factory */ OO.ui.Toolbar.prototype.getToolFactory = function () { return this.toolFactory; }; /** * Get the tool group factory. * * @return {OO.Factory} Tool group factory */ OO.ui.Toolbar.prototype.getToolGroupFactory = function () { return this.toolGroupFactory; }; /** * Handles mouse down events. * * @param {jQuery.Event} e Mouse down event */ OO.ui.Toolbar.prototype.onPointerDown = function ( e ) { var $closestWidgetToEvent = $( e.target ).closest( '.oo-ui-widget' ), $closestWidgetToToolbar = this.$element.closest( '.oo-ui-widget' ); if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[ 0 ] === $closestWidgetToToolbar[ 0 ] ) { return false; } }; /** * Handle window resize event. * * @private * @param {jQuery.Event} e Window resize event */ OO.ui.Toolbar.prototype.onWindowResize = function () { this.$element.toggleClass( 'oo-ui-toolbar-narrow', this.$bar.width() <= this.narrowThreshold ); }; /** * Sets up handles and preloads required information for the toolbar to work. * This must be called after it is attached to a visible document and before doing anything else. */ OO.ui.Toolbar.prototype.initialize = function () { this.initialized = true; this.narrowThreshold = this.$group.width() + this.$actions.width(); $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler ); this.onWindowResize(); }; /** * Setup toolbar. * * Tools can be specified in the following ways: * * - A specific tool: `{ name: 'tool-name' }` or `'tool-name'` * - All tools in a group: `{ group: 'group-name' }` * - All tools: `'*'` - Using this will make the group a list with a "More" label by default * * @param {Object.} groups List of tool group configurations * @param {Array|string} [groups.include] Tools to include * @param {Array|string} [groups.exclude] Tools to exclude * @param {Array|string} [groups.promote] Tools to promote to the beginning * @param {Array|string} [groups.demote] Tools to demote to the end */ OO.ui.Toolbar.prototype.setup = function ( groups ) { var i, len, type, group, items = [], defaultType = 'bar'; // Cleanup previous groups this.reset(); // Build out new groups for ( i = 0, len = groups.length; i < len; i++ ) { group = groups[ i ]; if ( group.include === '*' ) { // Apply defaults to catch-all groups if ( group.type === undefined ) { group.type = 'list'; } if ( group.label === undefined ) { group.label = OO.ui.msg( 'ooui-toolbar-more' ); } } // Check type has been registered type = this.getToolGroupFactory().lookup( group.type ) ? group.type : defaultType; items.push( this.getToolGroupFactory().create( type, this, group ) ); } this.addItems( items ); }; /** * Remove all tools and groups from the toolbar. */ OO.ui.Toolbar.prototype.reset = function () { var i, len; this.groups = []; this.tools = {}; for ( i = 0, len = this.items.length; i < len; i++ ) { this.items[ i ].destroy(); } this.clearItems(); }; /** * Destroys toolbar, removing event handlers and DOM elements. * * Call this whenever you are done using a toolbar. */ OO.ui.Toolbar.prototype.destroy = function () { $( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler ); this.reset(); this.$element.remove(); }; /** * Check if tool has not been used yet. * * @param {string} name Symbolic name of tool * @return {boolean} Tool is available */ OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) { return !this.tools[ name ]; }; /** * Prevent tool from being used again. * * @param {OO.ui.Tool} tool Tool to reserve */ OO.ui.Toolbar.prototype.reserveTool = function ( tool ) { this.tools[ tool.getName() ] = tool; }; /** * Allow tool to be used again. * * @param {OO.ui.Tool} tool Tool to release */ OO.ui.Toolbar.prototype.releaseTool = function ( tool ) { delete this.tools[ tool.getName() ]; }; /** * Get accelerator label for tool. * * This is a stub that should be overridden to provide access to accelerator information. * * @param {string} name Symbolic name of tool * @return {string|undefined} Tool accelerator label if available */ OO.ui.Toolbar.prototype.getToolAccelerator = function () { return undefined; }; /** * Collection of tools. * * Tools can be specified in the following ways: * * - A specific tool: `{ name: 'tool-name' }` or `'tool-name'` * - All tools in a group: `{ group: 'group-name' }` * - All tools: `'*'` * * @abstract * @class * @extends OO.ui.Widget * @mixins OO.ui.GroupElement * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options * @cfg {Array|string} [include=[]] List of tools to include * @cfg {Array|string} [exclude=[]] List of tools to exclude * @cfg {Array|string} [promote=[]] List of tools to promote to the beginning * @cfg {Array|string} [demote=[]] List of tools to demote to the end */ OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolbar ) && config === undefined ) { config = toolbar; toolbar = config.toolbar; } // Configuration initialization config = config || {}; // Parent constructor OO.ui.ToolGroup.super.call( this, config ); // Mixin constructors OO.ui.GroupElement.call( this, config ); // Properties this.toolbar = toolbar; this.tools = {}; this.pressed = null; this.autoDisabled = false; this.include = config.include || []; this.exclude = config.exclude || []; this.promote = config.promote || []; this.demote = config.demote || []; this.onCapturedMouseKeyUpHandler = this.onCapturedMouseKeyUp.bind( this ); // Events this.$element.on( { mousedown: this.onMouseKeyDown.bind( this ), mouseup: this.onMouseKeyUp.bind( this ), keydown: this.onMouseKeyDown.bind( this ), keyup: this.onMouseKeyUp.bind( this ), focus: this.onMouseOverFocus.bind( this ), blur: this.onMouseOutBlur.bind( this ), mouseover: this.onMouseOverFocus.bind( this ), mouseout: this.onMouseOutBlur.bind( this ) } ); this.toolbar.getToolFactory().connect( this, { register: 'onToolFactoryRegister' } ); this.aggregate( { disable: 'itemDisable' } ); this.connect( this, { itemDisable: 'updateDisabled' } ); // Initialization this.$group.addClass( 'oo-ui-toolGroup-tools' ); this.$element .addClass( 'oo-ui-toolGroup' ) .append( this.$group ); this.populate(); }; /* Setup */ OO.inheritClass( OO.ui.ToolGroup, OO.ui.Widget ); OO.mixinClass( OO.ui.ToolGroup, OO.ui.GroupElement ); /* Events */ /** * @event update */ /* Static Properties */ /** * Show labels in tooltips. * * @static * @inheritable * @property {boolean} */ OO.ui.ToolGroup.static.titleTooltips = false; /** * Show acceleration labels in tooltips. * * @static * @inheritable * @property {boolean} */ OO.ui.ToolGroup.static.accelTooltips = false; /** * Automatically disable the toolgroup when all tools are disabled * * @static * @inheritable * @property {boolean} */ OO.ui.ToolGroup.static.autoDisable = true; /* Methods */ /** * @inheritdoc */ OO.ui.ToolGroup.prototype.isDisabled = function () { return this.autoDisabled || OO.ui.ToolGroup.super.prototype.isDisabled.apply( this, arguments ); }; /** * @inheritdoc */ OO.ui.ToolGroup.prototype.updateDisabled = function () { var i, item, allDisabled = true; if ( this.constructor.static.autoDisable ) { for ( i = this.items.length - 1; i >= 0; i-- ) { item = this.items[ i ]; if ( !item.isDisabled() ) { allDisabled = false; break; } } this.autoDisabled = allDisabled; } OO.ui.ToolGroup.super.prototype.updateDisabled.apply( this, arguments ); }; /** * Handle mouse down and key down events. * * @param {jQuery.Event} e Mouse down or key down event */ OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) { if ( !this.isDisabled() && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { this.pressed = this.getTargetTool( e ); if ( this.pressed ) { this.pressed.setActive( true ); this.getElementDocument().addEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true ); this.getElementDocument().addEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true ); } return false; } }; /** * Handle captured mouse up and key up events. * * @param {Event} e Mouse up or key up event */ OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) { this.getElementDocument().removeEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true ); this.getElementDocument().removeEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true ); // onMouseKeyUp may be called a second time, depending on where the mouse is when the button is // released, but since `this.pressed` will no longer be true, the second call will be ignored. this.onMouseKeyUp( e ); }; /** * Handle mouse up and key up events. * * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) { var tool = this.getTargetTool( e ); if ( !this.isDisabled() && this.pressed && this.pressed === tool && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { this.pressed.onSelect(); this.pressed = null; return false; } this.pressed = null; }; /** * Handle mouse over and focus events. * * @param {jQuery.Event} e Mouse over or focus event */ OO.ui.ToolGroup.prototype.onMouseOverFocus = function ( e ) { var tool = this.getTargetTool( e ); if ( this.pressed && this.pressed === tool ) { this.pressed.setActive( true ); } }; /** * Handle mouse out and blur events. * * @param {jQuery.Event} e Mouse out or blur event */ OO.ui.ToolGroup.prototype.onMouseOutBlur = function ( e ) { var tool = this.getTargetTool( e ); if ( this.pressed && this.pressed === tool ) { this.pressed.setActive( false ); } }; /** * Get the closest tool to a jQuery.Event. * * Only tool links are considered, which prevents other elements in the tool such as popups from * triggering tool group interactions. * * @private * @param {jQuery.Event} e * @return {OO.ui.Tool|null} Tool, `null` if none was found */ OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) { var tool, $item = $( e.target ).closest( '.oo-ui-tool-link' ); if ( $item.length ) { tool = $item.parent().data( 'oo-ui-tool' ); } return tool && !tool.isDisabled() ? tool : null; }; /** * Handle tool registry register events. * * If a tool is registered after the group is created, we must repopulate the list to account for: * * - a tool being added that may be included * - a tool already included being overridden * * @param {string} name Symbolic name of tool */ OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () { this.populate(); }; /** * Get the toolbar this group is in. * * @return {OO.ui.Toolbar} Toolbar of group */ OO.ui.ToolGroup.prototype.getToolbar = function () { return this.toolbar; }; /** * Add and remove tools based on configuration. */ OO.ui.ToolGroup.prototype.populate = function () { var i, len, name, tool, toolFactory = this.toolbar.getToolFactory(), names = {}, add = [], remove = [], list = this.toolbar.getToolFactory().getTools( this.include, this.exclude, this.promote, this.demote ); // Build a list of needed tools for ( i = 0, len = list.length; i < len; 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 ] ) ) { // Hack to prevent infinite recursion via ToolGroupTool. We need to reserve the tool before // creating it, but we can't call reserveTool() yet because we haven't created the tool. this.toolbar.tools[ name ] = true; tool = this.tools[ name ]; if ( !tool ) { // Auto-initialize tools on first use this.tools[ name ] = tool = toolFactory.create( name, this ); tool.updateTitle(); } this.toolbar.reserveTool( tool ); add.push( tool ); 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 ( remove.length ) { this.removeItems( remove ); } // Update emptiness state if ( add.length ) { this.$element.removeClass( 'oo-ui-toolGroup-empty' ); } else { this.$element.addClass( 'oo-ui-toolGroup-empty' ); } // Re-add tools (moving existing ones to new locations) this.addItems( add ); // Disabled state may depend on items this.updateDisabled(); }; /** * Destroy tool group. */ OO.ui.ToolGroup.prototype.destroy = function () { var name; 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.$element.remove(); }; /** * MessageDialogs display a confirmation or alert message. By default, the rendered dialog box * consists of a header that contains the dialog title, a body with the message, and a footer that * contains any {@link OO.ui.ActionWidget action widgets}. The MessageDialog class is the only type * of {@link OO.ui.Dialog dialog} that is usually instantiated directly. * * There are two basic types of message dialogs, confirmation and alert: * * - **confirmation**: the dialog title describes what a progressive action will do and the message provides * more details about the consequences. * - **alert**: the dialog title describes which event occurred and the message provides more information * about why the event occurred. * * The MessageDialog class specifies two actions: ‘accept’, the primary * action (e.g., ‘ok’) and ‘reject,’ the safe action (e.g., ‘cancel’). Both will close the window, * passing along the selected action. * * For more information and examples, please see the [OOjs UI documentation on MediaWiki][1]. * * @example * // Example: Creating and opening a message dialog window. * var messageDialog = new OO.ui.MessageDialog(); * * // Create and append a window manager. * var windowManager = new OO.ui.WindowManager(); * $( 'body' ).append( windowManager.$element ); * windowManager.addWindows( [ messageDialog ] ); * // Open the window. * windowManager.openWindow( messageDialog, { * title: 'Basic message dialog', * message: 'This is the message' * } ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Message_Dialogs * * @class * @extends OO.ui.Dialog * * @constructor * @param {Object} [config] Configuration options */ OO.ui.MessageDialog = function OoUiMessageDialog( config ) { // Parent constructor OO.ui.MessageDialog.super.call( this, config ); // Properties this.verticalActionLayout = null; // Initialization this.$element.addClass( 'oo-ui-messageDialog' ); }; /* Inheritance */ OO.inheritClass( OO.ui.MessageDialog, OO.ui.Dialog ); /* Static Properties */ OO.ui.MessageDialog.static.name = 'message'; OO.ui.MessageDialog.static.size = 'small'; OO.ui.MessageDialog.static.verbose = false; /** * Dialog title. * * The title of a confirmation dialog describes what a progressive action will do. The * title of an alert dialog describes which event occurred. * * @static * @inheritable * @property {jQuery|string|Function|null} */ OO.ui.MessageDialog.static.title = null; /** * The message displayed in the dialog body. * * A confirmation message describes the consequences of a progressive action. An alert * message describes why an event occurred. * * @static * @inheritable * @property {jQuery|string|Function|null} */ OO.ui.MessageDialog.static.message = null; OO.ui.MessageDialog.static.actions = [ { action: 'accept', label: OO.ui.deferMsg( 'ooui-dialog-message-accept' ), flags: 'primary' }, { action: 'reject', label: OO.ui.deferMsg( 'ooui-dialog-message-reject' ), flags: 'safe' } ]; /* Methods */ /** * @inheritdoc */ OO.ui.MessageDialog.prototype.setManager = function ( manager ) { OO.ui.MessageDialog.super.prototype.setManager.call( this, manager ); // Events this.manager.connect( this, { resize: 'onResize' } ); return this; }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.onActionResize = function ( action ) { this.fitActions(); return OO.ui.MessageDialog.super.prototype.onActionResize.call( this, action ); }; /** * Handle window resized events. * * @private */ OO.ui.MessageDialog.prototype.onResize = function () { var dialog = this; dialog.fitActions(); // Wait for CSS transition to finish and do it again :( setTimeout( function () { dialog.fitActions(); }, 300 ); }; /** * Toggle action layout between vertical and horizontal. * * * @private * @param {boolean} [value] Layout actions vertically, omit to toggle * @chainable */ OO.ui.MessageDialog.prototype.toggleVerticalActionLayout = function ( value ) { value = value === undefined ? !this.verticalActionLayout : !!value; if ( value !== this.verticalActionLayout ) { this.verticalActionLayout = value; this.$actions .toggleClass( 'oo-ui-messageDialog-actions-vertical', value ) .toggleClass( 'oo-ui-messageDialog-actions-horizontal', !value ); } return this; }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.getActionProcess = function ( action ) { if ( action ) { return new OO.ui.Process( function () { this.close( { action: action } ); }, this ); } return OO.ui.MessageDialog.super.prototype.getActionProcess.call( this, action ); }; /** * @inheritdoc * * @param {Object} [data] Dialog opening data * @param {jQuery|string|Function|null} [data.title] Description of the action being confirmed * @param {jQuery|string|Function|null} [data.message] Description of the action's consequence * @param {boolean} [data.verbose] Message is verbose and should be styled as a long message * @param {Object[]} [data.actions] List of OO.ui.ActionOptionWidget configuration options for each * action item */ OO.ui.MessageDialog.prototype.getSetupProcess = function ( data ) { data = data || {}; // Parent method return OO.ui.MessageDialog.super.prototype.getSetupProcess.call( this, data ) .next( function () { this.title.setLabel( data.title !== undefined ? data.title : this.constructor.static.title ); this.message.setLabel( data.message !== undefined ? data.message : this.constructor.static.message ); this.message.$element.toggleClass( 'oo-ui-messageDialog-message-verbose', data.verbose !== undefined ? data.verbose : this.constructor.static.verbose ); }, this ); }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.getBodyHeight = function () { var bodyHeight, oldOverflow, $scrollable = this.container.$element; oldOverflow = $scrollable[ 0 ].style.overflow; $scrollable[ 0 ].style.overflow = 'hidden'; OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); bodyHeight = this.text.$element.outerHeight( true ); $scrollable[ 0 ].style.overflow = oldOverflow; return bodyHeight; }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) { var $scrollable = this.container.$element; OO.ui.MessageDialog.super.prototype.setDimensions.call( this, dim ); // Twiddle the overflow property, otherwise an unnecessary scrollbar will be produced. // Need to do it after transition completes (250ms), add 50ms just in case. setTimeout( function () { var oldOverflow = $scrollable[ 0 ].style.overflow; $scrollable[ 0 ].style.overflow = 'hidden'; OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); $scrollable[ 0 ].style.overflow = oldOverflow; }, 300 ); return this; }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.initialize = function () { // Parent method OO.ui.MessageDialog.super.prototype.initialize.call( this ); // Properties this.$actions = $( '
' ); this.container = new OO.ui.PanelLayout( { scrollable: true, classes: [ 'oo-ui-messageDialog-container' ] } ); this.text = new OO.ui.PanelLayout( { padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ] } ); this.message = new OO.ui.LabelWidget( { classes: [ 'oo-ui-messageDialog-message' ] } ); // Initialization this.title.$element.addClass( 'oo-ui-messageDialog-title' ); this.$content.addClass( 'oo-ui-messageDialog-content' ); this.container.$element.append( this.text.$element ); this.text.$element.append( this.title.$element, this.message.$element ); this.$body.append( this.container.$element ); this.$actions.addClass( 'oo-ui-messageDialog-actions' ); this.$foot.append( this.$actions ); }; /** * @inheritdoc */ OO.ui.MessageDialog.prototype.attachActions = function () { var i, len, other, special, others; // Parent method OO.ui.MessageDialog.super.prototype.attachActions.call( this ); special = this.actions.getSpecial(); others = this.actions.getOthers(); if ( special.safe ) { this.$actions.append( special.safe.$element ); special.safe.toggleFramed( false ); } if ( others.length ) { for ( i = 0, len = others.length; i < len; i++ ) { other = others[ i ]; this.$actions.append( other.$element ); other.toggleFramed( false ); } } if ( special.primary ) { this.$actions.append( special.primary.$element ); special.primary.toggleFramed( false ); } if ( !this.isOpening() ) { // If the dialog is currently opening, this will be called automatically soon. // This also calls #fitActions. this.updateSize(); } }; /** * Fit action actions into columns or rows. * * Columns will be used if all labels can fit without overflow, otherwise rows will be used. * * @private */ OO.ui.MessageDialog.prototype.fitActions = function () { var i, len, action, previous = this.verticalActionLayout, actions = this.actions.get(); // Detect clipping this.toggleVerticalActionLayout( false ); for ( i = 0, len = actions.length; i < len; 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 ) { // We changed the layout, window height might need to be updated. this.updateSize(); } }; /** * ProcessDialog windows encapsulate a {@link OO.ui.Process process} and all of the code necessary * to complete it. If the process terminates with an error, a customizable {@link OO.ui.Error error * interface} alerts users to the trouble, permitting the user to dismiss the error and try again when * relevant. The ProcessDialog class is always extended and customized with the actions and content * required for each process. * * The process dialog box consists of a header that visually represents the ‘working’ state of long * processes with an animation. The header contains the dialog title as well as * two {@link OO.ui.ActionWidget action widgets}: a ‘safe’ action on the left (e.g., ‘Cancel’) and * a ‘primary’ action on the right (e.g., ‘Done’). * * Like other windows, the process dialog is managed by a {@link OO.ui.WindowManager window manager}. * Please see the [OOjs UI documentation on MediaWiki][1] for more information and examples. * * @example * // Example: Creating and opening a process dialog window. * function MyProcessDialog( config ) { * MyProcessDialog.super.call( this, config ); * } * OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog ); * * MyProcessDialog.static.title = 'Process dialog'; * MyProcessDialog.static.actions = [ * { action: 'save', label: 'Done', flags: 'primary' }, * { label: 'Cancel', flags: 'safe' } * ]; * * MyProcessDialog.prototype.initialize = function () { * MyProcessDialog.super.prototype.initialize.apply( this, arguments ); * this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); * this.content.$element.append( '

This is a process dialog window. The header contains the title and two buttons: \'Cancel\' (a safe action) on the left and \'Done\' (a primary action) on the right.

' ); * this.$body.append( this.content.$element ); * }; * MyProcessDialog.prototype.getActionProcess = function ( action ) { * var dialog = this; * if ( action ) { * return new OO.ui.Process( function () { * dialog.close( { action: action } ); * } ); * } * return MyProcessDialog.super.prototype.getActionProcess.call( this, action ); * }; * * var windowManager = new OO.ui.WindowManager(); * $( 'body' ).append( windowManager.$element ); * * var dialog = new MyProcessDialog(); * windowManager.addWindows( [ dialog ] ); * windowManager.openWindow( dialog ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs * * @abstract * @class * @extends OO.ui.Dialog * * @constructor * @param {Object} [config] Configuration options */ OO.ui.ProcessDialog = function OoUiProcessDialog( config ) { // Parent constructor OO.ui.ProcessDialog.super.call( this, config ); // Initialization this.$element.addClass( 'oo-ui-processDialog' ); }; /* Setup */ OO.inheritClass( OO.ui.ProcessDialog, OO.ui.Dialog ); /* Methods */ /** * Handle dismiss button click events. * * Hides errors. * * @private */ OO.ui.ProcessDialog.prototype.onDismissErrorButtonClick = function () { this.hideErrors(); }; /** * Handle retry button click events. * * Hides errors and then tries again. * * @private */ OO.ui.ProcessDialog.prototype.onRetryButtonClick = function () { this.hideErrors(); this.executeAction( this.currentAction ); }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.onActionResize = function ( action ) { if ( this.actions.isSpecial( action ) ) { this.fitLabel(); } return OO.ui.ProcessDialog.super.prototype.onActionResize.call( this, action ); }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.initialize = function () { // Parent method OO.ui.ProcessDialog.super.prototype.initialize.call( this ); // Properties this.$navigation = $( '
' ); this.$location = $( '
' ); this.$safeActions = $( '
' ); this.$primaryActions = $( '
' ); this.$otherActions = $( '
' ); this.dismissButton = new OO.ui.ButtonWidget( { label: OO.ui.msg( 'ooui-dialog-process-dismiss' ) } ); this.retryButton = new OO.ui.ButtonWidget(); this.$errors = $( '
' ); this.$errorsTitle = $( '
' ); // Events this.dismissButton.connect( this, { click: 'onDismissErrorButtonClick' } ); this.retryButton.connect( this, { click: 'onRetryButtonClick' } ); // Initialization this.title.$element.addClass( 'oo-ui-processDialog-title' ); this.$location .append( this.title.$element ) .addClass( 'oo-ui-processDialog-location' ); this.$safeActions.addClass( 'oo-ui-processDialog-actions-safe' ); this.$primaryActions.addClass( 'oo-ui-processDialog-actions-primary' ); this.$otherActions.addClass( 'oo-ui-processDialog-actions-other' ); this.$errorsTitle .addClass( 'oo-ui-processDialog-errors-title' ) .text( OO.ui.msg( 'ooui-dialog-process-error' ) ); this.$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' ) .append( this.$errors ); this.$navigation .addClass( 'oo-ui-processDialog-navigation' ) .append( this.$safeActions, this.$location, this.$primaryActions ); this.$head.append( this.$navigation ); this.$foot.append( this.$otherActions ); }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.getActionWidgets = function ( actions ) { var i, len, widgets = []; for ( i = 0, len = actions.length; i < len; i++ ) { widgets.push( new OO.ui.ActionWidget( $.extend( { framed: true }, actions[ i ] ) ) ); } return widgets; }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.attachActions = function () { var i, len, other, special, others; // Parent method OO.ui.ProcessDialog.super.prototype.attachActions.call( this ); special = this.actions.getSpecial(); others = this.actions.getOthers(); if ( special.primary ) { this.$primaryActions.append( special.primary.$element ); } for ( i = 0, len = others.length; i < len; i++ ) { other = others[ i ]; this.$otherActions.append( other.$element ); } if ( special.safe ) { this.$safeActions.append( special.safe.$element ); } this.fitLabel(); this.$body.css( 'bottom', this.$foot.outerHeight( true ) ); }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.executeAction = function ( action ) { var process = this; return OO.ui.ProcessDialog.super.prototype.executeAction.call( this, action ) .fail( function ( errors ) { process.showErrors( errors || [] ); } ); }; /** * Fit label between actions. * * @private * @chainable */ OO.ui.ProcessDialog.prototype.fitLabel = function () { var width = Math.max( this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0, this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0 ); this.$location.css( { paddingLeft: width, paddingRight: width } ); return this; }; /** * Handle errors that occurred during accept or reject processes. * * @private * @param {OO.ui.Error[]|OO.ui.Error} errors Errors to be handled */ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { var i, len, $item, actions, items = [], abilities = {}, recoverable = true, warning = false; if ( errors instanceof OO.ui.Error ) { errors = [ errors ]; } for ( i = 0, len = errors.length; i < len; i++ ) { if ( !errors[ i ].isRecoverable() ) { recoverable = false; } if ( errors[ i ].isWarning() ) { warning = true; } $item = $( '
' ) .addClass( 'oo-ui-processDialog-error' ) .append( errors[ i ].getMessage() ); items.push( $item[ 0 ] ); } this.$errorItems = $( items ); if ( recoverable ) { abilities[this.currentAction] = true; // Copy the flags from the first matching action actions = this.actions.get( { actions: this.currentAction } ); if ( actions.length ) { this.retryButton.clearFlags().setFlags( actions[0].getFlags() ); } } else { abilities[this.currentAction] = false; this.actions.setAbilities( abilities ); } if ( warning ) { this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-continue' ) ); } else { this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-retry' ) ); } this.retryButton.toggle( recoverable ); this.$errorsTitle.after( this.$errorItems ); this.$errors.removeClass( 'oo-ui-element-hidden' ).scrollTop( 0 ); }; /** * Hide errors. * * @private */ OO.ui.ProcessDialog.prototype.hideErrors = function () { this.$errors.addClass( 'oo-ui-element-hidden' ); if ( this.$errorItems ) { this.$errorItems.remove(); this.$errorItems = null; } }; /** * @inheritdoc */ OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) { // Parent method return OO.ui.ProcessDialog.super.prototype.getTeardownProcess.call( this, data ) .first( function () { // Make sure to hide errors this.hideErrors(); }, this ); }; /** * 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 {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline' * @cfg {string} [help] Help text. When help text is specified, a help icon will appear * in the upper-right corner of the rendered field. */ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( fieldWidget ) && config === undefined ) { config = fieldWidget; fieldWidget = config.fieldWidget; } var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; // Configuration initialization config = $.extend( { align: 'left' }, config ); // Parent constructor OO.ui.FieldLayout.super.call( this, config ); // Mixin constructors OO.ui.LabelElement.call( this, config ); // Properties 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.$help = this.popupButtonWidget.$element; } else { this.$help = $( [] ); } // Events if ( hasInputWidget ) { this.$label.on( 'click', this.onLabelClick.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 ); }; /* Setup */ OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); /* Methods */ /** * Handle field disable events. * * @private * @param {boolean} value Field is disabled */ OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) { this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value ); }; /** * Handle label mouse click events. * * @private * @param {jQuery.Event} e Mouse click event */ OO.ui.FieldLayout.prototype.onLabelClick = function () { this.fieldWidget.simulateLabelClick(); return false; }; /** * Get the widget contained by the field. * * @return {OO.ui.Widget} Field widget */ OO.ui.FieldLayout.prototype.getField = function () { return this.fieldWidget; }; /** * Set the field alignment mode. * * @private * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline' * @chainable */ 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; }; /** * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button, * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}), * is required and is specified before any optional configuration settings. * * Labels can be 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 when help * text is specified. * * @example * // Example of an ActionFieldLayout * var actionFieldLayout = new OO.ui.ActionFieldLayout( * new OO.ui.TextInputWidget( { * placeholder: 'Field widget' * } ), * new OO.ui.ButtonWidget( { * label: 'Button' * } ), * { * label: 'An ActionFieldLayout. This label is aligned top', * align: 'top', * help: 'This is help text' * } * ); * * $( 'body' ).append( actionFieldLayout.$element ); * * * @class * @extends OO.ui.FieldLayout * * @constructor * @param {OO.ui.Widget} fieldWidget Field widget * @param {OO.ui.ButtonWidget} buttonWidget Button widget * @param {Object} [config] Configuration options * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline' * @cfg {string} [help] Help text. When help text is specified, a help icon will appear in the * upper-right corner of the rendered field. */ OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( fieldWidget ) && config === undefined ) { config = fieldWidget; fieldWidget = config.fieldWidget; buttonWidget = config.buttonWidget; } // Configuration initialization config = $.extend( { align: 'left' }, config ); // Parent constructor OO.ui.ActionFieldLayout.super.call( this, fieldWidget, 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 ); /** * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts}, * which each contain an individual widget and, optionally, a label. Each Fieldset can be * configured with a label as well. For more information and examples, * please see the [OOjs UI documentation on MediaWiki][1]. * * @example * // Example of a fieldset layout * var input1 = new OO.ui.TextInputWidget( { * placeholder: 'A text input field' * } ); * * var input2 = new OO.ui.TextInputWidget( { * placeholder: 'A text input field' * } ); * * var fieldset = new OO.ui.FieldsetLayout( { * label: 'Example of a fieldset layout' * } ); * * fieldset.addItems( [ * new OO.ui.FieldLayout( input1, { * label: 'Field One' * } ), * new OO.ui.FieldLayout( input2, { * label: 'Field Two' * } ) * ] ); * $( 'body' ).append( fieldset.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets * * @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] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields. */ 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 = $( [] ); } // 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 ); /** * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively. * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples. * * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as * some fancier controls. Some controls have both regular and InputWidget variants, for example * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and * often have simplified APIs to match the capabilities of HTML forms. * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs * * @example * // Example of a form layout that wraps a fieldset layout * var input1 = new OO.ui.TextInputWidget( { * placeholder: 'Username' * } ); * var input2 = new OO.ui.TextInputWidget( { * placeholder: 'Password', * type: 'password' * } ); * var submit = new OO.ui.ButtonInputWidget( { * label: 'Submit' * } ); * * var fieldset = new OO.ui.FieldsetLayout( { * label: 'A form layout' * } ); * fieldset.addItems( [ * new OO.ui.FieldLayout( input1, { * label: 'Username', * align: 'top' * } ), * new OO.ui.FieldLayout( input2, { * label: 'Password', * align: 'top' * } ), * new OO.ui.FieldLayout( submit ) * ] ); * var form = new OO.ui.FormLayout( { * items: [ fieldset ], * action: '/api/formhandler', * method: 'get' * } ) * $( 'body' ).append( form.$element ); * * @class * @extends OO.ui.Layout * @mixins OO.ui.GroupElement * * @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 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout. */ OO.ui.FormLayout = function OoUiFormLayout( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.FormLayout.super.call( this, config ); // Mixin constructors OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // 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 } ); if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; /* Setup */ OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); OO.mixinClass( OO.ui.FormLayout, OO.ui.GroupElement ); /* Events */ /** * A 'submit' event is emitted when the form is submitted. * * @event submit */ /* Static Properties */ OO.ui.FormLayout.static.tagName = 'form'; /* Methods */ /** * Handle form submit events. * * @private * @param {jQuery.Event} e Submit event * @fires submit */ OO.ui.FormLayout.prototype.onFormSubmit = function () { if ( this.emit( 'submit' ) ) { return false; } }; /** * MenuLayouts combine a menu and a content {@link OO.ui.PanelLayout panel}. The menu is positioned relative to the content (after, before, top, or bottom) * and its size is customized with the #menuSize config. The content area will fill all remaining space. * * @example * var menuLayout = new OO.ui.MenuLayout( { * position: 'top' * } ), * menuPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ), * contentPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ), * select = new OO.ui.SelectWidget( { * items: [ * new OO.ui.OptionWidget( { * data: 'before', * label: 'Before', * } ), * new OO.ui.OptionWidget( { * data: 'after', * label: 'After', * } ), * new OO.ui.OptionWidget( { * data: 'top', * label: 'Top', * } ), * new OO.ui.OptionWidget( { * data: 'bottom', * label: 'Bottom', * } ) * ] * } ).on( 'select', function ( item ) { * menuLayout.setMenuPosition( item.getData() ); * } ); * * menuLayout.$menu.append( * menuPanel.$element.append( 'Menu panel', select.$element ) * ); * menuLayout.$content.append( * contentPanel.$element.append( 'Content panel', '

Note that the menu is positioned relative to the content panel: top, bottom, after, before.

') * ); * $( 'body' ).append( menuLayout.$element ); * * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions * may be omitted. * * .oo-ui-menuLayout-menu { * height: 200px; * width: 200px; * } * .oo-ui-menuLayout-content { * top: 200px; * left: 200px; * right: 200px; * bottom: 200px; * } * * @class * @extends OO.ui.Layout * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [showMenu=true] Show menu * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before` */ OO.ui.MenuLayout = function OoUiMenuLayout( config ) { // Configuration initialization config = $.extend( { showMenu: true, menuPosition: 'before' }, config ); // Parent constructor OO.ui.MenuLayout.super.call( this, config ); /** * Menu DOM node * * @property {jQuery} */ this.$menu = $( '
' ); /** * Content DOM node * * @property {jQuery} */ this.$content = $( '
' ); // Initialization this.$menu .addClass( 'oo-ui-menuLayout-menu' ); this.$content.addClass( 'oo-ui-menuLayout-content' ); this.$element .addClass( 'oo-ui-menuLayout' ) .append( this.$content, this.$menu ); this.setMenuPosition( config.menuPosition ); this.toggleMenu( config.showMenu ); }; /* Setup */ OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout ); /* Methods */ /** * Toggle menu. * * @param {boolean} showMenu Show menu, omit to toggle * @chainable */ OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) { showMenu = showMenu === undefined ? !this.showMenu : !!showMenu; if ( this.showMenu !== showMenu ) { this.showMenu = showMenu; this.$element .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu ) .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu ); } return this; }; /** * Check if menu is visible * * @return {boolean} Menu is visible */ OO.ui.MenuLayout.prototype.isMenuVisible = function () { return this.showMenu; }; /** * Set menu position. * * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before` * @throws {Error} If position value is not supported * @chainable */ OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) { this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition ); this.menuPosition = position; this.$element.addClass( 'oo-ui-menuLayout-' + position ); return this; }; /** * Get menu position. * * @return {string} Menu position */ OO.ui.MenuLayout.prototype.getMenuPosition = function () { return this.menuPosition; }; /** * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate * through the pages and select which one to display. By default, only one page is * displayed at a time and the outline is hidden. When a user navigates to a new page, * the booklet layout automatically focuses on the first focusable element, unless the * default setting is changed. Optionally, booklets can be configured to show * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items. * * @example * // Example of a BookletLayout that contains two PageLayouts. * * function PageOneLayout( name, config ) { * PageOneLayout.super.call( this, name, config ); * this.$element.append( '

First page

(This booklet has an outline, displayed on the left)

' ); * } * OO.inheritClass( PageOneLayout, OO.ui.PageLayout ); * PageOneLayout.prototype.setupOutlineItem = function () { * this.outlineItem.setLabel( 'Page One' ); * }; * * function PageTwoLayout( name, config ) { * PageTwoLayout.super.call( this, name, config ); * this.$element.append( '

Second page

' ); * } * OO.inheritClass( PageTwoLayout, OO.ui.PageLayout ); * PageTwoLayout.prototype.setupOutlineItem = function () { * this.outlineItem.setLabel( 'Page Two' ); * }; * * var page1 = new PageOneLayout( 'one' ), * page2 = new PageTwoLayout( 'two' ); * * var booklet = new OO.ui.BookletLayout( { * outlined: true * } ); * * booklet.addPages ( [ page1, page2 ] ); * $( 'body' ).append( booklet.$element ); * * @class * @extends OO.ui.MenuLayout * * @constructor * @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 a new page is displayed. * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the pages of the booklet. * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages */ OO.ui.BookletLayout = function OoUiBookletLayout( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.BookletLayout.super.call( this, config ); // Properties 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 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 ) ); } // 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 ); } } }; /* Setup */ OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout ); /* Events */ /** * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the booklet layout. * @event set * @param {OO.ui.PageLayout} page Current page */ /** * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout. * * @event add * @param {OO.ui.PageLayout[]} page Added pages * @param {number} index Index pages were added at */ /** * A 'remove' event is emitted when pages are {@link #clearPages cleared} or * {@link #removePages removed} from the booklet. * * @event remove * @param {OO.ui.PageLayout[]} pages Removed pages */ /* Methods */ /** * Handle stack layout focus. * * @private * @param {jQuery.Event} e Focusin event */ 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 stack layout set events. * * @private * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel */ OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { var layout = this; if ( page ) { page.scrollElementIntoView( { complete: function () { if ( layout.autoFocus ) { layout.focus(); } } } ); } }; /** * Focus the first input in the current page. * * 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. * @param {number} [itemIndex] A specific item to focus on */ OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) { var $input, page, items = this.stackLayout.getItems(); if ( itemIndex !== undefined && items[ itemIndex ] ) { page = items[ itemIndex ]; } else { 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(); } } }; /** * Find the first focusable input in the booklet layout and focus * on it. */ OO.ui.BookletLayout.prototype.focusFirstFocusable = function () { var i, len, found = false, items = this.stackLayout.getItems(), checkAndFocus = function () { if ( OO.ui.isFocusableElement( $( this ) ) ) { $( this ).focus(); found = true; return false; } }; for ( i = 0, len = items.length; i < len; i++ ) { if ( found ) { break; } // Find all potentially focusable elements in the item // and check if they are focusable items[i].$element .find( 'input, select, textarea, button, object' ) /* jshint loopfunc:true */ .each( checkAndFocus ); } }; /** * Handle outline widget select events. * * @private * @param {OO.ui.OptionWidget|null} item Selected item */ OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { if ( item ) { this.setPage( item.getData() ); } }; /** * Check if booklet has an outline. * * @return {boolean} Booklet has an outline */ OO.ui.BookletLayout.prototype.isOutlined = function () { return this.outlined; }; /** * Check if booklet has editing controls. * * @return {boolean} Booklet is editable */ OO.ui.BookletLayout.prototype.isEditable = function () { return this.editable; }; /** * Check if booklet has a visible outline. * * @return {boolean} Outline is visible */ OO.ui.BookletLayout.prototype.isOutlineVisible = function () { return this.outlined && this.outlineVisible; }; /** * Hide or show the outline. * * @param {boolean} [show] Show outline, omit to invert current state * @chainable */ OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { if ( this.outlined ) { show = show === undefined ? !this.outlineVisible : !!show; this.outlineVisible = show; this.toggleMenu( show ); } return this; }; /** * Get the page closest to the specified page. * * @param {OO.ui.PageLayout} page Page to use as a reference point * @return {OO.ui.PageLayout|null} Page closest to the specified page */ OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { var next, prev, level, pages = this.stackLayout.getItems(), index = $.inArray( page, pages ); 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; }; /** * Get the outline widget. * * If the booklet is not outlined, the method will return `null`. * * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined */ OO.ui.BookletLayout.prototype.getOutline = function () { return this.outlineSelectWidget; }; /** * Get the outline controls widget. * * If the outline is not editable, the method will return `null`. * * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. */ OO.ui.BookletLayout.prototype.getOutlineControls = function () { return this.outlineControlsWidget; }; /** * Get a page by its symbolic 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 ]; }; /** * Get the current page. * * @return {OO.ui.PageLayout|undefined} Current page, if found */ OO.ui.BookletLayout.prototype.getCurrentPage = function () { var name = this.getCurrentPageName(); return name ? this.getPage( name ) : undefined; }; /** * Get the symbolic name of the current page. * * @return {string|null} Symbolic name of the current page */ OO.ui.BookletLayout.prototype.getCurrentPageName = function () { return this.currentPageName; }; /** * Add pages to the booklet 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. * * @param {OO.ui.PageLayout[]} pages Pages to add * @param {number} index Index of the insertion point * @fires add * @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( { 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 ); return this; }; /** * Remove the specified pages from the booklet layout. * * To remove all pages from the booklet, you may wish to use the #clearPages method instead. * * @param {OO.ui.PageLayout[]} pages An array of pages to remove * @fires remove * @chainable */ 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(); } this.stackLayout.removeItems( pages ); this.emit( 'remove', pages ); return this; }; /** * Clear all pages from the booklet layout. * * To remove only a subset of pages from the booklet, use the #removePages method. * * @fires remove * @chainable */ OO.ui.BookletLayout.prototype.clearPages = function () { var i, len, pages = this.stackLayout.getItems(); 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(); this.emit( 'remove', pages ); return this; }; /** * Set the current page by symbolic name. * * @fires set * @param {string} name Symbolic name of page */ OO.ui.BookletLayout.prototype.setPage = function ( name ) { var selectedItem, $focused, page = this.pages[ name ]; if ( name !== this.currentPageName ) { if ( this.outlined ) { selectedItem = this.outlineSelectWidget.getSelectedItem(); if ( selectedItem && selectedItem.getData() !== name ) { this.outlineSelectWidget.selectItemByData( 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 ); } } }; /** * Select the first selectable page. * * @chainable */ OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { if ( !this.outlineSelectWidget.getSelectedItem() ) { this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() ); } return this; }; /** * IndexLayouts contain {@link OO.ui.CardLayout card layouts} as well as * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the cards and * select which one to display. By default, only one card is displayed at a time. When a user * navigates to a new card, the index layout automatically focuses on the first focusable element, * unless the default setting is changed. * * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication * * @example * // Example of a IndexLayout that contains two CardLayouts. * * function CardOneLayout( name, config ) { * CardOneLayout.super.call( this, name, config ); * this.$element.append( '

First card

' ); * } * OO.inheritClass( CardOneLayout, OO.ui.CardLayout ); * CardOneLayout.prototype.setupTabItem = function () { * this.tabItem.setLabel( 'Card One' ); * }; * * function CardTwoLayout( name, config ) { * CardTwoLayout.super.call( this, name, config ); * this.$element.append( '

Second card

' ); * } * OO.inheritClass( CardTwoLayout, OO.ui.CardLayout ); * CardTwoLayout.prototype.setupTabItem = function () { * this.tabItem.setLabel( 'Card Two' ); * }; * * var card1 = new CardOneLayout( 'one' ), * card2 = new CardTwoLayout( 'two' ); * * var index = new OO.ui.IndexLayout(); * * index.addCards ( [ card1, card2 ] ); * $( 'body' ).append( index.$element ); * * @class * @extends OO.ui.MenuLayout * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [continuous=false] Show all cards, one after another * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed. */ OO.ui.IndexLayout = function OoUiIndexLayout( config ) { // Configuration initialization config = $.extend( {}, config, { menuPosition: 'top' } ); // Parent constructor OO.ui.IndexLayout.super.call( this, config ); // Properties this.currentCardName = null; this.cards = {}; 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.tabSelectWidget = new OO.ui.TabSelectWidget(); this.tabPanel = new OO.ui.PanelLayout(); this.$menu.append( this.tabPanel.$element ); this.toggleMenu( true ); // Events this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); this.tabSelectWidget.connect( this, { select: 'onTabSelectWidgetSelect' } ); if ( this.autoFocus ) { // Event 'focus' does not bubble, but 'focusin' does this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); } // Initialization this.$element.addClass( 'oo-ui-indexLayout' ); this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' ); this.tabPanel.$element .addClass( 'oo-ui-indexLayout-tabPanel' ) .append( this.tabSelectWidget.$element ); }; /* Setup */ OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout ); /* Events */ /** * A 'set' event is emitted when a card is {@link #setCard set} to be displayed by the index layout. * @event set * @param {OO.ui.CardLayout} card Current card */ /** * An 'add' event is emitted when cards are {@link #addCards added} to the index layout. * * @event add * @param {OO.ui.CardLayout[]} card Added cards * @param {number} index Index cards were added at */ /** * A 'remove' event is emitted when cards are {@link #clearCards cleared} or * {@link #removeCards removed} from the index. * * @event remove * @param {OO.ui.CardLayout[]} cards Removed cards */ /* Methods */ /** * Handle stack layout focus. * * @private * @param {jQuery.Event} e Focusin event */ OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) { var name, $target; // Find the card that an element was focused within $target = $( e.target ).closest( '.oo-ui-cardLayout' ); for ( name in this.cards ) { // Check for card match, exclude current card to find only card changes if ( this.cards[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentCardName ) { this.setCard( name ); break; } } }; /** * Handle stack layout set events. * * @private * @param {OO.ui.PanelLayout|null} card The card panel that is now the current panel */ OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) { var layout = this; if ( card ) { card.scrollElementIntoView( { complete: function () { if ( layout.autoFocus ) { layout.focus(); } } } ); } }; /** * Focus the first input in the current card. * * If no card is selected, the first selectable card will be selected. * If the focus is already in an element on the current card, nothing will happen. * @param {number} [itemIndex] A specific item to focus on */ OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) { var $input, card, items = this.stackLayout.getItems(); if ( itemIndex !== undefined && items[ itemIndex ] ) { card = items[ itemIndex ]; } else { card = this.stackLayout.getCurrentItem(); } if ( !card ) { this.selectFirstSelectableCard(); card = this.stackLayout.getCurrentItem(); } if ( !card ) { return; } // Only change the focus if is not already in the current card if ( !card.$element.find( ':focus' ).length ) { $input = card.$element.find( ':input:first' ); if ( $input.length ) { $input[ 0 ].focus(); } } }; /** * Find the first focusable input in the index layout and focus * on it. */ OO.ui.IndexLayout.prototype.focusFirstFocusable = function () { var i, len, found = false, items = this.stackLayout.getItems(), checkAndFocus = function () { if ( OO.ui.isFocusableElement( $( this ) ) ) { $( this ).focus(); found = true; return false; } }; for ( i = 0, len = items.length; i < len; i++ ) { if ( found ) { break; } // Find all potentially focusable elements in the item // and check if they are focusable items[i].$element .find( 'input, select, textarea, button, object' ) .each( checkAndFocus ); } }; /** * Handle tab widget select events. * * @private * @param {OO.ui.OptionWidget|null} item Selected item */ OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) { if ( item ) { this.setCard( item.getData() ); } }; /** * Get the card closest to the specified card. * * @param {OO.ui.CardLayout} card Card to use as a reference point * @return {OO.ui.CardLayout|null} Card closest to the specified card */ OO.ui.IndexLayout.prototype.getClosestCard = function ( card ) { var next, prev, level, cards = this.stackLayout.getItems(), index = $.inArray( card, cards ); if ( index !== -1 ) { next = cards[ index + 1 ]; prev = cards[ index - 1 ]; // Prefer adjacent cards at the same level level = this.tabSelectWidget.getItemFromData( card.getName() ).getLevel(); if ( prev && level === this.tabSelectWidget.getItemFromData( prev.getName() ).getLevel() ) { return prev; } if ( next && level === this.tabSelectWidget.getItemFromData( next.getName() ).getLevel() ) { return next; } } return prev || next || null; }; /** * Get the tabs widget. * * @return {OO.ui.TabSelectWidget} Tabs widget */ OO.ui.IndexLayout.prototype.getTabs = function () { return this.tabSelectWidget; }; /** * Get a card by its symbolic name. * * @param {string} name Symbolic name of card * @return {OO.ui.CardLayout|undefined} Card, if found */ OO.ui.IndexLayout.prototype.getCard = function ( name ) { return this.cards[ name ]; }; /** * Get the current card. * * @return {OO.ui.CardLayout|undefined} Current card, if found */ OO.ui.IndexLayout.prototype.getCurrentCard = function () { var name = this.getCurrentCardName(); return name ? this.getCard( name ) : undefined; }; /** * Get the symbolic name of the current card. * * @return {string|null} Symbolic name of the current card */ OO.ui.IndexLayout.prototype.getCurrentCardName = function () { return this.currentCardName; }; /** * Add cards to the index layout * * When cards are added with the same names as existing cards, the existing cards will be * automatically removed before the new cards are added. * * @param {OO.ui.CardLayout[]} cards Cards to add * @param {number} index Index of the insertion point * @fires add * @chainable */ OO.ui.IndexLayout.prototype.addCards = function ( cards, index ) { var i, len, name, card, item, currentIndex, stackLayoutCards = this.stackLayout.getItems(), remove = [], items = []; // Remove cards with same names for ( i = 0, len = cards.length; i < len; i++ ) { card = cards[ i ]; name = card.getName(); if ( Object.prototype.hasOwnProperty.call( this.cards, name ) ) { // Correct the insertion index currentIndex = $.inArray( this.cards[ name ], stackLayoutCards ); if ( currentIndex !== -1 && currentIndex + 1 < index ) { index--; } remove.push( this.cards[ name ] ); } } if ( remove.length ) { this.removeCards( remove ); } // Add new cards for ( i = 0, len = cards.length; i < len; i++ ) { card = cards[ i ]; name = card.getName(); this.cards[ card.getName() ] = card; item = new OO.ui.TabOptionWidget( { data: name } ); card.setTabItem( item ); items.push( item ); } if ( items.length ) { this.tabSelectWidget.addItems( items, index ); this.selectFirstSelectableCard(); } this.stackLayout.addItems( cards, index ); this.emit( 'add', cards, index ); return this; }; /** * Remove the specified cards from the index layout. * * To remove all cards from the index, you may wish to use the #clearCards method instead. * * @param {OO.ui.CardLayout[]} cards An array of cards to remove * @fires remove * @chainable */ OO.ui.IndexLayout.prototype.removeCards = function ( cards ) { var i, len, name, card, items = []; for ( i = 0, len = cards.length; i < len; i++ ) { card = cards[ i ]; name = card.getName(); delete this.cards[ name ]; items.push( this.tabSelectWidget.getItemFromData( name ) ); card.setTabItem( null ); } if ( items.length ) { this.tabSelectWidget.removeItems( items ); this.selectFirstSelectableCard(); } this.stackLayout.removeItems( cards ); this.emit( 'remove', cards ); return this; }; /** * Clear all cards from the index layout. * * To remove only a subset of cards from the index, use the #removeCards method. * * @fires remove * @chainable */ OO.ui.IndexLayout.prototype.clearCards = function () { var i, len, cards = this.stackLayout.getItems(); this.cards = {}; this.currentCardName = null; this.tabSelectWidget.clearItems(); for ( i = 0, len = cards.length; i < len; i++ ) { cards[ i ].setTabItem( null ); } this.stackLayout.clearItems(); this.emit( 'remove', cards ); return this; }; /** * Set the current card by symbolic name. * * @fires set * @param {string} name Symbolic name of card */ OO.ui.IndexLayout.prototype.setCard = function ( name ) { var selectedItem, $focused, card = this.cards[ name ]; if ( name !== this.currentCardName ) { selectedItem = this.tabSelectWidget.getSelectedItem(); if ( selectedItem && selectedItem.getData() !== name ) { this.tabSelectWidget.selectItemByData( name ); } if ( card ) { if ( this.currentCardName && this.cards[ this.currentCardName ] ) { this.cards[ this.currentCardName ].setActive( false ); // Blur anything focused if the next card doesn't have anything focusable - this // is not needed if the next card has something focusable because once it is focused // this blur happens automatically if ( this.autoFocus && !card.$element.find( ':input' ).length ) { $focused = this.cards[ this.currentCardName ].$element.find( ':focus' ); if ( $focused.length ) { $focused[ 0 ].blur(); } } } this.currentCardName = name; this.stackLayout.setItem( card ); card.setActive( true ); this.emit( 'set', card ); } } }; /** * Select the first selectable card. * * @chainable */ OO.ui.IndexLayout.prototype.selectFirstSelectableCard = function () { if ( !this.tabSelectWidget.getSelectedItem() ) { this.tabSelectWidget.selectItem( this.tabSelectWidget.getFirstSelectableItem() ); } return this; }; /** * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding, * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}. * * @example * // Example of a panel layout * var panel = new OO.ui.PanelLayout( { * expanded: false, * framed: true, * padded: true, * $content: $( '

A panel layout with padding and a frame.

' ) * } ); * $( 'body' ).append( panel.$element ); * * @class * @extends OO.ui.Layout * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [scrollable=false] Allow vertical scrolling * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel. * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element. * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content. */ OO.ui.PanelLayout = function OoUiPanelLayout( config ) { // Configuration initialization config = $.extend( { scrollable: false, padded: false, expanded: true, framed: false }, config ); // Parent constructor OO.ui.PanelLayout.super.call( this, config ); // Initialization this.$element.addClass( 'oo-ui-panelLayout' ); if ( config.scrollable ) { this.$element.addClass( 'oo-ui-panelLayout-scrollable' ); } if ( config.padded ) { this.$element.addClass( 'oo-ui-panelLayout-padded' ); } if ( config.expanded ) { this.$element.addClass( 'oo-ui-panelLayout-expanded' ); } if ( config.framed ) { this.$element.addClass( 'oo-ui-panelLayout-framed' ); } }; /* Setup */ OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout ); /** * CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display * from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly, * rather extended to include the required content and functionality. * * Each card must have a unique symbolic name, which is passed to the constructor. In addition, the card's tab * item is customized (with a label) using the #setupTabItem method. See * {@link OO.ui.IndexLayout IndexLayout} for an example. * * @class * @extends OO.ui.PanelLayout * * @constructor * @param {string} name Unique symbolic name of card * @param {Object} [config] Configuration options */ OO.ui.CardLayout = function OoUiCardLayout( name, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( name ) && config === undefined ) { config = name; name = config.name; } // Configuration initialization config = $.extend( { scrollable: true }, config ); // Parent constructor OO.ui.CardLayout.super.call( this, config ); // Properties this.name = name; this.tabItem = null; this.active = false; // Initialization this.$element.addClass( 'oo-ui-cardLayout' ); }; /* Setup */ OO.inheritClass( OO.ui.CardLayout, OO.ui.PanelLayout ); /* Events */ /** * An 'active' event is emitted when the card becomes active. Cards become active when they are * shown in a index layout that is configured to display only one card at a time. * * @event active * @param {boolean} active Card is active */ /* Methods */ /** * Get the symbolic name of the card. * * @return {string} Symbolic name of card */ OO.ui.CardLayout.prototype.getName = function () { return this.name; }; /** * Check if card is active. * * Cards become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is configured to display * only one card at a time. Additional CSS is applied to the card's tab item to reflect the active state. * * @return {boolean} Card is active */ OO.ui.CardLayout.prototype.isActive = function () { return this.active; }; /** * Get tab item. * * The tab item allows users to access the card from the index's tab * navigation. The tab item itself can be customized (with a label, level, etc.) using the #setupTabItem method. * * @return {OO.ui.TabOptionWidget|null} Tab option widget */ OO.ui.CardLayout.prototype.getTabItem = function () { return this.tabItem; }; /** * Set or unset the tab item. * * Specify a {@link OO.ui.TabOptionWidget tab option} to set it, * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab * level), use #setupTabItem instead of this method. * * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear * @chainable */ OO.ui.CardLayout.prototype.setTabItem = function ( tabItem ) { this.tabItem = tabItem || null; if ( tabItem ) { this.setupTabItem(); } return this; }; /** * Set up the tab item. * * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use * the #setTabItem method instead. * * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up * @chainable */ OO.ui.CardLayout.prototype.setupTabItem = function () { return this; }; /** * Set the card to its 'active' state. * * Cards become active when they are shown in a index layout that is configured to display only one card at a time. Additional * CSS is applied to the tab item to reflect the card's active state. Outside of the index * context, setting the active state on a card does nothing. * * @param {boolean} value Card is active * @fires active */ OO.ui.CardLayout.prototype.setActive = function ( active ) { active = !!active; if ( active !== this.active ) { this.active = active; this.$element.toggleClass( 'oo-ui-cardLayout-active', this.active ); this.emit( 'active', this.active ); } }; /** * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that users can select and display * from the booklet's optional {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated directly, * rather extended to include the required content and functionality. * * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the page's outline * item is customized (with a label, outline level, etc.) using the #setupOutlineItem method. See * {@link OO.ui.BookletLayout BookletLayout} for an example. * * @class * @extends OO.ui.PanelLayout * * @constructor * @param {string} name Unique symbolic name of page * @param {Object} [config] Configuration options */ OO.ui.PageLayout = function OoUiPageLayout( name, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( name ) && config === undefined ) { config = name; name = config.name; } // Configuration initialization config = $.extend( { scrollable: true }, config ); // Parent constructor OO.ui.PageLayout.super.call( this, config ); // Properties this.name = name; this.outlineItem = null; this.active = false; // Initialization this.$element.addClass( 'oo-ui-pageLayout' ); }; /* Setup */ OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout ); /* Events */ /** * An 'active' event is emitted when the page becomes active. Pages become active when they are * shown in a booklet layout that is configured to display only one page at a time. * * @event active * @param {boolean} active Page is active */ /* Methods */ /** * Get the symbolic name of the page. * * @return {string} Symbolic name of page */ OO.ui.PageLayout.prototype.getName = function () { return this.name; }; /** * Check if page is active. * * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is configured to display * only one page at a time. Additional CSS is applied to the page's outline item to reflect the active state. * * @return {boolean} Page is active */ OO.ui.PageLayout.prototype.isActive = function () { return this.active; }; /** * Get outline item. * * The outline item allows users to access the page from the booklet's outline * navigation. The outline item itself can be customized (with a label, level, etc.) using the #setupOutlineItem method. * * @return {OO.ui.OutlineOptionWidget|null} Outline option widget */ OO.ui.PageLayout.prototype.getOutlineItem = function () { return this.outlineItem; }; /** * Set or unset the outline item. * * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it, * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label or outline * level), use #setupOutlineItem instead of this method. * * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear * @chainable */ OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) { this.outlineItem = outlineItem || null; if ( outlineItem ) { this.setupOutlineItem(); } return this; }; /** * Set up the outline item. * * Use this method to customize the outline item (e.g., to add a label or outline level). To set or unset * the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or `null`), use * the #setOutlineItem method instead. * * @param {OO.ui.OutlineOptionWidget} outlineItem Outline option widget to set up * @chainable */ OO.ui.PageLayout.prototype.setupOutlineItem = function () { return this; }; /** * Set the page to its 'active' state. * * Pages become active when they are shown in a booklet layout that is configured to display only one page at a time. Additional * CSS is applied to the outline item to reflect the page's active state. Outside of the booklet * context, setting the active state on a page does nothing. * * @param {boolean} value Page is active * @fires active */ OO.ui.PageLayout.prototype.setActive = function ( active ) { active = !!active; if ( active !== this.active ) { this.active = active; this.$element.toggleClass( 'oo-ui-pageLayout-active', active ); this.emit( 'active', this.active ); } }; /** * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one panel is displayed * at a time, though the stack layout can also be configured to show all contained panels, one after another, * by setting the #continuous option to 'true'. * * @example * // A stack layout with two panels, configured to be displayed continously * var myStack = new OO.ui.StackLayout( { * items: [ * new OO.ui.PanelLayout( { * $content: $( '

Panel One

' ), * padded: true, * framed: true * } ), * new OO.ui.PanelLayout( { * $content: $( '

Panel Two

' ), * padded: true, * framed: true * } ) * ], * continuous: true * } ); * $( 'body' ).append( myStack.$element ); * * @class * @extends OO.ui.PanelLayout * @mixins OO.ui.GroupElement * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel is displayed at a time. * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout. */ OO.ui.StackLayout = function OoUiStackLayout( config ) { // Configuration initialization config = $.extend( { scrollable: true }, config ); // Parent constructor OO.ui.StackLayout.super.call( this, config ); // Mixin constructors OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Properties this.currentItem = null; this.continuous = !!config.continuous; // Initialization this.$element.addClass( 'oo-ui-stackLayout' ); if ( this.continuous ) { this.$element.addClass( 'oo-ui-stackLayout-continuous' ); } if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; /* Setup */ OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout ); OO.mixinClass( OO.ui.StackLayout, OO.ui.GroupElement ); /* Events */ /** * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed}, * {@link #clearItems cleared} or {@link #setItem displayed}. * * @event set * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown */ /* Methods */ /** * Get the current panel. * * @return {OO.ui.Layout|null} */ OO.ui.StackLayout.prototype.getCurrentItem = function () { return this.currentItem; }; /** * Unset the current item. * * @private * @param {OO.ui.StackLayout} layout * @fires set */ OO.ui.StackLayout.prototype.unsetCurrentItem = function () { var prevItem = this.currentItem; if ( prevItem === null ) { return; } this.currentItem = null; this.emit( 'set', null ); }; /** * Add panel layouts to the stack layout. * * Panels will be added to the end of the stack layout array unless the optional index parameter specifies a different * insertion point. Adding a panel that is already in the stack will move it to the end of the array or the point specified * by the index. * * @param {OO.ui.Layout[]} items Panels to add * @param {number} [index] Index of the insertion point * @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 ] ); } return this; }; /** * Remove the specified panels from the stack layout. * * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all panels, * you may wish to use the #clearItems method instead. * * @param {OO.ui.Layout[]} items Panels to remove * @chainable * @fires set */ OO.ui.StackLayout.prototype.removeItems = function ( items ) { // Mixin method OO.ui.GroupElement.prototype.removeItems.call( this, items ); if ( $.inArray( this.currentItem, items ) !== -1 ) { if ( this.items.length ) { this.setItem( this.items[ 0 ] ); } else { this.unsetCurrentItem(); } } return this; }; /** * Clear all panels from the stack layout. * * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only * a subset of panels, use the #removeItems method. * * @chainable * @fires set */ OO.ui.StackLayout.prototype.clearItems = function () { this.unsetCurrentItem(); OO.ui.GroupElement.prototype.clearItems.call( this ); return this; }; /** * Show the specified panel. * * If another panel is currently displayed, it will be hidden. * * @param {OO.ui.Layout} item Panel to show * @chainable * @fires set */ OO.ui.StackLayout.prototype.setItem = function ( item ) { if ( item !== this.currentItem ) { this.updateHiddenState( this.items, item ); if ( $.inArray( item, this.items ) !== -1 ) { this.currentItem = item; this.emit( 'set', item ); } else { this.unsetCurrentItem(); } } 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. * * @private * @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. * * @class * @extends OO.ui.ToolGroup * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options */ OO.ui.BarToolGroup = function OoUiBarToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolbar ) && config === undefined ) { config = toolbar; toolbar = config.toolbar; } // Parent constructor OO.ui.BarToolGroup.super.call( this, toolbar, config ); // Initialization this.$element.addClass( 'oo-ui-barToolGroup' ); }; /* Setup */ OO.inheritClass( OO.ui.BarToolGroup, OO.ui.ToolGroup ); /* Static Properties */ OO.ui.BarToolGroup.static.titleTooltips = true; OO.ui.BarToolGroup.static.accelTooltips = true; OO.ui.BarToolGroup.static.name = 'bar'; /** * Popup list of tools with an icon and optional label. * * @abstract * @class * @extends OO.ui.ToolGroup * @mixins OO.ui.IconElement * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.ClippableElement * @mixins OO.ui.TabIndexedElement * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options * @cfg {string} [header] Text to display at the top of the pop-up */ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolbar ) && config === undefined ) { config = toolbar; toolbar = config.toolbar; } // Configuration initialization config = config || {}; // Parent constructor OO.ui.PopupToolGroup.super.call( this, toolbar, config ); // Properties this.active = false; this.dragging = false; this.onBlurHandler = this.onBlur.bind( this ); this.$handle = $( '' ); // Mixin constructors OO.ui.IconElement.call( this, config ); OO.ui.IndicatorElement.call( this, config ); OO.ui.LabelElement.call( this, config ); OO.ui.TitledElement.call( this, config ); OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) ); OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); // Events this.$handle.on( { keydown: this.onHandleMouseKeyDown.bind( this ), keyup: this.onHandleMouseKeyUp.bind( this ), mousedown: this.onHandleMouseKeyDown.bind( this ), mouseup: this.onHandleMouseKeyUp.bind( this ) } ); // Initialization this.$handle .addClass( 'oo-ui-popupToolGroup-handle' ) .append( this.$icon, this.$label, this.$indicator ); // If the pop-up should have a header, add it to the top of the toolGroup. // Note: If this feature is useful for other widgets, we could abstract it into an // OO.ui.HeaderedElement mixin constructor. if ( config.header !== undefined ) { this.$group .prepend( $( '' ) .addClass( 'oo-ui-popupToolGroup-header' ) .text( config.header ) ); } this.$element .addClass( 'oo-ui-popupToolGroup' ) .prepend( this.$handle ); }; /* Setup */ OO.inheritClass( OO.ui.PopupToolGroup, OO.ui.ToolGroup ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.IconElement ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.LabelElement ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.TitledElement ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.ClippableElement ); OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.TabIndexedElement ); /* Methods */ /** * @inheritdoc */ OO.ui.PopupToolGroup.prototype.setDisabled = function () { // Parent method OO.ui.PopupToolGroup.super.prototype.setDisabled.apply( this, arguments ); if ( this.isDisabled() && this.isElementAttached() ) { this.setActive( false ); } }; /** * Handle focus being lost. * * The event is actually generated from a mouseup/keyup, so it is not a normal blur event object. * * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) { // Only deactivate when clicking outside the dropdown element if ( $( e.target ).closest( '.oo-ui-popupToolGroup' )[ 0 ] !== this.$element[ 0 ] ) { this.setActive( false ); } }; /** * @inheritdoc */ OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) { // Only close toolgroup when a tool was actually selected if ( !this.isDisabled() && this.pressed && this.pressed === this.getTargetTool( e ) && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { this.setActive( false ); } return OO.ui.PopupToolGroup.super.prototype.onMouseKeyUp.call( this, e ); }; /** * Handle mouse up and key up events. * * @param {jQuery.Event} e Mouse up or key up event */ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) { if ( !this.isDisabled() && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { return false; } }; /** * Handle mouse down and key down events. * * @param {jQuery.Event} e Mouse down or key down event */ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) { if ( !this.isDisabled() && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { this.setActive( !this.active ); return false; } }; /** * Switch into active mode. * * When active, mouseup events anywhere in the document will trigger deactivation. */ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) { value = !!value; if ( this.active !== value ) { this.active = value; if ( value ) { this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true ); this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true ); // Try anchoring the popup to the left first this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' ); this.toggleClipping( true ); if ( this.isClippedHorizontally() ) { // Anchoring to the left caused the popup to clip, so anchor it to the right instead this.toggleClipping( false ); this.$element .removeClass( 'oo-ui-popupToolGroup-left' ) .addClass( 'oo-ui-popupToolGroup-right' ); this.toggleClipping( true ); } } else { this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true ); this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true ); this.$element.removeClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left oo-ui-popupToolGroup-right' ); this.toggleClipping( false ); } } }; /** * Drop down list layout of tools as labeled icon buttons. * * This layout allows some tools to be collapsible, controlled by a "More" / "Fewer" option at the * bottom of the main list. These are not automatically positioned at the bottom of the list; you * may want to use the 'promote' and 'demote' configuration options to achieve this. * * @class * @extends OO.ui.PopupToolGroup * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options * @cfg {Array} [allowCollapse] List of tools that can be collapsed. Remaining tools will be always * shown. * @cfg {Array} [forceExpand] List of tools that *may not* be collapsed. All remaining tools will be * allowed to be collapsed. * @cfg {boolean} [expanded=false] Whether the collapsible tools are expanded by default */ OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolbar ) && config === undefined ) { config = toolbar; toolbar = config.toolbar; } // Configuration initialization config = config || {}; // Properties (must be set before parent constructor, which calls #populate) this.allowCollapse = config.allowCollapse; this.forceExpand = config.forceExpand; this.expanded = config.expanded !== undefined ? config.expanded : false; this.collapsibleTools = []; // Parent constructor OO.ui.ListToolGroup.super.call( this, toolbar, config ); // Initialization this.$element.addClass( 'oo-ui-listToolGroup' ); }; /* Setup */ OO.inheritClass( OO.ui.ListToolGroup, OO.ui.PopupToolGroup ); /* Static Properties */ OO.ui.ListToolGroup.static.name = 'list'; /* Methods */ /** * @inheritdoc */ OO.ui.ListToolGroup.prototype.populate = function () { var i, len, allowCollapse = []; OO.ui.ListToolGroup.super.prototype.populate.call( this ); // Update the list of collapsible tools if ( this.allowCollapse !== undefined ) { allowCollapse = this.allowCollapse; } else if ( this.forceExpand !== undefined ) { allowCollapse = OO.simpleArrayDifference( Object.keys( this.tools ), this.forceExpand ); } this.collapsibleTools = []; for ( i = 0, len = allowCollapse.length; i < len; i++ ) { if ( this.tools[ allowCollapse[ i ] ] !== undefined ) { this.collapsibleTools.push( this.tools[ allowCollapse[ i ] ] ); } } // Keep at the end, even when tools are added this.$group.append( this.getExpandCollapseTool().$element ); this.getExpandCollapseTool().toggle( this.collapsibleTools.length !== 0 ); this.updateCollapsibleState(); }; OO.ui.ListToolGroup.prototype.getExpandCollapseTool = function () { if ( this.expandCollapseTool === undefined ) { var ExpandCollapseTool = function () { ExpandCollapseTool.super.apply( this, arguments ); }; OO.inheritClass( ExpandCollapseTool, OO.ui.Tool ); ExpandCollapseTool.prototype.onSelect = function () { this.toolGroup.expanded = !this.toolGroup.expanded; this.toolGroup.updateCollapsibleState(); this.setActive( false ); }; ExpandCollapseTool.prototype.onUpdateState = function () { // Do nothing. Tool interface requires an implementation of this function. }; ExpandCollapseTool.static.name = 'more-fewer'; this.expandCollapseTool = new ExpandCollapseTool( this ); } return this.expandCollapseTool; }; /** * @inheritdoc */ OO.ui.ListToolGroup.prototype.onMouseKeyUp = function ( e ) { // Do not close the popup when the user wants to show more/fewer tools if ( $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length && ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { // HACK: Prevent the popup list from being hidden. Skip the PopupToolGroup implementation (which // hides the popup list when a tool is selected) and call ToolGroup's implementation directly. return OO.ui.ListToolGroup.super.super.prototype.onMouseKeyUp.call( this, e ); } else { return OO.ui.ListToolGroup.super.prototype.onMouseKeyUp.call( this, e ); } }; OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () { var i, len; this.getExpandCollapseTool() .setIcon( this.expanded ? 'collapse' : 'expand' ) .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 ); } }; /** * Drop down menu layout of tools as selectable menu items. * * @class * @extends OO.ui.PopupToolGroup * * @constructor * @param {OO.ui.Toolbar} toolbar * @param {Object} [config] Configuration options */ OO.ui.MenuToolGroup = function OoUiMenuToolGroup( toolbar, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolbar ) && config === undefined ) { config = toolbar; toolbar = config.toolbar; } // Configuration initialization config = config || {}; // Parent constructor OO.ui.MenuToolGroup.super.call( this, toolbar, config ); // Events this.toolbar.connect( this, { updateState: 'onUpdateState' } ); // Initialization this.$element.addClass( 'oo-ui-menuToolGroup' ); }; /* Setup */ OO.inheritClass( OO.ui.MenuToolGroup, OO.ui.PopupToolGroup ); /* Static Properties */ OO.ui.MenuToolGroup.static.name = 'menu'; /* Methods */ /** * Handle the toolbar state being updated. * * When the state changes, the title of each active item in the menu will be joined together and * used as a label for the group. The label will be empty if none of the items are active. */ OO.ui.MenuToolGroup.prototype.onUpdateState = function () { var name, labelTexts = []; for ( name in this.tools ) { if ( this.tools[ name ].isActive() ) { labelTexts.push( this.tools[ name ].getTitle() ); } } this.setLabel( labelTexts.join( ', ' ) || ' ' ); }; /** * Tool that shows a popup when selected. * * @abstract * @class * @extends OO.ui.Tool * @mixins OO.ui.PopupElement * * @constructor * @param {OO.ui.ToolGroup} toolGroup * @param {Object} [config] Configuration options */ OO.ui.PopupTool = function OoUiPopupTool( toolGroup, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolGroup ) && config === undefined ) { config = toolGroup; toolGroup = config.toolGroup; } // Parent constructor OO.ui.PopupTool.super.call( this, toolGroup, config ); // Mixin constructors OO.ui.PopupElement.call( this, config ); // Initialization this.$element .addClass( 'oo-ui-popupTool' ) .append( this.popup.$element ); }; /* Setup */ OO.inheritClass( OO.ui.PopupTool, OO.ui.Tool ); OO.mixinClass( OO.ui.PopupTool, OO.ui.PopupElement ); /* Methods */ /** * Handle the tool being selected. * * @inheritdoc */ OO.ui.PopupTool.prototype.onSelect = function () { if ( !this.isDisabled() ) { this.popup.toggle(); } this.setActive( false ); return false; }; /** * Handle the toolbar state being updated. * * @inheritdoc */ OO.ui.PopupTool.prototype.onUpdateState = function () { this.setActive( false ); }; /** * Tool that has a tool group inside. This is a bad workaround for the lack of proper hierarchical * menus in toolbars (T74159). * * @abstract * @class * @extends OO.ui.Tool * * @constructor * @param {OO.ui.ToolGroup} toolGroup * @param {Object} [config] Configuration options */ OO.ui.ToolGroupTool = function OoUiToolGroupTool( toolGroup, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( toolGroup ) && config === undefined ) { config = toolGroup; toolGroup = config.toolGroup; } // Parent constructor OO.ui.ToolGroupTool.super.call( this, toolGroup, config ); // Properties this.innerToolGroup = this.createGroup( this.constructor.static.groupConfig ); // Events this.innerToolGroup.connect( this, { disable: 'onToolGroupDisable' } ); // Initialization this.$link.remove(); this.$element .addClass( 'oo-ui-toolGroupTool' ) .append( this.innerToolGroup.$element ); }; /* Setup */ OO.inheritClass( OO.ui.ToolGroupTool, OO.ui.Tool ); /* Static Properties */ /** * Tool group configuration. See OO.ui.Toolbar#setup for the accepted values. * * @property {Object.} */ OO.ui.ToolGroupTool.static.groupConfig = {}; /* Methods */ /** * Handle the tool being selected. * * @inheritdoc */ OO.ui.ToolGroupTool.prototype.onSelect = function () { this.innerToolGroup.setActive( !this.innerToolGroup.active ); return false; }; /** * Synchronize disabledness state of the tool with the inner toolgroup. * * @private * @param {boolean} disabled Element is disabled */ OO.ui.ToolGroupTool.prototype.onToolGroupDisable = function ( disabled ) { this.setDisabled( disabled ); }; /** * Handle the toolbar state being updated. * * @inheritdoc */ OO.ui.ToolGroupTool.prototype.onUpdateState = function () { this.setActive( false ); }; /** * Build a OO.ui.ToolGroup from the configuration. * * @param {Object.} group Tool group configuration. See OO.ui.Toolbar#setup for the * accepted values. * @return {OO.ui.ListToolGroup} */ OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) { if ( group.include === '*' ) { // Apply defaults to catch-all groups if ( group.label === undefined ) { group.label = OO.ui.msg( 'ooui-toolbar-more' ); } } return this.toolbar.getToolGroupFactory().create( 'list', this.toolbar, group ); }; /** * Mixin for OO.ui.Widget subclasses to provide OO.ui.GroupElement. * * Use together with OO.ui.ItemWidget to make disabled state inheritable. * * @private * @abstract * @class * @extends OO.ui.GroupElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.GroupWidget = function OoUiGroupWidget( config ) { // Parent constructor OO.ui.GroupWidget.super.call( this, config ); }; /* Setup */ OO.inheritClass( OO.ui.GroupWidget, OO.ui.GroupElement ); /* Methods */ /** * Set the disabled state of the widget. * * This will also update the disabled state of child widgets. * * @param {boolean} disabled Disable widget * @chainable */ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { var i, len; // Parent method // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget OO.ui.Widget.prototype.setDisabled.call( this, disabled ); // During construction, #setDisabled is called before the OO.ui.GroupElement constructor if ( this.items ) { for ( i = 0, len = this.items.length; i < len; i++ ) { this.items[ i ].updateDisabled(); } } return this; }; /** * Mixin for widgets used as items in widgets that inherit OO.ui.GroupWidget. * * Item widgets have a reference to a OO.ui.GroupWidget while they are attached to the group. This * allows bidirectional communication. * * Use together with OO.ui.GroupWidget to make disabled state inheritable. * * @private * @abstract * @class * * @constructor */ OO.ui.ItemWidget = function OoUiItemWidget() { // }; /* Methods */ /** * Check if widget is disabled. * * Checks parent if present, making disabled state inheritable. * * @return {boolean} Widget is disabled */ OO.ui.ItemWidget.prototype.isDisabled = function () { return this.disabled || ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() ); }; /** * Set group element is in. * * @param {OO.ui.GroupElement|null} group Group element, null if none * @chainable */ OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) { // Parent method // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element OO.ui.Element.prototype.setElementGroup.call( this, group ); // Initialize item disabled states this.updateDisabled(); return this; }; /** * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}. * Controls include moving items up and down, removing items, and adding different kinds of items. * ####Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.#### * * @class * @extends OO.ui.Widget * @mixins OO.ui.GroupElement * @mixins OO.ui.IconElement * * @constructor * @param {OO.ui.OutlineSelectWidget} outline Outline to control * @param {Object} [config] Configuration options * @cfg {Object} [abilities] List of abilties * @cfg {boolean} [abilities.move=true] Allow moving movable items * @cfg {boolean} [abilities.remove=true] Allow removing removable items */ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) { // Allow passing positional parameters inside the config object if ( OO.isPlainObject( outline ) && config === undefined ) { config = outline; outline = config.outline; } // Configuration initialization config = $.extend( { icon: 'add' }, config ); // Parent constructor OO.ui.OutlineControlsWidget.super.call( this, config ); // Mixin constructors OO.ui.GroupElement.call( this, config ); OO.ui.IconElement.call( this, config ); // Properties this.outline = outline; this.$movers = $( '
' ); this.upButton = new OO.ui.ButtonWidget( { framed: false, icon: 'collapse', title: OO.ui.msg( 'ooui-outline-control-move-up' ) } ); this.downButton = new OO.ui.ButtonWidget( { framed: false, icon: 'expand', title: OO.ui.msg( 'ooui-outline-control-move-down' ) } ); this.removeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'remove', title: OO.ui.msg( 'ooui-outline-control-remove' ) } ); this.abilities = { move: true, remove: true }; // Events outline.connect( this, { select: 'onOutlineChange', add: 'onOutlineChange', remove: 'onOutlineChange' } ); this.upButton.connect( this, { click: [ 'emit', 'move', -1 ] } ); this.downButton.connect( this, { click: [ 'emit', 'move', 1 ] } ); this.removeButton.connect( this, { click: [ 'emit', 'remove' ] } ); // Initialization this.$element.addClass( 'oo-ui-outlineControlsWidget' ); this.$group.addClass( 'oo-ui-outlineControlsWidget-items' ); this.$movers .addClass( 'oo-ui-outlineControlsWidget-movers' ) .append( this.removeButton.$element, this.upButton.$element, this.downButton.$element ); this.$element.append( this.$icon, this.$group, this.$movers ); this.setAbilities( config.abilities || {} ); }; /* Setup */ OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.GroupElement ); OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.IconElement ); /* Events */ /** * @event move * @param {number} places Number of places to move */ /** * @event remove */ /* Methods */ /** * Set abilities. * * @param {Object} abilities List of abilties * @param {boolean} [abilities.move] Allow moving movable items * @param {boolean} [abilities.remove] Allow removing removable items */ OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) { var ability; for ( ability in this.abilities ) { if ( abilities[ability] !== undefined ) { this.abilities[ability] = !!abilities[ability]; } } this.onOutlineChange(); }; /** * * @private * Handle outline change events. */ OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () { var i, len, firstMovable, lastMovable, items = this.outline.getItems(), selectedItem = this.outline.getSelectedItem(), movable = this.abilities.move && selectedItem && selectedItem.isMovable(), removable = this.abilities.remove && selectedItem && selectedItem.isRemovable(); if ( movable ) { i = -1; len = items.length; while ( ++i < len ) { if ( items[ i ].isMovable() ) { firstMovable = items[ i ]; break; } } i = len; while ( i-- ) { if ( items[ i ].isMovable() ) { lastMovable = items[ i ]; break; } } } this.upButton.setDisabled( !movable || selectedItem === firstMovable ); this.downButton.setDisabled( !movable || selectedItem === lastMovable ); this.removeButton.setDisabled( !removable ); }; /** * ToggleWidget implements basic behavior of widgets with an on/off state. * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples. * * @abstract * @class * @extends OO.ui.Widget * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [value=false] The toggle’s initial on/off state. * By default, the toggle is in the 'off' state. */ OO.ui.ToggleWidget = function OoUiToggleWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.ToggleWidget.super.call( this, config ); // Properties this.value = null; // Initialization this.$element.addClass( 'oo-ui-toggleWidget' ); this.setValue( !!config.value ); }; /* Setup */ OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget ); /* Events */ /** * @event change * * A change event is emitted when the on/off state of the toggle changes. * * @param {boolean} value Value representing the new state of the toggle */ /* Methods */ /** * Get the value representing the toggle’s state. * * @return {boolean} The on/off state of the toggle */ OO.ui.ToggleWidget.prototype.getValue = function () { return this.value; }; /** * Set the state of the toggle: `true` for 'on', `false' for 'off'. * * @param {boolean} value The state of the toggle * @fires change * @chainable */ OO.ui.ToggleWidget.prototype.setValue = function ( value ) { value = !!value; if ( this.value !== value ) { this.value = 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; }; /** * 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 * @mixins OO.ui.GroupElement * * @constructor * @param {Object} [config] Configuration options * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add */ OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.ButtonGroupWidget.super.call( this, config ); // Mixin constructors OO.ui.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-buttonGroupWidget' ); if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; /* Setup */ OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); /** * 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 * @mixins OO.ui.ButtonElement * @mixins OO.ui.IconElement * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.FlaggedElement * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options * @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 || {}; // Parent constructor OO.ui.ButtonWidget.super.call( this, config ); // Mixin constructors OO.ui.ButtonElement.call( this, config ); OO.ui.IconElement.call( this, config ); OO.ui.IndicatorElement.call( this, config ); OO.ui.LabelElement.call( this, config ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); OO.ui.FlaggedElement.call( this, config ); OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); // Properties this.href = null; this.target = null; this.noFollow = false; // Events this.connect( this, { disable: 'onDisable' } ); // Initialization this.$button.append( this.$icon, this.$label, this.$indicator ); this.$element .addClass( 'oo-ui-buttonWidget' ) .append( this.$button ); this.setHref( config.href ); this.setTarget( config.target ); this.setNoFollow( config.noFollow ); }; /* Setup */ OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.ButtonElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TitledElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.FlaggedElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TabIndexedElement ); /* Methods */ /** * @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' ); } return OO.ui.ButtonElement.prototype.onMouseDown.call( this, e ); }; /** * @inheritdoc */ OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) { if ( !this.isDisabled() ) { // Restore the tab-index after the button is up to restore the button's accessibility this.$button.attr( 'tabindex', this.tabIndex ); } return OO.ui.ButtonElement.prototype.onMouseUp.call( this, e ); }; /** * Get hyperlink location. * * @return {string} Hyperlink location */ OO.ui.ButtonWidget.prototype.getHref = function () { return this.href; }; /** * Get hyperlink target. * * @return {string} Hyperlink target */ 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. * * @param {string|null} href Hyperlink location, null to remove */ OO.ui.ButtonWidget.prototype.setHref = function ( href ) { href = typeof href === 'string' ? href : null; if ( href !== this.href ) { this.href = href; this.updateHref(); } return this; }; /** * Update the `href` attribute, in case of changes to href or * disabled state. * * @private * @chainable */ OO.ui.ButtonWidget.prototype.updateHref = function () { if ( this.href !== null && !this.isDisabled() ) { this.$button.attr( 'href', this.href ); } else { this.$button.removeAttr( 'href' ); } return this; }; /** * Handle disable events. * * @private * @param {boolean} disabled Element is disabled */ OO.ui.ButtonWidget.prototype.onDisable = function () { this.updateHref(); }; /** * Set hyperlink target. * * @param {string|null} target Hyperlink target, null to remove */ OO.ui.ButtonWidget.prototype.setTarget = function ( target ) { target = typeof target === 'string' ? target : null; if ( target !== this.target ) { this.target = target; if ( target !== null ) { this.$button.attr( 'target', target ); } else { this.$button.removeAttr( 'target' ); } } return this; }; /** * 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. * * Both actions and action sets are primarily used with {@link OO.ui.Dialog Dialogs}. * 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 * @mixins OO.ui.PendingElement * * @constructor * @param {Object} [config] Configuration options * @cfg {string} [action] Symbolic name of the action (e.g., ‘continue’ or ‘cancel’). * @cfg {string[]} [modes] Symbolic names of the modes (e.g., ‘edit’ or ‘read’) in which the action * should be made available. See the action set's {@link OO.ui.ActionSet#setMode setMode} method * for more information about setting modes. * @cfg {boolean} [framed=false] Render the action button with a frame */ OO.ui.ActionWidget = function OoUiActionWidget( config ) { // Configuration initialization config = $.extend( { framed: false }, config ); // Parent constructor OO.ui.ActionWidget.super.call( this, config ); // Mixin constructors OO.ui.PendingElement.call( this, config ); // Properties this.action = config.action || ''; this.modes = config.modes || []; this.width = 0; this.height = 0; // Initialization this.$element.addClass( 'oo-ui-actionWidget' ); }; /* Setup */ OO.inheritClass( OO.ui.ActionWidget, OO.ui.ButtonWidget ); OO.mixinClass( OO.ui.ActionWidget, OO.ui.PendingElement ); /* Events */ /** * A resize event is emitted when the size of the widget changes. * * @event resize */ /* Methods */ /** * Check if the action is configured to be available in the specified `mode`. * * @param {string} mode Name of mode * @return {boolean} The action is configured with the mode */ OO.ui.ActionWidget.prototype.hasMode = function ( mode ) { return this.modes.indexOf( mode ) !== -1; }; /** * Get the symbolic name of the action (e.g., ‘continue’ or ‘cancel’). * * @return {string} */ OO.ui.ActionWidget.prototype.getAction = function () { return this.action; }; /** * Get the symbolic name of the mode or modes for which the action is configured to be available. * * The current mode is set with the action set's {@link OO.ui.ActionSet#setMode setMode} method. * Only actions that are configured to be avaiable in the current mode will be visible. All other actions * are hidden. * * @return {string[]} */ OO.ui.ActionWidget.prototype.getModes = function () { return this.modes.slice(); }; /** * Emit a resize event if the size has changed. * * @private * @chainable */ OO.ui.ActionWidget.prototype.propagateResize = function () { var width, height; if ( this.isElementAttached() ) { width = this.$element.width(); height = this.$element.height(); if ( width !== this.width || height !== this.height ) { this.width = width; this.height = height; this.emit( 'resize' ); } } return this; }; /** * @inheritdoc */ OO.ui.ActionWidget.prototype.setIcon = function () { // Mixin method OO.ui.IconElement.prototype.setIcon.apply( this, arguments ); this.propagateResize(); return this; }; /** * @inheritdoc */ OO.ui.ActionWidget.prototype.setLabel = function () { // Mixin method OO.ui.LabelElement.prototype.setLabel.apply( this, arguments ); this.propagateResize(); return this; }; /** * @inheritdoc */ OO.ui.ActionWidget.prototype.setFlags = function () { // Mixin method OO.ui.FlaggedElement.prototype.setFlags.apply( this, arguments ); this.propagateResize(); return this; }; /** * @inheritdoc */ OO.ui.ActionWidget.prototype.clearFlags = function () { // Mixin method OO.ui.FlaggedElement.prototype.clearFlags.apply( this, arguments ); this.propagateResize(); return this; }; /** * Toggle the visibility of the action button. * * @param {boolean} [show] Show button, omit to toggle visibility * @chainable */ OO.ui.ActionWidget.prototype.toggle = function () { // Parent method OO.ui.ActionWidget.super.prototype.toggle.apply( this, arguments ); this.propagateResize(); return this; }; /** * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget}, * which is used to display additional information or options. * * @example * // Example of a popup button. * var popupButton = new OO.ui.PopupButtonWidget( { * label: 'Popup button with options', * icon: 'menu', * popup: { * $content: $( '

Additional options here.

' ), * padded: true, * align: 'force-left' * } * } ); * // Append the button to the DOM. * $( 'body' ).append( popupButton.$element ); * * @class * @extends OO.ui.ButtonWidget * @mixins OO.ui.PopupElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) { // Parent constructor OO.ui.PopupButtonWidget.super.call( this, 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 ); }; /* Setup */ OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget ); OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.PopupElement ); /* Methods */ /** * Handle the button action being triggered. * * @private */ OO.ui.PopupButtonWidget.prototype.onAction = function () { this.popup.toggle(); }; /** * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be * configured with {@link OO.ui.IconElement icons}, {@link OO.ui.IndicatorElement indicators}, * {@link OO.ui.TitledElement titles}, {@link OO.ui.FlaggedElement styling flags}, * and {@link OO.ui.LabelElement labels}. Please see * the [OOjs UI documentation][1] on MediaWiki for more information. * * @example * // Toggle buttons in the 'off' and 'on' state. * var toggleButton1 = new OO.ui.ToggleButtonWidget( { * label: 'Toggle Button off' * } ); * var toggleButton2 = new OO.ui.ToggleButtonWidget( { * label: 'Toggle Button on', * value: true * } ); * // Append the buttons to the DOM. * $( 'body' ).append( toggleButton1.$element, toggleButton2.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Toggle_buttons * * @class * @extends OO.ui.ToggleWidget * @mixins OO.ui.ButtonElement * @mixins OO.ui.IconElement * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.FlaggedElement * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [value=false] The toggle button’s initial on/off * state. By default, the button is in the 'off' state. */ OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.ToggleButtonWidget.super.call( this, config ); // Mixin constructors OO.ui.ButtonElement.call( this, config ); OO.ui.IconElement.call( this, config ); OO.ui.IndicatorElement.call( this, config ); OO.ui.LabelElement.call( this, config ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); OO.ui.FlaggedElement.call( this, config ); OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); // Events this.connect( this, { click: 'onAction' } ); // Initialization this.$button.append( this.$icon, this.$label, this.$indicator ); this.$element .addClass( 'oo-ui-toggleButtonWidget' ) .append( this.$button ); }; /* Setup */ OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.ButtonElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.TitledElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.FlaggedElement ); OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.TabIndexedElement ); /* Methods */ /** * Handle the button action being triggered. * * @private */ OO.ui.ToggleButtonWidget.prototype.onAction = function () { this.setValue( !this.value ); }; /** * @inheritdoc */ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { value = !!value; if ( value !== this.value ) { // Might be called from parent constructor before ButtonElement constructor if ( this.$button ) { this.$button.attr( 'aria-pressed', value.toString() ); } this.setActive( value ); } // Parent method OO.ui.ToggleButtonWidget.super.prototype.setValue.call( this, value ); return this; }; /** * @inheritdoc */ OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) { if ( this.$button ) { this.$button.removeAttr( 'aria-pressed' ); } OO.ui.ButtonElement.prototype.setButtonElement.call( this, $button ); this.$button.attr( 'aria-pressed', this.value.toString() ); }; /** * 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' * } ) * ] * } * } ); * * $( 'body' ).append( dropDown.$element ); * * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options * * @class * @extends OO.ui.Widget * @mixins OO.ui.IconElement * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options * @cfg {Object} [menu] Configuration options to pass to menu widget */ OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) { // Configuration initialization config = $.extend( { indicator: 'down' }, 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( { widget: this }, config.menu ) ); // Events this.$handle.on( { click: this.onClick.bind( this ), keypress: this.onKeyPress.bind( this ) } ); this.menu.connect( this, { select: 'onMenuSelect' } ); // Initialization this.$handle .addClass( 'oo-ui-dropdownWidget-handle' ) .append( this.$icon, this.$label, this.$indicator ); this.$element .addClass( 'oo-ui-dropdownWidget' ) .append( this.$handle, this.menu.$element ); }; /* Setup */ OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TitledElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TabIndexedElement ); /* Methods */ /** * Get the menu. * * @return {OO.ui.MenuSelectWidget} Menu of widget */ OO.ui.DropdownWidget.prototype.getMenu = function () { return this.menu; }; /** * Handles menu select events. * * @private * @param {OO.ui.MenuOptionWidget} item Selected menu item */ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { var selectedLabel; if ( !item ) { return; } selectedLabel = item.getLabel(); // If the label is a DOM element, clone it, because setLabel will append() it if ( selectedLabel instanceof jQuery ) { selectedLabel = selectedLabel.clone(); } this.setLabel( selectedLabel ); }; /** * Handle mouse click events. * * @private * @param {jQuery.Event} e Mouse click event */ OO.ui.DropdownWidget.prototype.onClick = function ( e ) { if ( !this.isDisabled() && e.which === 1 ) { this.menu.toggle(); } return false; }; /** * 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; } }; /** * 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 ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @class * @extends OO.ui.Widget * @mixins OO.ui.IconElement * @mixins OO.ui.TitledElement * @mixins OO.ui.FlaggedElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.IconWidget = function OoUiIconWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.IconWidget.super.call( this, config ); // Mixin constructors OO.ui.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); OO.ui.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-iconWidget' ); }; /* Setup */ OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.IconWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.IconWidget, OO.ui.TitledElement ); OO.mixinClass( OO.ui.IconWidget, OO.ui.FlaggedElement ); /* Static Properties */ OO.ui.IconWidget.static.tagName = 'span'; /** * IndicatorWidgets create indicators, which are small graphics that are generally used to draw * attention to the status of an item or to clarify the function of a control. For a list of * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1]. * * @example * // Example of an indicator widget * var indicator1 = new OO.ui.IndicatorWidget( { * indicator: 'alert' * } ); * * // Create a fieldset layout to add a label * var fieldset = new OO.ui.FieldsetLayout(); * fieldset.addItems( [ * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } ) * ] ); * $( 'body' ).append( fieldset.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators * * @class * @extends OO.ui.Widget * @mixins OO.ui.IndicatorElement * @mixins OO.ui.TitledElement * * @constructor * @param {Object} [config] Configuration options */ OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.IndicatorWidget.super.call( this, config ); // Mixin constructors OO.ui.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) ); // Initialization this.$element.addClass( 'oo-ui-indicatorWidget' ); }; /* Setup */ OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.TitledElement ); /* Static Properties */ OO.ui.IndicatorWidget.static.tagName = 'span'; /** * 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 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute. * @cfg {string} [value=''] The value of the input. * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input * before it is accepted. */ OO.ui.InputWidget = function OoUiInputWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.InputWidget.super.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 ) ); // Initialization this.$input .attr( 'name', config.name ) .prop( 'disabled', this.isDisabled() ); this.$element.addClass( 'oo-ui-inputWidget' ).append( this.$input, $( '' ) ); this.setValue( config.value ); }; /* Setup */ OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); OO.mixinClass( OO.ui.InputWidget, OO.ui.TabIndexedElement ); /* Events */ /** * @event change * * A change event is emitted when the value of the input changes. * * @param {string} value */ /* Methods */ /** * 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 * @return {jQuery} Input element */ OO.ui.InputWidget.prototype.getInputElement = function () { return $( '' ); }; /** * Handle potentially value-changing events. * * @private * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event */ OO.ui.InputWidget.prototype.onEdit = function () { var widget = this; if ( !this.isDisabled() ) { // Allow the stack to clear so the value will be updated setTimeout( function () { widget.setValue( widget.$input.val() ); } ); } }; /** * Get the value of the input. * * @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; }; /** * Set the direction of the input, either RTL (right-to-left) or LTR (left-to-right). * * @param {boolean} isRTL * Direction is right-to-left */ OO.ui.InputWidget.prototype.setRTL = function ( isRTL ) { this.$input.prop( 'dir', isRTL ? 'rtl' : 'ltr' ); }; /** * Set the value of the input. * * @param {string} value New value * @fires change * @chainable */ OO.ui.InputWidget.prototype.setValue = function ( value ) { value = this.cleanUpValue( value ); // Update the DOM if it has changed. Note that with cleanUpValue, it // is possible for the DOM value to change without this.value changing. if ( this.$input.val() !== value ) { this.$input.val( value ); } if ( this.value !== value ) { this.value = value; this.emit( 'change', this.value ); } return this; }; /** * Clean up incoming value. * * Ensures value is a string, and converts undefined and null to empty string. * * @private * @param {string} value Original value * @return {string} Cleaned up value */ OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) { if ( value === undefined || value === null ) { return ''; } else if ( this.inputFilter ) { return this.inputFilter( String( value ) ); } else { return String( value ); } }; /** * Simulate the behavior of clicking on a label bound to this input. This method is only called by * {@link OO.ui.LabelWidget LabelWidget} and {@link OO.ui.FieldLayout FieldLayout}. It should not be * called directly. */ OO.ui.InputWidget.prototype.simulateLabelClick = function () { if ( !this.isDisabled() ) { if ( this.$input.is( ':checkbox, :radio' ) ) { this.$input.click(); } if ( this.$input.is( ':input' ) ) { this.$input[ 0 ].focus(); } } }; /** * @inheritdoc */ OO.ui.InputWidget.prototype.setDisabled = function ( state ) { OO.ui.InputWidget.super.prototype.setDisabled.call( this, state ); if ( this.$input ) { this.$input.prop( 'disabled', this.isDisabled() ); } return this; }; /** * Focus the input. * * @chainable */ OO.ui.InputWidget.prototype.focus = function () { this.$input[ 0 ].focus(); return this; }; /** * Blur the input. * * @chainable */ OO.ui.InputWidget.prototype.blur = function () { this.$input[ 0 ].blur(); return this; }; /** * 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 `