/*! * OOUI v0.26.4 * https://www.mediawiki.org/wiki/OOUI * * Copyright 2011–2018 OOUI Team and other contributors. * Released under the MIT license * http://oojs.mit-license.org * * Date: 2018-04-17T22:23:58Z */ ( 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 }; /** * 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 'ooui-' + 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.} 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. * // OOUI 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} User 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 . * * @return {jQuery} Default overlay node */ OO.ui.getDefaultOverlay = function () { if ( !OO.ui.$defaultOverlay ) { OO.ui.$defaultOverlay = $( '
' ).addClass( 'oo-ui-defaultOverlay' ); $( 'body' ).append( OO.ui.$defaultOverlay ); } return OO.ui.$defaultOverlay; }; /*! * Mixin namespace. */ /** * Namespace for OOUI 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 = {}; /** * 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 [OOUI documentation on MediaWiki][2] * for an example. * [2]: https://www.mediawiki.org/wiki/OOUI/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 ) { if ( OO.ui.isDemo ) { this.initialConfig = config; } // Configuration initialization config = config || {}; // Properties this.$ = $; this.elementId = null; this.visible = true; this.data = config.data; this.$element = config.$element || $( document.createElement( this.getTagName() ) ); this.elementGroup = null; // Initialization if ( Array.isArray( config.classes ) ) { this.$element.addClass( config.classes.join( ' ' ) ); } if ( config.id ) { this.setElementId( 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, error, data, cls, parts, parent, obj, top, state, infusedChildren; if ( typeof idOrNode === 'string' ) { id = idOrNode; $elem = $( document.getElementById( id ) ); } else { $elem = $( idOrNode ); id = $elem.attr( 'id' ); } if ( !$elem.length ) { if ( typeof idOrNode === 'string' ) { error = 'Widget not found: ' + idOrNode; } else if ( idOrNode && idOrNode.selector ) { error = 'Widget not found: ' + idOrNode.selector; } else { error = 'Widget not found'; } throw new Error( error ); } if ( $elem[ 0 ].oouiInfused ) { $elem = $elem[ 0 ].oouiInfused; } data = $elem.data( 'ooui-infused' ); if ( data ) { // cached! if ( data === true ) { throw new Error( 'Circular dependency! ' + id ); } if ( domPromise ) { // pick up dynamic state, like focus, value of form inputs, scroll position, etc. state = data.constructor.static.gatherPreInfuseState( $elem, data ); // restore dynamic state after the new element is re-inserted into DOM under infused parent domPromise.done( data.restorePreInfuseState.bind( data, state ) ); infusedChildren = $elem.data( 'ooui-infused-children' ); if ( infusedChildren && infusedChildren.length ) { infusedChildren.forEach( function ( data ) { var state = data.constructor.static.gatherPreInfuseState( $elem, data ); domPromise.done( data.restorePreInfuseState.bind( data, state ) ); } ); } } return data; } data = $elem.attr( 'data-ooui' ); if ( !data ) { throw new Error( 'No infusion data found: ' + id ); } try { data = JSON.parse( 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 ) { 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 infusedChildren = []; data = OO.copy( data, null, function deserialize( value ) { var infused; if ( OO.isPlainObject( value ) ) { if ( value.tag ) { infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise ); infusedChildren.push( infused ); // Flatten the structure infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] ); infused.$element.removeData( 'ooui-infused-children' ); return infused; } if ( value.html !== undefined ) { return new OO.ui.HtmlSnippet( value.html ); } } } ); // allow widgets to reuse parts of the DOM data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data ); // pick up dynamic state, like focus, value of form inputs, scroll position, etc. state = cls.static.gatherPreInfuseState( $elem[ 0 ], data ); // rebuild widget // eslint-disable-next-line new-cap obj = new cls( data ); // If anyone is holding a reference to the old DOM element, // let's allow them to OO.ui.infuse() it and do what they expect, see T105828. // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design. $elem[ 0 ].oouiInfused = obj.$element; // now replace old DOM with this new DOM. if ( top ) { // An efficient constructor might be able to reuse the entire DOM tree of the original element, // so only mutate the DOM if we need to. if ( $elem[ 0 ] !== obj.$element[ 0 ] ) { $elem.replaceWith( obj.$element ); } top.resolve(); } obj.$element.data( 'ooui-infused', obj ); obj.$element.data( 'ooui-infused-children', infusedChildren ); // 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; }; /** * Pick out parts of `node`'s DOM to be reused when infusing a widget. * * This method **must not** make any changes to the DOM, only find interesting pieces and add them * to `config` (which should then be returned). Actual DOM juggling should then be done by the * constructor, which will be given the enhanced config. * * @protected * @param {HTMLElement} node * @param {Object} config * @return {Object} */ OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) { return config; }; /** * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node * (and its children) that represent an Element of the same class and the given configuration, * 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 the newly created widget's #$element * is inserted into DOM to replace `node`. * * @protected * @param {HTMLElement} node * @param {Object} config * @return {Object} */ OO.ui.Element.static.gatherPreInfuseState = function () { return {}; }; /** * 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 === Node.DOCUMENT_NODE && obj ) || null; }; /** * Get the window of an element or document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for * @return {Window} Window object */ OO.ui.Element.static.getWindow = function ( obj ) { var doc = this.getDocument( obj ); return doc.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 === Node.DOCUMENT_NODE; isWin = obj.document !== undefined; if ( isDoc || isWin ) { if ( isWin ) { obj = obj.document; } obj = obj.body; } return $( obj ).css( 'direction' ); }; /** * Get the offset between two frames. * * TODO: Make this function not use recursion. * * @static * @param {Window} from Window of the child frame * @param {Window} [to=window] Window of the parent frame * @param {Object} [offset] Offset to start with, used internally * @return {Object} Offset object, containing left and top properties */ OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) { var i, len, frames, frame, rect; if ( !to ) { to = window; } if ( !offset ) { offset = { top: 0, left: 0 }; } if ( from.parent === from ) { return offset; } // Get iframe element frames = from.parent.document.getElementsByTagName( 'iframe' ); for ( i = 0, len = frames.length; i < len; i++ ) { if ( frames[ i ].contentWindow === from ) { frame = frames[ i ]; break; } } // Recursively accumulate offset values if ( frame ) { rect = frame.getBoundingClientRect(); offset.left += rect.left; offset.top += rect.top; if ( from !== to ) { this.getFrameOffset( from.parent, offset ); } } return offset; }; /** * Get the offset between two elements. * * The two elements may be in a different frame, but in that case the frame $element is in must * be contained in the frame $anchor is in. * * @static * @param {jQuery} $element Element whose position to get * @param {jQuery} $anchor Element to get $element's position relative to * @return {Object} Translated position coordinates, containing top and left properties */ OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) { var iframe, iframePos, pos = $element.offset(), anchorPos = $anchor.offset(), elementDocument = this.getDocument( $element ), anchorDocument = this.getDocument( $anchor ); // If $element isn't in the same document as $anchor, traverse up while ( elementDocument !== anchorDocument ) { iframe = elementDocument.defaultView.frameElement; if ( !iframe ) { throw new Error( '$element frame is not contained in $anchor frame' ); } iframePos = $( iframe ).offset(); pos.left += iframePos.left; pos.top += iframePos.top; elementDocument = iframe.ownerDocument; } pos.left -= anchorPos.left; pos.top -= anchorPos.top; return pos; }; /** * Get element border sizes. * * @static * @param {HTMLElement} el Element to measure * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties */ OO.ui.Element.static.getBorders = function ( el ) { var doc = el.ownerDocument, win = doc.defaultView, style = win.getComputedStyle( el, null ), $el = $( el ), top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0, left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0, bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0, right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0; return { top: top, left: left, bottom: bottom, right: right }; }; /** * Get dimensions of an element or window. * * @static * @param {HTMLElement|Window} el Element to measure * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties */ OO.ui.Element.static.getDimensions = function ( el ) { var $el, $win, doc = el.ownerDocument || el.document, win = doc.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 the number of pixels that an element's content is scrolled to the left. * * Adapted from . * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License. * * This function smooths out browser inconsistencies (nicely described in the README at * ) and produces a result consistent * with Firefox's 'scrollLeft', which seems the sanest. * * @static * @method * @param {HTMLElement|Window} el Element to measure * @return {number} Scroll position from the left. * If the element's direction is LTR, this is a positive number between `0` (initial scroll position) * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position). * If the element's direction is RTL, this is a negative number between `0` (initial scroll position) * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position). */ OO.ui.Element.static.getScrollLeft = ( function () { var rtlScrollType = null; function test() { var $definer = $( '
A
' ), definer = $definer[ 0 ]; $definer.appendTo( 'body' ); if ( definer.scrollLeft > 0 ) { // Safari, Chrome rtlScrollType = 'default'; } else { definer.scrollLeft = 1; if ( definer.scrollLeft === 0 ) { // Firefox, old Opera rtlScrollType = 'negative'; } else { // Internet Explorer, Edge rtlScrollType = 'reverse'; } } $definer.remove(); } return function getScrollLeft( el ) { var isRoot = el.window === el || el === el.ownerDocument.body || el === el.ownerDocument.documentElement, scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft, // All browsers use the correct scroll type ('negative') on the root, so don't // do any fixups when looking at the root element direction = isRoot ? 'ltr' : $( el ).css( 'direction' ); if ( direction === 'rtl' ) { if ( rtlScrollType === null ) { test(); } if ( rtlScrollType === 'reverse' ) { scrollLeft = -scrollLeft; } else if ( rtlScrollType === 'default' ) { scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth; } } return scrollLeft; }; }() ); /** * Get the root scrollable element of given element's document. * * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set * the scrollTop property; instead we have to use `document.body`. Changing and testing the 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 root scrollable parent for * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement` * depending on browser */ 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; // In some browsers (observed in Chrome 56 on Linux Mint 18.1), // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76 if ( Math.round( 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 root * scrollable element will be returned (see #getRootScrollableElement). * * @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, // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and // 'overflow-y' have different values, so we need to check the separate properties. props = [ 'overflow-x', 'overflow-y' ], $parent = $( el ).parent(); if ( dimension === 'x' || dimension === 'y' ) { props = [ 'overflow-' + dimension ]; } // Special case for the document root (which doesn't really have any scrollable container, since // it is the ultimate scrollable container, but this is probably saner than null or exception) if ( $( el ).is( 'html, body' ) ) { return this.getRootScrollableElement( el ); } while ( $parent.length ) { if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) { return $parent[ 0 ]; } i = props.length; while ( i-- ) { val = $parent.css( props[ i ] ); // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be // scrolled in that direction, but they can actually be scrolled programatically. The user can // unintentionally perform a scroll in such case even if the application doesn't scroll // programatically, e.g. when jumping to an anchor, or when using built-in find functionality. // This could cause funny issues... if ( val === 'auto' || val === 'scroll' ) { return $parent[ 0 ]; } } $parent = $parent.parent(); } // The element is unattached... return something mostly sane return this.getRootScrollableElement( el ); }; /** * Scroll element into view. * * @static * @param {HTMLElement} el Element to scroll into view * @param {Object} [config] Configuration options * @param {string} [config.duration='fast'] jQuery animation duration value * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit * to scroll in both directions * @return {jQuery.Promise} Promise which resolves when the scroll is complete */ OO.ui.Element.static.scrollIntoView = function ( el, config ) { var position, animations, container, $container, elementDimensions, containerDimensions, $window, deferred = $.Deferred(); // Configuration initialization config = config || {}; animations = {}; container = this.getClosestScrollableContainer( el, config.direction ); $container = $( container ); elementDimensions = this.getDimensions( el ); containerDimensions = this.getDimensions( container ); $window = $( this.getWindow( el ) ); // Compute the element's position relative to the container if ( $container.is( 'html, body' ) ) { // If the scrollable container is the root, this is easy position = { top: elementDimensions.rect.top, bottom: $window.innerHeight() - elementDimensions.rect.bottom, left: elementDimensions.rect.left, right: $window.innerWidth() - elementDimensions.rect.right }; } else { // Otherwise, we have to subtract el's coordinates from container's coordinates position = { top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ), bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom, left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ), right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right }; } if ( !config.direction || config.direction === 'y' ) { if ( position.top < 0 ) { animations.scrollTop = containerDimensions.scroll.top + position.top; } else if ( position.top > 0 && position.bottom < 0 ) { animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom ); } } if ( !config.direction || config.direction === 'x' ) { if ( position.left < 0 ) { animations.scrollLeft = containerDimensions.scroll.left + position.left; } else if ( position.left > 0 && position.right < 0 ) { animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right ); } } if ( !$.isEmptyObject( animations ) ) { $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration ); $container.queue( function ( next ) { deferred.resolve(); next(); } ); } else { deferred.resolve(); } return deferred.promise(); }; /** * Force the browser to reconsider whether it really needs to render scrollbars inside the element * and reserve space for them, because it probably doesn't. * * Workaround primarily for , but also * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow, * and then reattach (or show) them back. * * @static * @param {HTMLElement} el Element to reconsider the scrollbars on */ OO.ui.Element.static.reconsiderScrollbars = function ( el ) { var i, len, 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} data Element data * @chainable */ OO.ui.Element.prototype.setData = function ( data ) { this.data = data; return this; }; /** * Set the element has an 'id' attribute. * * @param {string} id * @chainable */ OO.ui.Element.prototype.setElementId = function ( id ) { this.elementId = id; this.$element.attr( 'id', id ); return this; }; /** * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing, * and return its value. * * @return {string} */ OO.ui.Element.prototype.getElementId = function () { if ( this.elementId === null ) { this.setElementId( OO.ui.generateElementId() ); } return this.elementId; }; /** * 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 () { OO.ui.theme.queueUpdateElementClasses( 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. * * @return {HTMLElement} 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 * @return {jQuery.Promise} Promise which resolves when the scroll is complete */ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) { if ( !this.isElementAttached() || !this.isVisible() || ( this.getElementGroup() && !this.getElementGroup().isVisible() ) ) { return $.Deferred().resolve(); } return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config ); }; /** * 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 () { }; /** * 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; }; /** * 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 OOUI 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 ); /* 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; }; /** * Get an ID of a labelable node which is part of this widget, if any, to be used for `