/**
* 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
};
/**
* Constants for MouseEvent.which
*
* @property {Object}
*/
OO.ui.MouseButtons = {
LEFT: 1,
MIDDLE: 2,
RIGHT: 3
};
/**
* @property {number}
* @private
*/
OO.ui.elementId = 0;
/**
* Generate a unique ID for element
*
* @return {string} ID
*/
OO.ui.generateElementId = function () {
OO.ui.elementId++;
return 'oojsui-' + OO.ui.elementId;
};
/**
* Check if an element is focusable.
* Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
*
* @param {jQuery} $element Element to test
* @return {boolean} Element is focusable
*/
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.pseudos.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, or 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 Function to debounce
* @param {number} [wait=0] Wait period in milliseconds
* @param {boolean} [immediate] Trigger on leading edge
* @return {Function} Debounced 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 );
}
if ( !timeout || wait ) {
clearTimeout( timeout );
timeout = setTimeout( later, wait );
}
};
};
/**
* Puts a console warning with provided message.
*
* @param {string} message Message
*/
OO.ui.warnDeprecation = function ( message ) {
if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
// eslint-disable-next-line no-console
console.warn( message );
}
};
/**
* Returns a function, that, when invoked, will only be triggered at most once
* during a given window of time. If called again during that window, it will
* wait until the window ends and then trigger itself again.
*
* As it's not knowable to the caller whether the function will actually run
* when the wrapper is called, return values from the function are entirely
* discarded.
*
* @param {Function} func Function to throttle
* @param {number} wait Throttle window length, in milliseconds
* @return {Function} Throttled function
*/
OO.ui.throttle = function ( func, wait ) {
var context, args, timeout,
previous = 0,
run = function () {
timeout = null;
previous = OO.ui.now();
func.apply( context, args );
};
return function () {
// Check how long it's been since the last time the function was
// called, and whether it's more or less than the requested throttle
// period. If it's less, run the function immediately. If it's more,
// set a timeout for the remaining time -- but don't replace an
// existing timeout, since that'd indefinitely prolong the wait.
var remaining = wait - ( OO.ui.now() - previous );
context = this;
args = arguments;
if ( remaining <= 0 ) {
// Note: unless wait was ridiculously large, this means we'll
// automatically run the first time the function was called in a
// given period. (If you provide a wait period larger than the
// current Unix timestamp, you *deserve* unexpected behavior.)
clearTimeout( timeout );
run();
} else if ( !timeout ) {
timeout = setTimeout( run, remaining );
}
};
};
/**
* A (possibly faster) way to get the current timestamp as an integer
*
* @return {number} Current timestamp, in milliseconds since the Unix epoch
*/
OO.ui.now = Date.now || function () {
return new Date().getTime();
};
/**
* 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 tooltip for the button that removes a tag item
'ooui-item-remove': 'Remove',
// 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.
*
* 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.
*
* 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. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
* follows.
*
* @example
* var i, iLen, button,
* messagePath = 'oojs-ui/dist/i18n/',
* languages = [ $.i18n().locale, 'ur', 'en' ],
* languageMap = {};
*
* for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
* languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
* }
*
* $.i18n().load( languageMap ).done( function() {
* // Replace the built-in `msg` only once we've loaded the internationalization.
* // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
* // you put off creating any widgets until this promise is complete, no English
* // will be displayed.
* OO.ui.msg = $.i18n;
*
* // A button displaying "OK" in the default locale
* button = new OO.ui.ButtonWidget( {
* label: OO.ui.msg( 'ooui-dialog-message-accept' ),
* icon: 'check'
* } );
* $( 'body' ).append( button.$element );
*
* // A button displaying "OK" in Urdu
* $.i18n().locale = 'ur';
* button = new OO.ui.ButtonWidget( {
* label: OO.ui.msg( 'ooui-dialog-message-accept' ),
* icon: 'check'
* } );
* $( 'body' ).append( button.$element );
* } );
*
* @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 ) {
// Keep this function in sync with php/Tag.php
var i, protocolWhitelist;
function stringStartsWith( haystack, needle ) {
return haystack.substr( 0, needle.length ) === needle;
}
protocolWhitelist = [
'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 === '' ) {
return true;
}
for ( i = 0; i < protocolWhitelist.length; i++ ) {
if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
return true;
}
}
// This matches '//' too
if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
return true;
}
if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
return true;
}
return false;
};
/**
* Check if the user has a 'mobile' device.
*
* For our purposes this means the user is primarily using an
* on-screen keyboard, touch input instead of a mouse and may
* have a physically small display.
*
* It is left up to implementors to decide how to compute this
* so the default implementation always returns false.
*
* @return {boolean} Use is on a mobile device
*/
OO.ui.isMobile = function () {
return false;
};
/**
* Get the additional spacing that should be taken into account when displaying elements that are
* clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
* such menus overlapping any fixed headers/toolbars/navigation used by the site.
*
* @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
* the extra spacing from that edge of viewport (in pixels)
*/
OO.ui.getViewportSpacing = function () {
return {
top: 0,
right: 0,
bottom: 0,
left: 0
};
};
/**
* Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
* See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
*
* @return {jQuery} Default overlay node
*/
OO.ui.getDefaultOverlay = function () {
if ( !OO.ui.$defaultOverlay ) {
OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
$( 'body' ).append( OO.ui.$defaultOverlay );
}
return OO.ui.$defaultOverlay;
};