%PDF- %PDF-
| Direktori : /www/varak.net/wiki.varak.net/resources/lib/oojs-ui/ |
| Current File : /www/varak.net/wiki.varak.net/resources/lib/oojs-ui/oojs-ui.js |
/*!
* OOjs UI v0.12.12
* https://www.mediawiki.org/wiki/OOjs_UI
*
* Copyright 2011–2015 OOjs UI Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
* Date: 2015-10-13T20:38:18Z
*/
( 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
};
/**
* @property {Number}
*/
OO.ui.elementId = 0;
/**
* Generate a unique ID for element
*
* @return {String} [id]
*/
OO.ui.generateElementId = function () {
OO.ui.elementId += 1;
return 'oojsui-' + OO.ui.elementId;
};
/**
* Check if an element is focusable.
* Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14
*
* @param {jQuery} element Element to test
* @return {boolean}
*/
OO.ui.isFocusableElement = function ( $element ) {
var nodeName,
element = $element[ 0 ];
// Anything disabled is not focusable
if ( element.disabled ) {
return false;
}
// Check if the element is visible
if ( !(
// This is quicker than calling $element.is( ':visible' )
$.expr.filters.visible( element ) &&
// Check that all parents are visible
!$element.parents().addBack().filter( function () {
return $.css( this, 'visibility' ) === 'hidden';
} ).length
) ) {
return false;
}
// Check if the element is ContentEditable, which is the string 'true'
if ( element.contentEditable === 'true' ) {
return true;
}
// Anything with a non-negative numeric tabIndex is focusable.
// Use .prop to avoid browser bugs
if ( $element.prop( 'tabIndex' ) >= 0 ) {
return true;
}
// Some element types are naturally focusable
// (indexOf is much faster than regex in Chrome and about the
// same in FF: https://jsperf.com/regex-vs-indexof-array2)
nodeName = element.nodeName.toLowerCase();
if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
return true;
}
// Links and areas are focusable if they have an href
if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
return true;
}
return false;
};
/**
* Find a focusable child
*
* @param {jQuery} $container Container to search in
* @param {boolean} [backwards] Search backwards
* @return {jQuery} Focusable child, an empty jQuery object if none found
*/
OO.ui.findFocusable = function ( $container, backwards ) {
var $focusable = $( [] ),
// $focusableCandidates is a superset of things that
// could get matched by isFocusableElement
$focusableCandidates = $container
.find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
if ( backwards ) {
$focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
}
$focusableCandidates.each( function () {
var $this = $( this );
if ( OO.ui.isFocusableElement( $this ) ) {
$focusable = $this;
return false;
}
} );
return $focusable;
};
/**
* 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.<string,Mixed>} 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 );
};
};
/**
* Proxy for `node.addEventListener( eventName, handler, true )`, if the browser supports it.
* Otherwise falls back to non-capturing event listeners.
*
* @param {HTMLElement} node
* @param {string} eventName
* @param {Function} handler
*/
OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
if ( node.addEventListener ) {
node.addEventListener( eventName, handler, true );
} else {
node.attachEvent( 'on' + eventName, handler );
}
};
/**
* Proxy for `node.removeEventListener( eventName, handler, true )`, if the browser supports it.
* Otherwise falls back to non-capturing event listeners.
*
* @param {HTMLElement} node
* @param {string} eventName
* @param {Function} handler
*/
OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) {
if ( node.addEventListener ) {
node.removeEventListener( eventName, handler, true );
} else {
node.detachEvent( 'on' + eventName, handler );
}
};
/**
* Reconstitute a JavaScript object corresponding to a widget created by
* the PHP implementation.
*
* 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',
// Label for the file selection widget's select file button
'ooui-selectfile-button-select': 'Select a file',
// Label for the file selection widget if file selection is not supported
'ooui-selectfile-not-supported': 'File selection is not supported',
// Label for the file selection widget when no file is currently selected
'ooui-selectfile-placeholder': 'No file is selected',
// Label for the file selection widget's drop target
'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
};
/**
* 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;
};
/**
* @param {string} url
* @return {boolean}
*/
OO.ui.isSafeUrl = function ( url ) {
var protocol,
// Keep in sync with php/Tag.php
whitelist = [
'bitcoin:', 'ftp:', 'ftps:', 'geo:', 'git:', 'gopher:', 'http:', 'https:', 'irc:', 'ircs:',
'magnet:', 'mailto:', 'mms:', 'news:', 'nntp:', 'redis:', 'sftp:', 'sip:', 'sips:', 'sms:', 'ssh:',
'svn:', 'tel:', 'telnet:', 'urn:', 'worldwind:', 'xmpp:'
];
if ( url.indexOf( ':' ) === -1 ) {
// No protocol, safe
return true;
}
protocol = url.split( ':', 1 )[ 0 ] + ':';
if ( !protocol.match( /^([A-za-z0-9\+\.\-])+:/ ) ) {
// Not a valid protocol, safe
return true;
}
// Safe if in the whitelist
return whitelist.indexOf( protocol ) !== -1;
};
} )();
/*!
* Mixin namespace.
*/
/**
* Namespace for OOjs UI mixins.
*
* Mixins are named according to the type of object they are intended to
* be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
* mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
* is intended to be mixed in to an instance of OO.ui.Widget.
*
* @class
* @singleton
*/
OO.ui.mixin = {};
/**
* PendingElement is a mixin that is used to create elements that notify users that something is happening
* and that they should wait before proceeding. The pending state is visually represented with a pending
* texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
* field of a {@link OO.ui.TextInputWidget text input widget}.
*
* Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
* used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
* in process dialogs.
*
* @example
* function MessageDialog( config ) {
* MessageDialog.parent.call( this, config );
* }
* OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
*
* MessageDialog.static.actions = [
* { action: 'save', label: 'Done', flags: 'primary' },
* { label: 'Cancel', flags: 'safe' }
* ];
*
* MessageDialog.prototype.initialize = function () {
* MessageDialog.parent.prototype.initialize.apply( this, arguments );
* this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
* this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
* this.$body.append( this.content.$element );
* };
* MessageDialog.prototype.getBodyHeight = function () {
* return 100;
* }
* MessageDialog.prototype.getActionProcess = function ( action ) {
* var dialog = this;
* if ( action === 'save' ) {
* dialog.getActions().get({actions: 'save'})[0].pushPending();
* return new OO.ui.Process()
* .next( 1000 )
* .next( function () {
* dialog.getActions().get({actions: 'save'})[0].popPending();
* } );
* }
* return MessageDialog.parent.prototype.getActionProcess.call( this, action );
* };
*
* var windowManager = new OO.ui.WindowManager();
* $( 'body' ).append( windowManager.$element );
*
* var dialog = new MessageDialog();
* windowManager.addWindows( [ dialog ] );
* windowManager.openWindow( dialog );
*
* @abstract
* @class
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
*/
OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.pending = 0;
this.$pending = null;
// Initialisation
this.setPendingElement( config.$pending || this.$element );
};
/* Setup */
OO.initClass( OO.ui.mixin.PendingElement );
/* Methods */
/**
* Set the pending element (and clean up any existing one).
*
* @param {jQuery} $pending The element to set to pending.
*/
OO.ui.mixin.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 an element is pending.
*
* @return {boolean} Element is pending
*/
OO.ui.mixin.PendingElement.prototype.isPending = function () {
return !!this.pending;
};
/**
* Increase the pending counter. The pending state will remain active until the counter is zero
* (i.e., the number of calls to #pushPending and #popPending is the same).
*
* @chainable
*/
OO.ui.mixin.PendingElement.prototype.pushPending = function () {
if ( this.pending === 0 ) {
this.$pending.addClass( 'oo-ui-pendingElement-pending' );
this.updateThemeClasses();
}
this.pending++;
return this;
};
/**
* Decrease the pending counter. The pending state will remain active until the counter is zero
* (i.e., the number of calls to #pushPending and #popPending is the same).
*
* @chainable
*/
OO.ui.mixin.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.parent.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.parent.prototype.initialize.apply( this, arguments );
* this.panel1 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
* this.panel1.$element.append( '<p>This dialog uses an action set (continue, help, cancel, back) configured with modes. This is edit mode. Click \'help\' to see help mode.</p>' );
* this.panel2 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
* this.panel2.$element.append( '<p>This is help mode. Only the \'back\' action widget is configured to be visible here. Click \'back\' to return to \'edit\' mode.</p>' );
* this.stackLayout = new OO.ui.StackLayout( {
* items: [ this.panel1, this.panel2 ]
* } );
* this.$body.append( this.stackLayout.$element );
* };
* MyProcessDialog.prototype.getSetupProcess = function ( data ) {
* return MyProcessDialog.parent.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.parent.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.<string,boolean>} 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 {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
* @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
* Data can also be specified with the #setData method.
*/
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 = OO.ui.debounce( this.debouncedUpdateThemeClasses );
// 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, false );
// 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 {jQuery.Promise|boolean} domPromise A promise that will be resolved
* when the top-level widget of this infusion is inserted into DOM,
* replacing the original node; or false for top-level invocation.
* @return {OO.ui.Element}
*/
OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
// look for a cached result of a previous infusion.
var id, $elem, data, cls, parts, parent, obj, top, state;
if ( typeof idOrNode === 'string' ) {
id = idOrNode;
$elem = $( document.getElementById( id ) );
} else {
$elem = $( idOrNode );
id = $elem.attr( 'id' );
}
if ( !$elem.length ) {
throw new Error( 'Widget not found: ' + id );
}
data = $elem.data( 'ooui-infused' ) || $elem[ 0 ].oouiInfused;
if ( data ) {
// cached!
if ( data === true ) {
throw new Error( 'Circular dependency! ' + id );
}
return data;
}
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 } );
}
parts = data._.split( '.' );
cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
if ( cls === undefined ) {
// The PHP output might be old and not including the "OO.ui" prefix
// TODO: Remove this back-compat after next major release
cls = OO.getProp.apply( OO, [ OO.ui ].concat( parts ) );
if ( cls === undefined ) {
throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
}
}
// Verify that we're creating an OO.ui.Element instance
parent = cls.parent;
while ( parent !== undefined ) {
if ( parent === OO.ui.Element ) {
// Safe
break;
}
parent = parent.parent;
}
if ( parent !== OO.ui.Element ) {
throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
}
if ( domPromise === false ) {
top = $.Deferred();
domPromise = top.promise();
}
$elem.data( 'ooui-infused', true ); // prevent loops
data.id = id; // implicit
data = OO.copy( data, null, function deserialize( value ) {
if ( OO.isPlainObject( value ) ) {
if ( value.tag ) {
return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
}
if ( value.html ) {
return new OO.ui.HtmlSnippet( value.html );
}
}
} );
// jscs:disable requireCapitalizedConstructors
obj = new cls( data ); // rebuild widget
// pick up dynamic state, like focus, value of form inputs, scroll position, etc.
state = obj.gatherPreInfuseState( $elem );
// now replace old DOM with this new DOM.
if ( top ) {
$elem.replaceWith( obj.$element );
// This element is now gone from the DOM, but if anyone is holding a reference to it,
// let's allow them to OO.ui.infuse() it and do what they expect (T105828).
// Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
$elem[ 0 ].oouiInfused = obj;
top.resolve();
}
obj.$element.data( 'ooui-infused', obj );
// set the 'data-ooui' attribute so we can identify infused widgets
obj.$element.attr( 'data-ooui', '' );
// restore dynamic state after the new element is inserted into DOM
domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
return obj;
};
/**
* 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 );
// Support: IE 8
// Standard Document.defaultView is IE9+
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,
// Support: IE 8
// Standard Document.defaultView is IE9+
win = doc.parentWindow || doc.defaultView,
style = win && win.getComputedStyle ?
win.getComputedStyle( el, null ) :
// Support: IE 8
// Standard getComputedStyle() is IE9+
el.currentStyle,
$el = $( el ),
top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
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,
// Support: IE 8
// Standard Document.defaultView is IE9+
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' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091
props = [ 'overflow-x', 'overflow-y' ],
$parent = $( el ).parent();
if ( dimension === 'x' || dimension === 'y' ) {
props = [ '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 ) {
var rel, anim, callback, sc, $sc, eld, scd, $win;
// Configuration initialization
config = config || {};
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 <https://code.google.com/p/chromium/issues/detail?id=387290>, 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, scrollLeft, scrollTop, nodes = [];
// Save scroll position
scrollLeft = el.scrollLeft;
scrollTop = el.scrollTop;
// 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 ] );
}
// Restore scroll position (no-op if scrollbars disappeared)
el.scrollLeft = scrollLeft;
el.scrollTop = scrollTop;
};
/* 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 () {
this.debouncedUpdateThemeClassesHandler();
};
/**
* @private
* @localdoc This method is called directly from the QUnit tests instead of #updateThemeClasses, to
* make them synchronous.
*/
OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () {
OO.ui.theme.updateElementClasses( this );
};
/**
* 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.mixin.GroupElement|null} Group element, null if none
*/
OO.ui.Element.prototype.getElementGroup = function () {
return this.elementGroup;
};
/**
* Set group element is in.
*
* @param {OO.ui.mixin.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 );
};
/**
* Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node
* (and its children) that represent an Element of the same type and configuration as the current
* one, generated by the PHP implementation.
*
* This method is called just before `node` is detached from the DOM. The return value of this
* function will be passed to #restorePreInfuseState after this widget's #$element is inserted into
* DOM to replace `node`.
*
* @protected
* @param {HTMLElement} node
* @return {Object}
*/
OO.ui.Element.prototype.gatherPreInfuseState = function () {
return {};
};
/**
* Restore the pre-infusion dynamic state for this widget.
*
* This method is called after #$element has been inserted into DOM. The parameter is the return
* value of #gatherPreInfuseState.
*
* @protected
* @param {Object} state
*/
OO.ui.Element.prototype.restorePreInfuseState = function () {
};
/**
* Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
* that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
* See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
* {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
* {@link OO.ui.HorizontalLayout HorizontalLayout}, 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.parent.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.parent.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 );
/* Static Properties */
/**
* Whether this widget will behave reasonably when wrapped in a HTML `<label>`. If this is true,
* wrappers such as OO.ui.FieldLayout may use a `<label>` instead of implementing own label click
* handling.
*
* @static
* @inheritable
* @property {boolean}
*/
OO.ui.Widget.static.supportsSimpleLabel = false;
/* Events */
/**
* @event disable
*
* A 'disable' event is emitted when the disabled state of the widget changes
* (i.e. on disable **and** enable).
*
* @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.parent.call( this, config );
// Mixin constructors
OO.EventEmitter.call( this );
// Properties
this.manager = null;
this.size = config.size || this.constructor.static.size;
this.$frame = $( '<div>' );
this.$overlay = $( '<div>' );
this.$content = $( '<div>' );
this.$focusTrapBefore = $( '<div>' ).prop( 'tabIndex', 0 );
this.$focusTrapAfter = $( '<div>' ).prop( 'tabIndex', 0 );
this.$focusTraps = this.$focusTrapBefore.add( this.$focusTrapAfter );
// Initialization
this.$overlay.addClass( 'oo-ui-window-overlay' );
this.$content
.addClass( 'oo-ui-window-content' )
.attr( 'tabindex', 0 );
this.$frame
.addClass( 'oo-ui-window-frame' )
.append( this.$focusTrapBefore, this.$content, this.$focusTrapAfter );
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 () {
var viewport = OO.ui.Element.static.getDimensions( this.getElementWindow() ),
sizes = this.manager.constructor.static.sizes,
size = this.size;
if ( !sizes[ size ] ) {
size = this.manager.constructor.static.defaultSize;
}
if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
size = 'full';
}
return size;
};
/**
* Get the size properties associated with the current window size
*
* @return {Object} Size properties
*/
OO.ui.Window.prototype.getSizeProperties = function () {
return this.manager.constructor.static.sizes[ this.getSize() ];
};
/**
* 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 OO.ui.Element.static.getDir( this.$content ) || 'ltr';
};
/**
* 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 = $( '<div>' );
this.$body = $( '<div>' );
this.$foot = $( '<div>' );
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;
};
/**
* Called when someone tries to focus the hidden element at the end of the dialog.
* Sends focus back to the start of the dialog.
*
* @param {jQuery.Event} event Focus event
*/
OO.ui.Window.prototype.onFocusTrapFocused = function ( event ) {
if ( this.$focusTrapBefore.is( event.target ) ) {
OO.ui.findFocusable( this.$content, true ).focus();
} else {
// this.$content is the part of the focus cycle, and is the first focusable element
this.$content.focus();
}
};
/**
* Open the window.
*
* This method is a wrapper around a call to the window manager’s {@link OO.ui.WindowManager#openWindow openWindow}
* 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.focusTrapHandler = OO.ui.bind( this.onFocusTrapFocused, this );
this.$focusTraps.on( 'focus', this.focusTrapHandler );
this.getSetupProcess( data ).execute().done( function () {
// Force redraw by asking the browser to measure the elements' widths
win.$element.addClass( 'oo-ui-window-active oo-ui-window-setup' ).width();
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.$focusTraps.off( 'focus', win.focusTrapHandler );
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.parent.call( this, config );
* }
* OO.inheritClass( MyDialog, OO.ui.Dialog );
* MyDialog.prototype.initialize = function () {
* MyDialog.parent.prototype.initialize.call( this );
* this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
* this.content.$element.append( '<p>A simple dialog window. Press \'Esc\' to close.</p>' );
* this.$body.append( this.content.$element );
* };
* 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.mixin.PendingElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.Dialog = function OoUiDialog( config ) {
// Parent constructor
OO.ui.Dialog.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.PendingElement.call( this );
// Properties
this.actions = new OO.ui.ActionSet();
this.attachedActions = [];
this.currentAction = null;
this.onDialogKeyDownHandler = this.onDialogKeyDown.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.mixin.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.mixin.LabelElement Label} node, or a function
* that will produce a Label node or string. The title can also be specified with data passed to the
* constructor (see #getSetupProcess). In this case, the static value will be overriden.
*
* @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.onDialogKeyDown = 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.parent.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.$element.on( 'keydown', this.onDialogKeyDownHandler );
}
}, this );
};
/**
* @inheritdoc
*/
OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) {
// Parent method
return OO.ui.Dialog.parent.prototype.getTeardownProcess.call( this, data )
.first( function () {
if ( this.constructor.static.escapable ) {
this.$element.off( 'keydown', this.onDialogKeyDownHandler );
}
this.actions.clear();
this.currentAction = null;
}, this );
};
/**
* @inheritdoc
*/
OO.ui.Dialog.prototype.initialize = function () {
var titleId;
// Parent method
OO.ui.Dialog.parent.prototype.initialize.call( this );
titleId = OO.ui.generateElementId();
// Properties
this.title = new OO.ui.LabelWidget( {
id: titleId
} );
// Initialization
this.$content.addClass( 'oo-ui-dialog-content' );
this.$element.attr( 'aria-labelledby', titleId );
this.setPendingElement( this.$head );
};
/**
* 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.parent.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.<string,OO.ui.Window>|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 ) {
var isFullscreen;
// Bypass for non-current, and thus invisible, windows
if ( win !== this.currentWindow ) {
return;
}
isFullscreen = win.getSize() === 'full';
this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', isFullscreen );
this.$element.toggleClass( 'oo-ui-windowManager-floating', !isFullscreen );
win.setDimensions( win.getSizeProperties() );
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 ) {
var scrollWidth, bodyMargin,
$body = $( this.getElementDocument().body ),
// We could have multiple window managers open so only modify
// the body css at the bottom of the stack
stackDepth = $body.data( 'windowManagerGlobalEvents' ) || 0 ;
on = on === undefined ? !!this.globalEvents : !!on;
if ( on ) {
if ( !this.globalEvents ) {
$( this.getElementWindow() ).on( {
// 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() :
$( '<div>' ).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;
};
/**
* A ToolFactory creates tools on demand. All tools ({@link OO.ui.Tool Tools}, {@link OO.ui.PopupTool PopupTools},
* and {@link OO.ui.ToolGroupTool ToolGroupTools}) must be registered with a tool factory. Tools are
* registered by their symbolic name. See {@link OO.ui.Toolbar toolbars} for an example.
*
* For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @class
* @extends OO.Factory
* @constructor
*/
OO.ui.ToolFactory = function OoUiToolFactory() {
// Parent constructor
OO.ui.ToolFactory.parent.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;
};
/**
* ToolGroupFactories create {@link OO.ui.ToolGroup toolgroups} on demand. The toolgroup classes must
* specify a symbolic name and be registered with the factory. The following classes are registered by
* default:
*
* - {@link OO.ui.BarToolGroup BarToolGroups} (‘bar’)
* - {@link OO.ui.MenuToolGroup MenuToolGroups} (‘menu’)
* - {@link OO.ui.ListToolGroup ListToolGroups} (‘list’)
*
* See {@link OO.ui.Toolbar toolbars} for an example.
*
* For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
* @class
* @extends OO.Factory
* @constructor
*/
OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() {
var i, l, defaultClasses;
// Parent constructor
OO.Factory.call( this );
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.<string,string[]>} Categorized class names with `on` and `off` lists
*/
OO.ui.Theme.prototype.getElementClasses = function () {
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.<string,string[]>} Categorized class names with `on` and `off` lists
*/
OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
var $elements = $( [] ),
classes = this.getElementClasses( element );
if ( element.$icon ) {
$elements = $elements.add( element.$icon );
}
if ( element.$indicator ) {
$elements = $elements.add( element.$indicator );
}
$elements
.removeClass( classes.off.join( ' ' ) )
.addClass( classes.on.join( ' ' ) );
};
/**
* 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.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
// Configuration initialization
config = $.extend( { tabIndex: 0 }, config );
// Properties
this.$tabIndexed = null;
this.tabIndex = null;
// Events
this.connect( this, { disable: 'onTabIndexedElementDisable' } );
// Initialization
this.setTabIndex( config.tabIndex );
this.setTabIndexedElement( config.$tabIndexed || this.$element );
};
/* Setup */
OO.initClass( OO.ui.mixin.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.mixin.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.mixin.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.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
if ( this.$tabIndexed ) {
if ( this.tabIndex !== null ) {
// Do not index over disabled elements
this.$tabIndexed.attr( {
tabindex: this.isDisabled() ? -1 : this.tabIndex,
// Support: ChromeVox and NVDA
// These do not seem to inherit aria-disabled 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.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
this.updateTabIndex();
};
/**
* Get the value of the tabindex.
*
* @return {number|null} Tabindex value
*/
OO.ui.mixin.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 `<a>`.
* @cfg {boolean} [framed=true] Render the button with a frame
*/
OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$button = null;
this.framed = 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.setButtonElement( config.$button || $( '<a>' ) );
};
/* Setup */
OO.initClass( OO.ui.mixin.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.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
* use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
* parent widget.
*
* @static
* @inheritable
* @property {boolean}
*/
OO.ui.mixin.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.mixin.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' } )
.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.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
if ( this.isDisabled() || e.which !== 1 ) {
return;
}
this.$element.addClass( 'oo-ui-buttonElement-pressed' );
// Run the mouseup handler no matter where the mouse is when the button is let go, so we can
// reliably remove the pressed class
OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler );
// 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.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
if ( this.isDisabled() || e.which !== 1 ) {
return;
}
this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
// Stop listening for mouseup, since we only needed this once
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler );
};
/**
* Handles mouse click events.
*
* @protected
* @param {jQuery.Event} e Mouse click event
* @fires click
*/
OO.ui.mixin.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.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
return;
}
this.$element.addClass( 'oo-ui-buttonElement-pressed' );
// Run the keyup handler no matter where the key is when the button is let go, so we can
// reliably remove the pressed class
OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler );
};
/**
* Handles key up events.
*
* @protected
* @param {jQuery.Event} e Key up event
*/
OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
return;
}
this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
// Stop listening for keyup, since we only needed this once
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler );
};
/**
* Handles key press events.
*
* @protected
* @param {jQuery.Event} e Key press event
* @fires click
*/
OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
if ( this.emit( 'click' ) ) {
return false;
}
}
};
/**
* Check if button has a frame.
*
* @return {boolean} Button is framed
*/
OO.ui.mixin.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.mixin.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 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.mixin.ButtonElement.prototype.setActive = function ( value ) {
this.active = !!value;
this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
return this;
};
/**
* Check if the button is active
*
* @return {boolean} The button is active
*/
OO.ui.mixin.ButtonElement.prototype.isActive = function () {
return this.active;
};
/**
* 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 `<div>`.
*/
OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$group = null;
this.items = [];
this.aggregateItemEvents = {};
// Initialization
this.setGroupElement( config.$group || $( '<div>' ) );
};
/* 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.mixin.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.mixin.GroupElement.prototype.isEmpty = function () {
return !this.items.length;
};
/**
* Get all items in the group.
*
* The method returns an array of item references (e.g., [button1, button2, button3]) and is useful
* when synchronizing groups of items, or whenever the references are required (e.g., when removing items
* from a group).
*
* @return {OO.ui.Element[]} An array of items.
*/
OO.ui.mixin.GroupElement.prototype.getItems = function () {
return this.items.slice( 0 );
};
/**
* Get an item by its data.
*
* 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.mixin.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.mixin.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.<string,string|null>} events An object keyed by the name of the event that should be
* aggregated (e.g., ‘click’) and the value of the new name to use (e.g., ‘groupClick’).
* A `null` value will remove aggregated events.
* @throws {Error} An error is thrown if aggregation already exists.
*/
OO.ui.mixin.GroupElement.prototype.aggregate = function ( events ) {
var i, len, item, add, remove, itemEvent, groupEvent;
for ( itemEvent in events ) {
groupEvent = events[ itemEvent ];
// Remove existing aggregated event
if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
// Don't allow duplicate aggregations
if ( groupEvent ) {
throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
}
// Remove event aggregation from existing items
for ( i = 0, len = this.items.length; i < len; i++ ) {
item = this.items[ i ];
if ( item.connect && item.disconnect ) {
remove = {};
remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
item.disconnect( this, remove );
}
}
// Prevent future items from aggregating event
delete this.aggregateItemEvents[ itemEvent ];
}
// Add new aggregate event
if ( groupEvent ) {
// Make future items aggregate event
this.aggregateItemEvents[ itemEvent ] = groupEvent;
// Add event aggregation to existing items
for ( i = 0, len = this.items.length; i < len; i++ ) {
item = this.items[ i ];
if ( item.connect && item.disconnect ) {
add = {};
add[ itemEvent ] = [ 'emit', groupEvent, item ];
item.connect( this, add );
}
}
}
}
};
/**
* Add items to the group.
*
* 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.mixin.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 = this.items.indexOf( item );
if ( currentIndex >= 0 ) {
this.removeItems( [ item ] );
// Adjust index to compensate for removal
if ( currentIndex < index ) {
index--;
}
}
// Add the item
if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
events = {};
for ( 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.mixin.GroupElement.prototype.removeItems = function ( items ) {
var i, len, item, index, remove, itemEvent;
// Remove specific items
for ( i = 0, len = items.length; i < len; i++ ) {
item = items[ i ];
index = this.items.indexOf( item );
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.mixin.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.mixin.DraggableGroupElement, which provides a container for
* the draggable elements.
*
* @abstract
* @class
*
* @constructor
*/
OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement() {
// 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.mixin.DraggableElement );
/* Events */
/**
* @event dragstart
*
* A dragstart event is emitted when the user clicks and begins dragging an item.
* @param {OO.ui.mixin.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.mixin.ButtonElement
*/
OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
/* Methods */
/**
* Respond to dragstart event.
*
* @private
* @param {jQuery.Event} event jQuery event
* @fires dragstart
*/
OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
var dataTransfer = e.originalEvent.dataTransfer;
// Define drop effect
dataTransfer.dropEffect = 'none';
dataTransfer.effectAllowed = 'move';
// Support: Firefox
// We must set up a dataTransfer data property or Firefox seems to
// ignore the fact the element is draggable.
try {
dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() );
} catch ( err ) {
// The above is only for Firefox. Move on if it fails.
}
// 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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.DraggableElement.prototype.getIndex = function () {
return this.index;
};
/**
* DraggableGroupElement is a mixin class used to create a group element to
* contain draggable elements, which are items that can be clicked and dragged by a mouse.
* The class is used with OO.ui.mixin.DraggableElement.
*
* @abstract
* @class
* @mixins OO.ui.mixin.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.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.mixin.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: this.onDragOver.bind( this ),
dragleave: this.onDragLeave.bind( this )
} );
// Initialize
if ( Array.isArray( config.items ) ) {
this.addItems( config.items );
}
this.$placeholder = $( '<div>' )
.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.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
/* Events */
/**
* A 'reorder' event is emitted when the order of items in the group changes.
*
* @event reorder
* @param {OO.ui.mixin.DraggableElement} item Reordered item
* @param {number} [newIndex] New index for the item
*/
/* Methods */
/**
* Respond to item drag start event
*
* @private
* @param {OO.ui.mixin.DraggableElement} item Dragged item
*/
OO.ui.mixin.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.mixin.DraggableGroupElement.prototype.onItemDragEnd = function () {
this.unsetDragItem();
return false;
};
/**
* Handle drop event and switch the order of the items accordingly
*
* @private
* @param {OO.ui.mixin.DraggableElement} item Dropped item
* @fires reorder
*/
OO.ui.mixin.DraggableGroupElement.prototype.onItemDrop = function ( item ) {
var toIndex = item.getIndex();
// Check if the dropped item is from the current group
// TODO: Figure out a way to configure a list of legally droppable
// 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.mixin.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.mixin.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.mixin.DraggableElement} item Dragged item
*/
OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
this.dragItem = item;
};
/**
* Unset the current dragged item
*/
OO.ui.mixin.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.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged
*/
OO.ui.mixin.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.mixin.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 `<span>`. 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 <div> tag instead of a <span>
* $icon: $("<div>")
* // 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.mixin.IconElement = function OoUiMixinIconElement( 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 || $( '<span>' ) );
};
/* Setup */
OO.initClass( OO.ui.mixin.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.mixin.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.mixin.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.mixin.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 );
}
this.updateThemeClasses();
};
/**
* 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.mixin.IconElement.prototype.setIcon = function ( icon ) {
icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
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.mixin.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.mixin.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.mixin.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 `<span>`.
* @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.mixin.IndicatorElement = function OoUiMixinIndicatorElement( 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 || $( '<span>' ) );
};
/* Setup */
OO.initClass( OO.ui.mixin.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.mixin.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.mixin.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.mixin.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 );
}
this.updateThemeClasses();
};
/**
* 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.mixin.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.mixin.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.mixin.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.mixin.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 `<span>`.
* @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.mixin.LabelElement = function OoUiMixinLabelElement( 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 || $( '<span>' ) );
};
/* Setup */
OO.initClass( OO.ui.mixin.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.mixin.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.mixin.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.mixin.LabelElement.prototype.setLabel = function ( label ) {
label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
label = ( ( typeof label === 'string' && label.length ) || label instanceof jQuery || label instanceof OO.ui.HtmlSnippet ) ? label : null;
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.mixin.LabelElement.prototype.getLabel = function () {
return this.label;
};
/**
* Fit the label.
*
* @chainable
*/
OO.ui.mixin.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.mixin.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.FloatingMenuSelectWidget menu} of suggested values for
* a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types
* into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen
* from the lookup menu, that value becomes the value of the input field.
*
* 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.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$overlay = config.$overlay || this.$element;
this.lookupMenu = new OO.ui.FloatingMenuSelectWidget( {
widget: this,
input: this,
$container: config.$container || this.$element
} );
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.mixin.LookupElement.prototype.onLookupInputFocus = function () {
this.lookupInputFocused = true;
this.populateLookupMenu();
};
/**
* Handle input blur event.
*
* @protected
* @param {jQuery.Event} e Input blur event
*/
OO.ui.mixin.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.mixin.LookupElement.prototype.onLookupInputMouseDown = function () {
// Only open the menu if the input was already focused.
// This way we allow the user to open the menu again after closing it with Esc
// by clicking in the input. Opening (and populating) the menu when initially
// 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.mixin.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.mixin.LookupElement.prototype.onLookupMenuToggle = function ( visible ) {
if ( !visible ) {
// When the menu is hidden, abort any active request and clear the menu.
// This has to be done here in addition to closeLookupMenu(), because
// 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.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) {
this.setValue( item.getData() );
};
/**
* Get lookup menu.
*
* @private
* @return {OO.ui.FloatingMenuSelectWidget}
*/
OO.ui.mixin.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.mixin.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.mixin.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.mixin.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.mixin.LookupElement.prototype.populateLookupMenu = function () {
var widget = this,
value = this.getValue();
if ( this.lookupsDisabled || this.isReadOnly() ) {
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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = function () {
// Stub, implemented in subclass
return [];
};
/**
* Set the read-only state of the widget.
*
* This will also disable/enable the lookups functionality.
*
* @param {boolean} readOnly Make input read-only
* @chainable
*/
OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
// Parent method
// Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
// During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
if ( this.isReadOnly() && this.lookupMenu ) {
this.closeLookupMenu();
}
return this;
};
/**
* PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
* A popup is a container for content. It is overlaid and positioned absolutely. By default, each
* popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
* 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.mixin.PopupElement = function OoUiMixinPopupElement( 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.mixin.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.mixin.FlaggedElement = function OoUiMixinFlaggedElement( 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.<string,boolean>} 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.mixin.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.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
// This may be called before the constructor, thus before this.flags is set
return this.flags && ( flag in this.flags );
};
/**
* Get the names of all flags set.
*
* @return {string[]} Flag names
*/
OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
// This may be called before the constructor, thus before this.flags is set
return Object.keys( this.flags || {} );
};
/**
* Clear all flags.
*
* @chainable
* @fires flag
*/
OO.ui.mixin.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.<string, boolean>} 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.mixin.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.mixin.TitledElement = function OoUiMixinTitledElement( 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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.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
* {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
* clipping correctly.
*
* The dimensions of #$clippableContainer will be compared to the boundaries of the
* nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
* then #$clippable will be given a fixed reduced height and/or width and will be made
* scrollable. By default, #$clippable and #$clippableContainer are the same element,
* but you can build a static footer by setting #$clippableContainer to an element that contains
* #$clippable and the footer.
*
* @abstract
* @class
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
* @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
* omit to use #$clippable
*/
OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$clippable = null;
this.$clippableContainer = null;
this.clipping = false;
this.clippedHorizontally = false;
this.clippedVertically = false;
this.$clippableScrollableContainer = null;
this.$clippableScroller = null;
this.$clippableWindow = null;
this.idealWidth = null;
this.idealHeight = null;
this.onClippableScrollHandler = this.clip.bind( this );
this.onClippableWindowResizeHandler = this.clip.bind( this );
// Initialization
if ( config.$clippableContainer ) {
this.setClippableContainer( config.$clippableContainer );
}
this.setClippableElement( config.$clippable || this.$element );
};
/* 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.mixin.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();
};
/**
* Set clippable container.
*
* This is the container that will be measured when deciding whether to clip. When clipping,
* #$clippable will be resized in order to keep the clippable container fully visible.
*
* If the clippable container is unset, #$clippable will be used.
*
* @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
*/
OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
this.$clippableContainer = $clippableContainer;
if ( this.$clippable ) {
this.clip();
}
};
/**
* Toggle clipping.
*
* Do not turn clipping on until after the element is attached to the DOM and visible.
*
* @param {boolean} [clipping] Enable clipping, omit to toggle
* @chainable
*/
OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
clipping = clipping === undefined ? !this.clipping : !!clipping;
if ( this.clipping !== clipping ) {
this.clipping = clipping;
if ( clipping ) {
this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
// If the clippable container is the root, we have to listen to scroll events and check
// jQuery.scrollTop on the window because of browser inconsistencies
this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
$( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
this.$clippableScrollableContainer;
this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
this.$clippableWindow = $( this.getElementWindow() )
.on( 'resize', this.onClippableWindowResizeHandler );
// Initial clip after visible
this.clip();
} else {
this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
this.$clippableScrollableContainer = null;
this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.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.mixin.ClippableElement.prototype.clip = function () {
var $container, extraHeight, extraWidth, ccOffset,
$scrollableContainer, scOffset, scHeight, scWidth,
ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
desiredWidth, desiredHeight, allotedWidth, allotedHeight,
naturalWidth, naturalHeight, clipWidth, clipHeight,
buffer = 7; // Chosen by fair dice roll
if ( !this.clipping ) {
// this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
return this;
}
$container = this.$clippableContainer || this.$clippable;
extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
ccOffset = $container.offset();
$scrollableContainer = this.$clippableScrollableContainer.is( 'html, body' ) ?
this.$clippableWindow : this.$clippableScrollableContainer;
scOffset = $scrollableContainer.offset() || { top: 0, left: 0 };
scHeight = $scrollableContainer.innerHeight() - buffer;
scWidth = $scrollableContainer.innerWidth() - buffer;
ccWidth = $container.outerWidth() + buffer;
scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
desiredWidth = ccOffset.left < 0 ?
ccWidth + ccOffset.left :
( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
allotedWidth = desiredWidth - extraWidth;
allotedHeight = desiredHeight - extraHeight;
naturalWidth = this.$clippable.prop( 'scrollWidth' );
naturalHeight = this.$clippable.prop( 'scrollHeight' );
clipWidth = allotedWidth < naturalWidth;
clipHeight = allotedHeight < naturalHeight;
if ( clipWidth ) {
this.$clippable.css( { overflowX: 'scroll', width: Math.max( 0, allotedWidth ) } );
} else {
this.$clippable.css( { width: this.idealWidth ? this.idealWidth - extraWidth : '', overflowX: '' } );
}
if ( clipHeight ) {
this.$clippable.css( { overflowY: 'scroll', height: Math.max( 0, allotedHeight ) } );
} else {
this.$clippable.css( { height: this.idealHeight ? this.idealHeight - extraHeight : '', overflowY: '' } );
}
// If we stopped clipping in at least one of the dimensions
if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
}
this.clippedHorizontally = clipWidth;
this.clippedVertically = clipHeight;
return this;
};
/**
* Element that will stick under a specified container, even when it is inserted elsewhere in the
* document (for example, in a OO.ui.Window's $overlay).
*
* The elements's position is automatically calculated and maintained when window is resized or the
* page is scrolled. If you reposition the container manually, you have to call #position to make
* sure the element is still placed correctly.
*
* As positioning is only possible when both the element and the container are attached to the DOM
* and visible, it's only done after you call #togglePositioning. You might want to do this inside
* the #toggle method to display a floating popup, for example.
*
* @abstract
* @class
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
* @cfg {jQuery} [$floatableContainer] Node to position below
*/
OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$floatable = null;
this.$floatableContainer = null;
this.$floatableWindow = null;
this.$floatableClosestScrollable = null;
this.onFloatableScrollHandler = this.position.bind( this );
this.onFloatableWindowResizeHandler = this.position.bind( this );
// Initialization
this.setFloatableContainer( config.$floatableContainer );
this.setFloatableElement( config.$floatable || this.$element );
};
/* Methods */
/**
* Set floatable element.
*
* If an element is already set, it will be cleaned up before setting up the new element.
*
* @param {jQuery} $floatable Element to make floatable
*/
OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
if ( this.$floatable ) {
this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
this.$floatable.css( { left: '', top: '' } );
}
this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
this.position();
};
/**
* Set floatable container.
*
* The element will be always positioned under the specified container.
*
* @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
*/
OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
this.$floatableContainer = $floatableContainer;
if ( this.$floatable ) {
this.position();
}
};
/**
* Toggle positioning.
*
* Do not turn positioning on until after the element is attached to the DOM and visible.
*
* @param {boolean} [positioning] Enable positioning, omit to toggle
* @chainable
*/
OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
var closestScrollableOfContainer, closestScrollableOfFloatable;
positioning = positioning === undefined ? !this.positioning : !!positioning;
if ( this.positioning !== positioning ) {
this.positioning = positioning;
closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] );
if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
// If the scrollable is the root, we have to listen to scroll events
// on the window because of browser inconsistencies (or do we? someone should verify this)
if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
}
}
if ( positioning ) {
this.$floatableWindow = $( this.getElementWindow() );
this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
this.$floatableClosestScrollable = $( closestScrollableOfContainer );
this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
}
// Initial position after visible
this.position();
} else {
if ( this.$floatableWindow ) {
this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
this.$floatableWindow = null;
}
if ( this.$floatableClosestScrollable ) {
this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
this.$floatableClosestScrollable = null;
}
this.$floatable.css( { left: '', top: '' } );
}
}
return this;
};
/**
* Position the floatable below its container.
*
* This should only be done when both of them are attached to the DOM and visible.
*
* @chainable
*/
OO.ui.mixin.FloatableElement.prototype.position = function () {
var pos;
if ( !this.positioning ) {
return this;
}
pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
// Position under container
pos.top += this.$floatableContainer.height();
this.$floatable.css( pos );
// We updated the position, so re-evaluate the clipping state.
// (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
// will not notice the need to update itself.)
// TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
// it not listen to the right events in the right places?
if ( this.clip ) {
this.clip();
}
return this;
};
/**
* AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
* Accesskeys allow an user to go to a specific element by using
* a shortcut combination of a browser specific keys + the key
* set to the field.
*
* @example
* // AccessKeyedElement provides an 'accesskey' attribute to the
* // ButtonWidget class
* var button = new OO.ui.ButtonWidget( {
* label: 'Button with Accesskey',
* accessKey: 'k'
* } );
* $( 'body' ).append( button.$element );
*
* @abstract
* @class
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
* If this config is omitted, the accesskey functionality is applied to $element, the
* element created by the class.
* @cfg {string|Function} [accessKey] The key or a function that returns the key. If
* this config is omitted, no accesskey will be added.
*/
OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$accessKeyed = null;
this.accessKey = null;
// Initialization
this.setAccessKey( config.accessKey || null );
this.setAccessKeyedElement( config.$accessKeyed || this.$element );
};
/* Setup */
OO.initClass( OO.ui.mixin.AccessKeyedElement );
/* Static Properties */
/**
* The access key, a function that returns a key, or `null` for no accesskey.
*
* @static
* @inheritable
* @property {string|Function|null}
*/
OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
/* Methods */
/**
* Set the accesskeyed element.
*
* This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
* If an element is already set, the mixin's effect on that element is removed before the new element is set up.
*
* @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
*/
OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
if ( this.$accessKeyed ) {
this.$accessKeyed.removeAttr( 'accesskey' );
}
this.$accessKeyed = $accessKeyed;
if ( this.accessKey ) {
this.$accessKeyed.attr( 'accesskey', this.accessKey );
}
};
/**
* Set accesskey.
*
* @param {string|Function|null} accesskey Key, a function that returns a key, or `null` for no accesskey
* @chainable
*/
OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
if ( this.accessKey !== accessKey ) {
if ( this.$accessKeyed ) {
if ( accessKey !== null ) {
this.$accessKeyed.attr( 'accesskey', accessKey );
} else {
this.$accessKeyed.removeAttr( 'accesskey' );
}
}
this.accessKey = accessKey;
}
return this;
};
/**
* Get accesskey.
*
* @return {string} accessKey string
*/
OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
return this.accessKey;
};
/**
* Tools, together with {@link OO.ui.ToolGroup toolgroups}, constitute {@link OO.ui.Toolbar toolbars}.
* Each tool is configured with a static name, title, and icon and is customized with the command to carry
* out when the tool is selected. Tools must also be registered with a {@link OO.ui.ToolFactory tool factory},
* which creates the tools on demand.
*
* Tools are added to toolgroups ({@link OO.ui.ListToolGroup ListToolGroup},
* {@link OO.ui.BarToolGroup BarToolGroup}, or {@link OO.ui.MenuToolGroup MenuToolGroup}), which determine how
* the tool is displayed in the toolbar. See {@link OO.ui.Toolbar toolbars} for an example.
*
* For more information, please see the [OOjs UI documentation on MediaWiki][1].
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @abstract
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.FlaggedElement
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {OO.ui.ToolGroup} toolGroup
* @param {Object} [config] Configuration options
* @cfg {string|Function} [title] Title text or a function that returns text. If this config is omitted, the value of
* the {@link #static-title static title} property is used.
*
* The title is used in different ways depending on the type of toolgroup that contains the tool. The
* title is used as a tooltip if the tool is part of a {@link OO.ui.BarToolGroup bar} toolgroup, or as the label text if the tool is
* part of a {@link OO.ui.ListToolGroup list} or {@link OO.ui.MenuToolGroup menu} toolgroup.
*
* For bar toolgroups, a description of the accelerator key is appended to the title if an accelerator key
* is associated with an action by the same name as the tool and accelerator functionality has been added to the application.
* To add accelerator key functionality, you must subclass OO.ui.Toolbar and override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method.
*/
OO.ui.Tool = function OoUiTool( toolGroup, config ) {
// Allow passing positional parameters inside the config object
if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
config = toolGroup;
toolGroup = config.toolGroup;
}
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.Tool.parent.call( this, config );
// Properties
this.toolGroup = toolGroup;
this.toolbar = this.toolGroup.getToolbar();
this.active = false;
this.$title = $( '<span>' );
this.$accel = $( '<span>' );
this.$link = $( '<a>' );
this.title = null;
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.FlaggedElement.call( this, config );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) );
// Events
this.toolbar.connect( this, { updateState: 'onUpdateState' } );
// 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.mixin.IconElement );
OO.mixinClass( OO.ui.Tool, OO.ui.mixin.FlaggedElement );
OO.mixinClass( OO.ui.Tool, OO.ui.mixin.TabIndexedElement );
/* Static Properties */
/**
* @static
* @inheritdoc
*/
OO.ui.Tool.static.tagName = 'span';
/**
* Symbolic name of tool.
*
* The symbolic name is used internally to register the tool with a {@link OO.ui.ToolFactory ToolFactory}. It can
* also be used when adding tools to toolgroups.
*
* @abstract
* @static
* @inheritable
* @property {string}
*/
OO.ui.Tool.static.name = '';
/**
* Symbolic name of the group.
*
* The group name is used to associate tools with each other so that they can be selected later by
* a {@link OO.ui.ToolGroup toolgroup}.
*
* @abstract
* @static
* @inheritable
* @property {string}
*/
OO.ui.Tool.static.group = '';
/**
* Tool title text or a function that returns title text. The value of the static property is overridden if the #title config option is used.
*
* @abstract
* @static
* @inheritable
* @property {string|Function}
*/
OO.ui.Tool.static.title = '';
/**
* Display both icon and label when the tool is used in a {@link OO.ui.BarToolGroup bar} toolgroup.
* Normally only the icon is displayed, or only the label if no icon is given.
*
* @static
* @inheritable
* @property {boolean}
*/
OO.ui.Tool.static.displayBothIconAndLabel = false;
/**
* Add tool to catch-all groups automatically.
*
* A catch-all group, which contains all tools that do not currently belong to a toolgroup,
* can be included in a toolgroup using the wildcard selector, an asterisk (*).
*
* @static
* @inheritable
* @property {boolean}
*/
OO.ui.Tool.static.autoAddToCatchall = true;
/**
* Add tool to named groups automatically.
*
* By default, tools that are configured with a static ‘group’ property are added
* to that group and will be selected when the symbolic name of the group is specified (e.g., when
* toolgroups include tools by group name).
*
* @static
* @property {boolean}
* @inheritable
*/
OO.ui.Tool.static.autoAddToGroup = true;
/**
* Check if this tool is compatible with given data.
*
* This is a stub that can be overriden to provide support for filtering tools based on an
* arbitrary piece of information (e.g., where the cursor is in a document). The implementation
* must also call this method so that the compatibility check can be performed.
*
* @static
* @inheritable
* @param {Mixed} data Data to check
* @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.
*
* @protected
* @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.
*
* @protected
* @abstract
*/
OO.ui.Tool.prototype.onSelect = function () {
throw new Error(
'OO.ui.Tool.onSelect not implemented in this subclass:' + this.constructor
);
};
/**
* Check if the tool is active.
*
* Tools become active when their #onSelect or #onUpdateState handlers change them to appear pressed
* with the #setActive method. Additional CSS is applied to the tool to reflect the active state.
*
* @return {boolean} Tool is active
*/
OO.ui.Tool.prototype.isActive = function () {
return this.active;
};
/**
* Make the tool appear active or inactive.
*
* This method should be called within #onSelect or #onUpdateState event handlers to make the tool
* appear pressed or not.
*
* @param {boolean} state Make tool appear active
*/
OO.ui.Tool.prototype.setActive = function ( state ) {
this.active = !!state;
if ( this.active ) {
this.$element.addClass( 'oo-ui-tool-active' );
} else {
this.$element.removeClass( 'oo-ui-tool-active' );
}
};
/**
* Set 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.
*
* Destroying the tool removes all event handlers and the tool’s DOM elements.
* Call this method whenever you are done using a tool.
*/
OO.ui.Tool.prototype.destroy = function () {
this.toolbar.disconnect( this );
this.$element.remove();
};
/**
* Toolbars are complex interface components that permit users to easily access a variety
* of {@link OO.ui.Tool tools} (e.g., formatting commands) and actions, which are additional commands that are
* part of the toolbar, but not configured as tools.
*
* Individual tools are customized and then registered with a {@link OO.ui.ToolFactory tool factory}, which creates
* the tools on demand. Each tool has a symbolic name (used when registering the tool), a title (e.g., ‘Insert
* picture’), and an icon.
*
* Individual tools are organized in {@link OO.ui.ToolGroup toolgroups}, which can be {@link OO.ui.MenuToolGroup menus}
* of tools, {@link OO.ui.ListToolGroup lists} of tools, or a single {@link OO.ui.BarToolGroup bar} of tools.
* The arrangement and order of the toolgroups is customized when the toolbar is set up. Tools can be presented in
* any order, but each can only appear once in the toolbar.
*
* The following is an example of a basic toolbar.
*
* @example
* // Example of a toolbar
* // Create the toolbar
* var toolFactory = new OO.ui.ToolFactory();
* var toolGroupFactory = new OO.ui.ToolGroupFactory();
* var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
*
* // We will be placing status text in this element when tools are used
* var $area = $( '<p>' ).text( '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.parent.apply( this, arguments );
* }
* OO.inheritClass( PictureTool, OO.ui.Tool );
* // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
* // of 'icon' and 'title' (displayed icon and text).
* PictureTool.static.name = 'picture';
* PictureTool.static.icon = 'picture';
* PictureTool.static.title = 'Insert picture';
* // Defines the action that will happen when this tool is selected (clicked).
* PictureTool.prototype.onSelect = function () {
* $area.text( 'Picture tool clicked!' );
* // Never display this tool as "active" (selected).
* this.setActive( false );
* };
* // Make this tool available in our toolFactory and thus our toolbar
* toolFactory.register( PictureTool );
*
* // Register two more tools, nothing interesting here
* function SettingsTool() {
* SettingsTool.parent.apply( this, arguments );
* }
* OO.inheritClass( SettingsTool, OO.ui.Tool );
* SettingsTool.static.name = 'settings';
* SettingsTool.static.icon = 'settings';
* SettingsTool.static.title = 'Change settings';
* SettingsTool.prototype.onSelect = function () {
* $area.text( 'Settings tool clicked!' );
* this.setActive( false );
* };
* toolFactory.register( SettingsTool );
*
* // Register two more tools, nothing interesting here
* function StuffTool() {
* StuffTool.parent.apply( this, arguments );
* }
* OO.inheritClass( StuffTool, OO.ui.Tool );
* StuffTool.static.name = 'stuff';
* StuffTool.static.icon = 'ellipsis';
* StuffTool.static.title = '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( '<p>I am helpful!</p>' );
* }
* OO.inheritClass( HelpTool, OO.ui.PopupTool );
* HelpTool.static.name = 'help';
* HelpTool.static.icon = 'help';
* HelpTool.static.title = 'Help';
* toolFactory.register( HelpTool );
*
* // Finally define which tools and in what order appear in the toolbar. Each tool may only be
* // used once (but not all defined tools must be used).
* toolbar.setup( [
* {
* // 'bar' tool groups display tools' 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' toolgroups 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 = $( '<p>' ).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.parent.apply( this, arguments );
* }
* OO.inheritClass( PictureTool, OO.ui.Tool );
* // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
* // of 'icon' and 'title' (displayed icon and text).
* PictureTool.static.name = 'picture';
* PictureTool.static.icon = 'picture';
* PictureTool.static.title = 'Insert picture';
* // Defines the action that will happen when this tool is selected (clicked).
* PictureTool.prototype.onSelect = function () {
* $area.text( 'Picture tool clicked!' );
* // Never display this tool as "active" (selected).
* this.setActive( false );
* };
* // 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.parent.apply( this, arguments );
* this.reallyActive = false;
* }
* OO.inheritClass( SettingsTool, OO.ui.Tool );
* SettingsTool.static.name = 'settings';
* SettingsTool.static.icon = 'settings';
* SettingsTool.static.title = 'Change settings';
* SettingsTool.prototype.onSelect = function () {
* $area.text( 'Settings tool clicked!' );
* // Toggle the active state on each click
* this.reallyActive = !this.reallyActive;
* this.setActive( this.reallyActive );
* // To update the menu label
* this.toolbar.emit( 'updateState' );
* };
* SettingsTool.prototype.onUpdateState = function () {
* };
* toolFactory.register( SettingsTool );
*
* // Register two more tools, nothing interesting here
* function StuffTool() {
* StuffTool.parent.apply( this, arguments );
* this.reallyActive = false;
* }
* OO.inheritClass( StuffTool, OO.ui.Tool );
* StuffTool.static.name = 'stuff';
* StuffTool.static.icon = 'ellipsis';
* StuffTool.static.title = 'More stuff';
* StuffTool.prototype.onSelect = function () {
* $area.text( 'More stuff tool clicked!' );
* // Toggle the active state on each click
* this.reallyActive = !this.reallyActive;
* this.setActive( this.reallyActive );
* // To update the menu label
* this.toolbar.emit( 'updateState' );
* };
* StuffTool.prototype.onUpdateState = function () {
* };
* toolFactory.register( StuffTool );
*
* // 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( '<p>I am helpful!</p>' );
* }
* OO.inheritClass( HelpTool, OO.ui.PopupTool );
* HelpTool.static.name = 'help';
* HelpTool.static.icon = 'help';
* HelpTool.static.title = 'Help';
* toolFactory.register( HelpTool );
*
* // Finally define which tools and in what order appear in the toolbar. Each tool may only be
* // used once (but not all defined tools must be used).
* toolbar.setup( [
* {
* // 'bar' tool groups display tools' 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.mixin.GroupElement
*
* @constructor
* @param {OO.ui.ToolFactory} toolFactory Factory for creating tools
* @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating toolgroups
* @param {Object} [config] Configuration options
* @cfg {boolean} [actions] Add an actions section to the toolbar. Actions are commands that are included
* in the toolbar, but are not configured as tools. By default, actions are displayed on the right side of
* the toolbar.
* @cfg {boolean} [shadow] Add a shadow below the toolbar.
*/
OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
// Allow passing positional parameters inside the config object
if ( OO.isPlainObject( toolFactory ) && config === undefined ) {
config = toolFactory;
toolFactory = config.toolFactory;
toolGroupFactory = config.toolGroupFactory;
}
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.Toolbar.parent.call( this, config );
// Mixin constructors
OO.EventEmitter.call( this );
OO.ui.mixin.GroupElement.call( this, config );
// Properties
this.toolFactory = toolFactory;
this.toolGroupFactory = toolGroupFactory;
this.groups = [];
this.tools = {};
this.$bar = $( '<div>' );
this.$actions = $( '<div>' );
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, '<div style="clear:both"></div>' );
if ( config.shadow ) {
this.$bar.append( '<div class="oo-ui-toolbar-shadow"></div>' );
}
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.mixin.GroupElement );
/* Methods */
/**
* Get the tool factory.
*
* @return {OO.ui.ToolFactory} Tool factory
*/
OO.ui.Toolbar.prototype.getToolFactory = function () {
return this.toolFactory;
};
/**
* Get the toolgroup factory.
*
* @return {OO.Factory} Toolgroup factory
*/
OO.ui.Toolbar.prototype.getToolGroupFactory = function () {
return this.toolGroupFactory;
};
/**
* Handles mouse down events.
*
* @private
* @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 () {
if ( !this.initialized ) {
this.initialized = true;
this.narrowThreshold = this.$group.width() + this.$actions.width();
$( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler );
this.onWindowResize();
}
};
/**
* Set up the toolbar.
*
* The toolbar is set up with a list of toolgroup configurations that specify the type of
* toolgroup ({@link OO.ui.BarToolGroup bar}, {@link OO.ui.MenuToolGroup menu}, or {@link OO.ui.ListToolGroup list})
* to add and which tools to include, exclude, promote, or demote within that toolgroup. Please
* see {@link OO.ui.ToolGroup toolgroups} for more information about including tools in toolgroups.
*
* @param {Object.<string,Array>} groups List of toolgroup configurations
* @param {Array|string} [groups.include] Tools to include in the toolgroup
* @param {Array|string} [groups.exclude] Tools to exclude from the toolgroup
* @param {Array|string} [groups.promote] Tools to promote to the beginning of the toolgroup
* @param {Array|string} [groups.demote] Tools to demote to the end of the toolgroup
*/
OO.ui.Toolbar.prototype.setup = function ( groups ) {
var i, len, type, group,
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 toolgroups 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();
};
/**
* Destroy the toolbar.
*
* Destroying the toolbar removes all event handlers and DOM elements that constitute the toolbar. Call
* this method whenever you are done using a toolbar.
*/
OO.ui.Toolbar.prototype.destroy = function () {
$( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler );
this.reset();
this.$element.remove();
};
/**
* Check if the tool is available.
*
* Available tools are ones that have not yet been added to the toolbar.
*
* @param {string} name Symbolic name of tool
* @return {boolean} Tool is available
*/
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.
*
* The OOjs UI library does not contain an accelerator system, but this is the hook for one. To
* use an accelerator system, subclass the toolbar and override this method, which is meant to return a label
* that describes the accelerator keys for the tool passed (by symbolic name) to the method.
*
* @param {string} name Symbolic name of tool
* @return {string|undefined} Tool accelerator label if available
*/
OO.ui.Toolbar.prototype.getToolAccelerator = function () {
return undefined;
};
/**
* ToolGroups are collections of {@link OO.ui.Tool tools} that are used in a {@link OO.ui.Toolbar toolbar}.
* The type of toolgroup ({@link OO.ui.ListToolGroup list}, {@link OO.ui.BarToolGroup bar}, or {@link OO.ui.MenuToolGroup menu})
* to which a tool belongs determines how the tool is arranged and displayed in the toolbar. Toolgroups
* themselves are created on demand with a {@link OO.ui.ToolGroupFactory toolgroup factory}.
*
* Toolgroups can contain individual tools, groups of tools, or all available tools:
*
* To include an individual tool (or array of individual tools), specify tools by symbolic name:
*
* include: [ 'tool-name' ] or [ { name: 'tool-name' }]
*
* To include a group of tools, specify the group name. (The tool's static ‘group’ config is used to assign the tool to a group.)
*
* include: [ { group: 'group-name' } ]
*
* To include all tools that are not yet assigned to a toolgroup, use the catch-all selector, an asterisk (*):
*
* include: '*'
*
* See {@link OO.ui.Toolbar toolbars} for a full example. For more information about toolbars in general,
* please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @abstract
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.GroupElement
*
* @constructor
* @param {OO.ui.Toolbar} toolbar
* @param {Object} [config] Configuration options
* @cfg {Array|string} [include=[]] List of tools to include in the toolgroup.
* @cfg {Array|string} [exclude=[]] List of tools to exclude from the toolgroup.
* @cfg {Array|string} [promote=[]] List of tools to promote to the beginning of the toolgroup.
* @cfg {Array|string} [demote=[]] List of tools to demote to the end of the toolgroup.
* This setting is particularly useful when tools have been added to the toolgroup
* en masse (e.g., via the catch-all selector).
*/
OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) {
// Allow passing positional parameters inside the config object
if ( OO.isPlainObject( toolbar ) && config === undefined ) {
config = toolbar;
toolbar = config.toolbar;
}
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.ToolGroup.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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.
*
* Note: The OOjs UI library does not include an accelerator system, but does contain
* a hook for one. To use an accelerator system, subclass the {@link OO.ui.Toolbar toolbar} and
* override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method, which is
* meant to return a label that describes the accelerator keys for a given tool (e.g., 'Ctrl + M').
*
* @static
* @inheritable
* @property {boolean}
*/
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.parent.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.parent.prototype.updateDisabled.apply( this, arguments );
};
/**
* Handle mouse down and key down events.
*
* @protected
* @param {jQuery.Event} e Mouse down or key down event
*/
OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) {
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 );
OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler );
OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler );
}
return false;
}
};
/**
* Handle captured mouse up and key up events.
*
* @protected
* @param {Event} e Mouse up or key up event
*/
OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) {
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler );
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler );
// onMouseKeyUp may be called a second time, depending on where the mouse is when the button is
// released, but since `this.pressed` will no longer be true, the second call will be ignored.
this.onMouseKeyUp( e );
};
/**
* Handle mouse up and key up events.
*
* @protected
* @param {jQuery.Event} e Mouse up or key up event
*/
OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) {
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.
*
* @protected
* @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.
*
* @protected
* @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
*
* @protected
* @param {string} name Symbolic name of tool
*/
OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () {
this.populate();
};
/**
* Get the toolbar that contains the toolgroup.
*
* @return {OO.ui.Toolbar} Toolbar that contains the toolgroup
*/
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 toolgroup.
*/
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.parent.call( this, config );
// Properties
this.verticalActionLayout = null;
// Initialization
this.$element.addClass( 'oo-ui-messageDialog' );
};
/* Setup */
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.parent.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.parent.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.parent.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.parent.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.getReadyProcess = function ( data ) {
data = data || {};
// Parent method
return OO.ui.MessageDialog.parent.prototype.getReadyProcess.call( this, data )
.next( function () {
// Focus the primary action button
var actions = this.actions.get();
actions = actions.filter( function ( action ) {
return action.getFlags().indexOf( 'primary' ) > -1;
} );
if ( actions.length > 0 ) {
actions[ 0 ].$button.focus();
}
}, this );
};
/**
* @inheritdoc
*/
OO.ui.MessageDialog.prototype.getBodyHeight = function () {
var bodyHeight, oldOverflow,
$scrollable = this.container.$element;
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.parent.prototype.setDimensions.call( this, dim );
// Twiddle the overflow property, otherwise an unnecessary scrollbar will be produced.
// Need to do it after transition completes (250ms), add 50ms just in case.
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.parent.prototype.initialize.call( this );
// Properties
this.$actions = $( '<div>' );
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.parent.prototype.attachActions.call( this );
special = this.actions.getSpecial();
others = this.actions.getOthers();
if ( special.safe ) {
this.$actions.append( special.safe.$element );
special.safe.toggleFramed( false );
}
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.parent.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.parent.prototype.initialize.apply( this, arguments );
* this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
* this.content.$element.append( '<p>This is a process dialog window. The header contains the title and two buttons: \'Cancel\' (a safe action) on the left and \'Done\' (a primary action) on the right.</p>' );
* this.$body.append( this.content.$element );
* };
* MyProcessDialog.prototype.getActionProcess = function ( action ) {
* var dialog = this;
* if ( action ) {
* return new OO.ui.Process( function () {
* dialog.close( { action: action } );
* } );
* }
* return MyProcessDialog.parent.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.parent.call( this, config );
// Properties
this.fitOnOpen = false;
// 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.parent.prototype.onActionResize.call( this, action );
};
/**
* @inheritdoc
*/
OO.ui.ProcessDialog.prototype.initialize = function () {
// Parent method
OO.ui.ProcessDialog.parent.prototype.initialize.call( this );
// Properties
this.$navigation = $( '<div>' );
this.$location = $( '<div>' );
this.$safeActions = $( '<div>' );
this.$primaryActions = $( '<div>' );
this.$otherActions = $( '<div>' );
this.dismissButton = new OO.ui.ButtonWidget( {
label: OO.ui.msg( 'ooui-dialog-process-dismiss' )
} );
this.retryButton = new OO.ui.ButtonWidget();
this.$errors = $( '<div>' );
this.$errorsTitle = $( '<div>' );
// 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.parent.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.parent.prototype.executeAction.call( this, action )
.fail( function ( errors ) {
process.showErrors( errors || [] );
} );
};
/**
* @inheritdoc
*/
OO.ui.ProcessDialog.prototype.setDimensions = function () {
// Parent method
OO.ui.ProcessDialog.parent.prototype.setDimensions.apply( this, arguments );
this.fitLabel();
};
/**
* Fit label between actions.
*
* @private
* @chainable
*/
OO.ui.ProcessDialog.prototype.fitLabel = function () {
var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth,
size = this.getSizeProperties();
if ( typeof size.width !== 'number' ) {
if ( this.isOpened() ) {
navigationWidth = this.$head.width() - 20;
} else if ( this.isOpening() ) {
if ( !this.fitOnOpen ) {
// Size is relative and the dialog isn't open yet, so wait.
this.manager.opening.done( this.fitLabel.bind( this ) );
this.fitOnOpen = true;
}
return;
} else {
return;
}
} else {
navigationWidth = size.width - 20;
}
safeWidth = this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0;
primaryWidth = this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0;
biggerWidth = Math.max( safeWidth, primaryWidth );
labelWidth = this.title.$element.width();
if ( 2 * biggerWidth + labelWidth < navigationWidth ) {
// We have enough space to center the label
leftWidth = rightWidth = biggerWidth;
} else {
// Let's hope we at least have enough space not to overlap, because we can't wrap the label…
if ( this.getDir() === 'ltr' ) {
leftWidth = safeWidth;
rightWidth = primaryWidth;
} else {
leftWidth = primaryWidth;
rightWidth = safeWidth;
}
}
this.$location.css( { paddingLeft: leftWidth, paddingRight: rightWidth } );
return this;
};
/**
* 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 = $( '<div>' )
.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.parent.prototype.getTeardownProcess.call( this, data )
.first( function () {
// Make sure to hide errors
this.hideErrors();
this.fitOnOpen = false;
}, 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.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {OO.ui.Widget} fieldWidget Field widget
* @param {Object} [config] Configuration options
* @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
* @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
* The array may contain strings or OO.ui.HtmlSnippet instances.
* @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
* The array may contain strings or OO.ui.HtmlSnippet instances.
* @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
* in the upper-right corner of the rendered field; clicking it will display the text in a popup.
* For important messages, you are advised to use `notices`, as they are always shown.
*
* @throws {Error} An error is thrown if no widget is specified
*/
OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
var hasInputWidget, div, i;
// Allow passing positional parameters inside the config object
if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
config = fieldWidget;
fieldWidget = config.fieldWidget;
}
// Make sure we have required constructor arguments
if ( fieldWidget === undefined ) {
throw new Error( 'Widget not found' );
}
hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel;
// Configuration initialization
config = $.extend( { align: 'left' }, config );
// Parent constructor
OO.ui.FieldLayout.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
// Properties
this.fieldWidget = fieldWidget;
this.errors = config.errors || [];
this.notices = config.notices || [];
this.$field = $( '<div>' );
this.$messages = $( '<ul>' );
this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' );
this.align = null;
if ( config.help ) {
this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
classes: [ 'oo-ui-fieldLayout-help' ],
framed: false,
icon: 'info'
} );
div = $( '<div>' );
if ( config.help instanceof OO.ui.HtmlSnippet ) {
div.html( config.help.toString() );
} else {
div.text( config.help );
}
this.popupButtonWidget.getPopup().$body.append(
div.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 );
if ( this.errors.length || this.notices.length ) {
this.$element.append( this.$messages );
}
this.$body.addClass( 'oo-ui-fieldLayout-body' );
this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
this.$field
.addClass( 'oo-ui-fieldLayout-field' )
.toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() )
.append( this.fieldWidget.$element );
for ( i = 0; i < this.notices.length; i++ ) {
this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
}
for ( i = 0; i < this.errors.length; i++ ) {
this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
}
this.setAlignment( config.align );
};
/* Setup */
OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
/* 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;
};
/**
* @param {string} kind 'error' or 'notice'
* @param {string|OO.ui.HtmlSnippet} text
* @return {jQuery}
*/
OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
var $listItem, $icon, message;
$listItem = $( '<li>' );
if ( kind === 'error' ) {
$icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
} else if ( kind === 'notice' ) {
$icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
} else {
$icon = '';
}
message = new OO.ui.LabelWidget( { label: text } );
$listItem
.append( $icon, message.$element )
.addClass( 'oo-ui-fieldLayout-messages-' + kind );
return $listItem;
};
/**
* Set the field alignment mode.
*
* @private
* @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
*/
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;
}
// Parent constructor
OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
// Properties
this.buttonWidget = buttonWidget;
this.$button = $( '<div>' );
this.$input = $( '<div>' );
// Initialization
this.$element
.addClass( 'oo-ui-actionFieldLayout' );
this.$button
.addClass( 'oo-ui-actionFieldLayout-button' )
.append( this.buttonWidget.$element );
this.$input
.addClass( 'oo-ui-actionFieldLayout-input' )
.append( this.fieldWidget.$element );
this.$field
.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.mixin.IconElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.GroupElement.call( this, config );
if ( config.help ) {
this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
classes: [ 'oo-ui-fieldsetLayout-help' ],
framed: false,
icon: 'info'
} );
this.popupButtonWidget.getPopup().$body.append(
$( '<div>' )
.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.mixin.IconElement );
OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
/**
* FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
* 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.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
// Events
this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
// Make sure the action is safe
if ( config.action !== undefined && !OO.ui.isSafeUrl( config.action ) ) {
throw new Error( 'Potentially unsafe action provided: ' + config.action );
}
// Initialization
this.$element
.addClass( 'oo-ui-formLayout' )
.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.mixin.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( '<b>Menu panel</b>', select.$element )
* );
* menuLayout.$content.append(
* contentPanel.$element.append( '<b>Content panel</b>', '<p>Note that the menu is positioned relative to the content panel: top, bottom, after, before.</p>')
* );
* $( '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.parent.call( this, config );
/**
* Menu DOM node
*
* @property {jQuery}
*/
this.$menu = $( '<div>' );
/**
* Content DOM node
*
* @property {jQuery}
*/
this.$content = $( '<div>' );
// 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.parent.call( this, name, config );
* this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' );
* }
* OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
* PageOneLayout.prototype.setupOutlineItem = function () {
* this.outlineItem.setLabel( 'Page One' );
* };
*
* function PageTwoLayout( name, config ) {
* PageTwoLayout.parent.call( this, name, config );
* this.$element.append( '<p>Second page</p>' );
* }
* 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.parent.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 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 ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
page.focus();
}
};
/**
* Find the first focusable input in the booklet layout and focus
* on it.
*/
OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
OO.ui.findFocusable( this.stackLayout.$element ).focus();
};
/**
* 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 = pages.indexOf( page );
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 = stackLayoutPages.indexOf( this.pages[ name ] );
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 ],
previousPage = this.currentPageName && this.pages[ this.currentPageName ];
if ( name !== this.currentPageName ) {
if ( this.outlined ) {
selectedItem = this.outlineSelectWidget.getSelectedItem();
if ( selectedItem && selectedItem.getData() !== name ) {
this.outlineSelectWidget.selectItemByData( name );
}
}
if ( page ) {
if ( previousPage ) {
previousPage.setActive( false );
// Blur anything focused if the next page doesn't have anything focusable.
// This is not needed if the next page has something focusable (because once it is focused
// this blur happens automatically). If the layout is non-continuous, this check is
// meaningless because the next page is not visible yet and thus can't hold focus.
if (
this.autoFocus &&
this.stackLayout.continuous &&
OO.ui.findFocusable( page.$element ).length !== 0
) {
$focused = previousPage.$element.find( ':focus' );
if ( $focused.length ) {
$focused[ 0 ].blur();
}
}
}
this.currentPageName = name;
page.setActive( true );
this.stackLayout.setItem( page );
if ( !this.stackLayout.continuous && previousPage ) {
// This should not be necessary, since any inputs on the previous page should have been
// blurred when it was hidden, but browsers are not very consistent about this.
$focused = previousPage.$element.find( ':focus' );
if ( $focused.length ) {
$focused[ 0 ].blur();
}
}
this.emit( 'set', page );
}
}
};
/**
* 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.parent.call( this, name, config );
* this.$element.append( '<p>First card</p>' );
* }
* OO.inheritClass( CardOneLayout, OO.ui.CardLayout );
* CardOneLayout.prototype.setupTabItem = function () {
* this.tabItem.setLabel( 'Card one' );
* };
*
* var card1 = new CardOneLayout( 'one' ),
* card2 = new CardLayout( 'two', { label: 'Card two' } );
*
* card2.$element.append( '<p>Second card</p>' );
*
* 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} [expanded=true] Expand the content panel to fill the entire parent element.
* @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed.
*/
OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
// Configuration initialization
config = $.extend( {}, config, { menuPosition: 'top' } );
// Parent constructor
OO.ui.IndexLayout.parent.call( this, config );
// Properties
this.currentCardName = null;
this.cards = {};
this.ignoreFocus = false;
this.stackLayout = new OO.ui.StackLayout( {
continuous: !!config.continuous,
expanded: config.expanded
} );
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 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 page
if ( !OO.ui.contains( card.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
card.focus();
}
};
/**
* Find the first focusable input in the index layout and focus
* on it.
*/
OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
OO.ui.findFocusable( this.stackLayout.$element ).focus();
};
/**
* 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 = cards.indexOf( card );
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 = stackLayoutCards.indexOf( this.cards[ name ] );
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 ],
previousCard = this.currentCardName && this.cards[ this.currentCardName ];
if ( name !== this.currentCardName ) {
selectedItem = this.tabSelectWidget.getSelectedItem();
if ( selectedItem && selectedItem.getData() !== name ) {
this.tabSelectWidget.selectItemByData( name );
}
if ( card ) {
if ( previousCard ) {
previousCard.setActive( false );
// Blur anything focused if the next card doesn't have anything focusable.
// This is not needed if the next card has something focusable (because once it is focused
// this blur happens automatically). If the layout is non-continuous, this check is
// meaningless because the next card is not visible yet and thus can't hold focus.
if (
this.autoFocus &&
this.stackLayout.continuous &&
OO.ui.findFocusable( card.$element ).length !== 0
) {
$focused = previousCard.$element.find( ':focus' );
if ( $focused.length ) {
$focused[ 0 ].blur();
}
}
}
this.currentCardName = name;
card.setActive( true );
this.stackLayout.setItem( card );
if ( !this.stackLayout.continuous && previousCard ) {
// This should not be necessary, since any inputs on the previous card should have been
// blurred when it was hidden, but browsers are not very consistent about this.
$focused = previousCard.$element.find( ':focus' );
if ( $focused.length ) {
$focused[ 0 ].blur();
}
}
this.emit( 'set', card );
}
}
};
/**
* 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: $( '<p>A panel layout with padding and a frame.</p>' )
* } );
* $( '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.parent.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 );
/* Methods */
/**
* Focus the panel layout
*
* The default implementation just focuses the first focusable element in the panel
*/
OO.ui.PanelLayout.prototype.focus = function () {
OO.ui.findFocusable( this.$element ).focus();
};
/**
* CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display
* from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly,
* 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
* @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for card's tab
*/
OO.ui.CardLayout = function OoUiCardLayout( name, config ) {
// Allow passing positional parameters inside the config object
if ( OO.isPlainObject( name ) && config === undefined ) {
config = name;
name = config.name;
}
// Configuration initialization
config = $.extend( { scrollable: true }, config );
// Parent constructor
OO.ui.CardLayout.parent.call( this, config );
// Properties
this.name = name;
this.label = config.label;
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 () {
if ( this.label ) {
this.tabItem.setLabel( this.label );
}
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.parent.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: $( '<p>Panel One</p>' ),
* padded: true,
* framed: true
* } ),
* new OO.ui.PanelLayout( {
* $content: $( '<p>Panel Two</p>' ),
* padded: true,
* framed: true
* } )
* ],
* continuous: true
* } );
* $( 'body' ).append( myStack.$element );
*
* @class
* @extends OO.ui.PanelLayout
* @mixins OO.ui.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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.mixin.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.mixin.GroupElement.prototype.removeItems.call( this, items );
if ( items.indexOf( this.currentItem ) !== -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.mixin.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 ( this.items.indexOf( item ) !== -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' );
}
}
};
/**
* HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
* items), with small margins between them. Convenient when you need to put a number of block-level
* widgets on a single line next to each other.
*
* Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
*
* @example
* // HorizontalLayout with a text input and a label
* var layout = new OO.ui.HorizontalLayout( {
* items: [
* new OO.ui.LabelWidget( { label: 'Label' } ),
* new OO.ui.TextInputWidget( { value: 'Text' } )
* ]
* } );
* $( 'body' ).append( layout.$element );
*
* @class
* @extends OO.ui.Layout
* @mixins OO.ui.mixin.GroupElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
*/
OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.HorizontalLayout.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
// Initialization
this.$element.addClass( 'oo-ui-horizontalLayout' );
if ( Array.isArray( config.items ) ) {
this.addItems( config.items );
}
};
/* Setup */
OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
/**
* BarToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
* create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
* and {@link OO.ui.ListToolGroup ListToolGroup}). The {@link OO.ui.Tool tools} in a BarToolGroup are
* displayed by icon in a single row. The title of the tool is displayed when users move the mouse over
* the tool.
*
* BarToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar is
* set up.
*
* @example
* // Example of a BarToolGroup with two tools
* var toolFactory = new OO.ui.ToolFactory();
* var toolGroupFactory = new OO.ui.ToolGroupFactory();
* var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
*
* // We will be placing status text in this element when tools are used
* var $area = $( '<p>' ).text( 'Example of a BarToolGroup with two tools.' );
*
* // Define the tools that we're going to place in our toolbar
*
* // Create a class inheriting from OO.ui.Tool
* function PictureTool() {
* PictureTool.parent.apply( this, arguments );
* }
* OO.inheritClass( PictureTool, OO.ui.Tool );
* // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
* // of 'icon' and 'title' (displayed icon and text).
* PictureTool.static.name = 'picture';
* PictureTool.static.icon = 'picture';
* PictureTool.static.title = 'Insert picture';
* // Defines the action that will happen when this tool is selected (clicked).
* PictureTool.prototype.onSelect = function () {
* $area.text( 'Picture tool clicked!' );
* // Never display this tool as "active" (selected).
* this.setActive( false );
* };
* // Make this tool available in our toolFactory and thus our toolbar
* toolFactory.register( PictureTool );
*
* // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
* // little popup window (a PopupWidget).
* function HelpTool( toolGroup, config ) {
* OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
* padded: true,
* label: 'Help',
* head: true
* } }, config ) );
* this.popup.$body.append( '<p>I am helpful!</p>' );
* }
* OO.inheritClass( HelpTool, OO.ui.PopupTool );
* HelpTool.static.name = 'help';
* HelpTool.static.icon = 'help';
* HelpTool.static.title = 'Help';
* toolFactory.register( HelpTool );
*
* // Finally define which tools and in what order appear in the toolbar. Each tool may only be
* // used once (but not all defined tools must be used).
* toolbar.setup( [
* {
* // 'bar' tool groups display tools by icon only
* type: 'bar',
* include: [ 'picture', 'help' ]
* }
* ] );
*
* // Create some UI around the toolbar and place it in the document
* var frame = new OO.ui.PanelLayout( {
* expanded: false,
* framed: true
* } );
* var contentFrame = new OO.ui.PanelLayout( {
* expanded: false,
* padded: true
* } );
* frame.$element.append(
* toolbar.$element,
* contentFrame.$element.append( $area )
* );
* $( 'body' ).append( frame.$element );
*
* // Here is where the toolbar is actually built. This must be done after inserting it into the
* // document.
* toolbar.initialize();
*
* For more information about how to add tools to a bar tool group, please see {@link OO.ui.ToolGroup toolgroup}.
* For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @class
* @extends OO.ui.ToolGroup
*
* @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.parent.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';
/**
* PopupToolGroup is an abstract base class used by both {@link OO.ui.MenuToolGroup MenuToolGroup}
* and {@link OO.ui.ListToolGroup ListToolGroup} to provide a popup--an overlaid menu or list of tools with an
* optional icon and label. This class can be used for other base classes that also use this functionality.
*
* @abstract
* @class
* @extends OO.ui.ToolGroup
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.ClippableElement
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {OO.ui.Toolbar} toolbar
* @param {Object} [config] Configuration options
* @cfg {string} [header] Text to display at the top of the popup
*/
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.parent.call( this, toolbar, config );
// Properties
this.active = false;
this.dragging = false;
this.onBlurHandler = this.onBlur.bind( this );
this.$handle = $( '<span>' );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, config );
OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
// Events
this.$handle.on( {
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( $( '<span>' )
.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.mixin.IconElement );
OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.ClippableElement );
OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* @inheritdoc
*/
OO.ui.PopupToolGroup.prototype.setDisabled = function () {
// Parent method
OO.ui.PopupToolGroup.parent.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.
*
* @protected
* @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.parent.prototype.onMouseKeyUp.call( this, e );
};
/**
* Handle mouse up and key up events.
*
* @protected
* @param {jQuery.Event} e Mouse up or key up event
*/
OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) {
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.
*
* @protected
* @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, the popup is visible. A mouseup event anywhere in the document will trigger
* deactivation.
*/
OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
var containerWidth, containerLeft;
value = !!value;
if ( this.active !== value ) {
this.active = value;
if ( value ) {
OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler );
OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler );
this.$clippable.css( 'left', '' );
// Try anchoring the popup to the left first
this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' );
this.toggleClipping( true );
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 );
}
if ( this.isClippedHorizontally() ) {
// Anchoring to the right also caused the popup to clip, so just make it fill the container
containerWidth = this.$clippableScrollableContainer.width();
containerLeft = this.$clippableScrollableContainer.offset().left;
this.toggleClipping( false );
this.$element.removeClass( 'oo-ui-popupToolGroup-right' );
this.$clippable.css( {
left: -( this.$element.offset().left - containerLeft ),
width: containerWidth
} );
}
} else {
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler );
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler );
this.$element.removeClass(
'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left oo-ui-popupToolGroup-right'
);
this.toggleClipping( false );
}
}
};
/**
* ListToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
* create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
* and {@link OO.ui.BarToolGroup BarToolGroup}). The {@link OO.ui.Tool tools} in a ListToolGroup are displayed
* by label in a dropdown menu. The title of the tool is used as the label text. The menu itself can be configured
* with a label, icon, indicator, header, and title.
*
* ListToolGroups can be configured to be expanded and collapsed. Collapsed lists will have a ‘More’ option that
* users can select to see the full list of tools. If a collapsed toolgroup is expanded, a ‘Fewer’ option permits
* users to collapse the list again.
*
* ListToolGroups are created by a {@link OO.ui.ToolGroupFactory toolgroup factory} when the toolbar is set up. The factory
* requires the ListToolGroup's symbolic name, 'list', which is specified along with the other configurations. For more
* information about how to add tools to a ListToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
*
* @example
* // Example of a ListToolGroup
* var toolFactory = new OO.ui.ToolFactory();
* var toolGroupFactory = new OO.ui.ToolGroupFactory();
* var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
*
* // Configure and register two tools
* function SettingsTool() {
* SettingsTool.parent.apply( this, arguments );
* }
* OO.inheritClass( SettingsTool, OO.ui.Tool );
* SettingsTool.static.name = 'settings';
* SettingsTool.static.icon = 'settings';
* SettingsTool.static.title = 'Change settings';
* SettingsTool.prototype.onSelect = function () {
* this.setActive( false );
* };
* toolFactory.register( SettingsTool );
* // Register two more tools, nothing interesting here
* function StuffTool() {
* StuffTool.parent.apply( this, arguments );
* }
* OO.inheritClass( StuffTool, OO.ui.Tool );
* StuffTool.static.name = 'stuff';
* StuffTool.static.icon = 'ellipsis';
* StuffTool.static.title = 'Change the world';
* StuffTool.prototype.onSelect = function () {
* this.setActive( false );
* };
* toolFactory.register( StuffTool );
* toolbar.setup( [
* {
* // Configurations for list toolgroup.
* type: 'list',
* label: 'ListToolGroup',
* indicator: 'down',
* icon: 'picture',
* title: 'This is the title, displayed when user moves the mouse over the list toolgroup',
* header: 'This is the header',
* include: [ 'settings', 'stuff' ],
* allowCollapse: ['stuff']
* }
* ] );
*
* // Create some UI around the toolbar and place it in the document
* var frame = new OO.ui.PanelLayout( {
* expanded: false,
* framed: true
* } );
* frame.$element.append(
* toolbar.$element
* );
* $( 'body' ).append( frame.$element );
* // Build the toolbar. This must be done after the toolbar has been appended to the document.
* toolbar.initialize();
*
* For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @class
* @extends OO.ui.PopupToolGroup
*
* @constructor
* @param {OO.ui.Toolbar} toolbar
* @param {Object} [config] Configuration options
* @cfg {Array} [allowCollapse] Allow the specified tools to be collapsed. By default, collapsible tools
* will only be displayed if users click the ‘More’ option displayed at the bottom of the list. If
* the list is expanded, a ‘Fewer’ option permits users to collapse the list again. Any tools that
* are included in the toolgroup, but are not designated as collapsible, will always be displayed.
* To open a collapsible list in its expanded state, set #expanded to 'true'.
* @cfg {Array} [forceExpand] Expand the specified tools. All other tools will be designated as collapsible.
* Unless #expanded is set to true, the collapsible tools will be collapsed when the list is first opened.
* @cfg {boolean} [expanded=false] Expand collapsible tools. This config is only relevant if tools have
* been designated as collapsible. When expanded is set to true, all tools in the group will be displayed
* when the list is first opened. Users can collapse the list with a ‘Fewer’ option at the bottom.
*/
OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) {
// Allow passing positional parameters inside the config object
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.parent.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.parent.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 () {
var ExpandCollapseTool;
if ( this.expandCollapseTool === undefined ) {
ExpandCollapseTool = function () {
ExpandCollapseTool.parent.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.parent.parent.prototype.onMouseKeyUp.call( this, e );
} else {
return OO.ui.ListToolGroup.parent.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 );
}
};
/**
* MenuToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
* create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.BarToolGroup BarToolGroup}
* and {@link OO.ui.ListToolGroup ListToolGroup}). MenuToolGroups contain selectable {@link OO.ui.Tool tools},
* which are displayed by label in a dropdown menu. The tool's title is used as the label text, and the
* menu label is updated to reflect which tool or tools are currently selected. If no tools are selected,
* the menu label is empty. The menu can be configured with an indicator, icon, title, and/or header.
*
* MenuToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar
* is set up. Note that all tools must define an {@link OO.ui.Tool#onUpdateState onUpdateState} method if
* a MenuToolGroup is used.
*
* @example
* // Example of a MenuToolGroup
* var toolFactory = new OO.ui.ToolFactory();
* var toolGroupFactory = new OO.ui.ToolGroupFactory();
* var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
*
* // We will be placing status text in this element when tools are used
* var $area = $( '<p>' ).text( 'An example of a MenuToolGroup. Select a tool from the dropdown menu.' );
*
* // Define the tools that we're going to place in our toolbar
*
* function SettingsTool() {
* SettingsTool.parent.apply( this, arguments );
* this.reallyActive = false;
* }
* OO.inheritClass( SettingsTool, OO.ui.Tool );
* SettingsTool.static.name = 'settings';
* SettingsTool.static.icon = 'settings';
* SettingsTool.static.title = 'Change settings';
* SettingsTool.prototype.onSelect = function () {
* $area.text( 'Settings tool clicked!' );
* // Toggle the active state on each click
* this.reallyActive = !this.reallyActive;
* this.setActive( this.reallyActive );
* // To update the menu label
* this.toolbar.emit( 'updateState' );
* };
* SettingsTool.prototype.onUpdateState = function () {
* };
* toolFactory.register( SettingsTool );
*
* function StuffTool() {
* StuffTool.parent.apply( this, arguments );
* this.reallyActive = false;
* }
* OO.inheritClass( StuffTool, OO.ui.Tool );
* StuffTool.static.name = 'stuff';
* StuffTool.static.icon = 'ellipsis';
* StuffTool.static.title = 'More stuff';
* StuffTool.prototype.onSelect = function () {
* $area.text( 'More stuff tool clicked!' );
* // Toggle the active state on each click
* this.reallyActive = !this.reallyActive;
* this.setActive( this.reallyActive );
* // To update the menu label
* this.toolbar.emit( 'updateState' );
* };
* StuffTool.prototype.onUpdateState = function () {
* };
* toolFactory.register( StuffTool );
*
* // Finally define which tools and in what order appear in the toolbar. Each tool may only be
* // used once (but not all defined tools must be used).
* toolbar.setup( [
* {
* type: 'menu',
* header: 'This is the (optional) header',
* title: 'This is the (optional) title',
* indicator: 'down',
* include: [ 'settings', 'stuff' ]
* }
* ] );
*
* // Create some UI around the toolbar and place it in the document
* var frame = new OO.ui.PanelLayout( {
* expanded: false,
* framed: true
* } );
* var contentFrame = new OO.ui.PanelLayout( {
* expanded: false,
* padded: true
* } );
* frame.$element.append(
* toolbar.$element,
* contentFrame.$element.append( $area )
* );
* $( 'body' ).append( frame.$element );
*
* // Here is where the toolbar is actually built. This must be done after inserting it into the
* // document.
* toolbar.initialize();
* toolbar.emit( 'updateState' );
*
* For more information about how to add tools to a MenuToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
* For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki] [1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @class
* @extends OO.ui.PopupToolGroup
*
* @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.parent.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.
*
* @private
*/
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( ', ' ) || ' ' );
};
/**
* Popup tools open a popup window when they are selected from the {@link OO.ui.Toolbar toolbar}. Each popup tool is configured
* with a static name, title, and icon, as well with as any popup configurations. Unlike other tools, popup tools do not require that developers specify
* an #onSelect or #onUpdateState method, as these methods have been implemented already.
*
* // Example of a popup tool. When selected, a popup tool displays
* // a popup window.
* function HelpTool( toolGroup, config ) {
* OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
* padded: true,
* label: 'Help',
* head: true
* } }, config ) );
* this.popup.$body.append( '<p>I am helpful!</p>' );
* };
* OO.inheritClass( HelpTool, OO.ui.PopupTool );
* HelpTool.static.name = 'help';
* HelpTool.static.icon = 'help';
* HelpTool.static.title = 'Help';
* toolFactory.register( HelpTool );
*
* For an example of a toolbar that contains a popup tool, see {@link OO.ui.Toolbar toolbars}. For more information about
* toolbars in genreral, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
*
* @abstract
* @class
* @extends OO.ui.Tool
* @mixins OO.ui.mixin.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.parent.call( this, toolGroup, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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 );
};
/**
* A ToolGroupTool is a special sort of tool that can contain other {@link OO.ui.Tool tools}
* and {@link OO.ui.ToolGroup toolgroups}. The ToolGroupTool was specifically designed to be used
* inside a {@link OO.ui.BarToolGroup bar} toolgroup to provide access to additional tools from
* the bar item. Included tools will be displayed in a dropdown {@link OO.ui.ListToolGroup list}
* when the ToolGroupTool is selected.
*
* // Example: ToolGroupTool with two nested tools, 'setting1' and 'setting2', defined elsewhere.
*
* function SettingsTool() {
* SettingsTool.parent.apply( this, arguments );
* };
* OO.inheritClass( SettingsTool, OO.ui.ToolGroupTool );
* SettingsTool.static.name = 'settings';
* SettingsTool.static.title = 'Change settings';
* SettingsTool.static.groupConfig = {
* icon: 'settings',
* label: 'ToolGroupTool',
* include: [ 'setting1', 'setting2' ]
* };
* toolFactory.register( SettingsTool );
*
* For more information, please see the [OOjs UI documentation on MediaWiki][1].
*
* Please note that this implementation is subject to change per [T74159] [2].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars#ToolGroupTool
* [2]: https://phabricator.wikimedia.org/T74159
*
* @abstract
* @class
* @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.parent.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 */
/**
* Toolgroup configuration.
*
* The toolgroup configuration consists of the tools to include, as well as an icon and label
* to use for the bar item. Tools can be included by symbolic name, group, or with the
* wildcard selector. Please see {@link OO.ui.ToolGroup toolgroup} for more information.
*
* @property {Object.<string,Array>}
*/
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 {@link OO.ui.ToolGroup toolgroup} from the specified configuration.
*
* @param {Object.<string,Array>} group Toolgroup configuration. Please see {@link OO.ui.ToolGroup toolgroup} for
* more information.
* @return {OO.ui.ListToolGroup}
*/
OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) {
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.mixin.GroupElement.
*
* Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
*
* @private
* @abstract
* @class
* @extends OO.ui.mixin.GroupElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
// Parent constructor
OO.ui.mixin.GroupWidget.parent.call( this, config );
};
/* Setup */
OO.inheritClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.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.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
var i, len;
// Parent method
// Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
OO.ui.Widget.prototype.setDisabled.call( this, disabled );
// During construction, #setDisabled is called before the OO.ui.mixin.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 mix in OO.ui.mixin.GroupWidget.
*
* Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
* allows bidirectional communication.
*
* Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
*
* @private
* @abstract
* @class
*
* @constructor
*/
OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
//
};
/* Methods */
/**
* Check if widget is disabled.
*
* Checks parent if present, making disabled state inheritable.
*
* @return {boolean} Widget is disabled
*/
OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
return this.disabled ||
( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
};
/**
* Set group element is in.
*
* @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
* @chainable
*/
OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
// Parent method
// Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
OO.ui.Element.prototype.setElementGroup.call( this, group );
// 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.mixin.GroupElement
* @mixins OO.ui.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, config );
OO.ui.mixin.IconElement.call( this, config );
// Properties
this.outline = outline;
this.$movers = $( '<div>' );
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.mixin.GroupElement );
OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.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.parent.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: $( '<p>List of categories...</p>' ),
* 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.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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.mixin.ButtonElement
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.FlaggedElement
* @mixins OO.ui.mixin.TabIndexedElement
* @mixins OO.ui.mixin.AccessKeyedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ButtonElement.call( this, config );
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
OO.ui.mixin.FlaggedElement.call( this, config );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
// Properties
this.href = null;
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.mixin.ButtonElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
/* Methods */
/**
* @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.mixin.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.mixin.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 !== null ) {
if ( !OO.ui.isSafeUrl( href ) ) {
throw new Error( 'Potentially unsafe href provided: ' + href );
}
}
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.mixin.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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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.mixin.IconElement.prototype.setIcon.apply( this, arguments );
this.propagateResize();
return this;
};
/**
* @inheritdoc
*/
OO.ui.ActionWidget.prototype.setLabel = function () {
// Mixin method
OO.ui.mixin.LabelElement.prototype.setLabel.apply( this, arguments );
this.propagateResize();
return this;
};
/**
* @inheritdoc
*/
OO.ui.ActionWidget.prototype.setFlags = function () {
// Mixin method
OO.ui.mixin.FlaggedElement.prototype.setFlags.apply( this, arguments );
this.propagateResize();
return this;
};
/**
* @inheritdoc
*/
OO.ui.ActionWidget.prototype.clearFlags = function () {
// Mixin method
OO.ui.mixin.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.parent.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: $( '<p>Additional options here.</p>' ),
* padded: true,
* align: 'force-left'
* }
* } );
* // Append the button to the DOM.
* $( 'body' ).append( popupButton.$element );
*
* @class
* @extends OO.ui.ButtonWidget
* @mixins OO.ui.mixin.PopupElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
// Parent constructor
OO.ui.PopupButtonWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.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.mixin.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.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators},
* {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
* and {@link OO.ui.mixin.LabelElement labels}. Please see
* the [OOjs UI documentation][1] on MediaWiki for more information.
*
* @example
* // 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.mixin.ButtonElement
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.FlaggedElement
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @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.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ButtonElement.call( this, config );
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
OO.ui.mixin.FlaggedElement.call( this, config );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
// Events
this.connect( this, { click: 'onAction' } );
// 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.mixin.ButtonElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* 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.parent.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.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
this.$button.attr( 'aria-pressed', this.value.toString() );
};
/**
* CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxWidget combo box widget}
* that allows for selecting multiple values.
*
* For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
*
* @example
* // Example: A CapsuleMultiSelectWidget.
* var capsule = new OO.ui.CapsuleMultiSelectWidget( {
* label: 'CapsuleMultiSelectWidget',
* selected: [ 'Option 1', 'Option 3' ],
* menu: {
* items: [
* new OO.ui.MenuOptionWidget( {
* data: 'Option 1',
* label: 'Option One'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 2',
* label: 'Option Two'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 3',
* label: 'Option Three'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 4',
* label: 'Option Four'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 5',
* label: 'Option Five'
* } )
* ]
* }
* } );
* $( 'body' ).append( capsule.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.TabIndexedElement
* @mixins OO.ui.mixin.GroupElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu.
* @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
* @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
* If specified, this popup will be shown instead of the menu (but the menu
* will still be used for item labels and allowArbitrary=false). The widgets
* in the popup should use this.addItemsFromData() or this.addItems() as necessary.
* @cfg {jQuery} [$overlay] Render the menu or popup into a separate layer.
* This configuration is useful in cases where the expanded menu is larger than
* its containing `<div>`. The specified overlay layer is usually on top of
* the containing `<div>` and has a larger area. By default, the menu uses
* relative positioning.
*/
OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget( config ) {
var $tabFocus;
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.CapsuleMultiSelectWidget.parent.call( this, config );
// Properties (must be set before mixin constructor calls)
this.$input = config.popup ? null : $( '<input>' );
this.$handle = $( '<div>' );
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, config );
if ( config.popup ) {
config.popup = $.extend( {}, config.popup, {
align: 'forwards',
anchor: false
} );
OO.ui.mixin.PopupElement.call( this, config );
$tabFocus = $( '<span>' );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
} else {
this.popup = null;
$tabFocus = null;
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
}
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.IconElement.call( this, config );
// Properties
this.allowArbitrary = !!config.allowArbitrary;
this.$overlay = config.$overlay || this.$element;
this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
{
widget: this,
$input: this.$input,
$container: this.$element,
filterFromInput: true,
disabled: this.isDisabled()
},
config.menu
) );
// Events
if ( this.popup ) {
$tabFocus.on( {
focus: this.onFocusForPopup.bind( this )
} );
this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
if ( this.popup.$autoCloseIgnore ) {
this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
}
this.popup.connect( this, {
toggle: function ( visible ) {
$tabFocus.toggle( !visible );
}
} );
} else {
this.$input.on( {
focus: this.onInputFocus.bind( this ),
blur: this.onInputBlur.bind( this ),
'propertychange change click mouseup keydown keyup input cut paste select': this.onInputChange.bind( this ),
keydown: this.onKeyDown.bind( this ),
keypress: this.onKeyPress.bind( this )
} );
}
this.menu.connect( this, {
choose: 'onMenuChoose',
add: 'onMenuItemsChange',
remove: 'onMenuItemsChange'
} );
this.$handle.on( {
click: this.onClick.bind( this )
} );
// Initialization
if ( this.$input ) {
this.$input.prop( 'disabled', this.isDisabled() );
this.$input.attr( {
role: 'combobox',
'aria-autocomplete': 'list'
} );
this.$input.width( '1em' );
}
if ( config.data ) {
this.setItemsFromData( config.data );
}
this.$group.addClass( 'oo-ui-capsuleMultiSelectWidget-group' );
this.$handle.addClass( 'oo-ui-capsuleMultiSelectWidget-handle' )
.append( this.$indicator, this.$icon, this.$group );
this.$element.addClass( 'oo-ui-capsuleMultiSelectWidget' )
.append( this.$handle );
if ( this.popup ) {
this.$handle.append( $tabFocus );
this.$overlay.append( this.popup.$element );
} else {
this.$handle.append( this.$input );
this.$overlay.append( this.menu.$element );
}
this.onMenuItemsChange();
};
/* Setup */
OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.GroupElement );
OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.PopupElement );
OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.TabIndexedElement );
OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IconElement );
/* Events */
/**
* @event change
*
* A change event is emitted when the set of selected items changes.
*
* @param {Mixed[]} datas Data of the now-selected items
*/
/* Methods */
/**
* Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
*
* @protected
* @param {Mixed} data Custom data of any type.
* @param {string} label The label text.
* @return {OO.ui.CapsuleItemWidget}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.createItemWidget = function ( data, label ) {
return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
};
/**
* Get the data of the items in the capsule
* @return {Mixed[]}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.getItemsData = function () {
return $.map( this.getItems(), function ( e ) { return e.data; } );
};
/**
* Set the items in the capsule by providing data
* @chainable
* @param {Mixed[]} datas
* @return {OO.ui.CapsuleMultiSelectWidget}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.setItemsFromData = function ( datas ) {
var widget = this,
menu = this.menu,
items = this.getItems();
$.each( datas, function ( i, data ) {
var j, label,
item = menu.getItemFromData( data );
if ( item ) {
label = item.label;
} else if ( widget.allowArbitrary ) {
label = String( data );
} else {
return;
}
item = null;
for ( j = 0; j < items.length; j++ ) {
if ( items[ j ].data === data && items[ j ].label === label ) {
item = items[ j ];
items.splice( j, 1 );
break;
}
}
if ( !item ) {
item = widget.createItemWidget( data, label );
}
widget.addItems( [ item ], i );
} );
if ( items.length ) {
widget.removeItems( items );
}
return this;
};
/**
* Add items to the capsule by providing their data
* @chainable
* @param {Mixed[]} datas
* @return {OO.ui.CapsuleMultiSelectWidget}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) {
var widget = this,
menu = this.menu,
items = [];
$.each( datas, function ( i, data ) {
var item;
if ( !widget.getItemFromData( data ) ) {
item = menu.getItemFromData( data );
if ( item ) {
items.push( widget.createItemWidget( data, item.label ) );
} else if ( widget.allowArbitrary ) {
items.push( widget.createItemWidget( data, String( data ) ) );
}
}
} );
if ( items.length ) {
this.addItems( items );
}
return this;
};
/**
* Remove items by data
* @chainable
* @param {Mixed[]} datas
* @return {OO.ui.CapsuleMultiSelectWidget}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) {
var widget = this,
items = [];
$.each( datas, function ( i, data ) {
var item = widget.getItemFromData( data );
if ( item ) {
items.push( item );
}
} );
if ( items.length ) {
this.removeItems( items );
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) {
var same, i, l,
oldItems = this.items.slice();
OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
if ( this.items.length !== oldItems.length ) {
same = false;
} else {
same = true;
for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
same = same && this.items[ i ] === oldItems[ i ];
}
}
if ( !same ) {
this.emit( 'change', this.getItemsData() );
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) {
var same, i, l,
oldItems = this.items.slice();
OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
if ( this.items.length !== oldItems.length ) {
same = false;
} else {
same = true;
for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
same = same && this.items[ i ] === oldItems[ i ];
}
}
if ( !same ) {
this.emit( 'change', this.getItemsData() );
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () {
if ( this.items.length ) {
OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
this.emit( 'change', this.getItemsData() );
}
return this;
};
/**
* Get the capsule widget's menu.
* @return {OO.ui.MenuSelectWidget} Menu widget
*/
OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () {
return this.menu;
};
/**
* Handle focus events
*
* @private
* @param {jQuery.Event} event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () {
if ( !this.isDisabled() ) {
this.menu.toggle( true );
}
};
/**
* Handle blur events
*
* @private
* @param {jQuery.Event} event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () {
if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
this.addItemsFromData( [ this.$input.val() ] );
}
this.clearInput();
};
/**
* Handle focus events
*
* @private
* @param {jQuery.Event} event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () {
if ( !this.isDisabled() ) {
this.popup.setSize( this.$handle.width() );
this.popup.toggle( true );
this.popup.$element.find( '*' )
.filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
.first()
.focus();
}
};
/**
* Handles popup focus out events.
*
* @private
* @param {Event} e Focus out event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onPopupFocusOut = function () {
var widget = this.popup;
setTimeout( function () {
if (
widget.isVisible() &&
!OO.ui.contains( widget.$element[ 0 ], document.activeElement, true ) &&
( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length )
) {
widget.toggle( false );
}
} );
};
/**
* Handle mouse click events.
*
* @private
* @param {jQuery.Event} e Mouse click event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onClick = function ( e ) {
if ( e.which === 1 ) {
this.focus();
return false;
}
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) {
var item;
if ( !this.isDisabled() ) {
if ( e.which === OO.ui.Keys.ESCAPE ) {
this.clearInput();
return false;
}
if ( !this.popup ) {
this.menu.toggle( true );
if ( e.which === OO.ui.Keys.ENTER ) {
item = this.menu.getItemFromLabel( this.$input.val(), true );
if ( item ) {
this.addItemsFromData( [ item.data ] );
this.clearInput();
} else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
this.addItemsFromData( [ this.$input.val() ] );
this.clearInput();
}
return false;
}
// Make sure the input gets resized.
setTimeout( this.onInputChange.bind( this ), 0 );
}
}
};
/**
* Handle key down events.
*
* @private
* @param {jQuery.Event} e Key down event
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) {
if ( !this.isDisabled() ) {
// 'keypress' event is not triggered for Backspace
if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) {
if ( this.items.length ) {
this.removeItems( this.items.slice( -1 ) );
}
return false;
}
}
};
/**
* Handle input change events.
*
* @private
* @param {jQuery.Event} e Event of some sort
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onInputChange = function () {
if ( !this.isDisabled() ) {
this.$input.width( this.$input.val().length + 'em' );
}
};
/**
* Handle menu choose events.
*
* @private
* @param {OO.ui.OptionWidget} item Chosen item
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) {
if ( item && item.isVisible() ) {
this.addItemsFromData( [ item.getData() ] );
this.clearInput();
}
};
/**
* Handle menu item change events.
*
* @private
*/
OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () {
this.setItemsFromData( this.getItemsData() );
this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() );
};
/**
* Clear the input field
* @private
*/
OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () {
if ( this.$input ) {
this.$input.val( '' );
this.$input.width( '1em' );
}
if ( this.popup ) {
this.popup.toggle( false );
}
this.menu.toggle( false );
this.menu.selectItem();
this.menu.highlightItem();
};
/**
* @inheritdoc
*/
OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) {
var i, len;
// Parent method
OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled );
if ( this.$input ) {
this.$input.prop( 'disabled', this.isDisabled() );
}
if ( this.menu ) {
this.menu.setDisabled( this.isDisabled() );
}
if ( this.popup ) {
this.popup.setDisabled( this.isDisabled() );
}
if ( this.items ) {
for ( i = 0, len = this.items.length; i < len; i++ ) {
this.items[ i ].updateDisabled();
}
}
return this;
};
/**
* Focus the widget
* @chainable
* @return {OO.ui.CapsuleMultiSelectWidget}
*/
OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () {
if ( !this.isDisabled() ) {
if ( this.popup ) {
this.popup.setSize( this.$handle.width() );
this.popup.toggle( true );
this.popup.$element.find( '*' )
.filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
.first()
.focus();
} else {
this.menu.toggle( true );
this.$input.focus();
}
}
return this;
};
/**
* CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget
* CapsuleMultiSelectWidget} to display the selected items.
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.ItemWidget
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.FlaggedElement
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.CapsuleItemWidget.parent.call( this, config );
// Properties (must be set before mixin constructor calls)
this.$indicator = $( '<span>' );
// Mixin constructors
OO.ui.mixin.ItemWidget.call( this );
OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.FlaggedElement.call( this, config );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
// Events
this.$indicator.on( {
keydown: this.onCloseKeyDown.bind( this ),
click: this.onCloseClick.bind( this )
} );
// Initialization
this.$element
.addClass( 'oo-ui-capsuleItemWidget' )
.append( this.$indicator, this.$label );
};
/* Setup */
OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* Handle close icon clicks
* @param {jQuery.Event} event
*/
OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
var element = this.getElementGroup();
if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) {
element.removeItems( [ this ] );
element.focus();
}
};
/**
* Handle close keyboard events
* @param {jQuery.Event} event Key down event
*/
OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) {
if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) {
switch ( e.which ) {
case OO.ui.Keys.ENTER:
case OO.ui.Keys.BACKSPACE:
case OO.ui.Keys.SPACE:
this.getElementGroup().removeItems( [ this ] );
return false;
}
}
};
/**
* DropdownWidgets are not menus themselves, rather they contain a menu of options created with
* OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
* users can interact with it.
*
* If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
* OO.ui.DropdownInputWidget instead.
*
* @example
* // Example: A DropdownWidget with a menu that contains three options
* var dropDown = new OO.ui.DropdownWidget( {
* 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 );
*
* dropDown.getMenu().selectItemByData( 'b' );
*
* dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
*
* For more information, please see the [OOjs UI documentation on MediaWiki] [1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.FloatingMenuSelectWidget menu select widget}
* @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
* the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
* containing `<div>` and has a larger area. By default, the menu uses relative positioning.
*/
OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
// Configuration initialization
config = $.extend( { indicator: 'down' }, config );
// Parent constructor
OO.ui.DropdownWidget.parent.call( this, config );
// Properties (must be set before TabIndexedElement constructor call)
this.$handle = this.$( '<span>' );
this.$overlay = config.$overlay || this.$element;
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
// Properties
this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( {
widget: this,
$container: this.$element
}, 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.$overlay.append( this.menu.$element );
};
/* Setup */
OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* 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 ) {
this.setLabel( null );
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 && !this.menu.isVisible() ) || e.which === OO.ui.Keys.ENTER )
) {
this.menu.toggle();
return false;
}
};
/**
* SelectFileWidgets allow for selecting files, using the HTML5 File API. These
* widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
* OO.ui.mixin.IndicatorElement indicators}.
* Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
*
* @example
* // Example of a file select widget
* var selectFile = new OO.ui.SelectFileWidget();
* $( 'body' ).append( selectFile.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.PendingElement
* @mixins OO.ui.mixin.LabelElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
* @cfg {string} [placeholder] Text to display when no file is selected.
* @cfg {string} [notsupported] Text to display when file support is missing in the browser.
* @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
* @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
* @cfg {boolean} [dragDropUI=false] Deprecated alias for showDropTarget
*/
OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
var dragHandler;
// TODO: Remove in next release
if ( config && config.dragDropUI ) {
config.showDropTarget = true;
}
// Configuration initialization
config = $.extend( {
accept: null,
placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
droppable: true,
showDropTarget: false
}, config );
// Parent constructor
OO.ui.SelectFileWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { autoFitLabel: true } ) );
// Properties
this.$info = $( '<span>' );
// Properties
this.showDropTarget = config.showDropTarget;
this.isSupported = this.constructor.static.isSupported();
this.currentFile = null;
if ( Array.isArray( config.accept ) ) {
this.accept = config.accept;
} else {
this.accept = null;
}
this.placeholder = config.placeholder;
this.notsupported = config.notsupported;
this.onFileSelectedHandler = this.onFileSelected.bind( this );
this.selectButton = new OO.ui.ButtonWidget( {
classes: [ 'oo-ui-selectFileWidget-selectButton' ],
label: 'Select a file',
disabled: this.disabled || !this.isSupported
} );
this.clearButton = new OO.ui.ButtonWidget( {
classes: [ 'oo-ui-selectFileWidget-clearButton' ],
framed: false,
icon: 'remove',
disabled: this.disabled
} );
// Events
this.selectButton.$button.on( {
keypress: this.onKeyPress.bind( this )
} );
this.clearButton.connect( this, {
click: 'onClearClick'
} );
if ( config.droppable ) {
dragHandler = this.onDragEnterOrOver.bind( this );
this.$element.on( {
dragenter: dragHandler,
dragover: dragHandler,
dragleave: this.onDragLeave.bind( this ),
drop: this.onDrop.bind( this )
} );
}
// Initialization
this.addInput();
this.updateUI();
this.$label.addClass( 'oo-ui-selectFileWidget-label' );
this.$info
.addClass( 'oo-ui-selectFileWidget-info' )
.append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
this.$element
.addClass( 'oo-ui-selectFileWidget' )
.append( this.$info, this.selectButton.$element );
if ( config.droppable && config.showDropTarget ) {
this.$dropTarget = $( '<div>' )
.addClass( 'oo-ui-selectFileWidget-dropTarget' )
.text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
.on( {
click: this.onDropTargetClick.bind( this )
} );
this.$element.prepend( this.$dropTarget );
}
};
/* Setup */
OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
/* Static Properties */
/**
* Check if this widget is supported
*
* @static
* @return {boolean}
*/
OO.ui.SelectFileWidget.static.isSupported = function () {
var $input;
if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
$input = $( '<input type="file">' );
OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
}
return OO.ui.SelectFileWidget.static.isSupportedCache;
};
OO.ui.SelectFileWidget.static.isSupportedCache = null;
/* Events */
/**
* @event change
*
* A change event is emitted when the on/off state of the toggle changes.
*
* @param {File|null} value New value
*/
/* Methods */
/**
* Get the current value of the field
*
* @return {File|null}
*/
OO.ui.SelectFileWidget.prototype.getValue = function () {
return this.currentFile;
};
/**
* Set the current value of the field
*
* @param {File|null} file File to select
*/
OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
if ( this.currentFile !== file ) {
this.currentFile = file;
this.updateUI();
this.emit( 'change', this.currentFile );
}
};
/**
* Focus the widget.
*
* Focusses the select file button.
*
* @chainable
*/
OO.ui.SelectFileWidget.prototype.focus = function () {
this.selectButton.$button[ 0 ].focus();
return this;
};
/**
* Update the user interface when a file is selected or unselected
*
* @protected
*/
OO.ui.SelectFileWidget.prototype.updateUI = function () {
var $label;
if ( !this.isSupported ) {
this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
this.setLabel( this.notsupported );
} else {
this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
if ( this.currentFile ) {
this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
$label = $( [] );
if ( this.currentFile.type !== '' ) {
$label = $label.add( $( '<span>' ).addClass( 'oo-ui-selectFileWidget-fileType' ).text( this.currentFile.type ) );
}
$label = $label.add( $( '<span>' ).text( this.currentFile.name ) );
this.setLabel( $label );
} else {
this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
this.setLabel( this.placeholder );
}
}
if ( this.$input ) {
this.$input.attr( 'title', this.getLabel() );
}
};
/**
* Add the input to the widget
*
* @private
*/
OO.ui.SelectFileWidget.prototype.addInput = function () {
if ( this.$input ) {
this.$input.remove();
}
if ( !this.isSupported ) {
this.$input = null;
return;
}
this.$input = $( '<input type="file">' );
this.$input.on( 'change', this.onFileSelectedHandler );
this.$input.attr( {
tabindex: -1,
title: this.getLabel()
} );
if ( this.accept ) {
this.$input.attr( 'accept', this.accept.join( ', ' ) );
}
this.selectButton.$button.append( this.$input );
};
/**
* Determine if we should accept this file
*
* @private
* @param {string} File MIME type
* @return {boolean}
*/
OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
var i, mimeTest;
if ( !this.accept || !mimeType ) {
return true;
}
for ( i = 0; i < this.accept.length; i++ ) {
mimeTest = this.accept[ i ];
if ( mimeTest === mimeType ) {
return true;
} else if ( mimeTest.substr( -2 ) === '/*' ) {
mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
return true;
}
}
}
return false;
};
/**
* Handle file selection from the input
*
* @private
* @param {jQuery.Event} e
*/
OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
var file = OO.getProp( e.target, 'files', 0 ) || null;
if ( file && !this.isAllowedType( file.type ) ) {
file = null;
}
this.setValue( file );
this.addInput();
};
/**
* Handle clear button click events.
*
* @private
*/
OO.ui.SelectFileWidget.prototype.onClearClick = function () {
this.setValue( null );
return false;
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
if ( this.isSupported && !this.isDisabled() && this.$input &&
( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
) {
this.$input.click();
return false;
}
};
/**
* Handle drop target click events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
if ( this.isSupported && !this.isDisabled() && this.$input ) {
this.$input.click();
return false;
}
};
/**
* Handle drag enter and over events
*
* @private
* @param {jQuery.Event} e Drag event
*/
OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
var itemOrFile,
droppableFile = false,
dt = e.originalEvent.dataTransfer;
e.preventDefault();
e.stopPropagation();
if ( this.isDisabled() || !this.isSupported ) {
this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
dt.dropEffect = 'none';
return false;
}
// DataTransferItem and File both have a type property, but in Chrome files
// have no information at this point.
itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
if ( itemOrFile ) {
if ( this.isAllowedType( itemOrFile.type ) ) {
droppableFile = true;
}
// dt.types is Array-like, but not an Array
} else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
// File information is not available at this point for security so just assume
// it is acceptable for now.
// https://bugzilla.mozilla.org/show_bug.cgi?id=640534
droppableFile = true;
}
this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
if ( !droppableFile ) {
dt.dropEffect = 'none';
}
return false;
};
/**
* Handle drag leave events
*
* @private
* @param {jQuery.Event} e Drag event
*/
OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
};
/**
* Handle drop events
*
* @private
* @param {jQuery.Event} e Drop event
*/
OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
var file = null,
dt = e.originalEvent.dataTransfer;
e.preventDefault();
e.stopPropagation();
this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
if ( this.isDisabled() || !this.isSupported ) {
return false;
}
file = OO.getProp( dt, 'files', 0 );
if ( file && !this.isAllowedType( file.type ) ) {
file = null;
}
if ( file ) {
this.setValue( file );
}
return false;
};
/**
* @inheritdoc
*/
OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
if ( this.selectButton ) {
this.selectButton.setDisabled( disabled );
}
if ( this.clearButton ) {
this.clearButton.setDisabled( disabled );
}
return this;
};
/**
* IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
* which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
* for a list of icons included in the library.
*
* @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.mixin.IconElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.FlaggedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.IconWidget = function OoUiIconWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.IconWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
// Initialization
this.$element.addClass( 'oo-ui-iconWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
/* Static Properties */
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.mixin.IndicatorElement
* @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.IndicatorWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
// Initialization
this.$element.addClass( 'oo-ui-indicatorWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.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.mixin.FlaggedElement
* @mixins OO.ui.mixin.TabIndexedElement
* @mixins OO.ui.mixin.TitledElement
* @mixins OO.ui.mixin.AccessKeyedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
* @cfg {string} [value=''] The value of the input.
* @cfg {string} [accessKey=''] The access key of the input.
* @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
* before it is accepted.
*/
OO.ui.InputWidget = function OoUiInputWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.InputWidget.parent.call( this, config );
// Properties
this.$input = this.getInputElement( config );
this.value = '';
this.inputFilter = config.inputFilter;
// Mixin constructors
OO.ui.mixin.FlaggedElement.call( this, config );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
// Events
this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
// Initialization
this.$input
.addClass( 'oo-ui-inputWidget-input' )
.attr( 'name', config.name )
.prop( 'disabled', this.isDisabled() );
this.$element
.addClass( 'oo-ui-inputWidget' )
.append( this.$input );
this.setValue( config.value );
this.setAccessKey( config.accessKey );
};
/* Setup */
OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
/* Static Properties */
OO.ui.InputWidget.static.supportsSimpleLabel = true;
/* Events */
/**
* @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).
*
* @protected
* @param {Object} config Configuration options
* @return {jQuery} Input element
*/
OO.ui.InputWidget.prototype.getInputElement = function () {
return $( '<input>' );
};
/**
* 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;
};
/**
* Set the input's access key.
* FIXME: This is the same code as in OO.ui.mixin.ButtonElement, maybe find a better place for it?
*
* @param {string} accessKey Input's access key, use empty string to remove
* @chainable
*/
OO.ui.InputWidget.prototype.setAccessKey = function ( accessKey ) {
accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null;
if ( this.accessKey !== accessKey ) {
if ( this.$input ) {
if ( accessKey !== null ) {
this.$input.attr( 'accesskey', accessKey );
} else {
this.$input.removeAttr( 'accesskey' );
}
}
this.accessKey = accessKey;
}
return this;
};
/**
* Clean up incoming value.
*
* Ensures value is a string, and converts undefined and null to empty string.
*
* @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.parent.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;
};
/**
* @inheritdoc
*/
OO.ui.InputWidget.prototype.gatherPreInfuseState = function ( node ) {
var
state = OO.ui.InputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
$input = state.$input || $( node ).find( '.oo-ui-inputWidget-input' );
state.value = $input.val();
// Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
state.focus = $input.is( ':focus' );
return state;
};
/**
* @inheritdoc
*/
OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
if ( state.value !== undefined && state.value !== this.getValue() ) {
this.setValue( state.value );
}
if ( state.focus ) {
this.focus();
}
};
/**
* ButtonInputWidget is used to submit HTML forms and is intended to be used within
* a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
* want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
* HTML `<button/>` (the default) or an HTML `<input/>` tags. See the
* [OOjs UI documentation on MediaWiki] [1] for more information.
*
* @example
* // A ButtonInputWidget rendered as an HTML button, the default.
* var button = new OO.ui.ButtonInputWidget( {
* label: 'Input button',
* icon: 'check',
* value: 'check'
* } );
* $( 'body' ).append( button.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
*
* @class
* @extends OO.ui.InputWidget
* @mixins OO.ui.mixin.ButtonElement
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
* @cfg {boolean} [useInputTag=false] Use an `<input/>` tag instead of a `<button/>` tag, the default.
* Widgets configured to be an `<input/>` do not support {@link #icon icons} and {@link #indicator indicators},
* non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
* be set to `true` when there’s need to support IE6 in a form with multiple buttons.
*/
OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
// Configuration initialization
config = $.extend( { type: 'button', useInputTag: false }, config );
// Properties (must be set before parent constructor, which calls #setValue)
this.useInputTag = config.useInputTag;
// Parent constructor
OO.ui.ButtonInputWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
// Initialization
if ( !config.useInputTag ) {
this.$input.append( this.$icon, this.$label, this.$indicator );
}
this.$element.addClass( 'oo-ui-buttonInputWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
/* Static Properties */
/**
* Disable generating `<label>` elements for buttons. One would very rarely need additional label
* for a button, and it's already a big clickable target, and it causes unexpected rendering.
*/
OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
/* Methods */
/**
* @inheritdoc
* @protected
*/
OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
var type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ?
config.type :
'button';
return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
};
/**
* Set label value.
*
* If #useInputTag is `true`, the label is set as the `value` of the `<input/>` tag.
*
* @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
* text, or `null` for no label
* @chainable
*/
OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
if ( this.useInputTag ) {
if ( typeof label === 'function' ) {
label = OO.ui.resolveMsg( label );
}
if ( label instanceof jQuery ) {
label = label.text();
}
if ( !label ) {
label = '';
}
this.$input.val( label );
}
return this;
};
/**
* Set the value of the input.
*
* This method is disabled for button inputs configured as {@link #useInputTag <input/> tags}, as
* they do not support {@link #value values}.
*
* @param {string} value New value
* @chainable
*/
OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
if ( !this.useInputTag ) {
OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
}
return this;
};
/**
* CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
* Note that these {@link OO.ui.InputWidget input widgets} are best laid out
* in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
* alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
*
* This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
*
* @example
* // An example of selected, unselected, and disabled checkbox inputs
* var checkbox1=new OO.ui.CheckboxInputWidget( {
* value: 'a',
* selected: true
* } );
* var checkbox2=new OO.ui.CheckboxInputWidget( {
* value: 'b'
* } );
* var checkbox3=new OO.ui.CheckboxInputWidget( {
* value:'c',
* disabled: true
* } );
* // Create a fieldset layout with fields for each checkbox.
* var fieldset = new OO.ui.FieldsetLayout( {
* label: 'Checkboxes'
* } );
* fieldset.addItems( [
* new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
* new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
* new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
* ] );
* $( 'body' ).append( fieldset.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
*
* @class
* @extends OO.ui.InputWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
*/
OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.CheckboxInputWidget.parent.call( this, config );
// Initialization
this.$element
.addClass( 'oo-ui-checkboxInputWidget' )
// Required for pretty styling in MediaWiki theme
.append( $( '<span>' ) );
this.setSelected( config.selected !== undefined ? config.selected : false );
};
/* Setup */
OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
/* Methods */
/**
* @inheritdoc
* @protected
*/
OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
return $( '<input type="checkbox" />' );
};
/**
* @inheritdoc
*/
OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
var widget = this;
if ( !this.isDisabled() ) {
// Allow the stack to clear so the value will be updated
setTimeout( function () {
widget.setSelected( widget.$input.prop( 'checked' ) );
} );
}
};
/**
* Set selection state of this checkbox.
*
* @param {boolean} state `true` for selected
* @chainable
*/
OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
state = !!state;
if ( this.selected !== state ) {
this.selected = state;
this.$input.prop( 'checked', this.selected );
this.emit( 'change', this.selected );
}
return this;
};
/**
* Check if this checkbox is selected.
*
* @return {boolean} Checkbox is selected
*/
OO.ui.CheckboxInputWidget.prototype.isSelected = 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 selected = this.$input.prop( 'checked' );
if ( this.selected !== selected ) {
this.setSelected( selected );
}
return this.selected;
};
/**
* @inheritdoc
*/
OO.ui.CheckboxInputWidget.prototype.gatherPreInfuseState = function ( node ) {
var
state = OO.ui.CheckboxInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
$input = $( node ).find( '.oo-ui-inputWidget-input' );
state.$input = $input; // shortcut for performance, used in InputWidget
state.checked = $input.prop( 'checked' );
return state;
};
/**
* @inheritdoc
*/
OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
this.setSelected( state.checked );
}
};
/**
* DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
* within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
* of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
* more information about input widgets.
*
* A DropdownInputWidget always has a value (one of the options is always selected), unless there
* are no options. If no `value` configuration option is provided, the first option is selected.
* If you need a state representing no value (no option being selected), use a DropdownWidget.
*
* This and OO.ui.RadioSelectInputWidget support the same configuration options.
*
* @example
* // Example: A DropdownInputWidget with three options
* var dropdownInput = new OO.ui.DropdownInputWidget( {
* options: [
* { data: 'a', label: 'First' },
* { data: 'b', label: 'Second'},
* { data: 'c', label: 'Third' }
* ]
* } );
* $( 'body' ).append( dropdownInput.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
*
* @class
* @extends OO.ui.InputWidget
* @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
* @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
*/
OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
// Configuration initialization
config = config || {};
// Properties (must be done before parent constructor which calls #setDisabled)
this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
// Parent constructor
OO.ui.DropdownInputWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TitledElement.call( this, config );
// Events
this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
// Initialization
this.setOptions( config.options || [] );
this.$element
.addClass( 'oo-ui-dropdownInputWidget' )
.append( this.dropdownWidget.$element );
};
/* Setup */
OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
/* Methods */
/**
* @inheritdoc
* @protected
*/
OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
return $( '<input type="hidden">' );
};
/**
* Handles menu select events.
*
* @private
* @param {OO.ui.MenuOptionWidget} item Selected menu item
*/
OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
this.setValue( item.getData() );
};
/**
* @inheritdoc
*/
OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
value = this.cleanUpValue( value );
this.dropdownWidget.getMenu().selectItemByData( value );
OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
return this;
};
/**
* @inheritdoc
*/
OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
this.dropdownWidget.setDisabled( state );
OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
return this;
};
/**
* Set the options available for this input.
*
* @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
* @chainable
*/
OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
var
value = this.getValue(),
widget = this;
// Rebuild the dropdown menu
this.dropdownWidget.getMenu()
.clearItems()
.addItems( options.map( function ( opt ) {
var optValue = widget.cleanUpValue( opt.data );
return new OO.ui.MenuOptionWidget( {
data: optValue,
label: opt.label !== undefined ? opt.label : optValue
} );
} ) );
// Restore the previous value, or reset to something sensible
if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
// Previous value is still available, ensure consistency with the dropdown
this.setValue( value );
} else {
// No longer valid, reset
if ( options.length ) {
this.setValue( options[ 0 ].data );
}
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.DropdownInputWidget.prototype.focus = function () {
this.dropdownWidget.getMenu().toggle( true );
return this;
};
/**
* @inheritdoc
*/
OO.ui.DropdownInputWidget.prototype.blur = function () {
this.dropdownWidget.getMenu().toggle( false );
return this;
};
/**
* RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
* in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
* with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
* please see the [OOjs UI documentation on MediaWiki][1].
*
* This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
*
* @example
* // An example of selected, unselected, and disabled radio inputs
* var radio1 = new OO.ui.RadioInputWidget( {
* value: 'a',
* selected: true
* } );
* var radio2 = new OO.ui.RadioInputWidget( {
* value: 'b'
* } );
* var radio3 = new OO.ui.RadioInputWidget( {
* value: 'c',
* disabled: true
* } );
* // Create a fieldset layout with fields for each radio button.
* var fieldset = new OO.ui.FieldsetLayout( {
* label: 'Radio inputs'
* } );
* fieldset.addItems( [
* new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
* new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
* new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
* ] );
* $( 'body' ).append( fieldset.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
*
* @class
* @extends OO.ui.InputWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
*/
OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.RadioInputWidget.parent.call( this, config );
// Initialization
this.$element
.addClass( 'oo-ui-radioInputWidget' )
// Required for pretty styling in MediaWiki theme
.append( $( '<span>' ) );
this.setSelected( config.selected !== undefined ? config.selected : false );
};
/* Setup */
OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
/* Methods */
/**
* @inheritdoc
* @protected
*/
OO.ui.RadioInputWidget.prototype.getInputElement = function () {
return $( '<input type="radio" />' );
};
/**
* @inheritdoc
*/
OO.ui.RadioInputWidget.prototype.onEdit = function () {
// RadioInputWidget doesn't track its state.
};
/**
* Set selection state of this radio button.
*
* @param {boolean} state `true` for selected
* @chainable
*/
OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
// RadioInputWidget doesn't track its state.
this.$input.prop( 'checked', state );
return this;
};
/**
* Check if this radio button is selected.
*
* @return {boolean} Radio is selected
*/
OO.ui.RadioInputWidget.prototype.isSelected = function () {
return this.$input.prop( 'checked' );
};
/**
* @inheritdoc
*/
OO.ui.RadioInputWidget.prototype.gatherPreInfuseState = function ( node ) {
var
state = OO.ui.RadioInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
$input = $( node ).find( '.oo-ui-inputWidget-input' );
state.$input = $input; // shortcut for performance, used in InputWidget
state.checked = $input.prop( 'checked' );
return state;
};
/**
* @inheritdoc
*/
OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
this.setSelected( state.checked );
}
};
/**
* RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
* within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
* of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
* more information about input widgets.
*
* This and OO.ui.DropdownInputWidget support the same configuration options.
*
* @example
* // Example: A RadioSelectInputWidget with three options
* var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
* options: [
* { data: 'a', label: 'First' },
* { data: 'b', label: 'Second'},
* { data: 'c', label: 'Third' }
* ]
* } );
* $( 'body' ).append( radioSelectInput.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
*
* @class
* @extends OO.ui.InputWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
*/
OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
// Configuration initialization
config = config || {};
// Properties (must be done before parent constructor which calls #setDisabled)
this.radioSelectWidget = new OO.ui.RadioSelectWidget();
// Parent constructor
OO.ui.RadioSelectInputWidget.parent.call( this, config );
// Events
this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
// Initialization
this.setOptions( config.options || [] );
this.$element
.addClass( 'oo-ui-radioSelectInputWidget' )
.append( this.radioSelectWidget.$element );
};
/* Setup */
OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
/* Static Properties */
OO.ui.RadioSelectInputWidget.static.supportsSimpleLabel = false;
/* Methods */
/**
* @inheritdoc
* @protected
*/
OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
return $( '<input type="hidden">' );
};
/**
* Handles menu select events.
*
* @private
* @param {OO.ui.RadioOptionWidget} item Selected menu item
*/
OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
this.setValue( item.getData() );
};
/**
* @inheritdoc
*/
OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
value = this.cleanUpValue( value );
this.radioSelectWidget.selectItemByData( value );
OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
return this;
};
/**
* @inheritdoc
*/
OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
this.radioSelectWidget.setDisabled( state );
OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
return this;
};
/**
* Set the options available for this input.
*
* @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
* @chainable
*/
OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
var
value = this.getValue(),
widget = this;
// Rebuild the radioSelect menu
this.radioSelectWidget
.clearItems()
.addItems( options.map( function ( opt ) {
var optValue = widget.cleanUpValue( opt.data );
return new OO.ui.RadioOptionWidget( {
data: optValue,
label: opt.label !== undefined ? opt.label : optValue
} );
} ) );
// Restore the previous value, or reset to something sensible
if ( this.radioSelectWidget.getItemFromData( value ) ) {
// Previous value is still available, ensure consistency with the radioSelect
this.setValue( value );
} else {
// No longer valid, reset
if ( options.length ) {
this.setValue( options[ 0 ].data );
}
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.RadioSelectInputWidget.prototype.gatherPreInfuseState = function ( node ) {
var state = OO.ui.RadioSelectInputWidget.parent.prototype.gatherPreInfuseState.call( this, node );
state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
return state;
};
/**
* TextInputWidgets, like HTML text inputs, can be configured with options that customize the
* size of the field as well as its presentation. In addition, these widgets can be configured
* with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
* validation-pattern (used to determine if an input value is valid or not) and an input filter,
* which modifies incoming values rather than validating them.
* Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
*
* This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
*
* @example
* // Example of a text input widget
* var textInput = new OO.ui.TextInputWidget( {
* value: 'Text input'
* } )
* $( 'body' ).append( textInput.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
*
* @class
* @extends OO.ui.InputWidget
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
* @mixins OO.ui.mixin.PendingElement
* @mixins OO.ui.mixin.LabelElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
* 'email' or 'url'. Ignored if `multiline` is true.
*
* Some values of `type` result in additional behaviors:
*
* - `search`: implies `icon: 'search'` and `indicator: 'clear'`; when clicked, the indicator
* empties the text field
* @cfg {string} [placeholder] Placeholder text
* @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
* instruct the browser to focus this widget.
* @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
* @cfg {number} [maxLength] Maximum number of characters allowed in the input.
* @cfg {boolean} [multiline=false] Allow multiple lines of text
* @cfg {number} [rows] If multiline, number of visible lines in textarea. If used with `autosize`,
* specifies minimum number of rows to display.
* @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
* Use the #maxRows config to specify a maximum number of displayed rows.
* @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
* Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
* @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
* the value or placeholder text: `'before'` or `'after'`
* @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
* @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
* @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
* pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
* (the value must contain only numbers); when RegExp, a regular expression that must match the
* value for it to be considered valid; when Function, a function receiving the value as parameter
* that must return true, or promise resolving to true, for it to be considered valid.
*/
OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
// Configuration initialization
config = $.extend( {
type: 'text',
labelPosition: 'after'
}, config );
if ( config.type === 'search' ) {
if ( config.icon === undefined ) {
config.icon = 'search';
}
// indicator: 'clear' is set dynamically later, depending on value
}
if ( config.required ) {
if ( config.indicator === undefined ) {
config.indicator = 'required';
}
}
// Parent constructor
OO.ui.TextInputWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
OO.ui.mixin.LabelElement.call( this, config );
// Properties
this.type = this.getSaneType( config );
this.readOnly = false;
this.multiline = !!config.multiline;
this.autosize = !!config.autosize;
this.minRows = config.rows !== undefined ? config.rows : '';
this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
this.validate = null;
// Clone for resizing
if ( this.autosize ) {
this.$clone = this.$input
.clone()
.insertAfter( this.$input )
.attr( 'aria-hidden', 'true' )
.addClass( 'oo-ui-element-hidden' );
}
this.setValidation( config.validate );
this.setLabelPosition( config.labelPosition );
// Events
this.$input.on( {
keypress: this.onKeyPress.bind( this ),
blur: this.onBlur.bind( this )
} );
this.$input.one( {
focus: this.onElementAttach.bind( this )
} );
this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
this.on( 'labelChange', this.updatePosition.bind( this ) );
this.connect( this, {
change: 'onChange',
disable: 'onDisable'
} );
// Initialization
this.$element
.addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
.append( this.$icon, this.$indicator );
this.setReadOnly( !!config.readOnly );
this.updateSearchIndicator();
if ( config.placeholder ) {
this.$input.attr( 'placeholder', config.placeholder );
}
if ( config.maxLength !== undefined ) {
this.$input.attr( 'maxlength', config.maxLength );
}
if ( config.autofocus ) {
this.$input.attr( 'autofocus', 'autofocus' );
}
if ( config.required ) {
this.$input.attr( 'required', 'required' );
this.$input.attr( 'aria-required', 'true' );
}
if ( config.autocomplete === false ) {
this.$input.attr( 'autocomplete', 'off' );
// Turning off autocompletion also disables "form caching" when the user navigates to a
// different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
$( window ).on( {
beforeunload: function () {
this.$input.removeAttr( 'autocomplete' );
}.bind( this ),
pageshow: function () {
// Browsers don't seem to actually fire this event on "Back", they instead just reload the
// whole page... it shouldn't hurt, though.
this.$input.attr( 'autocomplete', 'off' );
}.bind( this )
} );
}
if ( this.multiline && config.rows ) {
this.$input.attr( 'rows', config.rows );
}
if ( this.label || config.autosize ) {
this.installParentChangeDetector();
}
};
/* Setup */
OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
/* Static Properties */
OO.ui.TextInputWidget.static.validationPatterns = {
'non-empty': /.+/,
integer: /^\d+$/
};
/* Events */
/**
* An `enter` event is emitted when the user presses 'enter' inside the text box.
*
* Not emitted if the input is multiline.
*
* @event enter
*/
/* Methods */
/**
* Handle icon mouse down events.
*
* @private
* @param {jQuery.Event} e Mouse down event
* @fires icon
*/
OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
if ( e.which === 1 ) {
this.$input[ 0 ].focus();
return false;
}
};
/**
* Handle indicator mouse down events.
*
* @private
* @param {jQuery.Event} e Mouse down event
* @fires indicator
*/
OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
if ( e.which === 1 ) {
if ( this.type === 'search' ) {
// Clear the text field
this.setValue( '' );
}
this.$input[ 0 ].focus();
return false;
}
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
* @fires enter If enter key is pressed and input is not multiline
*/
OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
if ( e.which === OO.ui.Keys.ENTER && !this.multiline ) {
this.emit( 'enter', e );
}
};
/**
* Handle blur events.
*
* @private
* @param {jQuery.Event} e Blur event
*/
OO.ui.TextInputWidget.prototype.onBlur = function () {
this.setValidityFlag();
};
/**
* Handle element attach events.
*
* @private
* @param {jQuery.Event} e Element attach event
*/
OO.ui.TextInputWidget.prototype.onElementAttach = function () {
// Any previously calculated size is now probably invalid if we reattached elsewhere
this.valCache = null;
this.adjustSize();
this.positionLabel();
};
/**
* Handle change events.
*
* @param {string} value
* @private
*/
OO.ui.TextInputWidget.prototype.onChange = function () {
this.updateSearchIndicator();
this.setValidityFlag();
this.adjustSize();
};
/**
* Handle disable events.
*
* @param {boolean} disabled Element is disabled
* @private
*/
OO.ui.TextInputWidget.prototype.onDisable = function () {
this.updateSearchIndicator();
};
/**
* Check if the input is {@link #readOnly read-only}.
*
* @return {boolean}
*/
OO.ui.TextInputWidget.prototype.isReadOnly = function () {
return this.readOnly;
};
/**
* Set the {@link #readOnly read-only} state of the input.
*
* @param {boolean} state Make input read-only
* @chainable
*/
OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
this.readOnly = !!state;
this.$input.prop( 'readOnly', this.readOnly );
this.updateSearchIndicator();
return this;
};
/**
* Support function for making #onElementAttach work across browsers.
*
* This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
* event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
*
* Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
* first time that the element gets attached to the documented.
*/
OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
var mutationObserver, onRemove, topmostNode, fakeParentNode,
MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
widget = this;
if ( MutationObserver ) {
// The new way. If only it wasn't so ugly.
if ( this.$element.closest( 'html' ).length ) {
// Widget is attached already, do nothing. This breaks the functionality of this function when
// the widget is detached and reattached. Alas, doing this correctly with MutationObserver
// would require observation of the whole document, which would hurt performance of other,
// more important code.
return;
}
// Find topmost node in the tree
topmostNode = this.$element[ 0 ];
while ( topmostNode.parentNode ) {
topmostNode = topmostNode.parentNode;
}
// We have no way to detect the $element being attached somewhere without observing the entire
// DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
// parent node of $element, and instead detect when $element is removed from it (and thus
// probably attached somewhere else). If there is no parent, we create a "fake" one. If it
// doesn't get attached, we end up back here and create the parent.
mutationObserver = new MutationObserver( function ( mutations ) {
var i, j, removedNodes;
for ( i = 0; i < mutations.length; i++ ) {
removedNodes = mutations[ i ].removedNodes;
for ( j = 0; j < removedNodes.length; j++ ) {
if ( removedNodes[ j ] === topmostNode ) {
setTimeout( onRemove, 0 );
return;
}
}
}
} );
onRemove = function () {
// If the node was attached somewhere else, report it
if ( widget.$element.closest( 'html' ).length ) {
widget.onElementAttach();
}
mutationObserver.disconnect();
widget.installParentChangeDetector();
};
// Create a fake parent and observe it
fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
mutationObserver.observe( fakeParentNode, { childList: true } );
} else {
// Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
// detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
}
};
/**
* Automatically adjust the size of the text input.
*
* This only affects #multiline inputs that are {@link #autosize autosized}.
*
* @chainable
*/
OO.ui.TextInputWidget.prototype.adjustSize = function () {
var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError, idealHeight;
if ( this.multiline && this.autosize && this.$input.val() !== this.valCache ) {
this.$clone
.val( this.$input.val() )
.attr( 'rows', this.minRows )
// Set inline height property to 0 to measure scroll height
.css( 'height', 0 );
this.$clone.removeClass( 'oo-ui-element-hidden' );
this.valCache = this.$input.val();
scrollHeight = this.$clone[ 0 ].scrollHeight;
// Remove inline height property to measure natural heights
this.$clone.css( 'height', '' );
innerHeight = this.$clone.innerHeight();
outerHeight = this.$clone.outerHeight();
// Measure max rows height
this.$clone
.attr( 'rows', this.maxRows )
.css( 'height', 'auto' )
.val( '' );
maxInnerHeight = this.$clone.innerHeight();
// Difference between reported innerHeight and scrollHeight with no scrollbars present
// Equals 1 on Blink-based browsers and 0 everywhere else
measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
this.$clone.addClass( 'oo-ui-element-hidden' );
// Only apply inline height when expansion beyond natural height is needed
if ( idealHeight > innerHeight ) {
// Use the difference between the inner and outer height as a buffer
this.$input.css( 'height', idealHeight + ( outerHeight - innerHeight ) );
} else {
this.$input.css( 'height', '' );
}
}
return this;
};
/**
* @inheritdoc
* @protected
*/
OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
return config.multiline ?
$( '<textarea>' ) :
$( '<input type="' + this.getSaneType( config ) + '" />' );
};
/**
* Get sanitized value for 'type' for given config.
*
* @param {Object} config Configuration options
* @return {string|null}
* @private
*/
OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ?
config.type :
'text';
return config.multiline ? 'multiline' : type;
};
/**
* Check if the input supports multiple lines.
*
* @return {boolean}
*/
OO.ui.TextInputWidget.prototype.isMultiline = function () {
return !!this.multiline;
};
/**
* Check if the input automatically adjusts its size.
*
* @return {boolean}
*/
OO.ui.TextInputWidget.prototype.isAutosizing = function () {
return !!this.autosize;
};
/**
* Select the entire text of the input.
*
* @chainable
*/
OO.ui.TextInputWidget.prototype.select = function () {
this.$input.select();
return this;
};
/**
* Focus the input and move the cursor to the end.
*/
OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
var textRange,
element = this.$input[ 0 ];
this.focus();
if ( element.selectionStart !== undefined ) {
element.selectionStart = element.selectionEnd = element.value.length;
} else if ( element.createTextRange ) {
// IE 8 and below
textRange = element.createTextRange();
textRange.collapse( false );
textRange.select();
}
};
/**
* Set the validation pattern.
*
* The validation pattern is either a regular expression, a function, or the symbolic name of a
* pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
* value must contain only numbers).
*
* @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
* of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
*/
OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
if ( validate instanceof RegExp || validate instanceof Function ) {
this.validate = validate;
} else {
this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
}
};
/**
* Sets the 'invalid' flag appropriately.
*
* @param {boolean} [isValid] Optionally override validation result
*/
OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
var widget = this,
setFlag = function ( valid ) {
if ( !valid ) {
widget.$input.attr( 'aria-invalid', 'true' );
} else {
widget.$input.removeAttr( 'aria-invalid' );
}
widget.setFlags( { invalid: !valid } );
};
if ( isValid !== undefined ) {
setFlag( isValid );
} else {
this.getValidity().then( function () {
setFlag( true );
}, function () {
setFlag( false );
} );
}
};
/**
* Check if a value is valid.
*
* This method returns a promise that resolves with a boolean `true` if the current value is
* considered valid according to the supplied {@link #validate validation pattern}.
*
* @deprecated
* @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid.
*/
OO.ui.TextInputWidget.prototype.isValid = function () {
var result;
if ( this.validate instanceof Function ) {
result = this.validate( this.getValue() );
if ( $.isFunction( result.promise ) ) {
return result.promise();
} else {
return $.Deferred().resolve( !!result ).promise();
}
} else {
return $.Deferred().resolve( !!this.getValue().match( this.validate ) ).promise();
}
};
/**
* Get the validity of current value.
*
* This method returns a promise that resolves if the value is valid and rejects if
* it isn't. Uses the {@link #validate validation pattern} to check for validity.
*
* @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
*/
OO.ui.TextInputWidget.prototype.getValidity = function () {
var result, promise;
function rejectOrResolve( valid ) {
if ( valid ) {
return $.Deferred().resolve().promise();
} else {
return $.Deferred().reject().promise();
}
}
if ( this.validate instanceof Function ) {
result = this.validate( this.getValue() );
if ( $.isFunction( result.promise ) ) {
promise = $.Deferred();
result.then( function ( valid ) {
if ( valid ) {
promise.resolve();
} else {
promise.reject();
}
}, function () {
promise.reject();
} );
return promise.promise();
} else {
return rejectOrResolve( result );
}
} else {
return rejectOrResolve( this.getValue().match( this.validate ) );
}
};
/**
* Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
*
* @param {string} labelPosition Label position, 'before' or 'after'
* @chainable
*/
OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
this.labelPosition = labelPosition;
this.updatePosition();
return this;
};
/**
* Deprecated alias of #setLabelPosition
*
* @deprecated Use setLabelPosition instead.
*/
OO.ui.TextInputWidget.prototype.setPosition =
OO.ui.TextInputWidget.prototype.setLabelPosition;
/**
* Update the position of the inline label.
*
* This method is called by #setLabelPosition, and can also be called on its own if
* something causes the label to be mispositioned.
*
* @chainable
*/
OO.ui.TextInputWidget.prototype.updatePosition = function () {
var after = this.labelPosition === 'after';
this.$element
.toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
.toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
this.positionLabel();
return this;
};
/**
* Update the 'clear' indicator displayed on type: 'search' text fields, hiding it when the field is
* already empty or when it's not editable.
*/
OO.ui.TextInputWidget.prototype.updateSearchIndicator = function () {
if ( this.type === 'search' ) {
if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
this.setIndicator( null );
} else {
this.setIndicator( 'clear' );
}
}
};
/**
* Position the label by setting the correct padding on the input.
*
* @private
* @chainable
*/
OO.ui.TextInputWidget.prototype.positionLabel = function () {
var after, rtl, property;
// Clear old values
this.$input
// Clear old values if present
.css( {
'padding-right': '',
'padding-left': ''
} );
if ( this.label ) {
this.$element.append( this.$label );
} else {
this.$label.detach();
return;
}
after = this.labelPosition === 'after';
rtl = this.$element.css( 'direction' ) === 'rtl';
property = after === rtl ? 'padding-left' : 'padding-right';
this.$input.css( property, this.$label.outerWidth( true ) );
return this;
};
/**
* @inheritdoc
*/
OO.ui.TextInputWidget.prototype.gatherPreInfuseState = function ( node ) {
var
state = OO.ui.TextInputWidget.parent.prototype.gatherPreInfuseState.call( this, node ),
$input = $( node ).find( '.oo-ui-inputWidget-input' );
state.$input = $input; // shortcut for performance, used in InputWidget
if ( this.multiline ) {
state.scrollTop = $input.scrollTop();
}
return state;
};
/**
* @inheritdoc
*/
OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
if ( state.scrollTop !== undefined ) {
this.$input.scrollTop( state.scrollTop );
}
};
/**
* ComboBoxWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
* can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
* a value can be chosen instead). Users can choose options from the combo box in one of two ways:
*
* - by typing a value in the text input field. If the value exactly matches the value of a menu
* option, that option will appear to be selected.
* - by choosing a value from the menu. The value of the chosen option will then appear in the text
* input field.
*
* For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
*
* @example
* // Example: A ComboBoxWidget.
* var comboBox = new OO.ui.ComboBoxWidget( {
* label: 'ComboBoxWidget',
* input: { value: 'Option One' },
* menu: {
* items: [
* new OO.ui.MenuOptionWidget( {
* data: 'Option 1',
* label: 'Option One'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 2',
* label: 'Option Two'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 3',
* label: 'Option Three'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 4',
* label: 'Option Four'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'Option 5',
* label: 'Option Five'
* } )
* ]
* }
* } );
* $( 'body' ).append( comboBox.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.FloatingMenuSelectWidget menu select widget}.
* @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
* @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
* the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
* containing `<div>` and has a larger area. By default, the menu uses relative positioning.
*/
OO.ui.ComboBoxWidget = function OoUiComboBoxWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.ComboBoxWidget.parent.call( this, config );
// Properties (must be set before TabIndexedElement constructor call)
this.$indicator = this.$( '<span>' );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
// Properties
this.$overlay = config.$overlay || this.$element;
this.input = new OO.ui.TextInputWidget( $.extend(
{
indicator: 'down',
$indicator: this.$indicator,
disabled: this.isDisabled()
},
config.input
) );
this.input.$input.eq( 0 ).attr( {
role: 'combobox',
'aria-autocomplete': 'list'
} );
this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
{
widget: this,
input: this.input,
$container: this.input.$element,
disabled: this.isDisabled()
},
config.menu
) );
// Events
this.$indicator.on( {
click: this.onClick.bind( this ),
keypress: this.onKeyPress.bind( this )
} );
this.input.connect( this, {
change: 'onInputChange',
enter: 'onInputEnter'
} );
this.menu.connect( this, {
choose: 'onMenuChoose',
add: 'onMenuItemsChange',
remove: 'onMenuItemsChange'
} );
// Initialization
this.$element.addClass( 'oo-ui-comboBoxWidget' ).append( this.input.$element );
this.$overlay.append( this.menu.$element );
this.onMenuItemsChange();
};
/* Setup */
OO.inheritClass( OO.ui.ComboBoxWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.ComboBoxWidget, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* Get the combobox's menu.
* @return {OO.ui.FloatingMenuSelectWidget} Menu widget
*/
OO.ui.ComboBoxWidget.prototype.getMenu = function () {
return this.menu;
};
/**
* Get the combobox's text input widget.
* @return {OO.ui.TextInputWidget} Text input widget
*/
OO.ui.ComboBoxWidget.prototype.getInput = function () {
return this.input;
};
/**
* Handle input change events.
*
* @private
* @param {string} value New value
*/
OO.ui.ComboBoxWidget.prototype.onInputChange = function ( value ) {
var match = this.menu.getItemFromData( value );
this.menu.selectItem( match );
if ( this.menu.getHighlightedItem() ) {
this.menu.highlightItem( match );
}
if ( !this.isDisabled() ) {
this.menu.toggle( true );
}
};
/**
* Handle mouse click events.
*
* @private
* @param {jQuery.Event} e Mouse click event
*/
OO.ui.ComboBoxWidget.prototype.onClick = function ( e ) {
if ( !this.isDisabled() && e.which === 1 ) {
this.menu.toggle();
this.input.$input[ 0 ].focus();
}
return false;
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.ComboBoxWidget.prototype.onKeyPress = function ( e ) {
if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
this.menu.toggle();
this.input.$input[ 0 ].focus();
return false;
}
};
/**
* Handle input enter events.
*
* @private
*/
OO.ui.ComboBoxWidget.prototype.onInputEnter = function () {
if ( !this.isDisabled() ) {
this.menu.toggle( false );
}
};
/**
* Handle menu choose events.
*
* @private
* @param {OO.ui.OptionWidget} item Chosen item
*/
OO.ui.ComboBoxWidget.prototype.onMenuChoose = function ( item ) {
this.input.setValue( item.getData() );
};
/**
* Handle menu item change events.
*
* @private
*/
OO.ui.ComboBoxWidget.prototype.onMenuItemsChange = function () {
var match = this.menu.getItemFromData( this.input.getValue() );
this.menu.selectItem( match );
if ( this.menu.getHighlightedItem() ) {
this.menu.highlightItem( match );
}
this.$element.toggleClass( 'oo-ui-comboBoxWidget-empty', this.menu.isEmpty() );
};
/**
* @inheritdoc
*/
OO.ui.ComboBoxWidget.prototype.setDisabled = function ( disabled ) {
// Parent method
OO.ui.ComboBoxWidget.parent.prototype.setDisabled.call( this, disabled );
if ( this.input ) {
this.input.setDisabled( this.isDisabled() );
}
if ( this.menu ) {
this.menu.setDisabled( this.isDisabled() );
}
return this;
};
/**
* LabelWidgets help identify the function of interface elements. Each LabelWidget can
* be configured with a `label` option that is set to a string, a label node, or a function:
*
* - String: a plaintext string
* - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
* label that includes a link or special styling, such as a gray color or additional graphical elements.
* - Function: a function that will produce a string in the future. Functions are used
* in cases where the value of the label is not currently defined.
*
* In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
* will come into focus when the label is clicked.
*
* @example
* // Examples of LabelWidgets
* var label1 = new OO.ui.LabelWidget( {
* label: 'plaintext label'
* } );
* var label2 = new OO.ui.LabelWidget( {
* label: $( '<a href="default.html">jQuery label</a>' )
* } );
* // Create a fieldset layout with fields for each example
* var fieldset = new OO.ui.FieldsetLayout();
* fieldset.addItems( [
* new OO.ui.FieldLayout( label1 ),
* new OO.ui.FieldLayout( label2 )
* ] );
* $( 'body' ).append( fieldset.$element );
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.LabelElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
* Clicking the label will focus the specified input field.
*/
OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.LabelWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
OO.ui.mixin.TitledElement.call( this, config );
// Properties
this.input = config.input;
// Events
if ( this.input instanceof OO.ui.InputWidget ) {
this.$element.on( 'click', this.onClick.bind( this ) );
}
// Initialization
this.$element.addClass( 'oo-ui-labelWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
/* Static Properties */
OO.ui.LabelWidget.static.tagName = 'span';
/* Methods */
/**
* Handles label mouse click events.
*
* @private
* @param {jQuery.Event} e Mouse click event
*/
OO.ui.LabelWidget.prototype.onClick = function () {
this.input.simulateLabelClick();
return false;
};
/**
* OptionWidgets are special elements that can be selected and configured with data. The
* data is often unique for each option, but it does not have to be. OptionWidgets are used
* with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
* and examples, please see the [OOjs UI documentation on MediaWiki][1].
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.FlaggedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.OptionWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ItemWidget.call( this );
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.FlaggedElement.call( this, config );
// Properties
this.selected = false;
this.highlighted = false;
this.pressed = false;
// Initialization
this.$element
.data( 'oo-ui-optionWidget', this )
.attr( 'role', 'option' )
.attr( 'aria-selected', 'false' )
.addClass( 'oo-ui-optionWidget' )
.append( this.$label );
};
/* Setup */
OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
/* Static Properties */
OO.ui.OptionWidget.static.selectable = true;
OO.ui.OptionWidget.static.highlightable = true;
OO.ui.OptionWidget.static.pressable = true;
OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
/* Methods */
/**
* Check if the option can be selected.
*
* @return {boolean} Item is selectable
*/
OO.ui.OptionWidget.prototype.isSelectable = function () {
return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
};
/**
* Check if the option can be highlighted. A highlight indicates that the option
* may be selected when a user presses enter or clicks. Disabled items cannot
* be highlighted.
*
* @return {boolean} Item is highlightable
*/
OO.ui.OptionWidget.prototype.isHighlightable = function () {
return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
};
/**
* Check if the option can be pressed. The pressed state occurs when a user mouses
* down on an item, but has not yet let go of the mouse.
*
* @return {boolean} Item is pressable
*/
OO.ui.OptionWidget.prototype.isPressable = function () {
return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
};
/**
* Check if the option is selected.
*
* @return {boolean} Item is selected
*/
OO.ui.OptionWidget.prototype.isSelected = function () {
return this.selected;
};
/**
* Check if the option is highlighted. A highlight indicates that the
* item may be selected when a user presses enter or clicks.
*
* @return {boolean} Item is highlighted
*/
OO.ui.OptionWidget.prototype.isHighlighted = function () {
return this.highlighted;
};
/**
* Check if the option is pressed. The pressed state occurs when a user mouses
* down on an item, but has not yet let go of the mouse. The item may appear
* selected, but it will not be selected until the user releases the mouse.
*
* @return {boolean} Item is pressed
*/
OO.ui.OptionWidget.prototype.isPressed = function () {
return this.pressed;
};
/**
* Set the option’s selected state. In general, all modifications to the selection
* should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
* method instead of this method.
*
* @param {boolean} [state=false] Select option
* @chainable
*/
OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
if ( this.constructor.static.selectable ) {
this.selected = !!state;
this.$element
.toggleClass( 'oo-ui-optionWidget-selected', state )
.attr( 'aria-selected', state.toString() );
if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
this.scrollElementIntoView();
}
this.updateThemeClasses();
}
return this;
};
/**
* Set the option’s highlighted state. In general, all programmatic
* modifications to the highlight should be handled by the
* SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
* method instead of this method.
*
* @param {boolean} [state=false] Highlight option
* @chainable
*/
OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
if ( this.constructor.static.highlightable ) {
this.highlighted = !!state;
this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
this.updateThemeClasses();
}
return this;
};
/**
* Set the option’s pressed state. In general, all
* programmatic modifications to the pressed state should be handled by the
* SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
* method instead of this method.
*
* @param {boolean} [state=false] Press option
* @chainable
*/
OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
if ( this.constructor.static.pressable ) {
this.pressed = !!state;
this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
this.updateThemeClasses();
}
return this;
};
/**
* DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
* with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
* This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
* options. For more information about options and selects, please see the
* [OOjs UI documentation on MediaWiki][1].
*
* @example
* // Decorated options in a select widget
* var select = new OO.ui.SelectWidget( {
* items: [
* new OO.ui.DecoratedOptionWidget( {
* data: 'a',
* label: 'Option with icon',
* icon: 'help'
* } ),
* new OO.ui.DecoratedOptionWidget( {
* data: 'b',
* label: 'Option with indicator',
* indicator: 'next'
* } )
* ]
* } );
* $( 'body' ).append( select.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.OptionWidget
* @mixins OO.ui.mixin.IconElement
* @mixins OO.ui.mixin.IndicatorElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
// Parent constructor
OO.ui.DecoratedOptionWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.IconElement.call( this, config );
OO.ui.mixin.IndicatorElement.call( this, config );
// Initialization
this.$element
.addClass( 'oo-ui-decoratedOptionWidget' )
.prepend( this.$icon )
.append( this.$indicator );
};
/* Setup */
OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
/**
* ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
* can be selected and configured with data. The class is
* used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
* [OOjs UI documentation on MediaWiki] [1] for more information.
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_options
*
* @class
* @extends OO.ui.DecoratedOptionWidget
* @mixins OO.ui.mixin.ButtonElement
* @mixins OO.ui.mixin.TabIndexedElement
* @mixins OO.ui.mixin.TitledElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.ButtonOptionWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ButtonElement.call( this, config );
OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
$tabIndexed: this.$button,
tabIndex: -1
} ) );
// Initialization
this.$element.addClass( 'oo-ui-buttonOptionWidget' );
this.$button.append( this.$element.contents() );
this.$element.append( this.$button );
};
/* Setup */
OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.DecoratedOptionWidget );
OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement );
OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TabIndexedElement );
/* Static Properties */
// Allow button mouse down events to pass through so they can be handled by the parent select widget
OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
OO.ui.ButtonOptionWidget.static.highlightable = false;
/* Methods */
/**
* @inheritdoc
*/
OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state );
if ( this.constructor.static.selectable ) {
this.setActive( state );
}
return this;
};
/**
* RadioOptionWidget is an option widget that looks like a radio button.
* The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
* Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
*
* @class
* @extends OO.ui.OptionWidget
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
// Configuration initialization
config = config || {};
// Properties (must be done before parent constructor which calls #setDisabled)
this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
// Parent constructor
OO.ui.RadioOptionWidget.parent.call( this, config );
// Events
this.radio.$input.on( 'focus', this.onInputFocus.bind( this ) );
// Initialization
// Remove implicit role, we're handling it ourselves
this.radio.$input.attr( 'role', 'presentation' );
this.$element
.addClass( 'oo-ui-radioOptionWidget' )
.attr( 'role', 'radio' )
.attr( 'aria-checked', 'false' )
.removeAttr( 'aria-selected' )
.prepend( this.radio.$element );
};
/* Setup */
OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
/* Static Properties */
OO.ui.RadioOptionWidget.static.highlightable = false;
OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
OO.ui.RadioOptionWidget.static.pressable = false;
OO.ui.RadioOptionWidget.static.tagName = 'label';
/* Methods */
/**
* @param {jQuery.Event} e Focus event
* @private
*/
OO.ui.RadioOptionWidget.prototype.onInputFocus = function () {
this.radio.$input.blur();
this.$element.parent().focus();
};
/**
* @inheritdoc
*/
OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
this.radio.setSelected( state );
this.$element
.attr( 'aria-checked', state.toString() )
.removeAttr( 'aria-selected' );
return this;
};
/**
* @inheritdoc
*/
OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
this.radio.setDisabled( this.isDisabled() );
return this;
};
/**
* MenuOptionWidget is an option widget that looks like a menu item. The class is used with
* OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
* the [OOjs UI documentation on MediaWiki] [1] for more information.
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
*
* @class
* @extends OO.ui.DecoratedOptionWidget
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
// Configuration initialization
config = $.extend( { icon: 'check' }, config );
// Parent constructor
OO.ui.MenuOptionWidget.parent.call( this, config );
// Initialization
this.$element
.attr( 'role', 'menuitem' )
.addClass( 'oo-ui-menuOptionWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
/* Static Properties */
OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
/**
* MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
* {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
*
* @example
* var myDropdown = new OO.ui.DropdownWidget( {
* menu: {
* items: [
* new OO.ui.MenuSectionOptionWidget( {
* label: 'Dogs'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'corgi',
* label: 'Welsh Corgi'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'poodle',
* label: 'Standard Poodle'
* } ),
* new OO.ui.MenuSectionOptionWidget( {
* label: 'Cats'
* } ),
* new OO.ui.MenuOptionWidget( {
* data: 'lion',
* label: 'Lion'
* } )
* ]
* }
* } );
* $( 'body' ).append( myDropdown.$element );
*
* @class
* @extends OO.ui.DecoratedOptionWidget
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
// Parent constructor
OO.ui.MenuSectionOptionWidget.parent.call( this, config );
// Initialization
this.$element.addClass( 'oo-ui-menuSectionOptionWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
/* Static Properties */
OO.ui.MenuSectionOptionWidget.static.selectable = false;
OO.ui.MenuSectionOptionWidget.static.highlightable = false;
/**
* OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
*
* Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
* {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
* for an example.
*
* @class
* @extends OO.ui.DecoratedOptionWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {number} [level] Indentation level
* @cfg {boolean} [movable] Allow modification from {@link OO.ui.OutlineControlsWidget outline controls}.
*/
OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.OutlineOptionWidget.parent.call( this, config );
// Properties
this.level = 0;
this.movable = !!config.movable;
this.removable = !!config.removable;
// Initialization
this.$element.addClass( 'oo-ui-outlineOptionWidget' );
this.setLevel( config.level );
};
/* Setup */
OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
/* Static Properties */
OO.ui.OutlineOptionWidget.static.highlightable = false;
OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
OO.ui.OutlineOptionWidget.static.levels = 3;
/* Methods */
/**
* Check if item is movable.
*
* Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
*
* @return {boolean} Item is movable
*/
OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
return this.movable;
};
/**
* Check if item is removable.
*
* Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
*
* @return {boolean} Item is removable
*/
OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
return this.removable;
};
/**
* Get indentation level.
*
* @return {number} Indentation level
*/
OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
return this.level;
};
/**
* Set movability.
*
* Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
*
* @param {boolean} movable Item is movable
* @chainable
*/
OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
this.movable = !!movable;
this.updateThemeClasses();
return this;
};
/**
* Set removability.
*
* Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
*
* @param {boolean} movable Item is removable
* @chainable
*/
OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
this.removable = !!removable;
this.updateThemeClasses();
return this;
};
/**
* Set indentation level.
*
* @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
* @chainable
*/
OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
var levels = this.constructor.static.levels,
levelClass = this.constructor.static.levelClass,
i = levels;
this.level = level ? Math.max( 0, Math.min( levels - 1, level ) ) : 0;
while ( i-- ) {
if ( this.level === i ) {
this.$element.addClass( levelClass + i );
} else {
this.$element.removeClass( levelClass + i );
}
}
this.updateThemeClasses();
return this;
};
/**
* TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
*
* Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
* {@link OO.ui.CardLayout card layouts}. See {@link OO.ui.IndexLayout IndexLayout}
* for an example.
*
* @class
* @extends OO.ui.OptionWidget
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.TabOptionWidget.parent.call( this, config );
// Initialization
this.$element.addClass( 'oo-ui-tabOptionWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
/* Static Properties */
OO.ui.TabOptionWidget.static.highlightable = false;
/**
* PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
* By default, each popup has an anchor that points toward its origin.
* Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
*
* @example
* // A popup widget.
* var popup = new OO.ui.PopupWidget( {
* $content: $( '<p>Hi there!</p>' ),
* padded: true,
* width: 300
* } );
*
* $( 'body' ).append( popup.$element );
* // To display the popup, toggle the visibility to 'true'.
* popup.toggle( true );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.LabelElement
* @mixins OO.ui.mixin.ClippableElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {number} [width=320] Width of popup in pixels
* @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
* @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
* @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`.
* If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the
* popup is leaning towards the right of the screen.
* Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence
* in the given language, which means it will flip to the correct positioning in right-to-left languages.
* Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the
* sentence in the given language.
* @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
* See the [OOjs UI docs on MediaWiki][3] for an example.
* [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
* @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
* @cfg {jQuery} [$content] Content to append to the popup's body
* @cfg {jQuery} [$footer] Content to append to the popup's footer
* @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
* @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
* This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
* for an example.
* [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
* @cfg {boolean} [head] Show a popup header that contains a #label (if specified) and close
* button.
* @cfg {boolean} [padded] Add padding to the popup's body
*/
OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.PopupWidget.parent.call( this, config );
// Properties (must be set before ClippableElement constructor call)
this.$body = $( '<div>' );
this.$popup = $( '<div>' );
// Mixin constructors
OO.ui.mixin.LabelElement.call( this, config );
OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
$clippable: this.$body,
$clippableContainer: this.$popup
} ) );
// Properties
this.$head = $( '<div>' );
this.$footer = $( '<div>' );
this.$anchor = $( '<div>' );
// If undefined, will be computed lazily in updateDimensions()
this.$container = config.$container;
this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
this.autoClose = !!config.autoClose;
this.$autoCloseIgnore = config.$autoCloseIgnore;
this.transitionTimeout = null;
this.anchor = null;
this.width = config.width !== undefined ? config.width : 320;
this.height = config.height !== undefined ? config.height : null;
this.setAlignment( config.align );
this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
this.onMouseDownHandler = this.onMouseDown.bind( this );
this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
// Events
this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
// Initialization
this.toggleAnchor( config.anchor === undefined || config.anchor );
this.$body.addClass( 'oo-ui-popupWidget-body' );
this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
this.$head
.addClass( 'oo-ui-popupWidget-head' )
.append( this.$label, this.closeButton.$element );
this.$footer.addClass( 'oo-ui-popupWidget-footer' );
if ( !config.head ) {
this.$head.addClass( 'oo-ui-element-hidden' );
}
if ( !config.$footer ) {
this.$footer.addClass( 'oo-ui-element-hidden' );
}
this.$popup
.addClass( 'oo-ui-popupWidget-popup' )
.append( this.$head, this.$body, this.$footer );
this.$element
.addClass( 'oo-ui-popupWidget' )
.append( this.$popup, this.$anchor );
// Move content, which was added to #$element by OO.ui.Widget, to the body
if ( config.$content instanceof jQuery ) {
this.$body.append( config.$content );
}
if ( config.$footer instanceof jQuery ) {
this.$footer.append( config.$footer );
}
if ( config.padded ) {
this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
}
// 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.PopupWidget, OO.ui.Widget );
OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
/* Methods */
/**
* Handles mouse down events.
*
* @private
* @param {MouseEvent} e Mouse down event
*/
OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
if (
this.isVisible() &&
!$.contains( this.$element[ 0 ], e.target ) &&
( !this.$autoCloseIgnore || !this.$autoCloseIgnore.has( e.target ).length )
) {
this.toggle( false );
}
};
/**
* Bind mouse down listener.
*
* @private
*/
OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
// Capture clicks outside popup
OO.ui.addCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler );
};
/**
* Handles close button click events.
*
* @private
*/
OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
if ( this.isVisible() ) {
this.toggle( false );
}
};
/**
* Unbind mouse down listener.
*
* @private
*/
OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
OO.ui.removeCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler );
};
/**
* Handles key down events.
*
* @private
* @param {KeyboardEvent} e Key down event
*/
OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
if (
e.which === OO.ui.Keys.ESCAPE &&
this.isVisible()
) {
this.toggle( false );
e.preventDefault();
e.stopPropagation();
}
};
/**
* Bind key down listener.
*
* @private
*/
OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler );
};
/**
* Unbind key down listener.
*
* @private
*/
OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler );
};
/**
* Show, hide, or toggle the visibility of the anchor.
*
* @param {boolean} [show] Show anchor, omit to toggle
*/
OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
show = show === undefined ? !this.anchored : !!show;
if ( this.anchored !== show ) {
if ( show ) {
this.$element.addClass( 'oo-ui-popupWidget-anchored' );
} else {
this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
}
this.anchored = show;
}
};
/**
* Check if the anchor is visible.
*
* @return {boolean} Anchor is visible
*/
OO.ui.PopupWidget.prototype.hasAnchor = function () {
return this.anchor;
};
/**
* @inheritdoc
*/
OO.ui.PopupWidget.prototype.toggle = function ( show ) {
var change;
show = show === undefined ? !this.isVisible() : !!show;
change = show !== this.isVisible();
// Parent method
OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
if ( change ) {
if ( show ) {
if ( this.autoClose ) {
this.bindMouseDownListener();
this.bindKeyDownListener();
}
this.updateDimensions();
this.toggleClipping( true );
} else {
this.toggleClipping( false );
if ( this.autoClose ) {
this.unbindMouseDownListener();
this.unbindKeyDownListener();
}
}
}
return this;
};
/**
* Set the size of the popup.
*
* Changing the size may also change the popup's position depending on the alignment.
*
* @param {number} width Width in pixels
* @param {number} height Height in pixels
* @param {boolean} [transition=false] Use a smooth transition
* @chainable
*/
OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
this.width = width;
this.height = height !== undefined ? height : null;
if ( this.isVisible() ) {
this.updateDimensions( transition );
}
};
/**
* Update the size and position.
*
* Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
* be called automatically.
*
* @param {boolean} [transition=false] Use a smooth transition
* @chainable
*/
OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
var popupOffset, originOffset, containerLeft, containerWidth, containerRight,
popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth,
align = this.align,
widget = this;
if ( !this.$container ) {
// Lazy-initialize $container if not specified in constructor
this.$container = $( this.getClosestScrollableElementContainer() );
}
// Set height and width before measuring things, since it might cause our measurements
// to change (e.g. due to scrollbars appearing or disappearing)
this.$popup.css( {
width: this.width,
height: this.height !== null ? this.height : 'auto'
} );
// If we are in RTL, we need to flip the alignment, unless it is center
if ( align === 'forwards' || align === 'backwards' ) {
if ( this.$container.css( 'direction' ) === 'rtl' ) {
align = ( { forwards: 'force-left', backwards: 'force-right' } )[ this.align ];
} else {
align = ( { forwards: 'force-right', backwards: 'force-left' } )[ this.align ];
}
}
// Compute initial popupOffset based on alignment
popupOffset = this.width * ( { 'force-left': -1, center: -0.5, 'force-right': 0 } )[ align ];
// Figure out if this will cause the popup to go beyond the edge of the container
originOffset = this.$element.offset().left;
containerLeft = this.$container.offset().left;
containerWidth = this.$container.innerWidth();
containerRight = containerLeft + containerWidth;
popupLeft = popupOffset - this.containerPadding;
popupRight = popupOffset + this.containerPadding + this.width + this.containerPadding;
overlapLeft = ( originOffset + popupLeft ) - containerLeft;
overlapRight = containerRight - ( originOffset + popupRight );
// Adjust offset to make the popup not go beyond the edge, if needed
if ( overlapRight < 0 ) {
popupOffset += overlapRight;
} else if ( overlapLeft < 0 ) {
popupOffset -= overlapLeft;
}
// Adjust offset to avoid anchor being rendered too close to the edge
// $anchor.width() doesn't work with the pure CSS anchor (returns 0)
// TODO: Find a measurement that works for CSS anchors and image anchors
anchorWidth = this.$anchor[ 0 ].scrollWidth * 2;
if ( popupOffset + this.width < anchorWidth ) {
popupOffset = anchorWidth - this.width;
} else if ( -popupOffset < anchorWidth ) {
popupOffset = -anchorWidth;
}
// Prevent transition from being interrupted
clearTimeout( this.transitionTimeout );
if ( transition ) {
// Enable transition
this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
}
// Position body relative to anchor
this.$popup.css( 'margin-left', popupOffset );
if ( transition ) {
// Prevent transitioning after transition is complete
this.transitionTimeout = setTimeout( function () {
widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
}, 200 );
} else {
// Prevent transitioning immediately
this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
}
// Reevaluate clipping state since we've relocated and resized the popup
this.clip();
return this;
};
/**
* Set popup alignment
* @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
* `backwards` or `forwards`.
*/
OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
// Validate alignment and transform deprecated values
if ( [ 'left', 'right', 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
this.align = { left: 'force-right', right: 'force-left' }[ align ] || align;
} else {
this.align = 'center';
}
};
/**
* Get popup alignment
* @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
* `backwards` or `forwards`.
*/
OO.ui.PopupWidget.prototype.getAlignment = function () {
return this.align;
};
/**
* Progress bars visually display the status of an operation, such as a download,
* and can be either determinate or indeterminate:
*
* - **determinate** process bars show the percent of an operation that is complete.
*
* - **indeterminate** process bars use a visual display of motion to indicate that an operation
* is taking place. Because the extent of an indeterminate operation is unknown, the bar does
* not use percentages.
*
* The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
*
* @example
* // Examples of determinate and indeterminate progress bars.
* var progressBar1 = new OO.ui.ProgressBarWidget( {
* progress: 33
* } );
* var progressBar2 = new OO.ui.ProgressBarWidget();
*
* // Create a FieldsetLayout to layout progress bars
* var fieldset = new OO.ui.FieldsetLayout;
* fieldset.addItems( [
* new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
* new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
* ] );
* $( 'body' ).append( fieldset.$element );
*
* @class
* @extends OO.ui.Widget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
* To create a determinate progress bar, specify a number that reflects the initial percent complete.
* By default, the progress bar is indeterminate.
*/
OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.ProgressBarWidget.parent.call( this, config );
// Properties
this.$bar = $( '<div>' );
this.progress = null;
// Initialization
this.setProgress( config.progress !== undefined ? config.progress : false );
this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
this.$element
.attr( {
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100
} )
.addClass( 'oo-ui-progressBarWidget' )
.append( this.$bar );
};
/* Setup */
OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
/* Static Properties */
OO.ui.ProgressBarWidget.static.tagName = 'div';
/* Methods */
/**
* Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
*
* @return {number|boolean} Progress percent
*/
OO.ui.ProgressBarWidget.prototype.getProgress = function () {
return this.progress;
};
/**
* Set the percent of the process completed or `false` for an indeterminate process.
*
* @param {number|boolean} progress Progress percent or `false` for indeterminate
*/
OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
this.progress = progress;
if ( progress !== false ) {
this.$bar.css( 'width', this.progress + '%' );
this.$element.attr( 'aria-valuenow', this.progress );
} else {
this.$bar.css( 'width', '' );
this.$element.removeAttr( 'aria-valuenow' );
}
this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', !progress );
};
/**
* SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
* and a menu of search results, which is displayed beneath the query
* field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
* Users can choose an item from the menu or type a query into the text field to search for a matching result item.
* In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
*
* Each time the query is changed, the search result menu is cleared and repopulated. Please see
* the [OOjs UI demos][1] for an example.
*
* [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/#dialogs-mediawiki-vector-ltr
*
* @class
* @extends OO.ui.Widget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {string|jQuery} [placeholder] Placeholder text for query input
* @cfg {string} [value] Initial query value
*/
OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.SearchWidget.parent.call( this, config );
// Properties
this.query = new OO.ui.TextInputWidget( {
icon: 'search',
placeholder: config.placeholder,
value: config.value
} );
this.results = new OO.ui.SelectWidget();
this.$query = $( '<div>' );
this.$results = $( '<div>' );
// Events
this.query.connect( this, {
change: 'onQueryChange',
enter: 'onQueryEnter'
} );
this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
// Initialization
this.$query
.addClass( 'oo-ui-searchWidget-query' )
.append( this.query.$element );
this.$results
.addClass( 'oo-ui-searchWidget-results' )
.append( this.results.$element );
this.$element
.addClass( 'oo-ui-searchWidget' )
.append( this.$results, this.$query );
};
/* Setup */
OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
/* Methods */
/**
* Handle query key down events.
*
* @private
* @param {jQuery.Event} e Key down event
*/
OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
var highlightedItem, nextItem,
dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
if ( dir ) {
highlightedItem = this.results.getHighlightedItem();
if ( !highlightedItem ) {
highlightedItem = this.results.getSelectedItem();
}
nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
this.results.highlightItem( nextItem );
nextItem.scrollElementIntoView();
}
};
/**
* Handle select widget select events.
*
* Clears existing results. Subclasses should repopulate items according to new query.
*
* @private
* @param {string} value New value
*/
OO.ui.SearchWidget.prototype.onQueryChange = function () {
// Reset
this.results.clearItems();
};
/**
* Handle select widget enter key events.
*
* Chooses highlighted item.
*
* @private
* @param {string} value New value
*/
OO.ui.SearchWidget.prototype.onQueryEnter = function () {
var highlightedItem = this.results.getHighlightedItem();
if ( highlightedItem ) {
this.results.chooseItem( highlightedItem );
}
};
/**
* Get the query input.
*
* @return {OO.ui.TextInputWidget} Query input
*/
OO.ui.SearchWidget.prototype.getQuery = function () {
return this.query;
};
/**
* Get the search results menu.
*
* @return {OO.ui.SelectWidget} Menu of search results
*/
OO.ui.SearchWidget.prototype.getResults = function () {
return this.results;
};
/**
* A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
* select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
* {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
* menu selects}.
*
* This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
* information, please see the [OOjs UI documentation on MediaWiki][1].
*
* @example
* // Example of a select widget with three options
* var select = new OO.ui.SelectWidget( {
* items: [
* new OO.ui.OptionWidget( {
* data: 'a',
* label: 'Option One',
* } ),
* new OO.ui.OptionWidget( {
* data: 'b',
* label: 'Option Two',
* } ),
* new OO.ui.OptionWidget( {
* data: 'c',
* label: 'Option Three',
* } )
* ]
* } );
* $( 'body' ).append( select.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @abstract
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.GroupWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
* Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
* the [OOjs UI documentation on MediaWiki] [2] for examples.
* [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*/
OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.SelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
// Properties
this.pressed = false;
this.selecting = null;
this.onMouseUpHandler = this.onMouseUp.bind( this );
this.onMouseMoveHandler = this.onMouseMove.bind( this );
this.onKeyDownHandler = this.onKeyDown.bind( this );
this.onKeyPressHandler = this.onKeyPress.bind( this );
this.keyPressBuffer = '';
this.keyPressBufferTimer = null;
// Events
this.connect( this, {
toggle: 'onToggle'
} );
this.$element.on( {
mousedown: this.onMouseDown.bind( this ),
mouseover: this.onMouseOver.bind( this ),
mouseleave: this.onMouseLeave.bind( this )
} );
// Initialization
this.$element
.addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
.attr( 'role', 'listbox' );
if ( Array.isArray( config.items ) ) {
this.addItems( config.items );
}
};
/* Setup */
OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
// Need to mixin base class as well
OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupElement );
OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
/* Static */
OO.ui.SelectWidget.static.passAllFilter = function () {
return true;
};
/* Events */
/**
* @event highlight
*
* A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
*
* @param {OO.ui.OptionWidget|null} item Highlighted item
*/
/**
* @event press
*
* A `press` event is emitted when the #pressItem method is used to programmatically modify the
* pressed state of an option.
*
* @param {OO.ui.OptionWidget|null} item Pressed item
*/
/**
* @event select
*
* A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
*
* @param {OO.ui.OptionWidget|null} item Selected item
*/
/**
* @event choose
* A `choose` event is emitted when an item is chosen with the #chooseItem method.
* @param {OO.ui.OptionWidget} item Chosen item
*/
/**
* @event add
*
* An `add` event is emitted when options are added to the select with the #addItems method.
*
* @param {OO.ui.OptionWidget[]} items Added items
* @param {number} index Index of insertion point
*/
/**
* @event remove
*
* A `remove` event is emitted when options are removed from the select with the #clearItems
* or #removeItems methods.
*
* @param {OO.ui.OptionWidget[]} items Removed items
*/
/* Methods */
/**
* Handle mouse down events.
*
* @private
* @param {jQuery.Event} e Mouse down event
*/
OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
var item;
if ( !this.isDisabled() && e.which === 1 ) {
this.togglePressed( true );
item = this.getTargetItem( e );
if ( item && item.isSelectable() ) {
this.pressItem( item );
this.selecting = item;
OO.ui.addCaptureEventListener(
this.getElementDocument(),
'mouseup',
this.onMouseUpHandler
);
OO.ui.addCaptureEventListener(
this.getElementDocument(),
'mousemove',
this.onMouseMoveHandler
);
}
}
return false;
};
/**
* Handle mouse up events.
*
* @private
* @param {jQuery.Event} e Mouse up event
*/
OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
var item;
this.togglePressed( false );
if ( !this.selecting ) {
item = this.getTargetItem( e );
if ( item && item.isSelectable() ) {
this.selecting = item;
}
}
if ( !this.isDisabled() && e.which === 1 && this.selecting ) {
this.pressItem( null );
this.chooseItem( this.selecting );
this.selecting = null;
}
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup',
this.onMouseUpHandler );
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousemove',
this.onMouseMoveHandler );
return false;
};
/**
* Handle mouse move events.
*
* @private
* @param {jQuery.Event} e Mouse move event
*/
OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
var item;
if ( !this.isDisabled() && this.pressed ) {
item = this.getTargetItem( e );
if ( item && item !== this.selecting && item.isSelectable() ) {
this.pressItem( item );
this.selecting = item;
}
}
return false;
};
/**
* Handle mouse over events.
*
* @private
* @param {jQuery.Event} e Mouse over event
*/
OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
var item;
if ( !this.isDisabled() ) {
item = this.getTargetItem( e );
this.highlightItem( item && item.isHighlightable() ? item : null );
}
return false;
};
/**
* Handle mouse leave events.
*
* @private
* @param {jQuery.Event} e Mouse over event
*/
OO.ui.SelectWidget.prototype.onMouseLeave = function () {
if ( !this.isDisabled() ) {
this.highlightItem( null );
}
return false;
};
/**
* Handle key down events.
*
* @protected
* @param {jQuery.Event} e Key down event
*/
OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
var nextItem,
handled = false,
currentItem = this.getHighlightedItem() || this.getSelectedItem();
if ( !this.isDisabled() && this.isVisible() ) {
switch ( e.keyCode ) {
case OO.ui.Keys.ENTER:
if ( currentItem && currentItem.constructor.static.highlightable ) {
// Was only highlighted, now let's select it. No-op if already selected.
this.chooseItem( currentItem );
handled = true;
}
break;
case OO.ui.Keys.UP:
case OO.ui.Keys.LEFT:
this.clearKeyPressBuffer();
nextItem = this.getRelativeSelectableItem( currentItem, -1 );
handled = true;
break;
case OO.ui.Keys.DOWN:
case OO.ui.Keys.RIGHT:
this.clearKeyPressBuffer();
nextItem = this.getRelativeSelectableItem( currentItem, 1 );
handled = true;
break;
case OO.ui.Keys.ESCAPE:
case OO.ui.Keys.TAB:
if ( currentItem && currentItem.constructor.static.highlightable ) {
currentItem.setHighlighted( false );
}
this.unbindKeyDownListener();
this.unbindKeyPressListener();
// Don't prevent tabbing away / defocusing
handled = false;
break;
}
if ( nextItem ) {
if ( nextItem.constructor.static.highlightable ) {
this.highlightItem( nextItem );
} else {
this.chooseItem( nextItem );
}
nextItem.scrollElementIntoView();
}
if ( handled ) {
// Can't just return false, because e is not always a jQuery event
e.preventDefault();
e.stopPropagation();
}
}
};
/**
* Bind key down listener.
*
* @protected
*/
OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler );
};
/**
* Unbind key down listener.
*
* @protected
*/
OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler );
};
/**
* Clear the key-press buffer
*
* @protected
*/
OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
if ( this.keyPressBufferTimer ) {
clearTimeout( this.keyPressBufferTimer );
this.keyPressBufferTimer = null;
}
this.keyPressBuffer = '';
};
/**
* Handle key press events.
*
* @protected
* @param {jQuery.Event} e Key press event
*/
OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
var c, filter, item;
if ( !e.charCode ) {
if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
return false;
}
return;
}
if ( String.fromCodePoint ) {
c = String.fromCodePoint( e.charCode );
} else {
c = String.fromCharCode( e.charCode );
}
if ( this.keyPressBufferTimer ) {
clearTimeout( this.keyPressBufferTimer );
}
this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
item = this.getHighlightedItem() || this.getSelectedItem();
if ( this.keyPressBuffer === c ) {
// Common (if weird) special case: typing "xxxx" will cycle through all
// the items beginning with "x".
if ( item ) {
item = this.getRelativeSelectableItem( item, 1 );
}
} else {
this.keyPressBuffer += c;
}
filter = this.getItemMatcher( this.keyPressBuffer, false );
if ( !item || !filter( item ) ) {
item = this.getRelativeSelectableItem( item, 1, filter );
}
if ( item ) {
if ( item.constructor.static.highlightable ) {
this.highlightItem( item );
} else {
this.chooseItem( item );
}
item.scrollElementIntoView();
}
return false;
};
/**
* Get a matcher for the specific string
*
* @protected
* @param {string} s String to match against items
* @param {boolean} [exact=false] Only accept exact matches
* @return {Function} function ( OO.ui.OptionItem ) => boolean
*/
OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
var re;
if ( s.normalize ) {
s = s.normalize();
}
s = exact ? s.trim() : s.replace( /^\s+/, '' );
re = '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
if ( exact ) {
re += '\\s*$';
}
re = new RegExp( re, 'i' );
return function ( item ) {
var l = item.getLabel();
if ( typeof l !== 'string' ) {
l = item.$label.text();
}
if ( l.normalize ) {
l = l.normalize();
}
return re.test( l );
};
};
/**
* Bind key press listener.
*
* @protected
*/
OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
OO.ui.addCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler );
};
/**
* Unbind key down listener.
*
* If you override this, be sure to call this.clearKeyPressBuffer() from your
* implementation.
*
* @protected
*/
OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler );
this.clearKeyPressBuffer();
};
/**
* Visibility change handler
*
* @protected
* @param {boolean} visible
*/
OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
if ( !visible ) {
this.clearKeyPressBuffer();
}
};
/**
* Get the closest item to a jQuery.Event.
*
* @private
* @param {jQuery.Event} e
* @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
*/
OO.ui.SelectWidget.prototype.getTargetItem = function ( e ) {
return $( e.target ).closest( '.oo-ui-optionWidget' ).data( 'oo-ui-optionWidget' ) || null;
};
/**
* Get selected item.
*
* @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
*/
OO.ui.SelectWidget.prototype.getSelectedItem = function () {
var i, len;
for ( i = 0, len = this.items.length; i < len; i++ ) {
if ( this.items[ i ].isSelected() ) {
return this.items[ i ];
}
}
return null;
};
/**
* Get highlighted item.
*
* @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
*/
OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
var i, len;
for ( i = 0, len = this.items.length; i < len; i++ ) {
if ( this.items[ i ].isHighlighted() ) {
return this.items[ i ];
}
}
return null;
};
/**
* Toggle pressed state.
*
* Press is a state that occurs when a user mouses down on an item, but
* has not yet let go of the mouse. The item may appear selected, but it will not be selected
* until the user releases the mouse.
*
* @param {boolean} pressed An option is being pressed
*/
OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
if ( pressed === undefined ) {
pressed = !this.pressed;
}
if ( pressed !== this.pressed ) {
this.$element
.toggleClass( 'oo-ui-selectWidget-pressed', pressed )
.toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
this.pressed = pressed;
}
};
/**
* Highlight an option. If the `item` param is omitted, no options will be highlighted
* and any existing highlight will be removed. The highlight is mutually exclusive.
*
* @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
* @fires highlight
* @chainable
*/
OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
var i, len, highlighted,
changed = false;
for ( i = 0, len = this.items.length; i < len; i++ ) {
highlighted = this.items[ i ] === item;
if ( this.items[ i ].isHighlighted() !== highlighted ) {
this.items[ i ].setHighlighted( highlighted );
changed = true;
}
}
if ( changed ) {
this.emit( 'highlight', item );
}
return this;
};
/**
* Fetch an item by its label.
*
* @param {string} label Label of the item to select.
* @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
* @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
*/
OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
var i, item, found,
len = this.items.length,
filter = this.getItemMatcher( label, true );
for ( i = 0; i < len; i++ ) {
item = this.items[ i ];
if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
return item;
}
}
if ( prefix ) {
found = null;
filter = this.getItemMatcher( label, false );
for ( i = 0; i < len; i++ ) {
item = this.items[ i ];
if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
if ( found ) {
return null;
}
found = item;
}
}
if ( found ) {
return found;
}
}
return null;
};
/**
* Programmatically select an option by its label. If the item does not exist,
* all options will be deselected.
*
* @param {string} [label] Label of the item to select.
* @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
* @fires select
* @chainable
*/
OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
var itemFromLabel = this.getItemFromLabel( label, !!prefix );
if ( label === undefined || !itemFromLabel ) {
return this.selectItem();
}
return this.selectItem( itemFromLabel );
};
/**
* Programmatically select an option by its data. If the `data` parameter is omitted,
* or if the item does not exist, all options will be deselected.
*
* @param {Object|string} [data] Value of the item to select, omit to deselect all
* @fires select
* @chainable
*/
OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
var itemFromData = this.getItemFromData( data );
if ( data === undefined || !itemFromData ) {
return this.selectItem();
}
return this.selectItem( itemFromData );
};
/**
* Programmatically select an option by its reference. If the `item` parameter is omitted,
* all options will be deselected.
*
* @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
* @fires select
* @chainable
*/
OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
var i, len, selected,
changed = false;
for ( i = 0, len = this.items.length; i < len; i++ ) {
selected = this.items[ i ] === item;
if ( this.items[ i ].isSelected() !== selected ) {
this.items[ i ].setSelected( selected );
changed = true;
}
}
if ( changed ) {
this.emit( 'select', item );
}
return this;
};
/**
* Press an item.
*
* Press is a state that occurs when a user mouses down on an item, but has not
* yet let go of the mouse. The item may appear selected, but it will not be selected until the user
* releases the mouse.
*
* @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
* @fires press
* @chainable
*/
OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
var i, len, pressed,
changed = false;
for ( i = 0, len = this.items.length; i < len; i++ ) {
pressed = this.items[ i ] === item;
if ( this.items[ i ].isPressed() !== pressed ) {
this.items[ i ].setPressed( pressed );
changed = true;
}
}
if ( changed ) {
this.emit( 'press', item );
}
return this;
};
/**
* Choose an item.
*
* Note that ‘choose’ should never be modified programmatically. A user can choose
* an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
* use the #selectItem method.
*
* This method is identical to #selectItem, but may vary in subclasses that take additional action
* when users choose an item with the keyboard or mouse.
*
* @param {OO.ui.OptionWidget} item Item to choose
* @fires choose
* @chainable
*/
OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
if ( item ) {
this.selectItem( item );
this.emit( 'choose', item );
}
return this;
};
/**
* Get an option by its position relative to the specified item (or to the start of the option array,
* if item is `null`). The direction in which to search through the option array is specified with a
* number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
* `null` if there are no options in the array.
*
* @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
* @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
* @param {Function} filter Only consider items for which this function returns
* true. Function takes an OO.ui.OptionWidget and returns a boolean.
* @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
*/
OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
var currentIndex, nextIndex, i,
increase = direction > 0 ? 1 : -1,
len = this.items.length;
if ( !$.isFunction( filter ) ) {
filter = OO.ui.SelectWidget.static.passAllFilter;
}
if ( item instanceof OO.ui.OptionWidget ) {
currentIndex = this.items.indexOf( item );
nextIndex = ( currentIndex + increase + len ) % len;
} else {
// If no item is selected and moving forward, start at the beginning.
// If moving backward, start at the end.
nextIndex = direction > 0 ? 0 : len - 1;
}
for ( i = 0; i < len; i++ ) {
item = this.items[ nextIndex ];
if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
return item;
}
nextIndex = ( nextIndex + increase + len ) % len;
}
return null;
};
/**
* Get the next selectable item or `null` if there are no selectable items.
* Disabled options and menu-section markers and breaks are not selectable.
*
* @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
*/
OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
var i, len, item;
for ( i = 0, len = this.items.length; i < len; i++ ) {
item = this.items[ i ];
if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) {
return item;
}
}
return null;
};
/**
* Add an array of options to the select. Optionally, an index number can be used to
* specify an insertion point.
*
* @param {OO.ui.OptionWidget[]} items Items to add
* @param {number} [index] Index to insert items after
* @fires add
* @chainable
*/
OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
// Mixin method
OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
// Always provide an index, even if it was omitted
this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
return this;
};
/**
* Remove the specified array of options from the select. Options will be detached
* from the DOM, not removed, so they can be reused later. To remove all options from
* the select, you may wish to use the #clearItems method instead.
*
* @param {OO.ui.OptionWidget[]} items Items to remove
* @fires remove
* @chainable
*/
OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
var i, len, item;
// Deselect items being removed
for ( i = 0, len = items.length; i < len; i++ ) {
item = items[ i ];
if ( item.isSelected() ) {
this.selectItem( null );
}
}
// Mixin method
OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
this.emit( 'remove', items );
return this;
};
/**
* Clear all options from the select. Options will be detached from the DOM, not removed,
* so that they can be reused later. To remove a subset of options from the select, use
* the #removeItems method.
*
* @fires remove
* @chainable
*/
OO.ui.SelectWidget.prototype.clearItems = function () {
var items = this.items.slice();
// Mixin method
OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
// Clear selection
this.selectItem( null );
this.emit( 'remove', items );
return this;
};
/**
* ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
* button options and is used together with
* OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
* highlighting, choosing, and selecting mutually exclusive options. Please see
* the [OOjs UI documentation on MediaWiki] [1] for more information.
*
* @example
* // Example: A ButtonSelectWidget that contains three ButtonOptionWidgets
* var option1 = new OO.ui.ButtonOptionWidget( {
* data: 1,
* label: 'Option 1',
* title: 'Button option 1'
* } );
*
* var option2 = new OO.ui.ButtonOptionWidget( {
* data: 2,
* label: 'Option 2',
* title: 'Button option 2'
* } );
*
* var option3 = new OO.ui.ButtonOptionWidget( {
* data: 3,
* label: 'Option 3',
* title: 'Button option 3'
* } );
*
* var buttonSelect=new OO.ui.ButtonSelectWidget( {
* items: [ option1, option2, option3 ]
* } );
* $( 'body' ).append( buttonSelect.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.SelectWidget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
// Parent constructor
OO.ui.ButtonSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, config );
// Events
this.$element.on( {
focus: this.bindKeyDownListener.bind( this ),
blur: this.unbindKeyDownListener.bind( this )
} );
// Initialization
this.$element.addClass( 'oo-ui-buttonSelectWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
/**
* RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
* options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
* an interface for adding, removing and selecting options.
* Please see the [OOjs UI documentation on MediaWiki][1] for more information.
*
* If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
* OO.ui.RadioSelectInputWidget instead.
*
* @example
* // A RadioSelectWidget with RadioOptions.
* var option1 = new OO.ui.RadioOptionWidget( {
* data: 'a',
* label: 'Selected radio option'
* } );
*
* var option2 = new OO.ui.RadioOptionWidget( {
* data: 'b',
* label: 'Unselected radio option'
* } );
*
* var radioSelect=new OO.ui.RadioSelectWidget( {
* items: [ option1, option2 ]
* } );
*
* // Select 'option 1' using the RadioSelectWidget's selectItem() method.
* radioSelect.selectItem( option1 );
*
* $( 'body' ).append( radioSelect.$element );
*
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.SelectWidget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
// Parent constructor
OO.ui.RadioSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, config );
// Events
this.$element.on( {
focus: this.bindKeyDownListener.bind( this ),
blur: this.unbindKeyDownListener.bind( this )
} );
// Initialization
this.$element
.addClass( 'oo-ui-radioSelectWidget' )
.attr( 'role', 'radiogroup' );
};
/* Setup */
OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
/**
* MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
* is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
* See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxWidget ComboBoxWidget},
* and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
* MenuSelectWidgets themselves are not instantiated directly, rather subclassed
* and customized to be opened, closed, and displayed as needed.
*
* By default, menus are clipped to the visible viewport and are not visible when a user presses the
* mouse outside the menu.
*
* Menus also have support for keyboard interaction:
*
* - Enter/Return key: choose and select a menu option
* - Up-arrow key: highlight the previous menu option
* - Down-arrow key: highlight the next menu option
* - Esc key: hide the menu
*
* Please see the [OOjs UI documentation on MediaWiki][1] for more information.
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.SelectWidget
* @mixins OO.ui.mixin.ClippableElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
* the text the user types. This config is used by {@link OO.ui.ComboBoxWidget ComboBoxWidget}
* and {@link OO.ui.mixin.LookupElement LookupElement}
* @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
* the text the user types. This config is used by {@link OO.ui.CapsuleMultiSelectWidget CapsuleMultiSelectWidget}
* @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
* anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
* that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
* that button, unless the button (or its parent widget) is passed in here.
* @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
* @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
*/
OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.MenuSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
// Properties
this.newItems = null;
this.autoHide = config.autoHide === undefined || !!config.autoHide;
this.filterFromInput = !!config.filterFromInput;
this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
this.$widget = config.widget ? config.widget.$element : null;
this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
// Initialization
this.$element
.addClass( 'oo-ui-menuSelectWidget' )
.attr( 'role', 'menu' );
// 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.MenuSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
/* Methods */
/**
* Handles document mouse down events.
*
* @protected
* @param {jQuery.Event} e Key down event
*/
OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
if (
!OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
( !this.$widget || !OO.ui.contains( this.$widget[ 0 ], e.target, true ) )
) {
this.toggle( false );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
var currentItem = this.getHighlightedItem() || this.getSelectedItem();
if ( !this.isDisabled() && this.isVisible() ) {
switch ( e.keyCode ) {
case OO.ui.Keys.LEFT:
case OO.ui.Keys.RIGHT:
// Do nothing if a text field is associated, arrow keys will be handled natively
if ( !this.$input ) {
OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
}
break;
case OO.ui.Keys.ESCAPE:
case OO.ui.Keys.TAB:
if ( currentItem ) {
currentItem.setHighlighted( false );
}
this.toggle( false );
// Don't prevent tabbing away, prevent defocusing
if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
e.preventDefault();
e.stopPropagation();
}
break;
default:
OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
return;
}
}
};
/**
* Update menu item visibility after input changes.
* @protected
*/
OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
var i, item,
len = this.items.length,
showAll = !this.isVisible(),
filter = showAll ? null : this.getItemMatcher( this.$input.val() );
for ( i = 0; i < len; i++ ) {
item = this.items[ i ];
if ( item instanceof OO.ui.OptionWidget ) {
item.toggle( showAll || filter( item ) );
}
}
// Reevaluate clipping
this.clip();
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
if ( this.$input ) {
this.$input.on( 'keydown', this.onKeyDownHandler );
} else {
OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
if ( this.$input ) {
this.$input.off( 'keydown', this.onKeyDownHandler );
} else {
OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
if ( this.$input ) {
if ( this.filterFromInput ) {
this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
}
} else {
OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
if ( this.$input ) {
if ( this.filterFromInput ) {
this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
this.updateItemVisibility();
}
} else {
OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
}
};
/**
* Choose an item.
*
* When a user chooses an item, the menu is closed.
*
* Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
* or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
* @param {OO.ui.OptionWidget} item Item to choose
* @chainable
*/
OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
this.toggle( false );
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
var i, len, item;
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
// Auto-initialize
if ( !this.newItems ) {
this.newItems = [];
}
for ( i = 0, len = items.length; i < len; i++ ) {
item = items[ i ];
if ( this.isVisible() ) {
// Defer fitting label until item has been attached
item.fitLabel();
} else {
this.newItems.push( item );
}
}
// Reevaluate clipping
this.clip();
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
// Reevaluate clipping
this.clip();
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.clearItems = function () {
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
// Reevaluate clipping
this.clip();
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
var i, len, change;
visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
change = visible !== this.isVisible();
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
if ( change ) {
if ( visible ) {
this.bindKeyDownListener();
this.bindKeyPressListener();
if ( this.newItems && this.newItems.length ) {
for ( i = 0, len = this.newItems.length; i < len; i++ ) {
this.newItems[ i ].fitLabel();
}
this.newItems = null;
}
this.toggleClipping( true );
// Auto-hide
if ( this.autoHide ) {
OO.ui.addCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler );
}
} else {
this.unbindKeyDownListener();
this.unbindKeyPressListener();
OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler );
this.toggleClipping( false );
}
}
return this;
};
/**
* FloatingMenuSelectWidget is a menu that will stick under a specified
* container, even when it is inserted elsewhere in the document (for example,
* in a OO.ui.Window's $overlay). This is sometimes necessary to prevent the
* menu from being clipped too aggresively.
*
* The menu's position is automatically calculated and maintained when the menu
* is toggled or the window is resized.
*
* See OO.ui.ComboBoxWidget for an example of a widget that uses this class.
*
* @class
* @extends OO.ui.MenuSelectWidget
* @mixins OO.ui.mixin.FloatableElement
*
* @constructor
* @param {OO.ui.Widget} [inputWidget] Widget to provide the menu for.
* Deprecated, omit this parameter and specify `$container` instead.
* @param {Object} [config] Configuration options
* @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under
*/
OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) {
// Allow 'inputWidget' parameter and config for backwards compatibility
if ( OO.isPlainObject( inputWidget ) && config === undefined ) {
config = inputWidget;
inputWidget = config.inputWidget;
}
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.FloatingMenuSelectWidget.parent.call( this, config );
// Properties (must be set before mixin constructors)
this.inputWidget = inputWidget; // For backwards compatibility
this.$container = config.$container || this.inputWidget.$element;
// Mixins constructors
OO.ui.mixin.FloatableElement.call( this, $.extend( {}, config, { $floatableContainer: this.$container } ) );
// Initialization
this.$element.addClass( 'oo-ui-floatingMenuSelectWidget' );
// For backwards compatibility
this.$element.addClass( 'oo-ui-textInputMenuSelectWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget );
OO.mixinClass( OO.ui.FloatingMenuSelectWidget, OO.ui.mixin.FloatableElement );
// For backwards compatibility
OO.ui.TextInputMenuSelectWidget = OO.ui.FloatingMenuSelectWidget;
/* Methods */
/**
* @inheritdoc
*/
OO.ui.FloatingMenuSelectWidget.prototype.toggle = function ( visible ) {
var change;
visible = visible === undefined ? !this.isVisible() : !!visible;
change = visible !== this.isVisible();
if ( change && visible ) {
// Make sure the width is set before the parent method runs.
this.setIdealSize( this.$container.width() );
}
// Parent method
// This will call this.clip(), which is nonsensical since we're not positioned yet...
OO.ui.FloatingMenuSelectWidget.parent.prototype.toggle.call( this, visible );
if ( change ) {
this.togglePositioning( this.isVisible() );
}
return this;
};
/**
* OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options}
* A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget.
*
* **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
*
* @class
* @extends OO.ui.SelectWidget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
// Parent constructor
OO.ui.OutlineSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, config );
// Events
this.$element.on( {
focus: this.bindKeyDownListener.bind( this ),
blur: this.unbindKeyDownListener.bind( this )
} );
// Initialization
this.$element.addClass( 'oo-ui-outlineSelectWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
/**
* TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
*
* **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
*
* @class
* @extends OO.ui.SelectWidget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
*/
OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
// Parent constructor
OO.ui.TabSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, config );
// Events
this.$element.on( {
focus: this.bindKeyDownListener.bind( this ),
blur: this.unbindKeyDownListener.bind( this )
} );
// Initialization
this.$element.addClass( 'oo-ui-tabSelectWidget' );
};
/* Setup */
OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
/**
* NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
* can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
* (to adjust the value in increments) to allow the user to enter a number.
*
* @example
* // Example: A NumberInputWidget.
* var numberInput = new OO.ui.NumberInputWidget( {
* label: 'NumberInputWidget',
* input: { value: 5, min: 1, max: 10 }
* } );
* $( 'body' ).append( numberInput.$element );
*
* @class
* @extends OO.ui.Widget
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
* @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
* @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
* @cfg {boolean} [isInteger=false] Whether the field accepts only integer values.
* @cfg {number} [min=-Infinity] Minimum allowed value
* @cfg {number} [max=Infinity] Maximum allowed value
* @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
* @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
*/
OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
// Configuration initialization
config = $.extend( {
isInteger: false,
min: -Infinity,
max: Infinity,
step: 1,
pageStep: null
}, config );
// Parent constructor
OO.ui.NumberInputWidget.parent.call( this, config );
// Properties
this.input = new OO.ui.TextInputWidget( $.extend(
{
disabled: this.isDisabled()
},
config.input
) );
this.minusButton = new OO.ui.ButtonWidget( $.extend(
{
disabled: this.isDisabled(),
tabIndex: -1
},
config.minusButton,
{
classes: [ 'oo-ui-numberInputWidget-minusButton' ],
label: '−'
}
) );
this.plusButton = new OO.ui.ButtonWidget( $.extend(
{
disabled: this.isDisabled(),
tabIndex: -1
},
config.plusButton,
{
classes: [ 'oo-ui-numberInputWidget-plusButton' ],
label: '+'
}
) );
// Events
this.input.connect( this, {
change: this.emit.bind( this, 'change' ),
enter: this.emit.bind( this, 'enter' )
} );
this.input.$input.on( {
keydown: this.onKeyDown.bind( this ),
'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
} );
this.plusButton.connect( this, {
click: [ 'onButtonClick', +1 ]
} );
this.minusButton.connect( this, {
click: [ 'onButtonClick', -1 ]
} );
// Initialization
this.setIsInteger( !!config.isInteger );
this.setRange( config.min, config.max );
this.setStep( config.step, config.pageStep );
this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' )
.append(
this.minusButton.$element,
this.input.$element,
this.plusButton.$element
);
this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field );
this.input.setValidation( this.validateNumber.bind( this ) );
};
/* Setup */
OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget );
/* Events */
/**
* A `change` event is emitted when the value of the input changes.
*
* @event change
*/
/**
* An `enter` event is emitted when the user presses 'enter' inside the text box.
*
* @event enter
*/
/* Methods */
/**
* Set whether only integers are allowed
* @param {boolean} flag
*/
OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) {
this.isInteger = !!flag;
this.input.setValidityFlag();
};
/**
* Get whether only integers are allowed
* @return {boolean} Flag value
*/
OO.ui.NumberInputWidget.prototype.getIsInteger = function () {
return this.isInteger;
};
/**
* Set the range of allowed values
* @param {number} min Minimum allowed value
* @param {number} max Maximum allowed value
*/
OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
if ( min > max ) {
throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
}
this.min = min;
this.max = max;
this.input.setValidityFlag();
};
/**
* Get the current range
* @return {number[]} Minimum and maximum values
*/
OO.ui.NumberInputWidget.prototype.getRange = function () {
return [ this.min, this.max ];
};
/**
* Set the stepping deltas
* @param {number} step Normal step
* @param {number|null} pageStep Page step. If null, 10 * step will be used.
*/
OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
if ( step <= 0 ) {
throw new Error( 'Step value must be positive' );
}
if ( pageStep === null ) {
pageStep = step * 10;
} else if ( pageStep <= 0 ) {
throw new Error( 'Page step value must be positive' );
}
this.step = step;
this.pageStep = pageStep;
};
/**
* Get the current stepping values
* @return {number[]} Step and page step
*/
OO.ui.NumberInputWidget.prototype.getStep = function () {
return [ this.step, this.pageStep ];
};
/**
* Get the current value of the widget
* @return {string}
*/
OO.ui.NumberInputWidget.prototype.getValue = function () {
return this.input.getValue();
};
/**
* Get the current value of the widget as a number
* @return {number} May be NaN, or an invalid number
*/
OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
return +this.input.getValue();
};
/**
* Set the value of the widget
* @param {string} value Invalid values are allowed
*/
OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
this.input.setValue( value );
};
/**
* Adjust the value of the widget
* @param {number} delta Adjustment amount
*/
OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
var n, v = this.getNumericValue();
delta = +delta;
if ( isNaN( delta ) || !isFinite( delta ) ) {
throw new Error( 'Delta must be a finite number' );
}
if ( isNaN( v ) ) {
n = 0;
} else {
n = v + delta;
n = Math.max( Math.min( n, this.max ), this.min );
if ( this.isInteger ) {
n = Math.round( n );
}
}
if ( n !== v ) {
this.setValue( n );
}
};
/**
* Validate input
* @private
* @param {string} value Field value
* @return {boolean}
*/
OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
var n = +value;
if ( isNaN( n ) || !isFinite( n ) ) {
return false;
}
/*jshint bitwise: false */
if ( this.isInteger && ( n | 0 ) !== n ) {
return false;
}
/*jshint bitwise: true */
if ( n < this.min || n > this.max ) {
return false;
}
return true;
};
/**
* Handle mouse click events.
*
* @private
* @param {number} dir +1 or -1
*/
OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
this.adjustValue( dir * this.step );
};
/**
* Handle mouse wheel events.
*
* @private
* @param {jQuery.Event} event
*/
OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
var delta = 0;
// Standard 'wheel' event
if ( event.originalEvent.deltaMode !== undefined ) {
this.sawWheelEvent = true;
}
if ( event.originalEvent.deltaY ) {
delta = -event.originalEvent.deltaY;
} else if ( event.originalEvent.deltaX ) {
delta = event.originalEvent.deltaX;
}
// Non-standard events
if ( !this.sawWheelEvent ) {
if ( event.originalEvent.wheelDeltaX ) {
delta = -event.originalEvent.wheelDeltaX;
} else if ( event.originalEvent.wheelDeltaY ) {
delta = event.originalEvent.wheelDeltaY;
} else if ( event.originalEvent.wheelDelta ) {
delta = event.originalEvent.wheelDelta;
} else if ( event.originalEvent.detail ) {
delta = -event.originalEvent.detail;
}
}
if ( delta ) {
delta = delta < 0 ? -1 : 1;
this.adjustValue( delta * this.step );
}
return false;
};
/**
* Handle key down events.
*
* @private
* @param {jQuery.Event} e Key down event
*/
OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
if ( !this.isDisabled() ) {
switch ( e.which ) {
case OO.ui.Keys.UP:
this.adjustValue( this.step );
return false;
case OO.ui.Keys.DOWN:
this.adjustValue( -this.step );
return false;
case OO.ui.Keys.PAGEUP:
this.adjustValue( this.pageStep );
return false;
case OO.ui.Keys.PAGEDOWN:
this.adjustValue( -this.pageStep );
return false;
}
}
};
/**
* @inheritdoc
*/
OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
// Parent method
OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
if ( this.input ) {
this.input.setDisabled( this.isDisabled() );
}
if ( this.minusButton ) {
this.minusButton.setDisabled( this.isDisabled() );
}
if ( this.plusButton ) {
this.plusButton.setDisabled( this.isDisabled() );
}
return this;
};
/**
* ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
* value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
* visually by a slider in the leftmost position.
*
* @example
* // Toggle switches in the 'off' and 'on' position.
* var toggleSwitch1 = new OO.ui.ToggleSwitchWidget();
* var toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
* value: true
* } );
*
* // Create a FieldsetLayout to layout and label switches
* var fieldset = new OO.ui.FieldsetLayout( {
* label: 'Toggle switches'
* } );
* fieldset.addItems( [
* new OO.ui.FieldLayout( toggleSwitch1, { label: 'Off', align: 'top' } ),
* new OO.ui.FieldLayout( toggleSwitch2, { label: 'On', align: 'top' } )
* ] );
* $( 'body' ).append( fieldset.$element );
*
* @class
* @extends OO.ui.ToggleWidget
* @mixins OO.ui.mixin.TabIndexedElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
* By default, the toggle switch is in the 'off' position.
*/
OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
// Parent constructor
OO.ui.ToggleSwitchWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.TabIndexedElement.call( this, config );
// Properties
this.dragging = false;
this.dragStart = null;
this.sliding = false;
this.$glow = $( '<span>' );
this.$grip = $( '<span>' );
// Events
this.$element.on( {
click: this.onClick.bind( this ),
keypress: this.onKeyPress.bind( this )
} );
// Initialization
this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
this.$element
.addClass( 'oo-ui-toggleSwitchWidget' )
.attr( 'role', 'checkbox' )
.append( this.$glow, this.$grip );
};
/* Setup */
OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
/* Methods */
/**
* Handle mouse click events.
*
* @private
* @param {jQuery.Event} e Mouse click event
*/
OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
if ( !this.isDisabled() && e.which === 1 ) {
this.setValue( !this.value );
}
return false;
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
*/
OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
this.setValue( !this.value );
return false;
}
};
/*!
* Deprecated aliases for classes in the `OO.ui.mixin` namespace.
*/
/**
* @inheritdoc OO.ui.mixin.ButtonElement
* @deprecated Use {@link OO.ui.mixin.ButtonElement} instead.
*/
OO.ui.ButtonElement = OO.ui.mixin.ButtonElement;
/**
* @inheritdoc OO.ui.mixin.ClippableElement
* @deprecated Use {@link OO.ui.mixin.ClippableElement} instead.
*/
OO.ui.ClippableElement = OO.ui.mixin.ClippableElement;
/**
* @inheritdoc OO.ui.mixin.DraggableElement
* @deprecated Use {@link OO.ui.mixin.DraggableElement} instead.
*/
OO.ui.DraggableElement = OO.ui.mixin.DraggableElement;
/**
* @inheritdoc OO.ui.mixin.DraggableGroupElement
* @deprecated Use {@link OO.ui.mixin.DraggableGroupElement} instead.
*/
OO.ui.DraggableGroupElement = OO.ui.mixin.DraggableGroupElement;
/**
* @inheritdoc OO.ui.mixin.FlaggedElement
* @deprecated Use {@link OO.ui.mixin.FlaggedElement} instead.
*/
OO.ui.FlaggedElement = OO.ui.mixin.FlaggedElement;
/**
* @inheritdoc OO.ui.mixin.GroupElement
* @deprecated Use {@link OO.ui.mixin.GroupElement} instead.
*/
OO.ui.GroupElement = OO.ui.mixin.GroupElement;
/**
* @inheritdoc OO.ui.mixin.GroupWidget
* @deprecated Use {@link OO.ui.mixin.GroupWidget} instead.
*/
OO.ui.GroupWidget = OO.ui.mixin.GroupWidget;
/**
* @inheritdoc OO.ui.mixin.IconElement
* @deprecated Use {@link OO.ui.mixin.IconElement} instead.
*/
OO.ui.IconElement = OO.ui.mixin.IconElement;
/**
* @inheritdoc OO.ui.mixin.IndicatorElement
* @deprecated Use {@link OO.ui.mixin.IndicatorElement} instead.
*/
OO.ui.IndicatorElement = OO.ui.mixin.IndicatorElement;
/**
* @inheritdoc OO.ui.mixin.ItemWidget
* @deprecated Use {@link OO.ui.mixin.ItemWidget} instead.
*/
OO.ui.ItemWidget = OO.ui.mixin.ItemWidget;
/**
* @inheritdoc OO.ui.mixin.LabelElement
* @deprecated Use {@link OO.ui.mixin.LabelElement} instead.
*/
OO.ui.LabelElement = OO.ui.mixin.LabelElement;
/**
* @inheritdoc OO.ui.mixin.LookupElement
* @deprecated Use {@link OO.ui.mixin.LookupElement} instead.
*/
OO.ui.LookupElement = OO.ui.mixin.LookupElement;
/**
* @inheritdoc OO.ui.mixin.PendingElement
* @deprecated Use {@link OO.ui.mixin.PendingElement} instead.
*/
OO.ui.PendingElement = OO.ui.mixin.PendingElement;
/**
* @inheritdoc OO.ui.mixin.PopupElement
* @deprecated Use {@link OO.ui.mixin.PopupElement} instead.
*/
OO.ui.PopupElement = OO.ui.mixin.PopupElement;
/**
* @inheritdoc OO.ui.mixin.TabIndexedElement
* @deprecated Use {@link OO.ui.mixin.TabIndexedElement} instead.
*/
OO.ui.TabIndexedElement = OO.ui.mixin.TabIndexedElement;
/**
* @inheritdoc OO.ui.mixin.TitledElement
* @deprecated Use {@link OO.ui.mixin.TitledElement} instead.
*/
OO.ui.TitledElement = OO.ui.mixin.TitledElement;
}( OO ) );