3 * https://www.mediawiki.org/wiki/OOUI
5 * Copyright 2011–2024 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
9 * Date: 2024-12-05T17:34:41Z
16 * Namespace for all classes, static methods and static properties.
47 * Constants for MouseEvent.which
51 OO.ui.MouseButtons = {
64 * Generate a unique ID for element
68 OO.ui.generateElementId = function () {
70 return 'ooui-' + OO.ui.elementId;
74 * Check if an element is focusable.
75 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 * @param {jQuery} $element Element to test
78 * @return {boolean} Element is focusable
80 OO.ui.isFocusableElement = function ( $element ) {
81 const element = $element[ 0 ];
83 // Anything disabled is not focusable
84 if ( element.disabled ) {
88 // Check if the element is visible
90 // This is quicker than calling $element.is( ':visible' )
91 $.expr.pseudos.visible( element ) &&
92 // Check that all parents are visible
93 !$element.parents().addBack().filter( function () {
94 return $.css( this, 'visibility' ) === 'hidden';
100 // Check if the element is ContentEditable, which is the string 'true'
101 if ( element.contentEditable === 'true' ) {
105 // Anything with a non-negative numeric tabIndex is focusable.
106 // Use .prop to avoid browser bugs
107 if ( $element.prop( 'tabIndex' ) >= 0 ) {
111 // Some element types are naturally focusable
112 // (indexOf is much faster than regex in Chrome and about the
113 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
114 const nodeName = element.nodeName.toLowerCase();
115 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
119 // Links and areas are focusable if they have an href
120 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
128 * Find a focusable child.
130 * @param {jQuery} $container Container to search in
131 * @param {boolean} [backwards=false] Search backwards
132 * @return {jQuery} Focusable child, or an empty jQuery object if none found
134 OO.ui.findFocusable = function ( $container, backwards ) {
135 let $focusable = $( [] ),
136 // $focusableCandidates is a superset of things that
137 // could get matched by isFocusableElement
138 $focusableCandidates = $container
139 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 $focusableCandidates.each( ( i, el ) => {
147 if ( OO.ui.isFocusableElement( $el ) ) {
156 * Get the user's language and any fallback languages.
158 * These language codes are used to localize user interface elements in the user's language.
160 * In environments that provide a localization system, this function should be overridden to
161 * return the user's language(s). The default implementation returns English (en) only.
163 * @return {string[]} Language codes, in descending order of priority
165 OO.ui.getUserLanguages = function () {
170 * Get a value in an object keyed by language code.
172 * @param {Object.<string,any>} obj Object keyed by language code
173 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
174 * @param {string} [fallback] Fallback code, used if no matching language can be found
175 * @return {any} Local value
177 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
178 // Requested language
182 // Known user language
183 const langs = OO.ui.getUserLanguages();
184 for ( let i = 0, len = langs.length; i < len; i++ ) {
191 if ( obj[ fallback ] ) {
192 return obj[ fallback ];
194 // First existing language
195 // eslint-disable-next-line no-unreachable-loop
196 for ( lang in obj ) {
204 * Check if a node is contained within another node.
206 * Similar to jQuery#contains except a list of containers can be supplied
207 * and a boolean argument allows you to include the container in the match list
209 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
210 * @param {HTMLElement} contained Node to find
211 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match,
212 * otherwise only match descendants
213 * @return {boolean} The node is in the list of target nodes
215 OO.ui.contains = function ( containers, contained, matchContainers ) {
216 if ( !Array.isArray( containers ) ) {
217 containers = [ containers ];
219 for ( let i = containers.length - 1; i >= 0; i-- ) {
221 ( matchContainers && contained === containers[ i ] ) ||
222 $.contains( containers[ i ], contained )
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
236 * Ported from: http://underscorejs.org/underscore.js
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
243 OO.ui.debounce = function ( func, wait, immediate ) {
246 const context = this,
248 later = function () {
251 func.apply( context, args );
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
265 * Puts a console warning with provided message.
267 * @param {string} message Message
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
289 OO.ui.throttle = function ( func, wait ) {
290 let context, args, timeout,
291 previous = Date.now() - wait;
293 const run = function () {
295 previous = Date.now();
296 func.apply( context, args );
300 // Check how long it's been since the last time the function was
301 // called, and whether it's more or less than the requested throttle
302 // period. If it's less, run the function immediately. If it's more,
303 // set a timeout for the remaining time -- but don't replace an
304 // existing timeout, since that'd indefinitely prolong the wait.
305 const remaining = Math.max( wait - ( Date.now() - previous ), 0 );
309 // If time is up, do setTimeout( run, 0 ) so the function
310 // always runs asynchronously, just like Promise#then .
311 timeout = setTimeout( run, remaining );
317 * Reconstitute a JavaScript object corresponding to a widget created by
318 * the PHP implementation.
320 * This is an alias for `OO.ui.Element.static.infuse()`.
322 * @param {string|HTMLElement|jQuery} node A single node for the widget to infuse.
323 * String must be a selector (deprecated).
324 * @param {Object} [config] Configuration options
325 * @return {OO.ui.Element}
326 * The `OO.ui.Element` corresponding to this (infusable) document node.
328 OO.ui.infuse = function ( node, config ) {
329 if ( typeof node === 'string' ) {
330 // Deprecate passing a selector, which was accidentally introduced in Ibf95b0dee.
332 OO.ui.warnDeprecation(
333 'Passing a selector to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
336 return OO.ui.Element.static.infuse( node, config );
340 * Get a localized message.
342 * After the message key, message parameters may optionally be passed. In the default
343 * implementation, any occurrences of $1 are replaced with the first parameter, $2 with the
344 * second parameter, etc.
345 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long
346 * as they support unnamed, ordered message parameters.
348 * In environments that provide a localization system, this function should be overridden to
349 * return the message translated in the user's language. The default implementation always
350 * returns English messages. An example of doing this with
351 * [jQuery.i18n](https://github.com/wikimedia/jquery.i18n) follows.
354 * const messagePath = 'oojs-ui/dist/i18n/',
355 * languages = [ $.i18n().locale, 'ur', 'en' ],
358 * for ( let i = 0, iLen = languages.length; i < iLen; i++ ) {
359 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
362 * $.i18n().load( languageMap ).done( function() {
363 * // Replace the built-in `msg` only once we've loaded the internationalization.
364 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
365 * // you put off creating any widgets until this promise is complete, no English
366 * // will be displayed.
367 * OO.ui.msg = $.i18n;
369 * // A button displaying "OK" in the default locale
370 * const button = new OO.ui.ButtonWidget( {
371 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
374 * $( document.body ).append( button.$element );
376 * // A button displaying "OK" in Urdu
377 * $.i18n().locale = 'ur';
378 * button = new OO.ui.ButtonWidget( {
379 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
382 * $( document.body ).append( button.$element );
385 * @param {string} key Message key
386 * @param {...any} [params] Message parameters
387 * @return {string} Translated message with parameters substituted
389 OO.ui.msg = function ( key, ...params ) {
390 // `OO.ui.msg.messages` is defined in code generated during the build process
391 const messages = OO.ui.msg.messages;
393 let message = messages[ key ];
394 if ( typeof message === 'string' ) {
395 // Perform $1 substitution
396 message = message.replace( /\$(\d+)/g, ( unused, n ) => {
397 const i = parseInt( n, 10 );
398 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
401 // Return placeholder if message not found
402 message = '[' + key + ']';
408 * Package a message and arguments for deferred resolution.
410 * Use this when you are statically specifying a message and the message may not yet be present.
412 * @param {string} key Message key
413 * @param {...any} [params] Message parameters
414 * @return {Function} Function that returns the resolved message when executed
416 OO.ui.deferMsg = function () {
417 // eslint-disable-next-line mediawiki/msg-doc
418 return () => OO.ui.msg( ...arguments );
424 * If the message is a function it will be executed, otherwise it will pass through directly.
426 * @param {Function|string|any} msg
427 * @return {string|any} Resolved message when there was something to resolve, pass through
430 OO.ui.resolveMsg = function ( msg ) {
431 if ( typeof msg === 'function' ) {
438 * @param {string} url
441 OO.ui.isSafeUrl = function ( url ) {
442 // Keep this function in sync with php/Tag.php
444 function stringStartsWith( haystack, needle ) {
445 return haystack.slice( 0, needle.length ) === needle;
448 const protocolAllowList = [
449 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
450 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
451 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
458 for ( let i = 0; i < protocolAllowList.length; i++ ) {
459 if ( stringStartsWith( url, protocolAllowList[ i ] + ':' ) ) {
464 // This matches '//' too
465 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
468 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
476 * Check if the user has a 'mobile' device.
478 * For our purposes this means the user is primarily using an
479 * on-screen keyboard, touch input instead of a mouse and may
480 * have a physically small display.
482 * It is left up to implementors to decide how to compute this
483 * so the default implementation always returns false.
485 * @return {boolean} User is on a mobile device
487 OO.ui.isMobile = function () {
492 * Get the additional spacing that should be taken into account when displaying elements that are
493 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
494 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
496 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
497 * the extra spacing from that edge of viewport (in pixels)
499 OO.ui.getViewportSpacing = function () {
509 * Get the element where elements that are positioned outside of normal flow are inserted,
510 * for example dialogs and dropdown menus.
512 * This is meant to be overridden if the site needs to style this element in some way
513 * (e.g. setting font size), and doesn't want to style the whole document.
515 * @return {HTMLElement}
517 OO.ui.getTeleportTarget = function () {
518 return document.body;
522 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
523 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
525 * @return {jQuery} Default overlay node
527 OO.ui.getDefaultOverlay = function () {
528 if ( !OO.ui.$defaultOverlay ) {
529 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
530 $( OO.ui.getTeleportTarget() ).append( OO.ui.$defaultOverlay );
532 return OO.ui.$defaultOverlay;
535 // Define a custom HTML element that does nothing except to expose the `connectedCallback` callback
536 // as `onConnectOOUI` property. We use it in some widgets to detect when they are connected.
537 if ( window.customElements ) {
538 window.customElements.define( 'ooui-connect-detector', class extends HTMLElement {
539 connectedCallback() {
540 if ( this.onConnectOOUI instanceof Function ) {
541 this.onConnectOOUI();
548 * Message store for the default implementation of OO.ui.msg.
550 * Environments that provide a localization system should not use this, but should override
551 * OO.ui.msg altogether.
555 OO.ui.msg.messages = {
556 "ooui-copytextlayout-copy": "Copy",
557 "ooui-outline-control-move-down": "Move item down",
558 "ooui-outline-control-move-up": "Move item up",
559 "ooui-outline-control-remove": "Remove item",
560 "ooui-toolbar-more": "More",
561 "ooui-toolgroup-expand": "More",
562 "ooui-toolgroup-collapse": "Fewer",
563 "ooui-item-remove": "Remove",
564 "ooui-dialog-message-accept": "OK",
565 "ooui-dialog-message-reject": "Cancel",
566 "ooui-dialog-process-error": "Something went wrong",
567 "ooui-dialog-process-dismiss": "Dismiss",
568 "ooui-dialog-process-retry": "Try again",
569 "ooui-dialog-process-continue": "Continue",
570 "ooui-combobox-button-label": "Toggle options",
571 "ooui-selectfile-button-select": "Select a file",
572 "ooui-selectfile-button-select-multiple": "Select files",
573 "ooui-selectfile-placeholder": "No file is selected",
574 "ooui-selectfile-dragdrop-placeholder": "Drop file here",
575 "ooui-selectfile-dragdrop-placeholder-multiple": "Drop files here",
576 "ooui-popup-widget-close-button-aria-label": "Close",
577 "ooui-field-help": "Help"
585 * Namespace for OOUI mixins.
587 * Mixins are named according to the type of object they are intended to
588 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
589 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
590 * is intended to be mixed in to an instance of OO.ui.Widget.
596 // getDocument( element ) is preferrable to window.document
597 /* global document:off */
600 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
601 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not
602 * have events connected to them and can't be interacted with.
608 * @param {Object} [config] Configuration options
609 * @param {string[]} [config.classes] The names of the CSS classes to apply to the element. CSS styles are
610 * added to the top level (e.g., the outermost div) of the element. See the
611 * [OOUI documentation on MediaWiki][2] for an example.
612 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
613 * @param {string} [config.id] The HTML id attribute used in the rendered tag.
614 * @param {string} [config.text] Text to insert
615 * @param {Array} [config.content] An array of content elements to append (after #text).
616 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
617 * Instances of OO.ui.Element will have their $element appended.
618 * @param {jQuery} [config.$content] Content elements to append (after #text).
619 * @param {jQuery} [config.$element] Wrapper element. Defaults to a new element with #getTagName.
620 * @param {any} [config.data] Custom data of any type or combination of types (e.g., string, number,
622 * Data can also be specified with the #setData method.
624 OO.ui.Element = function OoUiElement( config ) {
625 if ( OO.ui.isDemo ) {
626 this.initialConfig = config;
628 // Configuration initialization
629 config = config || {};
632 this.elementId = null;
634 this.data = config.data;
635 this.$element = config.$element ||
636 $( window.document.createElement( this.getTagName() ) );
637 this.elementGroup = null;
640 const doc = OO.ui.Element.static.getDocument( this.$element );
641 if ( Array.isArray( config.classes ) ) {
642 this.$element.addClass( config.classes );
645 this.setElementId( config.id );
648 this.$element.text( config.text );
650 if ( config.content ) {
651 // The `content` property treats plain strings as text; use an
652 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
653 // appropriate $element appended.
654 this.$element.append( config.content.map( ( v ) => {
655 if ( typeof v === 'string' ) {
656 // Escape string so it is properly represented in HTML.
657 // Don't create empty text nodes for empty strings.
658 return v ? doc.createTextNode( v ) : undefined;
659 } else if ( v instanceof OO.ui.HtmlSnippet ) {
662 } else if ( v instanceof OO.ui.Element ) {
668 if ( config.$content ) {
669 // The `$content` property treats plain strings as HTML.
670 this.$element.append( config.$content );
676 OO.initClass( OO.ui.Element );
678 /* Static Properties */
681 * The name of the HTML tag used by the element.
683 * The static value may be ignored if the #getTagName method is overridden.
688 OO.ui.Element.static.tagName = 'div';
693 * Reconstitute a JavaScript object corresponding to a widget created
694 * by the PHP implementation.
696 * @param {HTMLElement|jQuery} node
697 * A single node for the widget to infuse.
698 * @param {Object} [config] Configuration options
699 * @return {OO.ui.Element}
700 * The `OO.ui.Element` corresponding to this (infusable) document node.
701 * For `Tag` objects emitted on the HTML side (used occasionally for content)
702 * the value returned is a newly-created Element wrapping around the existing
705 OO.ui.Element.static.infuse = function ( node, config ) {
706 const obj = OO.ui.Element.static.unsafeInfuse( node, config, false );
708 // Verify that the type matches up.
709 // FIXME: uncomment after T89721 is fixed, see T90929.
711 if ( !( obj instanceof this['class'] ) ) {
712 throw new Error( 'Infusion type mismatch!' );
719 * Implementation helper for `infuse`; skips the type check and has an
720 * extra property so that only the top-level invocation touches the DOM.
723 * @param {HTMLElement|jQuery} elem
724 * @param {Object} [config] Configuration options
725 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
726 * when the top-level widget of this infusion is inserted into DOM,
727 * replacing the original element; only used internally.
728 * @return {OO.ui.Element}
730 OO.ui.Element.static.unsafeInfuse = function ( elem, config, domPromise ) {
731 // look for a cached result of a previous infusion.
732 let $elem = $( elem );
734 if ( $elem.length > 1 ) {
735 throw new Error( 'Collection contains more than one element' );
737 if ( !$elem.length ) {
738 throw new Error( 'Widget not found' );
740 if ( $elem[ 0 ].$oouiInfused ) {
741 $elem = $elem[ 0 ].$oouiInfused;
744 const id = $elem.attr( 'id' );
745 const doc = this.getDocument( $elem );
746 let data = $elem.data( 'ooui-infused' );
749 if ( data === true ) {
750 throw new Error( 'Circular dependency! ' + id );
753 // Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
754 const stateCache = data.constructor.static.gatherPreInfuseState( $elem, data );
755 // Restore dynamic state after the new element is re-inserted into DOM under
757 domPromise.done( data.restorePreInfuseState.bind( data, stateCache ) );
758 const infusedChildrenCache = $elem.data( 'ooui-infused-children' );
759 if ( infusedChildrenCache && infusedChildrenCache.length ) {
760 infusedChildrenCache.forEach( ( childData ) => {
761 const childState = childData.constructor.static.gatherPreInfuseState(
766 childData.restorePreInfuseState.bind( childData, childState )
773 data = $elem.attr( 'data-ooui' );
775 throw new Error( 'No infusion data found: ' + id );
778 data = JSON.parse( data );
782 if ( !( data && data._ ) ) {
783 throw new Error( 'No valid infusion data found: ' + id );
785 if ( data._ === 'Tag' ) {
786 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
787 return new OO.ui.Element( Object.assign( {}, config, { $element: $elem } ) );
789 const parts = data._.split( '.' );
790 const cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
792 if ( !( cls && ( cls === OO.ui.Element || cls.prototype instanceof OO.ui.Element ) ) ) {
793 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
799 domPromise = top.promise();
801 $elem.data( 'ooui-infused', true ); // prevent loops
802 data.id = id; // implicit
803 const infusedChildren = [];
804 data = OO.copy( data, null, ( value ) => {
806 if ( OO.isPlainObject( value ) ) {
807 if ( value.tag && doc.getElementById( value.tag ) ) {
808 infused = OO.ui.Element.static.unsafeInfuse(
809 doc.getElementById( value.tag ), config, domPromise
811 infusedChildren.push( infused );
812 // Flatten the structure
813 infusedChildren.push.apply(
815 infused.$element.data( 'ooui-infused-children' ) || []
817 infused.$element.removeData( 'ooui-infused-children' );
820 if ( value.html !== undefined ) {
821 return new OO.ui.HtmlSnippet( value.html );
825 // allow widgets to reuse parts of the DOM
826 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
827 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
828 const state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
830 // eslint-disable-next-line new-cap
831 const obj = new cls( Object.assign( {}, config, data ) );
832 // If anyone is holding a reference to the old DOM element,
833 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
834 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
835 $elem[ 0 ].$oouiInfused = obj.$element;
836 // now replace old DOM with this new DOM.
838 // An efficient constructor might be able to reuse the entire DOM tree of the original
839 // element, so only mutate the DOM if we need to.
840 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
841 $elem.replaceWith( obj.$element );
848 'ooui-infused-children': infusedChildren
850 // set the 'data-ooui' attribute so we can identify infused widgets
851 .attr( 'data-ooui', '' );
852 // restore dynamic state after the new element is inserted into DOM
853 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
858 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
860 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
861 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
862 * constructor, which will be given the enhanced config.
865 * @param {HTMLElement} node
866 * @param {Object} config
869 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
874 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
875 * node (and its children) that represent an Element of the same class and the given configuration,
876 * generated by the PHP implementation.
878 * This method is called just before `node` is detached from the DOM. The return value of this
879 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
880 * is inserted into DOM to replace `node`.
883 * @param {HTMLElement} node
884 * @param {Object} config
887 OO.ui.Element.static.gatherPreInfuseState = function () {
892 * Get the document of an element.
895 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
896 * @return {HTMLDocument|null} Document object
898 OO.ui.Element.static.getDocument = function ( obj ) {
900 return obj.ownerDocument ||
904 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
905 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
906 ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
907 // Empty jQuery selections might have a context
913 * Get the window of an element or document.
916 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
917 * @return {Window} Window object
919 OO.ui.Element.static.getWindow = function ( obj ) {
920 const doc = this.getDocument( obj );
921 return doc.defaultView;
925 * Get the direction of an element or document.
928 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
929 * @return {string} Text direction, either 'ltr' or 'rtl'
931 OO.ui.Element.static.getDir = function ( obj ) {
932 if ( obj instanceof $ ) {
935 const isDoc = obj.nodeType === Node.DOCUMENT_NODE;
936 const isWin = obj.document !== undefined;
937 if ( isDoc || isWin ) {
943 return $( obj ).css( 'direction' );
947 * Get the offset between two frames.
949 * TODO: Make this function not use recursion.
952 * @param {Window} from Window of the child frame
953 * @param {Window} [to=window] Window of the parent frame
954 * @param {Object} [offset] Offset to start with, used internally
955 * @return {Object} Offset object, containing left and top properties
957 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
962 offset = { top: 0, left: 0 };
964 if ( from.parent === from ) {
968 // Get iframe element
970 const frames = from.parent.document.getElementsByTagName( 'iframe' );
971 for ( let i = 0, len = frames.length; i < len; i++ ) {
972 if ( frames[ i ].contentWindow === from ) {
978 // Recursively accumulate offset values
980 const rect = frame.getBoundingClientRect();
981 offset.left += rect.left;
982 offset.top += rect.top;
984 this.getFrameOffset( from.parent, offset );
991 * Get the offset between two elements.
993 * The two elements may be in a different frame, but in that case the frame $element is in must
994 * be contained in the frame $anchor is in.
997 * @param {jQuery} $element Element whose position to get
998 * @param {jQuery} $anchor Element to get $element's position relative to
999 * @return {Object} Translated position coordinates, containing top and left properties
1001 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1002 const pos = $element.offset();
1003 const anchorPos = $anchor.offset();
1004 const anchorDocument = this.getDocument( $anchor );
1006 let elementDocument = this.getDocument( $element );
1008 // If $element isn't in the same document as $anchor, traverse up
1009 while ( elementDocument !== anchorDocument ) {
1010 const iframe = elementDocument.defaultView.frameElement;
1012 throw new Error( '$element frame is not contained in $anchor frame' );
1014 const iframePos = $( iframe ).offset();
1015 pos.left += iframePos.left;
1016 pos.top += iframePos.top;
1017 elementDocument = this.getDocument( iframe );
1019 pos.left -= anchorPos.left;
1020 pos.top -= anchorPos.top;
1025 * Get element border sizes.
1028 * @param {HTMLElement} el Element to measure
1029 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1031 OO.ui.Element.static.getBorders = function ( el ) {
1032 const doc = this.getDocument( el ),
1033 win = doc.defaultView,
1034 style = win.getComputedStyle( el, null ),
1036 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1037 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1038 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1039 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1050 * Get dimensions of an element or window.
1053 * @param {HTMLElement|Window} el Element to measure
1054 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1056 OO.ui.Element.static.getDimensions = function ( el ) {
1057 const doc = this.getDocument( el ),
1058 win = doc.defaultView;
1060 if ( win === el || el === doc.documentElement ) {
1061 const $win = $( win );
1063 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1065 top: $win.scrollTop(),
1066 left: OO.ui.Element.static.getScrollLeft( win )
1068 scrollbar: { right: 0, bottom: 0 },
1072 bottom: $win.innerHeight(),
1073 right: $win.innerWidth()
1077 const $el = $( el );
1079 borders: this.getBorders( el ),
1081 top: $el.scrollTop(),
1082 left: OO.ui.Element.static.getScrollLeft( el )
1085 right: $el.innerWidth() - el.clientWidth,
1086 bottom: $el.innerHeight() - el.clientHeight
1088 rect: el.getBoundingClientRect()
1094 let rtlScrollType = null;
1096 // Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1097 // Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1098 function rtlScrollTypeTest() {
1099 const $definer = $( '<div>' ).attr( {
1101 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1103 definer = $definer[ 0 ];
1105 $definer.appendTo( 'body' );
1106 if ( definer.scrollLeft > 0 ) {
1108 rtlScrollType = 'default';
1110 definer.scrollLeft = 1;
1111 if ( definer.scrollLeft === 0 ) {
1112 // Firefox, old Opera
1113 rtlScrollType = 'negative';
1119 function isRoot( el ) {
1120 return el === el.window ||
1121 el === el.ownerDocument.body ||
1122 el === el.ownerDocument.documentElement;
1126 * Convert native `scrollLeft` value to a value consistent between browsers. See #getScrollLeft.
1128 * @param {number} nativeOffset Native `scrollLeft` value
1129 * @param {HTMLElement|Window} el Element from which the value was obtained
1132 OO.ui.Element.static.computeNormalizedScrollLeft = function ( nativeOffset, el ) {
1133 // All browsers use the correct scroll type ('negative') on the root, so don't
1134 // do any fixups when looking at the root element
1135 const direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );
1137 if ( direction === 'rtl' ) {
1138 if ( rtlScrollType === null ) {
1139 rtlScrollTypeTest();
1141 if ( rtlScrollType === 'reverse' ) {
1142 return -nativeOffset;
1143 } else if ( rtlScrollType === 'default' ) {
1144 return nativeOffset - el.scrollWidth + el.clientWidth;
1148 return nativeOffset;
1152 * Convert our normalized `scrollLeft` value to a value for current browser. See #getScrollLeft.
1154 * @param {number} normalizedOffset Normalized `scrollLeft` value
1155 * @param {HTMLElement|Window} el Element on which the value will be set
1158 OO.ui.Element.static.computeNativeScrollLeft = function ( normalizedOffset, el ) {
1159 // All browsers use the correct scroll type ('negative') on the root, so don't
1160 // do any fixups when looking at the root element
1161 const direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );
1163 if ( direction === 'rtl' ) {
1164 if ( rtlScrollType === null ) {
1165 rtlScrollTypeTest();
1167 if ( rtlScrollType === 'reverse' ) {
1168 return -normalizedOffset;
1169 } else if ( rtlScrollType === 'default' ) {
1170 return normalizedOffset + el.scrollWidth - el.clientWidth;
1174 return normalizedOffset;
1178 * Get the number of pixels that an element's content is scrolled to the left.
1180 * This function smooths out browser inconsistencies (nicely described in the README at
1181 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1182 * with Firefox's 'scrollLeft', which seems the most sensible.
1184 * (Firefox's scrollLeft handling is nice because it increases from left to right, consistently
1185 * with `getBoundingClientRect().left` and related APIs; because initial value is zero, so
1186 * resetting it is easy; because adapting a hardcoded scroll position to a symmetrical RTL
1187 * interface requires just negating it, rather than involving `clientWidth` and `scrollWidth`;
1188 * and because if you mess up and don't adapt your code to RTL, it will scroll to the beginning
1189 * rather than somewhere randomly in the middle but not where you wanted.)
1193 * @param {HTMLElement|Window} el Element to measure
1194 * @return {number} Scroll position from the left.
1195 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1196 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1197 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1198 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1200 OO.ui.Element.static.getScrollLeft = function ( el ) {
1201 let scrollLeft = isRoot( el ) ? $( window ).scrollLeft() : el.scrollLeft;
1202 scrollLeft = OO.ui.Element.static.computeNormalizedScrollLeft( scrollLeft, el );
1207 * Set the number of pixels that an element's content is scrolled to the left.
1209 * See #getScrollLeft.
1213 * @param {HTMLElement|Window} el Element to scroll (and to use in calculations)
1214 * @param {number} scrollLeft Scroll position from the left.
1215 * If the element's direction is LTR, this must be a positive number between
1216 * `0` (initial scroll position) and `el.scrollWidth - el.clientWidth`
1217 * (furthest possible scroll position).
1218 * If the element's direction is RTL, this must be a negative number between
1219 * `0` (initial scroll position) and `-el.scrollWidth + el.clientWidth`
1220 * (furthest possible scroll position).
1222 OO.ui.Element.static.setScrollLeft = function ( el, scrollLeft ) {
1223 scrollLeft = OO.ui.Element.static.computeNativeScrollLeft( scrollLeft, el );
1224 if ( isRoot( el ) ) {
1225 $( window ).scrollLeft( scrollLeft );
1227 el.scrollLeft = scrollLeft;
1233 * Get the root scrollable element of given element's document.
1235 * Support: Chrome <= 60
1236 * On older versions of Blink, `document.documentElement` can't be used to get or set
1237 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1238 * lets us use 'body' or 'documentElement' based on what is working.
1240 * https://code.google.com/p/chromium/issues/detail?id=303131
1243 * @param {HTMLElement} el Element to find root scrollable parent for
1244 * @return {HTMLBodyElement|HTMLHtmlElement} Scrollable parent, `<body>` or `<html>`
1246 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1247 const doc = this.getDocument( el );
1249 if ( OO.ui.scrollableElement === undefined ) {
1250 const body = doc.body;
1251 const scrollTop = body.scrollTop;
1254 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1255 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1256 if ( Math.round( body.scrollTop ) === 1 ) {
1257 body.scrollTop = scrollTop;
1258 OO.ui.scrollableElement = 'body';
1260 OO.ui.scrollableElement = 'documentElement';
1264 return doc[ OO.ui.scrollableElement ];
1268 * Get closest scrollable container.
1270 * Traverses up until either a scrollable element or the root is reached, in which case the root
1271 * scrollable element will be returned (see #getRootScrollableElement).
1274 * @param {HTMLElement} el Element to find scrollable container for
1275 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1276 * @return {HTMLElement} Closest scrollable container
1278 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1279 const doc = this.getDocument( el );
1280 const rootScrollableElement = this.getRootScrollableElement( el );
1281 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1282 // 'overflow-y' have different values, so we need to check the separate properties.
1283 let props = [ 'overflow-x', 'overflow-y' ];
1284 let $parent = $( el ).parent();
1286 if ( el === doc.documentElement ) {
1287 return rootScrollableElement;
1290 if ( dimension === 'x' || dimension === 'y' ) {
1291 props = [ 'overflow-' + dimension ];
1294 // The parent of <html> is the document, so check we haven't traversed that far
1295 while ( $parent.length && $parent[ 0 ] !== doc ) {
1296 if ( $parent[ 0 ] === rootScrollableElement ) {
1297 return $parent[ 0 ];
1299 let i = props.length;
1301 const val = $parent.css( props[ i ] );
1302 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1303 // never be scrolled in that direction, but they can actually be scrolled
1304 // programatically. The user can unintentionally perform a scroll in such case even if
1305 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1306 // when using built-in find functionality.
1307 // This could cause funny issues...
1308 if ( val === 'auto' || val === 'scroll' ) {
1309 if ( $parent[ 0 ] === doc.body ) {
1310 // If overflow is set on <body>, return the rootScrollableElement
1311 // (<body> or <html>) as <body> may not be scrollable.
1312 return rootScrollableElement;
1314 return $parent[ 0 ];
1318 $parent = $parent.parent();
1320 // The element is unattached… return something moderately sensible.
1321 return rootScrollableElement;
1325 * Scroll element into view.
1328 * @param {HTMLElement|Object} elOrPosition Element to scroll into view
1329 * @param {Object} [config] Configuration options
1330 * @param {string} [config.animate=true] Animate to the new scroll offset.
1331 * @param {string} [config.duration='fast'] jQuery animation duration value
1332 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1333 * to scroll in both directions
1334 * @param {Object} [config.alignToTop=false] Aligns the top of the element to the top of the visible
1335 * area of the scrollable ancestor.
1336 * @param {Object} [config.padding] Additional padding on the container to scroll past.
1337 * Object containing any of 'top', 'bottom', 'left', or 'right' as numbers.
1338 * @param {Object} [config.scrollContainer] Scroll container. Defaults to
1339 * getClosestScrollableContainer of the element.
1340 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1342 OO.ui.Element.static.scrollIntoView = function ( elOrPosition, config ) {
1343 const deferred = $.Deferred();
1345 // Configuration initialization
1346 config = config || {};
1348 const padding = Object.assign( {
1353 }, config.padding );
1355 let animate = config.animate !== false;
1356 if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) {
1357 // Respect 'prefers-reduced-motion' user preference
1361 const animations = {};
1362 const elementPosition = elOrPosition instanceof HTMLElement ?
1363 this.getDimensions( elOrPosition ).rect :
1365 const container = config.scrollContainer || (
1366 elOrPosition instanceof HTMLElement ?
1367 this.getClosestScrollableContainer( elOrPosition, config.direction ) :
1368 // No scrollContainer or element, use global document
1369 this.getClosestScrollableContainer( window.document.body )
1371 const $container = $( container );
1372 const containerDimensions = this.getDimensions( container );
1373 const $window = $( this.getWindow( container ) );
1375 // Compute the element's position relative to the container
1377 if ( $container.is( 'html, body' ) ) {
1378 // If the scrollable container is the root, this is easy
1380 top: elementPosition.top,
1381 bottom: $window.innerHeight() - elementPosition.bottom,
1382 left: elementPosition.left,
1383 right: $window.innerWidth() - elementPosition.right
1386 // Otherwise, we have to subtract el's coordinates from container's coordinates
1388 top: elementPosition.top -
1389 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1390 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1391 containerDimensions.scrollbar.bottom - elementPosition.bottom,
1392 left: elementPosition.left -
1393 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1394 right: containerDimensions.rect.right - containerDimensions.borders.right -
1395 containerDimensions.scrollbar.right - elementPosition.right
1399 if ( !config.direction || config.direction === 'y' ) {
1400 if ( position.top < padding.top || config.alignToTop ) {
1401 animations.scrollTop = containerDimensions.scroll.top + position.top - padding.top;
1402 } else if ( position.bottom < padding.bottom ) {
1403 animations.scrollTop = containerDimensions.scroll.top +
1404 // Scroll the bottom into view, but not at the expense
1405 // of scrolling the top out of view
1406 Math.min( position.top - padding.top, -position.bottom + padding.bottom );
1409 if ( !config.direction || config.direction === 'x' ) {
1410 if ( position.left < padding.left ) {
1411 animations.scrollLeft = containerDimensions.scroll.left + position.left - padding.left;
1412 } else if ( position.right < padding.right ) {
1413 animations.scrollLeft = containerDimensions.scroll.left +
1414 // Scroll the right into view, but not at the expense
1415 // of scrolling the left out of view
1416 Math.min( position.left - padding.left, -position.right + padding.right );
1418 if ( animations.scrollLeft !== undefined ) {
1419 animations.scrollLeft =
1420 OO.ui.Element.static.computeNativeScrollLeft( animations.scrollLeft, container );
1423 if ( !$.isEmptyObject( animations ) ) {
1425 // eslint-disable-next-line no-jquery/no-animate
1426 $container.stop( true ).animate( animations, {
1427 duration: config.duration === undefined ? 'fast' : config.duration,
1428 always: deferred.resolve
1431 $container.stop( true );
1432 for ( const method in animations ) {
1433 $container[ method ]( animations[ method ] );
1440 return deferred.promise();
1444 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1445 * and reserve space for them, because it probably doesn't.
1447 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1448 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1449 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1450 * reflow, and then reattach (or show) them back.
1453 * @param {HTMLElement} el Element to reconsider the scrollbars on
1455 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1456 // Save scroll position
1457 const scrollLeft = el.scrollLeft;
1458 const scrollTop = el.scrollTop;
1460 // Detach all children
1461 while ( el.firstChild ) {
1462 nodes.push( el.firstChild );
1463 el.removeChild( el.firstChild );
1466 // eslint-disable-next-line no-unused-expressions
1468 // Reattach all children
1469 for ( let i = 0, len = nodes.length; i < len; i++ ) {
1470 el.appendChild( nodes[ i ] );
1472 // Restore scroll position (no-op if scrollbars disappeared)
1473 el.scrollLeft = scrollLeft;
1474 el.scrollTop = scrollTop;
1480 * Toggle visibility of an element.
1482 * @param {boolean} [show] Make element visible, omit to toggle visibility
1483 * @fires OO.ui.Widget#toggle
1485 * @return {OO.ui.Element} The element, for chaining
1487 OO.ui.Element.prototype.toggle = function ( show ) {
1488 show = show === undefined ? !this.visible : !!show;
1490 if ( show !== this.isVisible() ) {
1491 this.visible = show;
1492 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1493 this.emit( 'toggle', show );
1500 * Check if element is visible.
1502 * @return {boolean} element is visible
1504 OO.ui.Element.prototype.isVisible = function () {
1505 return this.visible;
1511 * @return {any} Element data
1513 OO.ui.Element.prototype.getData = function () {
1520 * @param {any} data Element data
1522 * @return {OO.ui.Element} The element, for chaining
1524 OO.ui.Element.prototype.setData = function ( data ) {
1530 * Set the element has an 'id' attribute.
1532 * @param {string} id
1534 * @return {OO.ui.Element} The element, for chaining
1536 OO.ui.Element.prototype.setElementId = function ( id ) {
1537 this.elementId = id;
1538 this.$element.attr( 'id', id );
1543 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1544 * and return its value.
1548 OO.ui.Element.prototype.getElementId = function () {
1549 if ( this.elementId === null ) {
1550 this.setElementId( OO.ui.generateElementId() );
1552 return this.elementId;
1556 * Check if element supports one or more methods.
1558 * @param {string|string[]} methods Method or list of methods to check
1559 * @return {boolean} All methods are supported
1561 OO.ui.Element.prototype.supports = function ( methods ) {
1562 if ( !Array.isArray( methods ) ) {
1563 return typeof this[ methods ] === 'function';
1566 return methods.every( ( method ) => typeof this[ method ] === 'function' );
1570 * Update the theme-provided classes.
1572 * This is called in element mixins and widget classes any time state changes.
1573 * Updating is debounced, minimizing overhead of changing multiple attributes and
1574 * guaranteeing that theme updates do not occur within an element's constructor
1576 OO.ui.Element.prototype.updateThemeClasses = function () {
1577 OO.ui.theme.queueUpdateElementClasses( this );
1581 * Get the HTML tag name.
1583 * Override this method to base the result on instance information.
1585 * @return {string} HTML tag name
1587 OO.ui.Element.prototype.getTagName = function () {
1588 return this.constructor.static.tagName;
1592 * Check if the element is attached to the DOM
1594 * @return {boolean} The element is attached to the DOM
1596 OO.ui.Element.prototype.isElementAttached = function () {
1597 return this.$element[ 0 ].isConnected;
1601 * Get the DOM document.
1603 * @return {HTMLDocument} Document object
1605 OO.ui.Element.prototype.getElementDocument = function () {
1606 // Don't cache this in other ways either because subclasses could can change this.$element
1607 return OO.ui.Element.static.getDocument( this.$element );
1611 * Get the DOM window.
1613 * @return {Window} Window object
1615 OO.ui.Element.prototype.getElementWindow = function () {
1616 return OO.ui.Element.static.getWindow( this.$element );
1620 * Get closest scrollable container.
1622 * @return {HTMLElement} Closest scrollable container
1624 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1625 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1629 * Get group element is in.
1631 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1633 OO.ui.Element.prototype.getElementGroup = function () {
1634 return this.elementGroup;
1638 * Set group element is in.
1640 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1642 * @return {OO.ui.Element} The element, for chaining
1644 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1645 this.elementGroup = group;
1650 * Scroll element into view.
1652 * @param {Object} [config] Configuration options
1653 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1655 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1657 !this.isElementAttached() ||
1658 !this.isVisible() ||
1659 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1661 return $.Deferred().resolve();
1663 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1667 * Restore the pre-infusion dynamic state for this widget.
1669 * This method is called after #$element has been inserted into DOM. The parameter is the return
1670 * value of #gatherPreInfuseState.
1673 * @param {Object} state
1675 OO.ui.Element.prototype.restorePreInfuseState = function () {
1679 * Wraps an HTML snippet for use with configuration values which default
1680 * to strings. This bypasses the default html-escaping done to string
1686 * @param {string} content HTML content
1688 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1690 this.content = content;
1695 OO.initClass( OO.ui.HtmlSnippet );
1702 * @return {string} Unchanged HTML snippet.
1704 OO.ui.HtmlSnippet.prototype.toString = function () {
1705 return this.content;
1709 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1710 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1712 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1713 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1714 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1715 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1716 * for more information and examples.
1720 * @extends OO.ui.Element
1721 * @mixes OO.EventEmitter
1724 * @param {Object} [config] Configuration options
1726 OO.ui.Layout = function OoUiLayout( config ) {
1727 // Configuration initialization
1728 config = config || {};
1730 // Parent constructor
1731 OO.ui.Layout.super.call( this, config );
1733 // Mixin constructors
1734 OO.EventEmitter.call( this );
1737 this.$element.addClass( 'oo-ui-layout' );
1742 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1743 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1748 * Reset scroll offsets
1751 * @return {OO.ui.Layout} The layout, for chaining
1753 OO.ui.Layout.prototype.resetScroll = function () {
1754 this.$element[ 0 ].scrollTop = 0;
1755 OO.ui.Element.static.setScrollLeft( this.$element[ 0 ], 0 );
1761 * Widgets are compositions of one or more OOUI elements that users can both view
1762 * and interact with. All widgets can be configured and modified via a standard API,
1763 * and their state can change dynamically according to a model.
1767 * @extends OO.ui.Element
1768 * @mixes OO.EventEmitter
1771 * @param {Object} [config] Configuration options
1772 * @param {boolean} [config.disabled=false] Disable the widget. Disabled widgets cannot be used and their
1773 * appearance reflects this state.
1775 OO.ui.Widget = function OoUiWidget( config ) {
1776 // Parent constructor
1777 OO.ui.Widget.super.call( this, config );
1779 // Mixin constructors
1780 OO.EventEmitter.call( this );
1783 this.disabled = null;
1784 this.wasDisabled = null;
1787 this.$element.addClass( 'oo-ui-widget' );
1788 this.setDisabled( config && config.disabled );
1793 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1794 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1799 * A 'disable' event is emitted when the disabled state of the widget changes
1800 * (i.e. on disable **and** enable).
1802 * @event OO.ui.Widget#disable
1803 * @param {boolean} disabled Widget is disabled
1807 * A 'toggle' event is emitted when the visibility of the widget changes.
1809 * @event OO.ui.Widget#toggle
1810 * @param {boolean} visible Widget is visible
1816 * Check if the widget is disabled.
1818 * @return {boolean} Widget is disabled
1820 OO.ui.Widget.prototype.isDisabled = function () {
1821 return this.disabled;
1825 * Set the 'disabled' state of the widget.
1827 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1829 * @param {boolean} [disabled=false] Disable widget
1831 * @return {OO.ui.Widget} The widget, for chaining
1833 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1834 this.disabled = !!disabled;
1835 const isDisabled = this.isDisabled();
1836 if ( isDisabled !== this.wasDisabled ) {
1837 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1838 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1839 this.$element.attr( 'aria-disabled', isDisabled ? 'true' : null );
1840 this.emit( 'disable', isDisabled );
1841 this.updateThemeClasses();
1842 this.wasDisabled = isDisabled;
1849 * Update the disabled state, in case of changes in parent widget.
1852 * @return {OO.ui.Widget} The widget, for chaining
1854 OO.ui.Widget.prototype.updateDisabled = function () {
1855 this.setDisabled( this.disabled );
1860 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1863 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1866 * @return {string|null} The ID of the labelable element
1868 OO.ui.Widget.prototype.getInputId = function () {
1873 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1874 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1875 * override this method to provide intuitive, accessible behavior.
1877 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1878 * Individual widgets may override it too.
1880 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1883 OO.ui.Widget.prototype.simulateLabelClick = function () {
1887 * Set the element with the given ID as a label for this widget.
1889 * @param {string|null} id
1891 OO.ui.Widget.prototype.setLabelledBy = function ( id ) {
1893 this.$element.attr( 'aria-labelledby', id );
1895 this.$element.removeAttr( 'aria-labelledby' );
1907 OO.ui.Theme = function OoUiTheme() {
1908 this.elementClassesQueue = [];
1909 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1914 OO.initClass( OO.ui.Theme );
1919 * Get a list of classes to be applied to a widget.
1921 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1922 * otherwise state transitions will not work properly.
1924 * @param {OO.ui.Element} element Element for which to get classes
1925 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1927 OO.ui.Theme.prototype.getElementClasses = function () {
1928 return { on: [], off: [] };
1932 * Update CSS classes provided by the theme.
1934 * For elements with theme logic hooks, this should be called any time there's a state change.
1936 * @param {OO.ui.Element} element Element for which to update classes
1938 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1939 const domElements = [];
1941 if ( element.$icon ) {
1942 domElements.push( element.$icon[ 0 ] );
1944 if ( element.$indicator ) {
1945 domElements.push( element.$indicator[ 0 ] );
1948 if ( domElements.length ) {
1949 const classes = this.getElementClasses( element );
1951 .removeClass( classes.off )
1952 .addClass( classes.on );
1959 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1960 for ( let i = 0; i < this.elementClassesQueue.length; i++ ) {
1961 this.updateElementClasses( this.elementClassesQueue[ i ] );
1964 this.elementClassesQueue = [];
1968 * Queue #updateElementClasses to be called for this element.
1970 * QUnit tests override this method to directly call #queueUpdateElementClasses,
1971 * to make them synchronous.
1973 * @param {OO.ui.Element} element Element for which to update classes
1975 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1976 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1977 // the most common case (this method is often called repeatedly for the same element).
1978 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1981 this.elementClassesQueue.push( element );
1982 this.debouncedUpdateQueuedElementClasses();
1986 * Get the transition duration in milliseconds for dialogs opening/closing
1988 * The dialog should be fully rendered this many milliseconds after the
1989 * ready process has executed.
1991 * @return {number} Transition duration in milliseconds
1993 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1998 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1999 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
2000 * order in which users will navigate through the focusable elements via the Tab key.
2003 * // TabIndexedElement is mixed into the ButtonWidget class
2004 * // to provide a tabIndex property.
2005 * const button1 = new OO.ui.ButtonWidget( {
2009 * button2 = new OO.ui.ButtonWidget( {
2013 * button3 = new OO.ui.ButtonWidget( {
2017 * button4 = new OO.ui.ButtonWidget( {
2021 * $( document.body ).append(
2032 * @param {Object} [config] Configuration options
2033 * @param {jQuery} [config.$tabIndexed] The element that should use the tabindex functionality. By default,
2034 * the functionality is applied to the element created by the class ($element). If a different
2035 * element is specified, the tabindex functionality will be applied to it instead.
2036 * @param {string|number|null} [config.tabIndex=0] Number that specifies the element’s position in the
2037 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
2038 * navigation order; use -1 to remove the element from the tab-navigation flow.
2040 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
2041 // Configuration initialization
2042 config = Object.assign( { tabIndex: 0 }, config );
2045 this.$tabIndexed = null;
2046 this.tabIndex = null;
2049 this.connect( this, {
2050 disable: 'onTabIndexedElementDisable'
2054 this.setTabIndex( config.tabIndex );
2055 this.setTabIndexedElement( config.$tabIndexed || this.$element );
2060 OO.initClass( OO.ui.mixin.TabIndexedElement );
2065 * Set the element that should use the tabindex functionality.
2067 * This method is used to retarget a tabindex mixin so that its functionality applies
2068 * to the specified element. If an element is currently using the functionality, the mixin’s
2069 * effect on that element is removed before the new element is set up.
2071 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
2073 * @return {OO.ui.Element} The element, for chaining
2075 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
2076 const tabIndex = this.tabIndex;
2077 // Remove attributes from old $tabIndexed
2078 this.setTabIndex( null );
2079 // Force update of new $tabIndexed
2080 this.$tabIndexed = $tabIndexed;
2081 this.tabIndex = tabIndex;
2082 return this.updateTabIndex();
2086 * Set the value of the tabindex.
2088 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
2090 * @return {OO.ui.Element} The element, for chaining
2092 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
2093 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2095 if ( this.tabIndex !== tabIndex ) {
2096 this.tabIndex = tabIndex;
2097 this.updateTabIndex();
2104 * Update the `tabindex` attribute, in case of changes to tab index or
2109 * @return {OO.ui.Element} The element, for chaining
2111 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2112 if ( this.$tabIndexed ) {
2113 if ( this.tabIndex !== null ) {
2114 // Do not index over disabled elements
2115 this.$tabIndexed.attr( {
2116 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2117 // Support: ChromeVox and NVDA
2118 // These do not seem to inherit aria-disabled from parent elements
2119 'aria-disabled': this.isDisabled() ? 'true' : null
2122 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2129 * Handle disable events.
2132 * @param {boolean} disabled Element is disabled
2134 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2135 this.updateTabIndex();
2139 * Get the value of the tabindex.
2141 * @return {number|null} Tabindex value
2143 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2144 return this.tabIndex;
2148 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2150 * If the element already has an ID then that is returned, otherwise unique ID is
2151 * generated, set on the element, and returned.
2153 * @return {string|null} The ID of the focusable element
2155 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2156 if ( !this.$tabIndexed ) {
2159 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2163 let id = this.$tabIndexed.attr( 'id' );
2164 if ( id === undefined ) {
2165 id = OO.ui.generateElementId();
2166 this.$tabIndexed.attr( 'id', id );
2173 * Whether the node is 'labelable' according to the HTML spec
2174 * (i.e., whether it can be interacted with through a `<label for="…">`).
2175 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2178 * @param {jQuery} $node
2181 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2183 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2184 tagName = ( $node.prop( 'tagName' ) || '' ).toLowerCase();
2186 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2189 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2196 * Focus this element.
2199 * @return {OO.ui.Element} The element, for chaining
2201 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2202 if ( !this.isDisabled() ) {
2203 this.$tabIndexed.trigger( 'focus' );
2209 * Blur this element.
2212 * @return {OO.ui.Element} The element, for chaining
2214 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2215 this.$tabIndexed.trigger( 'blur' );
2220 * @inheritdoc OO.ui.Widget
2222 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2227 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2228 * interface element that can be configured with access keys for keyboard interaction.
2229 * See the [OOUI documentation on MediaWiki][1] for examples.
2231 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2237 * @param {Object} [config] Configuration options
2238 * @param {jQuery} [config.$button] The button element created by the class.
2239 * If this configuration is omitted, the button element will use a generated `<a>`.
2240 * @param {boolean} [config.framed=true] Render the button with a frame
2242 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2243 // Configuration initialization
2244 config = config || {};
2247 this.$button = null;
2249 this.active = config.active !== undefined && config.active;
2250 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2251 this.onMouseDownHandler = this.onMouseDown.bind( this );
2252 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2253 this.onKeyDownHandler = this.onKeyDown.bind( this );
2254 this.onClickHandler = this.onClick.bind( this );
2255 this.onKeyPressHandler = this.onKeyPress.bind( this );
2258 this.$element.addClass( 'oo-ui-buttonElement' );
2259 this.toggleFramed( config.framed === undefined || config.framed );
2260 this.setButtonElement( config.$button || $( '<a>' ) );
2265 OO.initClass( OO.ui.mixin.ButtonElement );
2267 /* Static Properties */
2270 * Cancel mouse down events.
2272 * This property is usually set to `true` to prevent the focus from changing when the button is
2274 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2275 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2276 * behavior is possible and mousedown events can be handled by a parent widget.
2279 * @property {boolean}
2281 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2286 * A 'click' event is emitted when the button element is clicked.
2288 * @event OO.ui.mixin.ButtonElement#click
2294 * Set the button element.
2296 * This method is used to retarget a button mixin so that its functionality applies to
2297 * the specified button element instead of the one created by the class. If a button element
2298 * is already set, the method will remove the mixin’s effect on that element.
2300 * @param {jQuery} $button Element to use as button
2302 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2303 if ( this.$button ) {
2305 .removeClass( 'oo-ui-buttonElement-button' )
2306 .removeAttr( 'role accesskey' )
2308 mousedown: this.onMouseDownHandler,
2309 keydown: this.onKeyDownHandler,
2310 click: this.onClickHandler,
2311 keypress: this.onKeyPressHandler
2315 this.$button = $button
2316 .addClass( 'oo-ui-buttonElement-button' )
2318 mousedown: this.onMouseDownHandler,
2319 keydown: this.onKeyDownHandler,
2320 click: this.onClickHandler,
2321 keypress: this.onKeyPressHandler
2324 // Add `role="button"` on `<a>` elements, where it's needed
2325 // `toUpperCase()` is added for XHTML documents
2326 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2327 this.$button.attr( 'role', 'button' );
2332 * Handles mouse down events.
2335 * @param {jQuery.Event} e Mouse down event
2336 * @return {undefined|boolean} False to prevent default if event is handled
2338 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2339 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2342 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2343 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2344 // reliably remove the pressed class
2345 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2346 // Prevent change of focus unless specifically configured otherwise
2347 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2353 * Handles document mouse up events.
2356 * @param {MouseEvent} e Mouse up event
2358 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2359 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2362 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2363 // Stop listening for mouseup, since we only needed this once
2364 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2368 * Handles mouse click events.
2371 * @param {jQuery.Event} e Mouse click event
2372 * @fires OO.ui.mixin.ButtonElement#click
2373 * @return {undefined|boolean} False to prevent default if event is handled
2375 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2376 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2377 if ( this.emit( 'click' ) ) {
2384 * Handles key down events.
2387 * @param {jQuery.Event} e Key down event
2389 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2390 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2393 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2394 // Run the keyup handler no matter where the key is when the button is let go, so we can
2395 // reliably remove the pressed class
2396 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2400 * Handles document key up events.
2403 * @param {KeyboardEvent} e Key up event
2405 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2406 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2409 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2410 // Stop listening for keyup, since we only needed this once
2411 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2415 * Handles key press events.
2418 * @param {jQuery.Event} e Key press event
2419 * @fires OO.ui.mixin.ButtonElement#click
2420 * @return {undefined|boolean} False to prevent default if event is handled
2422 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2423 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2424 if ( this.emit( 'click' ) ) {
2431 * Check if button has a frame.
2433 * @return {boolean} Button is framed
2435 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2440 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2443 * @param {boolean} [framed] Make button framed, omit to toggle
2445 * @return {OO.ui.Element} The element, for chaining
2447 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2448 framed = framed === undefined ? !this.framed : !!framed;
2449 if ( framed !== this.framed ) {
2450 this.framed = framed;
2452 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2453 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2454 this.updateThemeClasses();
2461 * Set the button's active state.
2463 * The active state can be set on:
2465 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2466 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2467 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2470 * @param {boolean} [value=false] Make button active
2472 * @return {OO.ui.Element} The element, for chaining
2474 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2475 this.active = !!value;
2476 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2477 this.updateThemeClasses();
2482 * Check if the button is active
2485 * @return {boolean} The button is active
2487 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2492 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2493 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2494 * items from the group is done through the interface the class provides.
2495 * For more information, please see the [OOUI documentation on MediaWiki][1].
2497 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2500 * @mixes OO.EmitterList
2504 * @param {Object} [config] Configuration options
2505 * @param {jQuery} [config.$group] The container element created by the class. If this configuration
2506 * is omitted, the group element will use a generated `<div>`.
2508 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2509 // Configuration initialization
2510 config = config || {};
2512 // Mixin constructors
2513 OO.EmitterList.call( this, config );
2519 this.setGroupElement( config.$group || $( '<div>' ) );
2524 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2529 * A change event is emitted when the set of selected items changes.
2531 * @event OO.ui.mixin.GroupElement#change
2532 * @param {OO.ui.Element[]} items Items currently in the group
2538 * Set the group element.
2540 * If an element is already set, items will be moved to the new element.
2542 * @param {jQuery} $group Element to use as group
2544 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2545 this.$group = $group;
2546 for ( let i = 0, len = this.items.length; i < len; i++ ) {
2547 this.$group.append( this.items[ i ].$element );
2552 * Find an item by its data.
2554 * Only the first item with matching data will be returned. To return all matching items,
2555 * use the #findItemsFromData method.
2557 * @param {any} data Item data to search for
2558 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2560 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2561 const hash = OO.getHash( data );
2563 for ( let i = 0, len = this.items.length; i < len; i++ ) {
2564 const item = this.items[ i ];
2565 if ( hash === OO.getHash( item.getData() ) ) {
2574 * Find items by their data.
2576 * All items with matching data will be returned. To return only the first match, use the
2577 * #findItemFromData method instead.
2579 * @param {any} data Item data to search for
2580 * @return {OO.ui.Element[]} Items with equivalent data
2582 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2583 const hash = OO.getHash( data ),
2586 for ( let i = 0, len = this.items.length; i < len; i++ ) {
2587 const item = this.items[ i ];
2588 if ( hash === OO.getHash( item.getData() ) ) {
2597 * Add items to the group.
2599 * Items will be added to the end of the group array unless the optional `index` parameter
2600 * specifies a different insertion point. Adding an existing item will move it to the end of the
2601 * array or the point specified by the `index`.
2603 * @param {OO.ui.Element|OO.ui.Element[]} [items] Elements to add to the group
2604 * @param {number} [index] Index of the insertion point
2606 * @return {OO.ui.Element} The element, for chaining
2608 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2609 if ( !items || items.length === 0 ) {
2614 OO.EmitterList.prototype.addItems.call( this, items, index );
2616 this.emit( 'change', this.getItems() );
2621 * Move an item from its current position to a new index.
2623 * The item is expected to exist in the list. If it doesn't,
2624 * the method will throw an exception.
2626 * See https://doc.wikimedia.org/oojs/master/OO.EmitterList.html
2629 * @param {OO.EventEmitter} items Item to add
2630 * @param {number} newIndex Index to move the item to
2631 * @return {number} The index the item was moved to
2632 * @throws {Error} If item is not in the list
2634 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2635 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2636 this.insertItemElements( items, newIndex );
2639 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2645 * Utility method to insert an item into the list, and
2646 * connect it to aggregate events.
2648 * Don't call this directly unless you know what you're doing.
2649 * Use #addItems instead.
2651 * This method can be extended in child classes to produce
2652 * different behavior when an item is inserted. For example,
2653 * inserted items may also be attached to the DOM or may
2654 * interact with some other nodes in certain ways. Extending
2655 * this method is allowed, but if overridden, the aggregation
2656 * of events must be preserved, or behavior of emitted events
2659 * If you are extending this method, please make sure the
2660 * parent method is called.
2662 * See https://doc.wikimedia.org/oojs/master/OO.EmitterList.html
2665 * @param {OO.EventEmitter|Object} item Item to add
2666 * @param {number} index Index to add items at
2667 * @return {number} The index the item was added at
2669 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2670 item.setElementGroup( this );
2671 this.insertItemElements( item, index );
2674 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2680 * Insert elements into the group
2683 * @param {OO.ui.Element} item Item to insert
2684 * @param {number} index Insertion index
2686 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( item, index ) {
2687 if ( index === undefined || index < 0 || index >= this.items.length ) {
2688 this.$group.append( item.$element );
2689 } else if ( index === 0 ) {
2690 this.$group.prepend( item.$element );
2692 this.items[ index ].$element.before( item.$element );
2697 * Remove the specified items from a group.
2699 * Removed items are detached (not removed) from the DOM so that they may be reused.
2700 * To remove all items from a group, you may wish to use the #clearItems method instead.
2702 * @param {OO.ui.Element[]} items An array of items to remove
2704 * @return {OO.ui.Element} The element, for chaining
2706 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2707 if ( items.length === 0 ) {
2711 // Remove specific items elements
2712 for ( let i = 0, len = items.length; i < len; i++ ) {
2713 const item = items[ i ];
2714 const index = this.items.indexOf( item );
2715 if ( index !== -1 ) {
2716 item.setElementGroup( null );
2717 item.$element.detach();
2722 OO.EmitterList.prototype.removeItems.call( this, items );
2724 this.emit( 'change', this.getItems() );
2729 * Clear all items from the group.
2731 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2732 * To remove only a subset of items from a group, use the #removeItems method.
2735 * @return {OO.ui.Element} The element, for chaining
2737 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2738 // Remove all item elements
2739 for ( let i = 0, len = this.items.length; i < len; i++ ) {
2740 this.items[ i ].setElementGroup( null );
2741 this.items[ i ].$element.detach();
2745 OO.EmitterList.prototype.clearItems.call( this );
2747 this.emit( 'change', this.getItems() );
2752 * LabelElement is often mixed into other classes to generate a label, which
2753 * helps identify the function of an interface element.
2754 * See the [OOUI documentation on MediaWiki][1] for more information.
2756 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2762 * @param {Object} [config] Configuration options
2763 * @param {jQuery} [config.$label] The label element created by the class. If this
2764 * configuration is omitted, the label element will use a generated `<span>`.
2765 * @param {jQuery|string|Function|OO.ui.HtmlSnippet} [config.label] The label text. The label can be
2766 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2767 * produce a string in the future. See the [OOUI documentation on MediaWiki][2] for examples.
2768 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2769 * @param {boolean} [config.invisibleLabel=false] Whether the label should be visually hidden (but still
2770 * accessible to screen-readers).
2772 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2773 // Configuration initialization
2774 config = config || {};
2779 this.invisibleLabel = false;
2782 this.setLabel( config.label || this.constructor.static.label );
2783 this.setLabelElement( config.$label || $( '<span>' ) );
2784 this.setInvisibleLabel( config.invisibleLabel );
2789 OO.initClass( OO.ui.mixin.LabelElement );
2794 * @event OO.ui.mixin.LabelElement#labelChange
2797 /* Static Properties */
2800 * The label text. The label can be specified as a plaintext string, a function that will
2801 * produce a string (will be resolved on construction time), or `null` for no label. The static
2802 * value will be overridden if a label is specified with the #label config option.
2805 * @property {string|Function|null}
2807 OO.ui.mixin.LabelElement.static.label = null;
2809 /* Static methods */
2812 * Highlight the first occurrence of the query in the given text
2814 * @param {string} text Text
2815 * @param {string} query Query to find
2816 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2817 * @param {boolean} [combineMarks=false] Pull combining marks into highlighted text
2818 * @return {jQuery} Text with the first match of the query
2819 * sub-string wrapped in highlighted span
2821 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare, combineMarks ) {
2828 const $result = $( '<span>' );
2831 const tLen = text.length;
2832 const qLen = query.length;
2833 for ( let i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2834 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2839 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2842 if ( !query.length || offset === -1 ) {
2843 $result.text( text );
2845 // Look for combining characters after the match
2846 if ( combineMarks ) {
2847 // Equivalent to \p{Mark} (which is not currently available in JavaScript)
2848 comboMarks = '[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]';
2850 comboRegex = new RegExp( '(^)' + comboMarks + '*' );
2851 comboMatch = text.slice( offset + query.length ).match( comboRegex );
2853 if ( comboMatch && comboMatch.length ) {
2854 comboLength = comboMatch[ 0 ].length;
2859 document.createTextNode( text.slice( 0, offset ) ),
2861 .addClass( 'oo-ui-labelElement-label-highlight' )
2862 .text( text.slice( offset, offset + query.length + comboLength ) ),
2863 document.createTextNode( text.slice( offset + query.length + comboLength ) )
2866 return $result.contents();
2872 * Replace the wrapper element (an empty `<span>` by default) with another one (e.g. an
2873 * `<a href="…">`), without touching the label's content. This is the same as using the "$label"
2874 * config on construction time.
2876 * If an element is already set, it will be cleaned up before setting up the new element.
2878 * @param {jQuery} $label Element to use as label
2880 * @return {OO.ui.mixin.LabelElement} The element, for chaining
2882 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2883 if ( this.$label ) {
2884 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2887 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2888 this.setLabelContent( this.label );
2893 * Set the 'id' attribute of the label element.
2895 * @param {string} id
2897 * @return {OO.ui.mixin.LabelElement} The element, for chaining
2899 OO.ui.mixin.LabelElement.prototype.setLabelId = function ( id ) {
2900 this.$label.attr( 'id', id );
2905 * Replace both the visible content of the label (same as #setLabelContent) as well as the value
2906 * returned by #getLabel, without touching the label's wrapper element. This is the same as using
2907 * the "label" config on construction time.
2909 * An empty string will result in the label being hidden. A string containing only whitespace will
2910 * be converted to a single ` `.
2912 * To change the wrapper element, use #setLabelElement or the "$label" config.
2914 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2915 * returns nodes or text; or null for no label
2917 * @return {OO.ui.Element} The element, for chaining
2919 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2920 label = OO.ui.resolveMsg( label );
2921 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2923 if ( this.label !== label ) {
2924 if ( this.$label ) {
2925 this.setLabelContent( label );
2928 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2929 this.emit( 'labelChange' );
2936 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2938 * @param {boolean} [invisibleLabel=false]
2940 * @return {OO.ui.Element} The element, for chaining
2942 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2943 invisibleLabel = !!invisibleLabel;
2945 if ( this.invisibleLabel !== invisibleLabel ) {
2946 this.invisibleLabel = invisibleLabel;
2947 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2948 // Pretend that there is no label, a lot of CSS has been written with this assumption
2949 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2950 this.emit( 'labelChange' );
2957 * Set the label as plain text with a highlighted query
2959 * @param {string} text Text label to set
2960 * @param {string} query Substring of text to highlight
2961 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2962 * @param {boolean} [combineMarks=false] Pull combining marks into highlighted text
2964 * @return {OO.ui.Element} The element, for chaining
2966 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function (
2967 text, query, compare, combineMarks
2969 return this.setLabel(
2970 this.constructor.static.highlightQuery( text, query, compare, combineMarks )
2975 * Get the label's value as provided via #setLabel or the "label" config. Note this is not
2976 * necessarily the same as the label's visible content when #setLabelContent was used.
2978 * @return {jQuery|string|null} Label nodes; text; or null for no label
2980 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2985 * Replace the visible content of the label, without touching it's wrapper element. Note this is not
2986 * the same as using the "label" config on construction time. #setLabelContent does not change the
2987 * value returned by #getLabel.
2989 * To change the value as well, use #setLabel or the "label" config. To change the wrapper element,
2990 * use #setLabelElement or the "$label" config.
2993 * @param {jQuery|string|null} label Label nodes; text; or null for no label
2995 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2996 if ( typeof label === 'string' ) {
2997 if ( label.match( /^\s*$/ ) ) {
2998 // Convert whitespace only string to a single non-breaking space
2999 this.$label.html( ' ' );
3001 this.$label.text( label );
3003 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3004 this.$label.html( label.toString() );
3005 } else if ( label instanceof $ ) {
3006 this.$label.empty().append( label );
3008 this.$label.empty();
3013 * IconElement is often mixed into other classes to generate an icon.
3014 * Icons are graphics, about the size of normal text. They are used to aid the user
3015 * in locating a control or to convey information in a space-efficient way. See the
3016 * [OOUI documentation on MediaWiki][1] for a list of icons
3017 * included in the library.
3019 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3025 * @param {Object} [config] Configuration options
3026 * @param {jQuery} [config.$icon] The icon element created by the class. If this configuration is omitted,
3027 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
3028 * the icon element be set to an existing icon instead of the one generated by this class, set a
3029 * value using a jQuery selection. For example:
3031 * // Use a <div> tag instead of a <span>
3032 * $icon: $( '<div>' )
3033 * // Use an existing icon element instead of the one generated by the class
3034 * $icon: this.$element
3035 * // Use an icon element from a child widget
3036 * $icon: this.childwidget.$element
3037 * @param {Object|string} [config.icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
3038 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
3039 * name and additional names keyed by language code. The `default` name is used when no icon is
3040 * keyed by the user's language.
3042 * Example of an i18n map:
3044 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
3045 * See the [OOUI documentation on MediaWiki][2] for a list of icons included in the library.
3046 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3048 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
3049 // Configuration initialization
3050 config = config || {};
3057 this.setIcon( config.icon || this.constructor.static.icon );
3058 this.setIconElement( config.$icon || $( '<span>' ) );
3063 OO.initClass( OO.ui.mixin.IconElement );
3065 /* Static Properties */
3068 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
3069 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
3070 * language code. The `default` name is used when no icon is keyed by the user's language.
3072 * Example of an i18n map:
3074 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
3076 * Note: the static property will be overridden if the #icon configuration is used.
3079 * @property {Object|string}
3081 OO.ui.mixin.IconElement.static.icon = null;
3084 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
3085 * function that returns title text, or `null` for no title.
3087 * The static property will be overridden if the #iconTitle configuration is used.
3090 * @property {string|Function|null}
3092 OO.ui.mixin.IconElement.static.iconTitle = null;
3097 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
3098 * applies to the specified icon element instead of the one created by the class. If an icon
3099 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
3100 * and mixin methods will no longer affect the element.
3102 * @param {jQuery} $icon Element to use as icon
3104 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
3107 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
3108 .removeAttr( 'title' );
3112 .addClass( 'oo-ui-iconElement-icon' )
3113 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
3114 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
3115 if ( this.iconTitle !== null ) {
3116 this.$icon.attr( 'title', this.iconTitle );
3119 this.updateThemeClasses();
3123 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
3124 * The icon parameter can also be set to a map of icon names. See the #icon config setting
3127 * @param {Object|string|null} icon A symbolic icon name, a {@link OO.ui.mixin.IconElement.static.icon map of icon names} keyed
3128 * by language code, or `null` to remove the icon.
3130 * @return {OO.ui.Element} The element, for chaining
3132 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
3133 if ( icon && typeof icon !== 'string' ) {
3134 icon = OO.ui.getLocalValue( icon, null, 'default' );
3137 if ( this.icon === icon ) {
3141 this.$element.toggleClass( 'oo-ui-iconElement', !!icon );
3144 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
3147 this.$icon.addClass( 'oo-ui-icon-' + icon );
3149 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !icon );
3153 this.updateThemeClasses();
3159 * Get the symbolic name of the icon.
3161 * @return {string} Icon name
3163 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3168 * IndicatorElement is often mixed into other classes to generate an indicator.
3169 * Indicators are small graphics that are generally used in two ways:
3171 * - To draw attention to the status of an item. For example, an indicator might be
3172 * used to show that an item in a list has errors that need to be resolved.
3173 * - To clarify the function of a control that acts in an exceptional way (a button
3174 * that opens a menu instead of performing an action directly, for example).
3176 * For a list of indicators included in the library, please see the
3177 * [OOUI documentation on MediaWiki][1].
3179 * Note that indicators don't come with any functionality by default. See e.g.
3180 * {@link OO.ui.SearchInputWidget SearchInputWidget} for a working 'clear' or
3181 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} for a working 'down' indicator.
3183 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3189 * @param {Object} [config] Configuration options
3190 * @param {jQuery} [config.$indicator] The indicator element created by the class. If this
3191 * configuration is omitted, the indicator element will use a generated `<span>`.
3192 * @param {string} [config.indicator] Symbolic name of the indicator (e.g. ‘required’ or ‘down’).
3193 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3195 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3197 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3198 // Configuration initialization
3199 config = config || {};
3202 this.$indicator = null;
3203 this.indicator = null;
3206 this.setIndicator( config.indicator || this.constructor.static.indicator );
3207 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3212 OO.initClass( OO.ui.mixin.IndicatorElement );
3214 /* Static Properties */
3217 * Symbolic name of the indicator (e.g. ‘required’ or ‘down’).
3218 * The static property will be overridden if the #indicator configuration is used.
3221 * @property {string|null}
3223 OO.ui.mixin.IndicatorElement.static.indicator = null;
3226 * A text string used as the indicator title, a function that returns title text, or `null`
3227 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3231 * @property {string|Function|null}
3233 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3238 * Set the indicator element.
3240 * If an element is already set, it will be cleaned up before setting up the new element.
3242 * @param {jQuery} $indicator Element to use as indicator
3244 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3245 if ( this.$indicator ) {
3247 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3248 .removeAttr( 'title' );
3251 this.$indicator = $indicator
3252 .addClass( 'oo-ui-indicatorElement-indicator' )
3253 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3254 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3255 if ( this.indicatorTitle !== null ) {
3256 this.$indicator.attr( 'title', this.indicatorTitle );
3259 this.updateThemeClasses();
3263 * Set the indicator by its symbolic name. Built-in names currently include ‘clear’, ‘up’,
3264 * ‘down’ and ‘required’ (declared via indicators.json). Use `null` to remove the indicator.
3266 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3268 * @return {OO.ui.Element} The element, for chaining
3270 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3271 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3273 if ( this.indicator !== indicator ) {
3274 if ( this.$indicator ) {
3275 if ( this.indicator !== null ) {
3276 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3278 if ( indicator !== null ) {
3279 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3282 this.indicator = indicator;
3285 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3286 if ( this.$indicator ) {
3287 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3289 this.updateThemeClasses();
3295 * Get the symbolic name of the indicator (e.g., ‘required’ or ‘down’).
3297 * @return {string|null} Symbolic name of indicator, null if not set
3299 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3300 return this.indicator;
3304 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3305 * additional functionality to an element created by another class. The class provides
3306 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3307 * which are used to customize the look and feel of a widget to better describe its
3308 * importance and functionality.
3310 * The library currently contains the following styling flags for general use:
3312 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3313 * forward in a process.
3314 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3317 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3318 * action, use these flags: **primary** and **safe**.
3319 * Please see the [OOUI documentation on MediaWiki][1] for more information.
3321 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3323 * The flags affect the appearance of the buttons:
3326 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3327 * const button1 = new OO.ui.ButtonWidget( {
3328 * label: 'Progressive',
3329 * flags: 'progressive'
3331 * button2 = new OO.ui.ButtonWidget( {
3332 * label: 'Destructive',
3333 * flags: 'destructive'
3335 * $( document.body ).append( button1.$element, button2.$element );
3341 * @param {Object} [config] Configuration options
3342 * @param {string|string[]} [config.flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3344 * Please see the [OOUI documentation on MediaWiki][2] for more information about available flags.
3345 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3346 * @param {jQuery} [config.$flagged] The flagged element. By default,
3347 * the flagged functionality is applied to the element created by the class ($element).
3348 * If a different element is specified, the flagged functionality will be applied to it instead.
3350 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3351 // Configuration initialization
3352 config = config || {};
3356 this.$flagged = null;
3359 this.setFlags( config.flags || this.constructor.static.flags );
3360 this.setFlaggedElement( config.$flagged || this.$element );
3365 OO.initClass( OO.ui.mixin.FlaggedElement );
3370 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3371 * parameter contains the name of each modified flag and indicates whether it was
3374 * @event OO.ui.mixin.FlaggedElement#flag
3375 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3376 * that the flag was added, `false` that the flag was removed.
3379 /* Static Properties */
3382 * Initial value to pass to setFlags if no value is provided in config.
3385 * @property {string|string[]|Object.<string, boolean>}
3387 OO.ui.mixin.FlaggedElement.static.flags = null;
3392 * Set the flagged element.
3394 * This method is used to retarget a flagged mixin so that its functionality applies to the
3395 * specified element.
3396 * If an element is already set, the method will remove the mixin’s effect on that element.
3398 * @param {jQuery} $flagged Element that should be flagged
3400 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3401 const classNames = Object.keys( this.flags ).map( ( flag ) => 'oo-ui-flaggedElement-' + flag );
3403 if ( this.$flagged ) {
3404 this.$flagged.removeClass( classNames );
3407 this.$flagged = $flagged.addClass( classNames );
3411 * Check if the specified flag is set.
3413 * @param {string} flag Name of flag
3414 * @return {boolean} The flag is set
3416 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3417 // This may be called before the constructor, thus before this.flags is set
3418 return this.flags && ( flag in this.flags );
3422 * Get the names of all flags set.
3424 * @return {string[]} Flag names
3426 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3427 // This may be called before the constructor, thus before this.flags is set
3428 return Object.keys( this.flags || {} );
3435 * @return {OO.ui.Element} The element, for chaining
3436 * @fires OO.ui.mixin.FlaggedElement#flag
3438 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3441 classPrefix = 'oo-ui-flaggedElement-';
3443 for ( const flag in this.flags ) {
3444 const className = classPrefix + flag;
3445 changes[ flag ] = false;
3446 delete this.flags[ flag ];
3447 remove.push( className );
3450 if ( this.$flagged ) {
3451 this.$flagged.removeClass( remove );
3454 this.updateThemeClasses();
3455 this.emit( 'flag', changes );
3461 * Add one or more flags.
3463 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3464 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3465 * be added (`true`) or removed (`false`).
3467 * @return {OO.ui.Element} The element, for chaining
3468 * @fires OO.ui.mixin.FlaggedElement#flag
3470 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3474 classPrefix = 'oo-ui-flaggedElement-';
3476 let className, flag;
3477 if ( typeof flags === 'string' ) {
3478 className = classPrefix + flags;
3480 if ( !this.flags[ flags ] ) {
3481 this.flags[ flags ] = true;
3482 add.push( className );
3484 } else if ( Array.isArray( flags ) ) {
3485 for ( let i = 0, len = flags.length; i < len; i++ ) {
3487 className = classPrefix + flag;
3489 if ( !this.flags[ flag ] ) {
3490 changes[ flag ] = true;
3491 this.flags[ flag ] = true;
3492 add.push( className );
3495 } else if ( OO.isPlainObject( flags ) ) {
3496 for ( flag in flags ) {
3497 className = classPrefix + flag;
3498 if ( flags[ flag ] ) {
3500 if ( !this.flags[ flag ] ) {
3501 changes[ flag ] = true;
3502 this.flags[ flag ] = true;
3503 add.push( className );
3507 if ( this.flags[ flag ] ) {
3508 changes[ flag ] = false;
3509 delete this.flags[ flag ];
3510 remove.push( className );
3516 if ( this.$flagged ) {
3519 .removeClass( remove );
3522 this.updateThemeClasses();
3523 this.emit( 'flag', changes );
3529 * TitledElement is mixed into other classes to provide a `title` attribute.
3530 * Titles are rendered by the browser and are made visible when the user moves
3531 * the mouse over the element. Titles are not visible on touch devices.
3534 * // TitledElement provides a `title` attribute to the
3535 * // ButtonWidget class.
3536 * const button = new OO.ui.ButtonWidget( {
3537 * label: 'Button with Title',
3538 * title: 'I am a button'
3540 * $( document.body ).append( button.$element );
3546 * @param {Object} [config] Configuration options
3547 * @param {jQuery} [config.$titled] The element to which the `title` attribute is applied.
3548 * If this config is omitted, the title functionality is applied to $element, the
3549 * element created by the class.
3550 * @param {string|Function} [config.title] The title text or a function that returns text. If
3551 * this config is omitted, the value of the {@link OO.ui.mixin.TitledElement.static.title static title} property is used.
3552 * If config for an invisible label ({@link OO.ui.mixin.LabelElement}) is present, and a title is
3553 * omitted, the label will be used as a fallback for the title.
3555 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3556 // Configuration initialization
3557 config = config || {};
3560 this.$titled = null;
3564 let title = config.title !== undefined ? config.title : this.constructor.static.title;
3567 config.invisibleLabel &&
3568 typeof config.label === 'string'
3570 // If config for an invisible label is present, use this as a fallback title
3571 title = config.label;
3573 this.setTitle( title );
3574 this.setTitledElement( config.$titled || this.$element );
3579 OO.initClass( OO.ui.mixin.TitledElement );
3581 /* Static Properties */
3584 * The title text, a function that returns text, or `null` for no title. The value of the static
3585 * property is overridden if the #title config option is used.
3587 * If the element has a default title (e.g. `<input type=file>`), `null` will allow that title to be
3588 * shown. Use empty string to suppress it.
3591 * @property {string|Function|null}
3593 OO.ui.mixin.TitledElement.static.title = null;
3598 * Set the titled element.
3600 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3601 * specified element.
3602 * If an element is already set, the mixin’s effect on that element is removed before the new
3603 * element is set up.
3605 * @param {jQuery} $titled Element that should use the 'titled' functionality
3607 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3608 if ( this.$titled ) {
3609 this.$titled.removeAttr( 'title' );
3612 this.$titled = $titled;
3619 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3622 * @return {OO.ui.Element} The element, for chaining
3624 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3625 title = OO.ui.resolveMsg( title );
3626 title = typeof title === 'string' ? title : null;
3628 if ( this.title !== title ) {
3637 * Update the title attribute, in case of changes to title or accessKey.
3641 * @return {OO.ui.Element} The element, for chaining
3643 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3644 let title = this.getTitle();
3645 if ( this.$titled ) {
3646 if ( title !== null ) {
3647 // Only if this is an AccessKeyedElement
3648 if ( this.formatTitleWithAccessKey ) {
3649 title = this.formatTitleWithAccessKey( title );
3651 this.$titled.attr( 'title', title );
3653 this.$titled.removeAttr( 'title' );
3662 * @return {string|null} Title string
3664 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3669 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3670 * Access keys allow an user to go to a specific element by using
3671 * a shortcut combination of a browser specific keys + the key
3675 * // AccessKeyedElement provides an `accesskey` attribute to the
3676 * // ButtonWidget class.
3677 * const button = new OO.ui.ButtonWidget( {
3678 * label: 'Button with access key',
3681 * $( document.body ).append( button.$element );
3687 * @param {Object} [config] Configuration options
3688 * @param {jQuery} [config.$accessKeyed] The element to which the `accesskey` attribute is applied.
3689 * If this config is omitted, the access key functionality is applied to $element, the
3690 * element created by the class.
3691 * @param {string|Function|null} [config.accessKey=null] The key or a function that returns the key. If
3692 * this config is omitted, no access key will be added.
3694 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3695 // Configuration initialization
3696 config = config || {};
3699 this.$accessKeyed = null;
3700 this.accessKey = null;
3703 this.setAccessKey( config.accessKey || null );
3704 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3706 // If this is also a TitledElement and it initialized before we did, we may have
3707 // to update the title with the access key
3708 if ( this.updateTitle ) {
3715 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3717 /* Static Properties */
3720 * The access key, a function that returns a key, or `null` for no access key.
3723 * @property {string|Function|null}
3725 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3730 * Set the access keyed element.
3732 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3733 * the specified element.
3734 * If an element is already set, the mixin's effect on that element is removed before the new
3735 * element is set up.
3737 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3739 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3740 if ( this.$accessKeyed ) {
3741 this.$accessKeyed.removeAttr( 'accesskey' );
3744 this.$accessKeyed = $accessKeyed;
3745 if ( this.accessKey ) {
3746 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3753 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3756 * @return {OO.ui.Element} The element, for chaining
3758 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3759 accessKey = OO.ui.resolveMsg( accessKey );
3760 accessKey = typeof accessKey === 'string' ? accessKey : null;
3762 if ( this.accessKey !== accessKey ) {
3763 if ( this.$accessKeyed ) {
3764 if ( accessKey !== null ) {
3765 this.$accessKeyed.attr( 'accesskey', accessKey );
3767 this.$accessKeyed.removeAttr( 'accesskey' );
3770 this.accessKey = accessKey;
3772 // Only if this is a TitledElement
3773 if ( this.updateTitle ) {
3784 * @return {string} accessKey string
3786 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3787 return this.accessKey;
3791 * Add information about the access key to the element's tooltip label.
3792 * (This is only public for hacky usage in FieldLayout.)
3794 * @param {string} title Tooltip label for `title` attribute
3797 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3798 if ( !this.$accessKeyed ) {
3799 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3803 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3806 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3807 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3809 accessKey = this.getAccessKey();
3812 title += ' [' + accessKey + ']';
3818 * RequiredElement is mixed into other classes to provide a `required` attribute.
3824 * @param {Object} [config] Configuration options
3825 * @param {jQuery} [config.$required] The element to which the `required` attribute is applied.
3826 * If this config is omitted, the required functionality is applied to $input if it
3827 * exists, or $element if it doesn't.
3828 * @param {boolean} [config.required=false] Mark the field as required with `true`.
3829 * @param {OO.ui.Element} [config.indicatorElement=this] Element which mixes in OO.ui.mixin.IndicatorElement
3830 * Will set the indicator on that element to 'required' when the element is required.
3831 * Note that `false` & setting `indicator: 'required'` will result in no indicator shown.
3833 OO.ui.mixin.RequiredElement = function OoUiMixinRequiredElement( config ) {
3834 // Configuration initialization
3835 config = config || {};
3838 this.$required = config.$required || this.$input || this.$element;
3839 this.required = false;
3840 this.indicatorElement = config.indicatorElement !== undefined ? config.indicatorElement : this;
3841 if ( this.indicatorElement && !this.indicatorElement.getIndicator ) {
3842 throw new Error( 'config.indicatorElement must mixin OO.ui.mixin.IndicatorElement.' );
3846 this.setRequired( !!config.required );
3851 OO.initClass( OO.ui.mixin.RequiredElement );
3856 * Set the element which can take the required attribute.
3858 * This method is used to retarget a RequiredElement mixin so that its functionality applies to the
3859 * specified element.
3860 * If an element is already set, the mixin’s effect on that element is removed before the new
3861 * element is set up.
3863 * @param {jQuery} $required Element that should use the 'required' functionality
3865 OO.ui.mixin.RequiredElement.prototype.setRequiredElement = function ( $required ) {
3866 if ( this.$required === $required ) {
3870 if ( this.$required && this.required ) {
3871 this.updateRequiredElement( false );
3874 this.$required = $required;
3875 this.updateRequiredElement();
3880 * @param {boolean} [state]
3882 OO.ui.mixin.RequiredElement.prototype.updateRequiredElement = function ( state ) {
3883 if ( state === undefined ) {
3884 state = this.required;
3888 .prop( 'required', state );
3892 * Check if the input is {@link OO.ui.mixin.RequiredElement#required required}.
3896 OO.ui.mixin.RequiredElement.prototype.isRequired = function () {
3897 return this.required;
3901 * Set the {@link OO.ui.mixin.RequiredElement#required required} state of the input.
3903 * @param {boolean} state Make input required
3905 * @return {OO.ui.Widget} The widget, for chaining
3907 OO.ui.mixin.RequiredElement.prototype.setRequired = function ( state ) {
3908 if ( this.required === state ) {
3912 this.required = !!state;
3913 this.updateRequiredElement();
3914 if ( this.indicatorElement ) {
3915 // Make sure to not destroy other, unrelated indicators
3916 const expected = state ? null : 'required';
3917 if ( this.indicatorElement.getIndicator() === expected ) {
3918 this.indicatorElement.setIndicator( state ? 'required' : null );
3925 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3926 * feels, and functionality can be customized via the class’s configuration options
3927 * and methods. Please see the [OOUI documentation on MediaWiki][1] for more information
3930 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3932 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3935 * // A button widget.
3936 * const button = new OO.ui.ButtonWidget( {
3937 * label: 'Button with Icon',
3941 * $( document.body ).append( button.$element );
3944 * @extends OO.ui.Widget
3945 * @mixes OO.ui.mixin.ButtonElement
3946 * @mixes OO.ui.mixin.IconElement
3947 * @mixes OO.ui.mixin.IndicatorElement
3948 * @mixes OO.ui.mixin.LabelElement
3949 * @mixes OO.ui.mixin.TitledElement
3950 * @mixes OO.ui.mixin.FlaggedElement
3951 * @mixes OO.ui.mixin.TabIndexedElement
3952 * @mixes OO.ui.mixin.AccessKeyedElement
3955 * @param {Object} [config] Configuration options
3956 * @param {boolean} [config.active=false] Whether button should be shown as active
3957 * @param {string} [config.href=null] Hyperlink to visit when the button is clicked.
3958 * @param {string} [config.target=null] The frame or window in which to open the hyperlink.
3959 * @param {boolean} [config.noFollow=true] Search engine traversal hint
3960 * @param {string|string[]} [config.rel=[]] Relationship attributes for the hyperlink
3962 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3963 // Configuration initialization
3964 config = config || {};
3966 // Parent constructor
3967 OO.ui.ButtonWidget.super.call( this, config );
3969 // Mixin constructors
3970 OO.ui.mixin.ButtonElement.call( this, config );
3971 OO.ui.mixin.IconElement.call( this, config );
3972 OO.ui.mixin.IndicatorElement.call( this, config );
3973 OO.ui.mixin.LabelElement.call( this, config );
3974 OO.ui.mixin.TitledElement.call( this, Object.assign( {
3975 $titled: this.$button
3977 OO.ui.mixin.FlaggedElement.call( this, config );
3978 OO.ui.mixin.TabIndexedElement.call( this, Object.assign( {
3979 $tabIndexed: this.$button
3981 OO.ui.mixin.AccessKeyedElement.call( this, Object.assign( {
3982 $accessKeyed: this.$button
3988 this.noFollow = false;
3992 this.connect( this, {
3993 disable: 'onDisable'
3997 this.$button.append( this.$icon, this.$label, this.$indicator );
3999 .addClass( 'oo-ui-buttonWidget' )
4000 .append( this.$button );
4001 this.setActive( config.active );
4002 this.setHref( config.href );
4003 this.setTarget( config.target );
4004 if ( config.rel !== undefined ) {
4005 this.setRel( config.rel );
4007 this.setNoFollow( config.noFollow );
4013 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
4014 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
4015 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
4016 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
4017 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
4018 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
4019 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
4020 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
4021 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
4023 /* Static Properties */
4029 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
4035 OO.ui.ButtonWidget.static.tagName = 'span';
4040 * Get hyperlink location.
4042 * @return {string|null} Hyperlink location
4044 OO.ui.ButtonWidget.prototype.getHref = function () {
4049 * Get hyperlink target.
4051 * @return {string|null} Hyperlink target
4053 OO.ui.ButtonWidget.prototype.getTarget = function () {
4058 * Get search engine traversal hint.
4060 * @return {boolean} Whether search engines should avoid traversing this hyperlink
4062 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
4063 return this.noFollow;
4067 * Get the relationship attribute of the hyperlink.
4069 * @return {string[]} Relationship attributes that apply to the hyperlink
4071 OO.ui.ButtonWidget.prototype.getRel = function () {
4076 * Set hyperlink location.
4078 * @param {string|null} href Hyperlink location, null to remove
4080 * @return {OO.ui.Widget} The widget, for chaining
4082 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
4083 href = typeof href === 'string' ? href : null;
4084 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
4088 if ( href !== this.href ) {
4097 * Update the `href` attribute, in case of changes to href or
4102 * @return {OO.ui.Widget} The widget, for chaining
4104 OO.ui.ButtonWidget.prototype.updateHref = function () {
4105 if ( this.href !== null && !this.isDisabled() ) {
4106 this.$button.attr( 'href', this.href );
4108 this.$button.removeAttr( 'href' );
4115 * Handle disable events.
4118 * @param {boolean} disabled Element is disabled
4120 OO.ui.ButtonWidget.prototype.onDisable = function () {
4125 * Set hyperlink target.
4127 * @param {string|null} target Hyperlink target, null to remove
4128 * @return {OO.ui.Widget} The widget, for chaining
4130 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
4131 target = typeof target === 'string' ? target : null;
4133 if ( target !== this.target ) {
4134 this.target = target;
4135 if ( target !== null ) {
4136 this.$button.attr( 'target', target );
4138 this.$button.removeAttr( 'target' );
4146 * Set search engine traversal hint.
4148 * @param {boolean} [noFollow=true] True if search engines should avoid traversing this hyperlink
4149 * @return {OO.ui.Widget} The widget, for chaining
4151 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
4152 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
4154 if ( noFollow !== this.noFollow ) {
4157 rel = this.rel.concat( [ 'nofollow' ] );
4159 rel = this.rel.filter( ( value ) => value !== 'nofollow' );
4168 * Set the `rel` attribute of the hyperlink.
4170 * @param {string|string[]} [rel] Relationship attributes for the hyperlink, omit to remove
4171 * @return {OO.ui.Widget} The widget, for chaining
4173 OO.ui.ButtonWidget.prototype.setRel = function ( rel ) {
4174 if ( !Array.isArray( rel ) ) {
4175 rel = rel ? [ rel ] : [];
4179 // For backwards compatibility.
4180 this.noFollow = rel.indexOf( 'nofollow' ) !== -1;
4181 this.$button.attr( 'rel', rel.join( ' ' ) || null );
4186 // Override method visibility hints from ButtonElement
4189 * @inheritdoc OO.ui.mixin.ButtonElement
4190 * @memberof OO.ui.ButtonWidget#
4194 * @inheritdoc OO.ui.mixin.ButtonElement
4195 * @memberof OO.ui.ButtonWidget#
4199 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
4200 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
4201 * removed, and cleared from the group.
4204 * // A ButtonGroupWidget with two buttons.
4205 * const button1 = new OO.ui.PopupButtonWidget( {
4206 * label: 'Select a category',
4209 * $content: $( '<p>List of categories…</p>' ),
4214 * button2 = new OO.ui.ButtonWidget( {
4217 * buttonGroup = new OO.ui.ButtonGroupWidget( {
4218 * items: [ button1, button2 ]
4220 * $( document.body ).append( buttonGroup.$element );
4223 * @extends OO.ui.Widget
4224 * @mixes OO.ui.mixin.GroupElement
4225 * @mixes OO.ui.mixin.TitledElement
4228 * @param {Object} [config] Configuration options
4229 * @param {OO.ui.ButtonWidget[]} [config.items] Buttons to add
4231 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
4232 // Configuration initialization
4233 config = config || {};
4235 // Parent constructor
4236 OO.ui.ButtonGroupWidget.super.call( this, config );
4238 // Mixin constructors
4239 OO.ui.mixin.GroupElement.call( this, Object.assign( {
4240 $group: this.$element
4242 OO.ui.mixin.TitledElement.call( this, config );
4245 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
4246 this.addItems( config.items || [] );
4251 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
4252 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
4253 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
4255 /* Static Properties */
4261 OO.ui.ButtonGroupWidget.static.tagName = 'span';
4269 * @return {OO.ui.Widget} The widget, for chaining
4271 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4272 if ( !this.isDisabled() ) {
4273 if ( this.items[ 0 ] ) {
4274 this.items[ 0 ].focus();
4283 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4288 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
4289 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
4290 * identifies the icon’s function. See the [OOUI documentation on MediaWiki][1]
4291 * for a list of icons included in the library.
4293 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4296 * // An IconWidget with a label via LabelWidget.
4297 * const myIcon = new OO.ui.IconWidget( {
4301 * // Create a label.
4302 * iconLabel = new OO.ui.LabelWidget( {
4305 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4308 * @extends OO.ui.Widget
4309 * @mixes OO.ui.mixin.IconElement
4310 * @mixes OO.ui.mixin.TitledElement
4311 * @mixes OO.ui.mixin.LabelElement
4312 * @mixes OO.ui.mixin.FlaggedElement
4315 * @param {Object} [config] Configuration options
4317 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4318 // Configuration initialization
4319 config = config || {};
4321 // Parent constructor
4322 OO.ui.IconWidget.super.call( this, config );
4324 // Mixin constructors
4325 OO.ui.mixin.IconElement.call( this, Object.assign( {
4326 $icon: this.$element
4328 OO.ui.mixin.TitledElement.call( this, Object.assign( {
4329 $titled: this.$element
4331 OO.ui.mixin.LabelElement.call( this, Object.assign( {
4332 $label: this.$element,
4333 invisibleLabel: true
4335 OO.ui.mixin.FlaggedElement.call( this, Object.assign( {
4336 $flagged: this.$element
4340 this.$element.addClass( 'oo-ui-iconWidget' );
4341 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4342 // nested in other widgets, because this widget used to not mix in LabelElement.
4343 this.$element.removeClass( 'oo-ui-labelElement-label' );
4348 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4349 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4350 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4351 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4352 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4354 /* Static Properties */
4360 OO.ui.IconWidget.static.tagName = 'span';
4363 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4364 * attention to the status of an item or to clarify the function within a control. For a list of
4365 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4367 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4370 * // An indicator widget.
4371 * const indicator1 = new OO.ui.IndicatorWidget( {
4372 * indicator: 'required'
4374 * // Create a fieldset layout to add a label.
4375 * fieldset = new OO.ui.FieldsetLayout();
4376 * fieldset.addItems( [
4377 * new OO.ui.FieldLayout( indicator1, {
4378 * label: 'A required indicator:'
4381 * $( document.body ).append( fieldset.$element );
4384 * @extends OO.ui.Widget
4385 * @mixes OO.ui.mixin.IndicatorElement
4386 * @mixes OO.ui.mixin.TitledElement
4387 * @mixes OO.ui.mixin.LabelElement
4390 * @param {Object} [config] Configuration options
4392 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4393 // Configuration initialization
4394 config = config || {};
4396 // Parent constructor
4397 OO.ui.IndicatorWidget.super.call( this, config );
4399 // Mixin constructors
4400 OO.ui.mixin.IndicatorElement.call( this, Object.assign( {
4401 $indicator: this.$element
4403 OO.ui.mixin.TitledElement.call( this, Object.assign( {
4404 $titled: this.$element
4406 OO.ui.mixin.LabelElement.call( this, Object.assign( {
4407 $label: this.$element,
4408 invisibleLabel: true
4412 this.$element.addClass( 'oo-ui-indicatorWidget' );
4413 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4414 // nested in other widgets, because this widget used to not mix in LabelElement.
4415 this.$element.removeClass( 'oo-ui-labelElement-label' );
4420 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4421 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4422 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4423 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4425 /* Static Properties */
4431 OO.ui.IndicatorWidget.static.tagName = 'span';
4434 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4435 * be configured with a `label` option that is set to a string, a label node, or a function:
4437 * - String: a plaintext string
4438 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4439 * label that includes a link or special styling, such as a gray color or additional
4440 * graphical elements.
4441 * - Function: a function that will produce a string in the future. Functions are used
4442 * in cases where the value of the label is not currently defined.
4444 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4445 * which will come into focus when the label is clicked.
4448 * // Two LabelWidgets.
4449 * const label1 = new OO.ui.LabelWidget( {
4450 * label: 'plaintext label'
4452 * label2 = new OO.ui.LabelWidget( {
4453 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4455 * // Create a fieldset layout with fields for each example.
4456 * fieldset = new OO.ui.FieldsetLayout();
4457 * fieldset.addItems( [
4458 * new OO.ui.FieldLayout( label1 ),
4459 * new OO.ui.FieldLayout( label2 )
4461 * $( document.body ).append( fieldset.$element );
4464 * @extends OO.ui.Widget
4465 * @mixes OO.ui.mixin.LabelElement
4466 * @mixes OO.ui.mixin.TitledElement
4469 * @param {Object} [config] Configuration options
4470 * @param {OO.ui.InputWidget} [config.input] {@link OO.ui.InputWidget Input widget} that uses the label.
4471 * Clicking the label will focus the specified input field.
4473 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4474 // Configuration initialization
4475 config = config || {};
4477 // Parent constructor
4478 OO.ui.LabelWidget.super.call( this, config );
4480 // Mixin constructors
4481 OO.ui.mixin.LabelElement.call( this, Object.assign( {
4482 $label: this.$element
4484 OO.ui.mixin.TitledElement.call( this, config );
4487 this.input = config.input;
4491 if ( this.input.getInputId() ) {
4492 this.$element.attr( 'for', this.input.getInputId() );
4494 this.$label.on( 'click', () => {
4495 this.input.simulateLabelClick();
4499 this.$element.addClass( 'oo-ui-labelWidget' );
4504 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4505 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4506 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4508 /* Static Properties */
4514 OO.ui.LabelWidget.static.tagName = 'label';
4517 * MessageWidget produces a visual component for sending a notice to the user
4518 * with an icon and distinct design noting its purpose. The MessageWidget changes
4519 * its visual presentation based on the type chosen, which also denotes its UX
4523 * @extends OO.ui.Widget
4524 * @mixes OO.ui.mixin.IconElement
4525 * @mixes OO.ui.mixin.LabelElement
4526 * @mixes OO.ui.mixin.TitledElement
4527 * @mixes OO.ui.mixin.FlaggedElement
4530 * @param {Object} [config] Configuration options
4531 * @param {string} [config.type='notice'] The type of the notice widget. This will also
4532 * impact the flags that the widget receives (and hence its CSS design) as well
4533 * as the icon that appears. Available types:
4534 * 'notice', 'error', 'warning', 'success'
4535 * @param {boolean} [config.inline=false] Set the notice as an inline notice. The default
4536 * is not inline, or 'boxed' style.
4537 * @param {boolean} [config.showClose] Show a close button. Can't be used with inline.
4539 OO.ui.MessageWidget = function OoUiMessageWidget( config ) {
4540 // Configuration initialization
4541 config = config || {};
4543 // Parent constructor
4544 OO.ui.MessageWidget.super.call( this, config );
4546 // Mixin constructors
4547 OO.ui.mixin.IconElement.call( this, config );
4548 OO.ui.mixin.LabelElement.call( this, config );
4549 OO.ui.mixin.TitledElement.call( this, config );
4550 OO.ui.mixin.FlaggedElement.call( this, config );
4553 this.setType( config.type );
4554 this.setInline( config.inline );
4556 // If an icon is passed in, set it again as setType will
4557 // have overridden the setIcon call in the IconElement constructor
4558 if ( config.icon ) {
4559 this.setIcon( config.icon );
4562 if ( !this.inline && config.showClose ) {
4563 this.closeButton = new OO.ui.ButtonWidget( {
4564 classes: [ 'oo-ui-messageWidget-close' ],
4567 label: OO.ui.msg( 'ooui-popup-widget-close-button-aria-label' ),
4568 invisibleLabel: true
4570 this.closeButton.connect( this, {
4571 click: 'onCloseButtonClick'
4573 this.$element.addClass( 'oo-ui-messageWidget-showClose' );
4578 .append( this.$icon, this.$label, this.closeButton && this.closeButton.$element )
4579 .addClass( 'oo-ui-messageWidget' );
4584 OO.inheritClass( OO.ui.MessageWidget, OO.ui.Widget );
4585 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.IconElement );
4586 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.LabelElement );
4587 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.TitledElement );
4588 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.FlaggedElement );
4593 * @event OO.ui.MessageWidget#close
4596 /* Static Properties */
4599 * An object defining the icon name per defined type.
4602 * @property {Object}
4604 OO.ui.MessageWidget.static.iconMap = {
4605 notice: 'infoFilled',
4614 * Set the inline state of the widget.
4616 * @param {boolean} [inline=false] Widget is inline
4618 OO.ui.MessageWidget.prototype.setInline = function ( inline ) {
4621 if ( this.inline !== inline ) {
4622 this.inline = inline;
4624 .toggleClass( 'oo-ui-messageWidget-block', !this.inline );
4628 * Set the widget type. The given type must belong to the list of
4629 * legal types set by OO.ui.MessageWidget.static.iconMap
4631 * @param {string} [type='notice']
4633 OO.ui.MessageWidget.prototype.setType = function ( type ) {
4634 if ( !this.constructor.static.iconMap[ type ] ) {
4638 if ( this.type !== type ) {
4641 this.setFlags( type );
4643 // Set the icon and its variant
4644 this.setIcon( this.constructor.static.iconMap[ type ] );
4645 this.$icon.removeClass( 'oo-ui-image-' + this.type );
4646 this.$icon.addClass( 'oo-ui-image-' + type );
4648 if ( type === 'error' ) {
4649 this.$element.attr( 'role', 'alert' );
4650 this.$element.removeAttr( 'aria-live' );
4652 this.$element.removeAttr( 'role' );
4653 this.$element.attr( 'aria-live', 'polite' );
4661 * Handle click events on the close button
4663 * @param {jQuery} e jQuery event
4664 * @fires OO.ui.MessageWidget#close
4666 OO.ui.MessageWidget.prototype.onCloseButtonClick = function () {
4667 this.toggle( false );
4668 this.emit( 'close' );
4672 * ToggleWidget implements basic behavior of widgets with an on/off state.
4673 * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
4677 * @extends OO.ui.Widget
4678 * @mixes OO.ui.mixin.TitledElement
4681 * @param {Object} [config] Configuration options
4682 * @param {boolean} [config.value=false] The toggle’s initial on/off state.
4683 * By default, the toggle is in the 'off' state.
4685 OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
4686 // Configuration initialization
4687 config = config || {};
4689 // Parent constructor
4690 OO.ui.ToggleWidget.super.call( this, config );
4692 // Mixin constructor
4693 OO.ui.mixin.TitledElement.call( this, config );
4699 this.$element.addClass( 'oo-ui-toggleWidget' );
4700 this.setValue( !!config.value );
4705 OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
4706 OO.mixinClass( OO.ui.ToggleWidget, OO.ui.mixin.TitledElement );
4711 * A change event is emitted when the on/off state of the toggle changes.
4713 * @event OO.ui.ToggleWidget#change
4714 * @param {boolean} value Value representing the new state of the toggle
4720 * Get the value representing the toggle’s state.
4722 * @return {boolean} The on/off state of the toggle
4724 OO.ui.ToggleWidget.prototype.getValue = function () {
4729 * Set the state of the toggle: `true` for 'on', `false` for 'off'.
4731 * @param {boolean} [value=false] The state of the toggle
4732 * @fires OO.ui.ToggleWidget#change
4734 * @return {OO.ui.Widget} The widget, for chaining
4736 OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
4738 if ( this.value !== value ) {
4740 this.emit( 'change', value );
4741 this.$element.toggleClass( 'oo-ui-toggleWidget-on', value );
4742 this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value );
4748 * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
4749 * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
4750 * visually by a slider in the leftmost position.
4753 * // Toggle switches in the 'off' and 'on' position.
4754 * const toggleSwitch1 = new OO.ui.ToggleSwitchWidget(),
4755 * toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
4758 * // Create a FieldsetLayout to layout and label switches.
4759 * fieldset = new OO.ui.FieldsetLayout( {
4760 * label: 'Toggle switches'
4762 * fieldset.addItems( [
4763 * new OO.ui.FieldLayout( toggleSwitch1, {
4767 * new OO.ui.FieldLayout( toggleSwitch2, {
4772 * $( document.body ).append( fieldset.$element );
4775 * @extends OO.ui.ToggleWidget
4776 * @mixes OO.ui.mixin.TabIndexedElement
4779 * @param {Object} [config] Configuration options
4780 * @param {boolean} [config.value=false] The toggle switch’s initial on/off state.
4781 * By default, the toggle switch is in the 'off' position.
4783 OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
4784 // Parent constructor
4785 OO.ui.ToggleSwitchWidget.super.call( this, config );
4787 // Mixin constructors
4788 OO.ui.mixin.TabIndexedElement.call( this, config );
4791 this.dragging = false;
4792 this.dragStart = null;
4793 this.sliding = false;
4794 this.$glow = $( '<span>' );
4795 this.$grip = $( '<span>' );
4799 click: this.onClick.bind( this ),
4800 keypress: this.onKeyPress.bind( this )
4804 this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
4805 this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
4807 .addClass( 'oo-ui-toggleSwitchWidget' )
4808 .attr( 'role', 'switch' )
4809 .append( this.$glow, this.$grip );
4814 OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
4815 OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
4820 * Handle mouse click events.
4823 * @param {jQuery.Event} e Mouse click event
4824 * @return {undefined|boolean} False to prevent default if event is handled
4826 OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
4827 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
4828 this.setValue( !this.value );
4834 * Handle key press events.
4837 * @param {jQuery.Event} e Key press event
4838 * @return {undefined|boolean} False to prevent default if event is handled
4840 OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
4841 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
4842 this.setValue( !this.value );
4850 OO.ui.ToggleSwitchWidget.prototype.setValue = function ( value ) {
4851 OO.ui.ToggleSwitchWidget.super.prototype.setValue.call( this, value );
4852 this.$element.attr( 'aria-checked', this.value.toString() );
4859 OO.ui.ToggleSwitchWidget.prototype.simulateLabelClick = function () {
4860 if ( !this.isDisabled() ) {
4861 this.setValue( !this.value );
4867 * PendingElement is a mixin that is used to create elements that notify users that something is
4868 * happening and that they should wait before proceeding. The pending state is visually represented
4869 * with a pending texture that appears in the head of a pending
4870 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4871 * {@link OO.ui.TextInputWidget text input widget}.
4873 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4874 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4875 * not currently supported for action widgets used in process dialogs.
4878 * function MessageDialog( config ) {
4879 * MessageDialog.super.call( this, config );
4881 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4883 * MessageDialog.static.name = 'myMessageDialog';
4884 * MessageDialog.static.actions = [
4885 * { action: 'save', label: 'Done', flags: 'primary' },
4886 * { label: 'Cancel', flags: 'safe' }
4889 * MessageDialog.prototype.initialize = function () {
4890 * MessageDialog.super.prototype.initialize.apply( this, arguments );
4891 * this.content = new OO.ui.PanelLayout( { padded: true } );
4892 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4893 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4894 * 'process dialogs.</p>' );
4895 * this.$body.append( this.content.$element );
4897 * MessageDialog.prototype.getBodyHeight = function () {
4900 * MessageDialog.prototype.getActionProcess = function ( action ) {
4901 * if ( action === 'save' ) {
4902 * this.getActions().get({actions: 'save'})[0].pushPending();
4903 * return new OO.ui.Process()
4906 * this.getActions().get({actions: 'save'})[0].popPending();
4909 * return MessageDialog.super.prototype.getActionProcess.call( this, action );
4912 * const windowManager = new OO.ui.WindowManager();
4913 * $( document.body ).append( windowManager.$element );
4915 * const dialog = new MessageDialog();
4916 * windowManager.addWindows( [ dialog ] );
4917 * windowManager.openWindow( dialog );
4923 * @param {Object} [config] Configuration options
4924 * @param {jQuery} [config.$pending] Element to mark as pending, defaults to this.$element
4926 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4927 // Configuration initialization
4928 config = config || {};
4932 this.$pending = null;
4935 this.setPendingElement( config.$pending || this.$element );
4940 OO.initClass( OO.ui.mixin.PendingElement );
4945 * Set the pending element (and clean up any existing one).
4947 * @param {jQuery} $pending The element to set to pending.
4949 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4950 if ( this.$pending ) {
4951 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4954 this.$pending = $pending;
4955 if ( this.pending > 0 ) {
4956 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4961 * Check if an element is pending.
4963 * @return {boolean} Element is pending
4965 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4966 return !!this.pending;
4970 * Increase the pending counter. The pending state will remain active until the counter is zero
4971 * (i.e., the number of calls to #pushPending and #popPending is the same).
4974 * @return {OO.ui.Element} The element, for chaining
4976 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4977 if ( this.pending === 0 ) {
4978 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4979 this.updateThemeClasses();
4987 * Decrease the pending counter. The pending state will remain active until the counter is zero
4988 * (i.e., the number of calls to #pushPending and #popPending is the same).
4991 * @return {OO.ui.Element} The element, for chaining
4993 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4994 if ( this.pending === 1 ) {
4995 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4996 this.updateThemeClasses();
4998 this.pending = Math.max( 0, this.pending - 1 );
5004 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
5005 * in the document (for example, in an OO.ui.Window's $overlay).
5007 * The elements's position is automatically calculated and maintained when window is resized or the
5008 * page is scrolled. If you reposition the container manually, you have to call #position to make
5009 * sure the element is still placed correctly.
5011 * As positioning is only possible when both the element and the container are attached to the DOM
5012 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
5013 * the #toggle method to display a floating popup, for example.
5019 * @param {Object} [config] Configuration options
5020 * @param {jQuery} [config.$floatable] Node to position, assigned to #$floatable, omit to use #$element
5021 * @param {jQuery} [config.$floatableContainer] Node to position adjacent to
5022 * @param {string} [config.verticalPosition='below'] Where to position $floatable vertically:
5023 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
5024 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
5025 * 'top': Align the top edge with $floatableContainer's top edge
5026 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
5027 * 'center': Vertically align the center with $floatableContainer's center
5028 * @param {string} [config.horizontalPosition='start'] Where to position $floatable horizontally:
5029 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
5030 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
5031 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
5032 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
5033 * 'center': Horizontally align the center with $floatableContainer's center
5034 * @param {boolean} [config.hideWhenOutOfView=true] Whether to hide the floatable element if the
5035 * container is out of view
5036 * @param {number} [config.spacing=0] Spacing from $floatableContainer, when $floatable is
5037 * positioned outside the container (i.e. below/above/before/after).
5039 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
5040 // Configuration initialization
5041 config = config || {};
5044 this.$floatable = null;
5045 this.$floatableContainer = null;
5046 this.$floatableWindow = null;
5047 this.$floatableClosestScrollable = null;
5048 this.floatableOutOfView = false;
5049 this.onFloatableScrollHandler = this.position.bind( this );
5050 this.onFloatableWindowResizeHandler = this.position.bind( this );
5053 this.setFloatableContainer( config.$floatableContainer );
5054 this.setFloatableElement( config.$floatable || this.$element );
5055 this.setVerticalPosition( config.verticalPosition || 'below' );
5056 this.setHorizontalPosition( config.horizontalPosition || 'start' );
5057 this.spacing = config.spacing || 0;
5058 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
5059 true : !!config.hideWhenOutOfView;
5065 * Set floatable element.
5067 * If an element is already set, it will be cleaned up before setting up the new element.
5069 * @param {jQuery} $floatable Element to make floatable
5071 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
5072 if ( this.$floatable ) {
5073 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
5074 this.$floatable.css( { top: '', left: '', bottom: '', right: '' } );
5077 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
5082 * Set floatable container.
5084 * The element will be positioned relative to the specified container.
5086 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
5088 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
5089 this.$floatableContainer = $floatableContainer;
5090 if ( this.$floatable ) {
5096 * Change how the element is positioned vertically.
5098 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
5100 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
5101 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
5102 throw new Error( 'Invalid value for vertical position: ' + position );
5104 if ( this.verticalPosition !== position ) {
5105 this.verticalPosition = position;
5106 if ( this.$floatable ) {
5113 * Change how the element is positioned horizontally.
5115 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
5117 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
5118 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
5119 throw new Error( 'Invalid value for horizontal position: ' + position );
5121 if ( this.horizontalPosition !== position ) {
5122 this.horizontalPosition = position;
5123 if ( this.$floatable ) {
5130 * Toggle positioning.
5132 * Do not turn positioning on until after the element is attached to the DOM and visible.
5134 * @param {boolean} [positioning] Enable positioning, omit to toggle
5136 * @return {OO.ui.Element} The element, for chaining
5138 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
5139 if ( !this.$floatable || !this.$floatableContainer ) {
5143 positioning = positioning === undefined ? !this.positioning : !!positioning;
5145 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
5146 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
5147 this.warnedUnattached = true;
5150 if ( this.positioning !== positioning ) {
5151 this.positioning = positioning;
5153 let closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
5154 this.$floatableContainer[ 0 ]
5156 // If the scrollable is the root, we have to listen to scroll events
5157 // on the window because of browser inconsistencies.
5158 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
5159 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
5160 closestScrollableOfContainer
5164 if ( positioning ) {
5165 this.$floatableWindow = $( this.getElementWindow() );
5166 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
5168 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
5169 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
5171 // Initial position after visible
5174 if ( this.$floatableWindow ) {
5175 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
5176 this.$floatableWindow = null;
5179 if ( this.$floatableClosestScrollable ) {
5180 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
5181 this.$floatableClosestScrollable = null;
5184 this.$floatable.css( { top: '', left: '', bottom: '', right: '' } );
5192 * Check whether the bottom edge of the given element is within the viewport of the given
5196 * @param {jQuery} $element
5197 * @param {jQuery} $container
5200 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
5201 const direction = $element.css( 'direction' );
5203 const elemRect = $element[ 0 ].getBoundingClientRect();
5205 if ( $container[ 0 ] === window ) {
5206 const viewportSpacing = OO.ui.getViewportSpacing();
5210 right: document.documentElement.clientWidth,
5211 bottom: document.documentElement.clientHeight
5213 contRect.top += viewportSpacing.top;
5214 contRect.left += viewportSpacing.left;
5215 contRect.right -= viewportSpacing.right;
5216 contRect.bottom -= viewportSpacing.bottom;
5218 contRect = $container[ 0 ].getBoundingClientRect();
5221 const topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
5222 const bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
5223 const leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
5224 const rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
5225 let startEdgeInBounds, endEdgeInBounds;
5226 if ( direction === 'rtl' ) {
5227 startEdgeInBounds = rightEdgeInBounds;
5228 endEdgeInBounds = leftEdgeInBounds;
5230 startEdgeInBounds = leftEdgeInBounds;
5231 endEdgeInBounds = rightEdgeInBounds;
5234 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
5237 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
5240 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
5243 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
5247 // The other positioning values are all about being inside the container,
5248 // so in those cases all we care about is that any part of the container is visible.
5249 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
5250 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
5254 * Check if the floatable is hidden to the user because it was offscreen.
5256 * @return {boolean} Floatable is out of view
5258 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
5259 return this.floatableOutOfView;
5263 * Position the floatable below its container.
5265 * This should only be done when both of them are attached to the DOM and visible.
5268 * @return {OO.ui.Element} The element, for chaining
5270 OO.ui.mixin.FloatableElement.prototype.position = function () {
5271 if ( !this.positioning ) {
5276 // To continue, some things need to be true:
5277 // The element must actually be in the DOM
5278 this.isElementAttached() && (
5279 // The closest scrollable is the current window
5280 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
5281 // OR is an element in the element's DOM
5282 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
5285 // Abort early if important parts of the widget are no longer attached to the DOM
5289 this.floatableOutOfView = this.hideWhenOutOfView &&
5290 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
5291 this.$floatable.toggleClass( 'oo-ui-element-hidden', this.floatableOutOfView );
5292 if ( this.floatableOutOfView ) {
5296 this.$floatable.css( this.computePosition() );
5298 // We updated the position, so re-evaluate the clipping state.
5299 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
5300 // will not notice the need to update itself.)
5301 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
5302 // Why does it not listen to the right events in the right places?
5311 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
5312 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
5313 * but may be overridden by subclasses if they want to change or add to the positioning logic.
5315 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
5317 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
5318 const newPos = { top: '', left: '', bottom: '', right: '' };
5319 const direction = this.$floatableContainer.css( 'direction' );
5321 let $offsetParent = this.$floatable.offsetParent();
5323 if ( $offsetParent.is( 'html' ) ) {
5324 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
5325 // <html> element, but they do work on the <body>
5326 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
5328 const isBody = $offsetParent.is( 'body' );
5329 const scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
5330 $offsetParent.css( 'overflow-x' ) === 'auto';
5331 const scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
5332 $offsetParent.css( 'overflow-y' ) === 'auto';
5334 const vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
5335 const horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
5336 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
5337 // is the body, or if it isn't scrollable
5338 const scrollTop = scrollableY && !isBody ?
5339 $offsetParent.scrollTop() : 0;
5340 const scrollLeft = scrollableX && !isBody ?
5341 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
5343 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
5344 // if the <body> has a margin
5345 const containerPos = isBody ?
5346 this.$floatableContainer.offset() :
5347 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
5348 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
5349 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
5350 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
5351 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
5353 if ( this.verticalPosition === 'below' ) {
5354 newPos.top = containerPos.bottom + this.spacing;
5355 } else if ( this.verticalPosition === 'above' ) {
5356 newPos.bottom = $offsetParent.outerHeight() - containerPos.top + this.spacing;
5357 } else if ( this.verticalPosition === 'top' ) {
5358 newPos.top = containerPos.top;
5359 } else if ( this.verticalPosition === 'bottom' ) {
5360 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
5361 } else if ( this.verticalPosition === 'center' ) {
5362 newPos.top = containerPos.top +
5363 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
5366 if ( this.horizontalPosition === 'before' ) {
5367 newPos.end = containerPos.start - this.spacing;
5368 } else if ( this.horizontalPosition === 'after' ) {
5369 newPos.start = containerPos.end + this.spacing;
5370 } else if ( this.horizontalPosition === 'start' ) {
5371 newPos.start = containerPos.start;
5372 } else if ( this.horizontalPosition === 'end' ) {
5373 newPos.end = containerPos.end;
5374 } else if ( this.horizontalPosition === 'center' ) {
5375 newPos.left = containerPos.left +
5376 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
5379 if ( newPos.start !== undefined ) {
5380 if ( direction === 'rtl' ) {
5381 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
5382 $offsetParent ).outerWidth() - newPos.start;
5384 newPos.left = newPos.start;
5386 delete newPos.start;
5388 if ( newPos.end !== undefined ) {
5389 if ( direction === 'rtl' ) {
5390 newPos.left = newPos.end;
5392 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
5393 $offsetParent ).outerWidth() - newPos.end;
5398 // Account for scroll position
5399 if ( newPos.top !== '' ) {
5400 newPos.top += scrollTop;
5402 if ( newPos.bottom !== '' ) {
5403 newPos.bottom -= scrollTop;
5405 if ( newPos.left !== '' ) {
5406 newPos.left += scrollLeft;
5408 if ( newPos.right !== '' ) {
5409 newPos.right -= scrollLeft;
5412 // Account for scrollbar gutter
5413 if ( newPos.bottom !== '' ) {
5414 newPos.bottom -= horizScrollbarHeight;
5416 if ( direction === 'rtl' ) {
5417 if ( newPos.left !== '' ) {
5418 newPos.left -= vertScrollbarWidth;
5421 if ( newPos.right !== '' ) {
5422 newPos.right -= vertScrollbarWidth;
5430 * Element that can be automatically clipped to visible boundaries.
5432 * Whenever the element's natural height changes, you have to call
5433 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
5434 * clipping correctly.
5436 * The dimensions of #$clippableContainer will be compared to the boundaries of the
5437 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
5438 * then #$clippable will be given a fixed reduced height and/or width and will be made
5439 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
5440 * but you can build a static footer by setting #$clippableContainer to an element that contains
5441 * #$clippable and the footer.
5447 * @param {Object} [config] Configuration options
5448 * @param {jQuery} [config.$clippable] Node to clip, assigned to #$clippable, omit to use #$element
5449 * @param {jQuery} [config.$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
5450 * omit to use #$clippable
5452 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
5453 // Configuration initialization
5454 config = config || {};
5457 this.$clippable = null;
5458 this.$clippableContainer = null;
5459 this.clipping = false;
5460 this.clippedHorizontally = false;
5461 this.clippedVertically = false;
5462 this.$clippableScrollableContainer = null;
5463 this.$clippableScroller = null;
5464 this.$clippableWindow = null;
5465 this.idealWidth = null;
5466 this.idealHeight = null;
5467 this.onClippableScrollHandler = this.clip.bind( this );
5468 this.onClippableWindowResizeHandler = this.clip.bind( this );
5471 if ( config.$clippableContainer ) {
5472 this.setClippableContainer( config.$clippableContainer );
5474 this.setClippableElement( config.$clippable || this.$element );
5480 * Set clippable element.
5482 * If an element is already set, it will be cleaned up before setting up the new element.
5484 * @param {jQuery} $clippable Element to make clippable
5486 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
5487 if ( this.$clippable ) {
5488 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
5489 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
5490 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5493 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
5498 * Set clippable container.
5500 * This is the container that will be measured when deciding whether to clip. When clipping,
5501 * #$clippable will be resized in order to keep the clippable container fully visible.
5503 * If the clippable container is unset, #$clippable will be used.
5505 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
5507 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
5508 this.$clippableContainer = $clippableContainer;
5509 if ( this.$clippable ) {
5517 * Do not turn clipping on until after the element is attached to the DOM and visible.
5519 * @param {boolean} [clipping] Enable clipping, omit to toggle
5521 * @return {OO.ui.Element} The element, for chaining
5523 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
5524 clipping = clipping === undefined ? !this.clipping : !!clipping;
5526 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
5527 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
5528 this.warnedUnattached = true;
5531 if ( this.clipping !== clipping ) {
5532 this.clipping = clipping;
5534 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
5535 // If the clippable container is the root, we have to listen to scroll events and check
5536 // jQuery.scrollTop on the window because of browser inconsistencies
5537 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
5538 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
5539 this.$clippableScrollableContainer;
5540 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
5541 this.$clippableWindow = $( this.getElementWindow() )
5542 .on( 'resize', this.onClippableWindowResizeHandler );
5543 // Initial clip after visible
5546 this.$clippable.css( {
5554 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5556 this.$clippableScrollableContainer = null;
5557 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
5558 this.$clippableScroller = null;
5559 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
5560 this.$clippableWindow = null;
5568 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
5570 * @return {boolean} Element will be clipped to the visible area
5572 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
5573 return this.clipping;
5577 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
5579 * @return {boolean} Part of the element is being clipped
5581 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
5582 return this.clippedHorizontally || this.clippedVertically;
5586 * Check if the right of the element is being clipped by the nearest scrollable container.
5588 * @return {boolean} Part of the element is being clipped
5590 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
5591 return this.clippedHorizontally;
5595 * Check if the bottom of the element is being clipped by the nearest scrollable container.
5597 * @return {boolean} Part of the element is being clipped
5599 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
5600 return this.clippedVertically;
5604 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
5606 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
5607 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
5609 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
5610 this.idealWidth = width;
5611 this.idealHeight = height;
5613 if ( !this.clipping ) {
5614 // Update dimensions
5615 this.$clippable.css( { width: width, height: height } );
5617 // While clipping, idealWidth and idealHeight are not considered
5621 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5622 * ClippableElement will clip the opposite side when reducing element's width.
5624 * Classes that mix in ClippableElement should override this to return 'right' if their
5625 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5626 * If your class also mixes in FloatableElement, this is handled automatically.
5628 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5629 * always in pixels, even if they were unset or set to 'auto'.)
5631 * When in doubt, 'left' (or 'right' in RTL) is a reasonable fallback.
5633 * @return {string} 'left' or 'right'
5635 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5636 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5643 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5644 * ClippableElement will clip the opposite side when reducing element's width.
5646 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5647 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5648 * If your class also mixes in FloatableElement, this is handled automatically.
5650 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5651 * always in pixels, even if they were unset or set to 'auto'.)
5653 * When in doubt, 'top' is a reasonable fallback.
5655 * @return {string} 'top' or 'bottom'
5657 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5658 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5665 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5666 * when the element's natural height changes.
5668 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5669 * overlapped by, the visible area of the nearest scrollable container.
5671 * Because calling clip() when the natural height changes isn't always possible, we also set
5672 * max-height when the element isn't being clipped. This means that if the element tries to grow
5673 * beyond the edge, something reasonable will happen before clip() is called.
5676 * @return {OO.ui.Element} The element, for chaining
5678 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5679 if ( !this.clipping ) {
5680 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5685 function rectCopy( rect ) {
5690 bottom: rect.bottom,
5698 function rectIntersection( a, b ) {
5700 out.top = Math.max( a.top, b.top );
5701 out.left = Math.max( a.left, b.left );
5702 out.bottom = Math.min( a.bottom, b.bottom );
5703 out.right = Math.min( a.right, b.right );
5707 const viewportSpacing = OO.ui.getViewportSpacing();
5709 let $viewport, viewportRect;
5710 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5711 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5712 // Dimensions of the browser window, rather than the element!
5716 right: document.documentElement.clientWidth,
5717 bottom: document.documentElement.clientHeight
5719 viewportRect.top += viewportSpacing.top;
5720 viewportRect.left += viewportSpacing.left;
5721 viewportRect.right -= viewportSpacing.right;
5722 viewportRect.bottom -= viewportSpacing.bottom;
5724 $viewport = this.$clippableScrollableContainer;
5725 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5726 // Convert into a plain object
5727 viewportRect = rectCopy( viewportRect );
5730 // Account for scrollbar gutter
5731 const direction = $viewport.css( 'direction' );
5732 const vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5733 const horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5734 viewportRect.bottom -= horizScrollbarHeight;
5735 if ( direction === 'rtl' ) {
5736 viewportRect.left += vertScrollbarWidth;
5738 viewportRect.right -= vertScrollbarWidth;
5741 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5742 // by one or two pixels. (And also so that we have space to display drop shadows.)
5743 // Chosen by fair dice roll.
5745 viewportRect.top += buffer;
5746 viewportRect.left += buffer;
5747 viewportRect.right -= buffer;
5748 viewportRect.bottom -= buffer;
5750 const $item = this.$clippableContainer || this.$clippable;
5752 const extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5753 const extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5755 let itemRect = $item[ 0 ].getBoundingClientRect();
5756 // Convert into a plain object
5757 itemRect = rectCopy( itemRect );
5759 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5760 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5761 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5762 itemRect.left = viewportRect.left;
5764 itemRect.right = viewportRect.right;
5766 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5767 itemRect.top = viewportRect.top;
5769 itemRect.bottom = viewportRect.bottom;
5772 const availableRect = rectIntersection( viewportRect, itemRect );
5774 let desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5775 let desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5776 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5777 desiredWidth = Math.min( desiredWidth,
5778 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5779 desiredHeight = Math.min( desiredHeight,
5780 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5781 const allotedWidth = Math.ceil( desiredWidth - extraWidth );
5782 const allotedHeight = Math.ceil( desiredHeight - extraHeight );
5783 const naturalWidth = this.$clippable.prop( 'scrollWidth' );
5784 const naturalHeight = this.$clippable.prop( 'scrollHeight' );
5785 const clipWidth = allotedWidth < naturalWidth;
5786 const clipHeight = allotedHeight < naturalHeight;
5789 // The hacks below are no longer needed for Firefox and Chrome after T349034,
5790 // but may still be needed for Safari. TODO: Test and maybe remove them.
5792 // Set overflow to 'scroll' first to avoid browser bugs causing bogus scrollbars (T67059),
5793 // then to 'auto' which is what we want.
5794 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5796 this.$clippable.css( 'overflowX', 'scroll' );
5797 // eslint-disable-next-line no-unused-expressions
5798 this.$clippable[ 0 ].offsetHeight; // Force reflow
5799 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5801 this.$clippable.css( 'overflowX', 'auto' );
5802 // eslint-disable-next-line no-unused-expressions
5803 this.$clippable[ 0 ].offsetHeight; // Force reflow
5804 this.$clippable.css( {
5805 width: Math.max( 0, allotedWidth ),
5809 this.$clippable.css( {
5811 width: this.idealWidth || '',
5812 maxWidth: Math.max( 0, allotedWidth )
5816 // The hacks below are no longer needed for Firefox and Chrome after T349034,
5817 // but may still be needed for Safari. TODO: Test and maybe remove them.
5819 // Set overflow to 'scroll' first to avoid browser bugs causing bogus scrollbars (T67059),
5820 // then to 'auto' which is what we want.
5821 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5823 this.$clippable.css( 'overflowY', 'scroll' );
5824 // eslint-disable-next-line no-unused-expressions
5825 this.$clippable[ 0 ].offsetHeight; // Force reflow
5826 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5828 this.$clippable.css( 'overflowY', 'auto' );
5829 // eslint-disable-next-line no-unused-expressions
5830 this.$clippable[ 0 ].offsetHeight; // Force reflow
5831 this.$clippable.css( {
5832 height: Math.max( 0, allotedHeight ),
5836 this.$clippable.css( {
5838 height: this.idealHeight || '',
5839 maxHeight: Math.max( 0, allotedHeight )
5843 // If we stopped clipping in at least one of the dimensions
5844 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5845 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5848 this.clippedHorizontally = clipWidth;
5849 this.clippedVertically = clipHeight;
5855 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5856 * By default, each popup has an anchor that points toward its origin.
5857 * Please see the [OOUI documentation on MediaWiki.org][1] for more information and examples.
5859 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5861 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5865 * const popup = new OO.ui.PopupWidget( {
5866 * $content: $( '<p>Hi there!</p>' ),
5871 * $( document.body ).append( popup.$element );
5872 * // To display the popup, toggle the visibility to 'true'.
5873 * popup.toggle( true );
5876 * @extends OO.ui.Widget
5877 * @mixes OO.ui.mixin.IconElement
5878 * @mixes OO.ui.mixin.LabelElement
5879 * @mixes OO.ui.mixin.ClippableElement
5880 * @mixes OO.ui.mixin.FloatableElement
5883 * @param {Object} [config] Configuration options
5884 * @param {number|null} [config.width=320] Width of popup in pixels. Pass `null` to use automatic width.
5885 * @param {number|null} [config.height=null] Height of popup in pixels. Pass `null` to use automatic height.
5886 * @param {boolean} [config.anchor=true] Show anchor pointing to origin of popup
5887 * @param {string} [config.position='below'] Where to position the popup relative to $floatableContainer
5888 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5889 * of $floatableContainer
5890 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5891 * of $floatableContainer
5892 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5893 * endwards (right/left) to the vertical center of $floatableContainer
5894 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5895 * startwards (left/right) to the vertical center of $floatableContainer
5896 * @param {string} [config.align='center'] How to align the popup to $floatableContainer
5897 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5898 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5899 * move the popup as far downwards as possible.
5900 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5901 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5902 * move the popup as far upwards as possible.
5903 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5904 * center of the popup with the center of $floatableContainer.
5905 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5906 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5907 * @param {boolean} [config.autoFlip=true] Whether to automatically switch the popup's position between
5908 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5909 * desired direction to display the popup without clipping
5910 * @param {jQuery} [config.$container] Constrain the popup to the boundaries of the specified container.
5911 * See the [OOUI docs on MediaWiki][3] for an example.
5912 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5913 * @param {number} [config.containerPadding=10] Padding between the popup and its container, specified as a
5915 * @param {jQuery} [config.$content] Content to append to the popup's body
5916 * @param {jQuery} [config.$footer] Content to append to the popup's footer
5917 * @param {boolean} [config.autoClose=false] Automatically close the popup when it loses focus.
5918 * @param {jQuery} [config.$autoCloseIgnore] Elements that will not close the popup when clicked.
5919 * This config option is only relevant if #autoClose is set to `true`. See the
5920 * [OOUI documentation on MediaWiki][2] for an example.
5921 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5922 * @param {boolean} [config.head=false] Show a popup header that contains a #label (if specified) and close
5924 * @param {boolean} [config.hideCloseButton=false]
5925 * @param {boolean} [config.padded=false] Add padding to the popup's body
5927 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5928 // Configuration initialization
5929 config = config || {};
5931 // Parent constructor
5932 OO.ui.PopupWidget.super.call( this, config );
5934 // Properties (must be set before ClippableElement constructor call)
5935 this.$body = $( '<div>' );
5936 this.$popup = $( '<div>' );
5938 // Mixin constructors
5939 OO.ui.mixin.IconElement.call( this, config );
5940 OO.ui.mixin.LabelElement.call( this, config );
5941 OO.ui.mixin.ClippableElement.call( this, Object.assign( {
5942 $clippable: this.$body,
5943 $clippableContainer: this.$popup
5945 OO.ui.mixin.FloatableElement.call( this, config );
5948 this.$anchor = $( '<div>' );
5949 // If undefined, will be computed lazily in computePosition()
5950 this.$container = config.$container;
5951 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5952 this.autoClose = !!config.autoClose;
5953 this.transitionTimeout = null;
5954 this.anchored = false;
5955 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5956 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5957 this.onTabKeyDownHandler = this.onTabKeyDown.bind( this );
5958 this.onShiftTabKeyDownHandler = this.onShiftTabKeyDown.bind( this );
5961 this.setSize( config.width, config.height );
5962 this.toggleAnchor( config.anchor === undefined || config.anchor );
5963 this.setAlignment( config.align || 'center' );
5964 this.setPosition( config.position || 'below' );
5965 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5966 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5967 this.$body.addClass( 'oo-ui-popupWidget-body' );
5968 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5970 .addClass( 'oo-ui-popupWidget-popup' )
5971 .append( this.$body );
5973 .addClass( 'oo-ui-popupWidget' )
5974 .append( this.$popup, this.$anchor );
5975 // Move content, which was added to #$element by OO.ui.Widget, to the body
5976 // FIXME This is gross, we should use '$body' or something for the config
5977 if ( config.$content instanceof $ ) {
5978 this.$body.append( config.$content );
5981 this.padded = !!config.padded;
5982 if ( config.padded ) {
5983 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5986 if ( config.head ) {
5987 if ( !config.hideCloseButton ) {
5988 this.closeButton = new OO.ui.ButtonWidget( {
5991 label: OO.ui.msg( 'ooui-popup-widget-close-button-aria-label' ),
5992 invisibleLabel: true
5994 this.closeButton.connect( this, {
5995 click: 'onCloseButtonClick'
5998 this.$head = $( '<div>' )
5999 .addClass( 'oo-ui-popupWidget-head' )
6000 .append( this.$icon, this.$label, this.closeButton && this.closeButton.$element );
6001 this.$popup.prepend( this.$head );
6004 if ( config.$footer ) {
6005 this.$footer = $( '<div>' )
6006 .addClass( 'oo-ui-popupWidget-footer' )
6007 .append( config.$footer );
6008 this.$popup.append( this.$footer );
6011 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
6012 // that reference properties not initialized at that time of parent class construction
6013 // TODO: Find a better way to handle post-constructor setup
6014 this.visible = false;
6015 this.$element.addClass( 'oo-ui-element-hidden' );
6020 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
6021 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.IconElement );
6022 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
6023 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
6024 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
6029 * The popup is ready: it is visible and has been positioned and clipped.
6031 * @event OO.ui.PopupWidget#ready
6035 * The popup is no longer visible.
6037 * @event OO.ui.PopupWidget#closing
6043 * Handles document mouse down events.
6046 * @param {MouseEvent} e Mouse down event
6048 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
6051 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
6053 this.toggle( false );
6058 * Bind document mouse down listener.
6062 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
6063 // Capture clicks outside popup
6064 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
6065 // We add 'click' event because iOS safari needs to respond to this event.
6066 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
6067 // then it will trigger when scrolling. While iOS Safari has some reported behavior
6068 // of occasionally not emitting 'click' properly, that event seems to be the standard
6069 // that it should be emitting, so we add it to this and will operate the event handler
6070 // on whichever of these events was triggered first
6071 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
6075 * Handles close button click events.
6079 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
6080 if ( this.isVisible() ) {
6081 this.toggle( false );
6086 * Unbind document mouse down listener.
6090 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
6091 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
6092 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
6096 * Handles document key down events.
6099 * @param {KeyboardEvent} e Key down event
6101 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
6103 e.which === OO.ui.Keys.ESCAPE &&
6106 this.toggle( false );
6108 e.stopPropagation();
6113 * Bind document key down listener.
6117 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
6118 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6122 * Unbind document key down listener.
6126 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
6127 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6131 * Handles Tab key down events.
6134 * @param {KeyboardEvent} e Key down event
6136 OO.ui.PopupWidget.prototype.onTabKeyDown = function ( e ) {
6137 if ( !e.shiftKey && e.which === OO.ui.Keys.TAB ) {
6139 this.toggle( false );
6144 * Handles Shift + Tab key down events.
6147 * @param {KeyboardEvent} e Key down event
6149 OO.ui.PopupWidget.prototype.onShiftTabKeyDown = function ( e ) {
6150 if ( e.shiftKey && e.which === OO.ui.Keys.TAB ) {
6152 this.toggle( false );
6157 * Show, hide, or toggle the visibility of the anchor.
6159 * @param {boolean} [show] Show anchor, omit to toggle
6161 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
6162 show = show === undefined ? !this.anchored : !!show;
6164 if ( this.anchored !== show ) {
6165 this.$element.toggleClass( 'oo-ui-popupWidget-anchored oo-ui-popupWidget-anchored-' + this.anchorEdge, show );
6166 this.anchored = show;
6171 * Change which edge the anchor appears on.
6173 * @param {string} edge 'top', 'bottom', 'start' or 'end'
6175 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
6176 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
6177 throw new Error( 'Invalid value for edge: ' + edge );
6179 if ( this.anchorEdge !== null ) {
6180 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
6182 this.anchorEdge = edge;
6183 if ( this.anchored ) {
6184 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
6189 * Check if the anchor is visible.
6191 * @return {boolean} Anchor is visible
6193 OO.ui.PopupWidget.prototype.hasAnchor = function () {
6194 return this.anchored;
6198 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
6199 * `.toggle( true )` after its #$element is attached to the DOM.
6201 * Do not show the popup while it is not attached to the DOM. The calculations required to display
6202 * it in the right place and with the right dimensions only work correctly while it is attached.
6203 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
6204 * strictly enforced, so currently it only generates a warning in the browser console.
6206 * @fires OO.ui.PopupWidget#ready
6209 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
6210 show = show === undefined ? !this.isVisible() : !!show;
6212 const change = show !== this.isVisible();
6214 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
6215 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
6216 this.warnedUnattached = true;
6218 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
6219 // Fall back to the parent node if the floatableContainer is not set
6220 this.setFloatableContainer( this.$element.parent() );
6223 if ( change && show && this.autoFlip ) {
6224 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
6225 // flip (e.g. if the user scrolled).
6226 this.isAutoFlipped = false;
6230 OO.ui.PopupWidget.super.prototype.toggle.call( this, show );
6233 this.togglePositioning( show && !!this.$floatableContainer );
6235 // Find the first and last focusable element in the popup widget
6236 const $lastFocusableElement = OO.ui.findFocusable( this.$element, true );
6237 const $firstFocusableElement = OO.ui.findFocusable( this.$element, false );
6240 if ( this.autoClose ) {
6241 this.bindDocumentMouseDownListener();
6242 this.bindDocumentKeyDownListener();
6244 // Bind a keydown event to the first and last focusable element
6245 // If user presses the tab key on this item, dismiss the popup and
6246 // take focus back to the caller, ideally the caller implements this functionality
6247 // This is to prevent illogical focus order, a common accessibility pitfall.
6248 // Alternative Fix: Implement focus trap for popup widget.
6249 $lastFocusableElement.on( 'keydown', this.onTabKeyDownHandler );
6250 $firstFocusableElement.on( 'keydown', this.onShiftTabKeyDownHandler );
6252 this.updateDimensions();
6253 this.toggleClipping( true );
6255 if ( this.autoFlip ) {
6256 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
6257 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
6258 // If opening the popup in the normal direction causes it to be clipped,
6259 // open in the opposite one instead
6260 const normalHeight = this.$element.height();
6261 this.isAutoFlipped = !this.isAutoFlipped;
6263 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
6264 // If that also causes it to be clipped, open in whichever direction
6265 // we have more space
6266 const oppositeHeight = this.$element.height();
6267 if ( oppositeHeight < normalHeight ) {
6268 this.isAutoFlipped = !this.isAutoFlipped;
6274 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
6275 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
6276 // If opening the popup in the normal direction causes it to be clipped,
6277 // open in the opposite one instead
6278 const normalWidth = this.$element.width();
6279 this.isAutoFlipped = !this.isAutoFlipped;
6280 // Due to T180173 horizontally clipped PopupWidgets have messed up
6281 // dimensions, which causes positioning to be off. Toggle clipping back and
6282 // forth to work around.
6283 this.toggleClipping( false );
6285 this.toggleClipping( true );
6286 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
6287 // If that also causes it to be clipped, open in whichever direction
6288 // we have more space
6289 const oppositeWidth = this.$element.width();
6290 if ( oppositeWidth < normalWidth ) {
6291 this.isAutoFlipped = !this.isAutoFlipped;
6292 // Due to T180173, horizontally clipped PopupWidgets have messed up
6293 // dimensions, which causes positioning to be off. Toggle clipping
6294 // back and forth to work around.
6295 this.toggleClipping( false );
6297 this.toggleClipping( true );
6304 this.emit( 'ready' );
6306 this.toggleClipping( false );
6307 if ( this.autoClose ) {
6308 // Remove binded keydown event from the first and last focusable elements
6309 // when popup closes
6310 $lastFocusableElement.off( 'keydown', this.onTabKeyDownHandler );
6311 $firstFocusableElement.off( 'keydown', this.onShiftTabKeyDownHandler );
6313 this.unbindDocumentMouseDownListener();
6314 this.unbindDocumentKeyDownListener();
6317 // This is so we can restore focus to the parent when the pop widget dismisses
6318 // Also, we're emitting an event
6319 // so we don't have to tie this implementation to the caller.
6320 // Let the caller handle this details.
6321 this.emit( 'closing' );
6329 * Set the size of the popup.
6331 * Changing the size may also change the popup's position depending on the alignment.
6333 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
6334 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
6335 * @param {boolean} [transition=false] Use a smooth transition
6338 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
6339 this.width = width !== undefined ? width : 320;
6340 this.height = height !== undefined ? height : null;
6341 if ( this.isVisible() ) {
6342 this.updateDimensions( transition );
6347 * Update the size and position.
6349 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
6350 * be called automatically.
6352 * @param {boolean} [transition=false] Use a smooth transition
6355 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
6356 // Prevent transition from being interrupted
6357 clearTimeout( this.transitionTimeout );
6359 // Enable transition
6360 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
6366 // Prevent transitioning after transition is complete
6367 this.transitionTimeout = setTimeout( () => {
6368 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
6371 // Prevent transitioning immediately
6372 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
6379 OO.ui.PopupWidget.prototype.computePosition = function () {
6380 const popupPos = {},
6381 anchorCss = { left: '', right: '', top: '', bottom: '' },
6382 popupPositionOppositeMap = {
6390 'force-left': 'backwards',
6391 'force-right': 'forwards'
6394 'force-left': 'forwards',
6395 'force-right': 'backwards'
6407 backwards: this.anchored ? 'before' : 'end'
6415 if ( !this.$container ) {
6416 // Lazy-initialize $container if not specified in constructor
6417 this.$container = $( this.getClosestScrollableElementContainer() );
6419 const direction = this.$container.css( 'direction' );
6421 // Set height and width before we do anything else, since it might cause our measurements
6422 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
6424 // The properties refer to the width of this.$popup, but we set the properties on this.$body
6425 // to make calculations work out right (T180173), so we subtract padding here.
6426 this.width !== null ? this.width - ( this.padded ? 24 : 0 ) : 'auto',
6427 this.height !== null ? this.height - ( this.padded ? 10 : 0 ) : 'auto'
6430 const align = alignMap[ direction ][ this.align ] || this.align;
6431 let popupPosition = this.popupPosition;
6432 if ( this.isAutoFlipped ) {
6433 popupPosition = popupPositionOppositeMap[ popupPosition ];
6436 // If the popup is positioned before or after, then the anchor positioning is vertical,
6437 // otherwise horizontal
6438 const vertical = popupPosition === 'before' || popupPosition === 'after';
6439 const start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
6440 const end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
6441 const near = vertical ? 'top' : 'left';
6442 const far = vertical ? 'bottom' : 'right';
6443 const sizeProp = vertical ? 'Height' : 'Width';
6444 const popupSize = vertical ? this.$popup.height() : this.$popup.width();
6446 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
6447 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
6448 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
6451 const parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
6452 // Find out which property FloatableElement used for positioning, and adjust that value
6453 const positionProp = vertical ?
6454 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
6455 ( parentPosition.left !== '' ? 'left' : 'right' );
6457 // Figure out where the near and far edges of the popup and $floatableContainer are
6458 const floatablePos = this.$floatableContainer.offset();
6459 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
6460 // Measure where the offsetParent is and compute our position based on that and parentPosition
6461 const offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
6462 { top: 0, left: 0 } :
6463 this.$element.offsetParent().offset();
6465 if ( positionProp === near ) {
6466 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
6467 popupPos[ far ] = popupPos[ near ] + popupSize;
6469 popupPos[ far ] = offsetParentPos[ near ] +
6470 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
6471 popupPos[ near ] = popupPos[ far ] - popupSize;
6474 let anchorOffset, positionAdjustment;
6475 if ( this.anchored ) {
6476 // Position the anchor (which is positioned relative to the popup) to point to
6477 // $floatableContainer
6478 const anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
6479 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
6481 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
6482 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
6483 // scrollWidth/Height
6484 const anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
6485 const anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
6486 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
6487 // Not enough space for the anchor on the start side; pull the popup startwards
6488 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
6489 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
6490 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
6491 // Not enough space for the anchor on the end side; pull the popup endwards
6492 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
6493 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
6495 positionAdjustment = 0;
6498 positionAdjustment = 0;
6501 // Check if the popup will go beyond the edge of this.$container
6502 const containerPos = this.$container[ 0 ] === document.documentElement ?
6503 { top: 0, left: 0 } :
6504 this.$container.offset();
6505 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
6506 if ( this.$container[ 0 ] === document.documentElement ) {
6507 const viewportSpacing = OO.ui.getViewportSpacing();
6508 containerPos[ near ] += viewportSpacing[ near ];
6509 containerPos[ far ] -= viewportSpacing[ far ];
6511 // Take into account how much the popup will move because of the adjustments we're going to make
6512 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
6513 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
6514 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
6515 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
6516 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
6517 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
6518 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
6519 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
6520 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
6521 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
6524 if ( this.anchored ) {
6525 // Adjust anchorOffset for positionAdjustment
6526 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
6528 // Position the anchor
6529 anchorCss[ start ] = anchorOffset;
6530 this.$anchor.css( anchorCss );
6533 // Move the popup if needed
6534 parentPosition[ positionProp ] += positionAdjustment;
6536 return parentPosition;
6540 * Set popup alignment
6542 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
6543 * `backwards` or `forwards`.
6545 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
6546 // Validate alignment
6547 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
6550 this.align = 'center';
6556 * Get popup alignment
6558 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
6559 * `backwards` or `forwards`.
6561 OO.ui.PopupWidget.prototype.getAlignment = function () {
6566 * Change the positioning of the popup.
6568 * @param {string} position 'above', 'below', 'before' or 'after'
6570 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
6571 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
6574 this.popupPosition = position;
6579 * Get popup positioning.
6581 * @return {string} 'above', 'below', 'before' or 'after'
6583 OO.ui.PopupWidget.prototype.getPosition = function () {
6584 return this.popupPosition;
6588 * Set popup auto-flipping.
6590 * @param {boolean} [autoFlip=false] Whether to automatically switch the popup's position between
6591 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
6592 * desired direction to display the popup without clipping
6594 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
6595 autoFlip = !!autoFlip;
6597 if ( this.autoFlip !== autoFlip ) {
6598 this.autoFlip = autoFlip;
6603 * Set which elements will not close the popup when clicked.
6605 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
6607 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
6609 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
6610 this.$autoCloseIgnore = $autoCloseIgnore;
6614 * Get an ID of the body element, this can be used as the
6615 * `aria-describedby` attribute for an input field.
6617 * @return {string} The ID of the body element
6619 OO.ui.PopupWidget.prototype.getBodyId = function () {
6620 let id = this.$body.attr( 'id' );
6621 if ( id === undefined ) {
6622 id = OO.ui.generateElementId();
6623 this.$body.attr( 'id', id );
6629 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
6630 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
6631 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
6632 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
6638 * @param {Object} [config] Configuration options
6639 * @param {Object} [config.popup] Configuration to pass to popup
6640 * @param {boolean} [config.popup.autoClose=true] Popup auto-closes when it loses focus
6642 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
6643 // Configuration initialization
6644 config = config || {};
6647 this.popup = new OO.ui.PopupWidget( Object.assign(
6650 $floatableContainer: this.$element
6654 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
6664 * @return {OO.ui.PopupWidget} Popup widget
6666 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
6671 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
6672 * which is used to display additional information or options.
6675 * // A PopupButtonWidget.
6676 * const popupButton = new OO.ui.PopupButtonWidget( {
6677 * label: 'Popup button with options',
6680 * $content: $( '<p>Additional options here.</p>' ),
6682 * align: 'force-left'
6685 * // Append the button to the DOM.
6686 * $( document.body ).append( popupButton.$element );
6689 * @extends OO.ui.ButtonWidget
6690 * @mixes OO.ui.mixin.PopupElement
6693 * @param {Object} [config] Configuration options
6694 * @param {jQuery} [config.$overlay] Render the popup into a separate layer. This configuration is useful
6695 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
6696 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
6697 * uses relative positioning.
6698 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6700 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6701 // Configuration initialization
6702 config = config || {};
6704 // Parent constructor
6705 OO.ui.PopupButtonWidget.super.call( this, config );
6707 // Mixin constructors
6708 OO.ui.mixin.PopupElement.call( this, config );
6711 this.$overlay = ( config.$overlay === true ?
6712 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6715 this.connect( this, {
6720 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6721 this.$button.attr( {
6722 'aria-haspopup': 'dialog',
6723 'aria-owns': this.popup.getElementId()
6726 .addClass( 'oo-ui-popupButtonWidget-popup' )
6729 'aria-describedby': this.getElementId()
6731 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6732 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6733 this.$overlay.append( this.popup.$element );
6738 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6739 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6744 * Handle the button action being triggered.
6748 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6749 this.popup.toggle();
6753 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6755 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6760 * @mixes OO.ui.mixin.GroupElement
6763 * @param {Object} [config] Configuration options
6765 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6766 // Mixin constructors
6767 OO.ui.mixin.GroupElement.call( this, config );
6772 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6777 * Set the disabled state of the widget.
6779 * This will also update the disabled state of child widgets.
6781 * @param {boolean} [disabled=false] Disable widget
6783 * @return {OO.ui.Widget} The widget, for chaining
6785 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6787 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6788 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6790 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6792 for ( let i = 0, len = this.items.length; i < len; i++ ) {
6793 this.items[ i ].updateDisabled();
6801 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6803 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6804 * This allows bidirectional communication.
6806 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6814 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6821 * Check if widget is disabled.
6823 * Checks parent if present, making disabled state inheritable.
6825 * @return {boolean} Widget is disabled
6827 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6828 return this.disabled ||
6829 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6833 * Set group element is in.
6835 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6837 * @return {OO.ui.Widget} The widget, for chaining
6839 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6841 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6842 OO.ui.Element.prototype.setElementGroup.call( this, group );
6844 // Initialize item disabled states
6845 this.updateDisabled();
6851 * OptionWidgets are special elements that can be selected and configured with data. The
6852 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6853 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6854 * and examples, please see the [OOUI documentation on MediaWiki][1].
6856 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6859 * @extends OO.ui.Widget
6860 * @mixes OO.ui.mixin.ItemWidget
6861 * @mixes OO.ui.mixin.LabelElement
6862 * @mixes OO.ui.mixin.FlaggedElement
6863 * @mixes OO.ui.mixin.AccessKeyedElement
6864 * @mixes OO.ui.mixin.TitledElement
6867 * @param {Object} [config] Configuration options
6868 * @param {boolean} [config.selected=false]
6870 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6871 // Configuration initialization
6872 config = config || {};
6874 // Parent constructor
6875 OO.ui.OptionWidget.super.call( this, config );
6877 // Mixin constructors
6878 OO.ui.mixin.ItemWidget.call( this );
6879 OO.ui.mixin.LabelElement.call( this, config );
6880 OO.ui.mixin.FlaggedElement.call( this, config );
6881 OO.ui.mixin.AccessKeyedElement.call( this, config );
6882 OO.ui.mixin.TitledElement.call( this, config );
6885 this.highlighted = false;
6886 this.pressed = false;
6887 this.setSelected( !!config.selected );
6891 .data( 'oo-ui-optionWidget', this )
6892 // Allow programmatic focussing (and by access key), but not tabbing
6897 .addClass( 'oo-ui-optionWidget' )
6898 .append( this.$label );
6903 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6904 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6905 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6906 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6907 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6908 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6910 /* Static Properties */
6913 * Whether this option can be selected. See #setSelected.
6916 * @property {boolean}
6918 OO.ui.OptionWidget.static.selectable = true;
6921 * Whether this option can be highlighted. See #setHighlighted.
6924 * @property {boolean}
6926 OO.ui.OptionWidget.static.highlightable = true;
6929 * Whether this option can be pressed. See #setPressed.
6932 * @property {boolean}
6934 OO.ui.OptionWidget.static.pressable = true;
6937 * Whether this option will be scrolled into view when it is selected.
6940 * @property {boolean}
6942 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6947 * Check if the option can be selected.
6949 * @return {boolean} Item is selectable
6951 OO.ui.OptionWidget.prototype.isSelectable = function () {
6952 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6956 * Check if the option can be highlighted. A highlight indicates that the option
6957 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6960 * @return {boolean} Item is highlightable
6962 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6963 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6967 * Check if the option can be pressed. The pressed state occurs when a user mouses
6968 * down on an item, but has not yet let go of the mouse.
6970 * @return {boolean} Item is pressable
6972 OO.ui.OptionWidget.prototype.isPressable = function () {
6973 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6977 * Check if the option is selected.
6979 * @return {boolean} Item is selected
6981 OO.ui.OptionWidget.prototype.isSelected = function () {
6982 return this.selected;
6986 * Check if the option is highlighted. A highlight indicates that the
6987 * item may be selected when a user presses Enter key or clicks.
6989 * @return {boolean} Item is highlighted
6991 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6992 return this.highlighted;
6996 * Check if the option is pressed. The pressed state occurs when a user mouses
6997 * down on an item, but has not yet let go of the mouse. The item may appear
6998 * selected, but it will not be selected until the user releases the mouse.
7000 * @return {boolean} Item is pressed
7002 OO.ui.OptionWidget.prototype.isPressed = function () {
7003 return this.pressed;
7007 * Set the option’s selected state. In general, all modifications to the selection
7008 * should be handled by the SelectWidget’s
7009 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
7011 * @param {boolean} [state=false] Select option
7013 * @return {OO.ui.Widget} The widget, for chaining
7015 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
7016 if ( this.constructor.static.selectable ) {
7017 this.selected = !!state;
7019 .toggleClass( 'oo-ui-optionWidget-selected', state )
7020 .attr( 'aria-selected', this.selected.toString() );
7021 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
7022 this.scrollElementIntoView();
7024 this.updateThemeClasses();
7030 * Set the option’s highlighted state. In general, all programmatic
7031 * modifications to the highlight should be handled by the
7032 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
7033 * method instead of this method.
7035 * @param {boolean} [state=false] Highlight option
7037 * @return {OO.ui.Widget} The widget, for chaining
7039 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
7040 if ( this.constructor.static.highlightable ) {
7041 this.highlighted = !!state;
7042 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
7043 this.updateThemeClasses();
7049 * Set the option’s pressed state. In general, all
7050 * programmatic modifications to the pressed state should be handled by the
7051 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
7052 * method instead of this method.
7054 * @param {boolean} [state=false] Press option
7056 * @return {OO.ui.Widget} The widget, for chaining
7058 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
7059 if ( this.constructor.static.pressable ) {
7060 this.pressed = !!state;
7061 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
7062 this.updateThemeClasses();
7068 * Get text to match search strings against.
7070 * The default implementation returns the label text, but subclasses
7071 * can override this to provide more complex behavior.
7073 * @return {string|boolean} String to match search string against
7075 OO.ui.OptionWidget.prototype.getMatchText = function () {
7076 const label = this.getLabel();
7077 return typeof label === 'string' ? label : this.$label.text();
7081 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
7082 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
7083 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
7086 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
7087 * more information, please see the [OOUI documentation on MediaWiki][1].
7089 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7092 * // A select widget with three options.
7093 * const select = new OO.ui.SelectWidget( {
7095 * new OO.ui.OptionWidget( {
7097 * label: 'Option One',
7099 * new OO.ui.OptionWidget( {
7101 * label: 'Option Two',
7103 * new OO.ui.OptionWidget( {
7105 * label: 'Option Three',
7109 * $( document.body ).append( select.$element );
7113 * @extends OO.ui.Widget
7114 * @mixes OO.ui.mixin.GroupWidget
7117 * @param {Object} [config] Configuration options
7118 * @param {OO.ui.OptionWidget[]} [config.items] An array of options to add to the select.
7119 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
7120 * the [OOUI documentation on MediaWiki][2] for examples.
7121 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7122 * @param {boolean} [config.multiselect=false] Allow for multiple selections
7124 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
7125 // Configuration initialization
7126 config = config || {};
7128 // Parent constructor
7129 OO.ui.SelectWidget.super.call( this, config );
7131 // Mixin constructors
7132 OO.ui.mixin.GroupWidget.call( this, Object.assign( {
7133 $group: this.$element
7137 this.pressed = false;
7138 this.selecting = null;
7139 this.multiselect = !!config.multiselect;
7140 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
7141 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
7142 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
7143 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
7144 this.keyPressBuffer = '';
7145 this.keyPressBufferTimer = null;
7146 this.blockMouseOverEvents = 0;
7149 this.connect( this, {
7153 focusin: this.onFocus.bind( this ),
7154 mousedown: this.onMouseDown.bind( this ),
7155 mouseover: this.onMouseOver.bind( this ),
7156 mouseleave: this.onMouseLeave.bind( this )
7161 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed' )
7164 'aria-multiselectable': this.multiselect.toString()
7166 this.setFocusOwner( this.$element );
7167 this.addItems( config.items || [] );
7172 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
7173 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
7178 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
7180 * @event OO.ui.SelectWidget#highlight
7181 * @param {OO.ui.OptionWidget|null} item Highlighted item
7185 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
7186 * pressed state of an option.
7188 * @event OO.ui.SelectWidget#press
7189 * @param {OO.ui.OptionWidget|null} item Pressed item
7193 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
7196 * @event OO.ui.SelectWidget#select
7197 * @param {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} items Currently selected items
7201 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
7203 * @event OO.ui.SelectWidget#choose
7204 * @param {OO.ui.OptionWidget} item Chosen item
7205 * @param {boolean} selected Item is selected
7209 * An `add` event is emitted when options are added to the select with the #addItems method.
7211 * @event OO.ui.SelectWidget#add
7212 * @param {OO.ui.OptionWidget[]} items Added items
7213 * @param {number} index Index of insertion point
7217 * A `remove` event is emitted when options are removed from the select with the #clearItems
7218 * or #removeItems methods.
7220 * @event OO.ui.SelectWidget#remove
7221 * @param {OO.ui.OptionWidget[]} items Removed items
7224 /* Static Properties */
7227 * Whether this widget will respond to the navigation keys Home, End, PageUp, PageDown.
7230 * @property {boolean}
7232 OO.ui.SelectWidget.static.handleNavigationKeys = false;
7235 * Whether selecting items using arrow keys or navigation keys in this widget will wrap around after
7236 * the user reaches the beginning or end of the list.
7239 * @property {boolean}
7241 OO.ui.SelectWidget.static.listWrapsAround = true;
7243 /* Static methods */
7246 * Normalize text for filter matching
7248 * @param {string} text Text
7249 * @return {string} Normalized text
7251 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
7252 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
7253 let normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
7255 // Normalize Unicode
7256 normalized = normalized.normalize();
7264 * Handle focus events
7267 * @param {jQuery.Event} event
7269 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
7271 if ( event.target === this.$element[ 0 ] ) {
7272 // This widget was focussed, e.g. by the user tabbing to it.
7273 // The styles for focus state depend on one of the items being selected.
7274 if ( !this.findFirstSelectedItem() ) {
7275 item = this.findFirstSelectableItem();
7278 if ( event.target.tabIndex === -1 ) {
7279 // One of the options got focussed (and the event bubbled up here).
7280 // They can't be tabbed to, but they can be activated using access keys.
7281 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
7282 item = this.findTargetItem( event );
7283 if ( item && !( item.isHighlightable() || item.isSelectable() ) ) {
7284 // The item is disabled (weirdly, disabled items can be focussed in Firefox and IE,
7285 // but not in Chrome). Do nothing (do not highlight or select anything).
7289 // There is something actually user-focusable in one of the labels of the options, and
7290 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
7297 if ( item.constructor.static.highlightable ) {
7298 this.highlightItem( item );
7300 this.selectItem( item );
7304 if ( event.target !== this.$element[ 0 ] ) {
7305 this.$focusOwner.trigger( 'focus' );
7310 * Handle mouse down events.
7313 * @param {jQuery.Event} e Mouse down event
7314 * @return {undefined|boolean} False to prevent default if event is handled
7316 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
7317 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7318 this.togglePressed( true );
7319 const item = this.findTargetItem( e );
7320 if ( item && item.isSelectable() ) {
7321 this.pressItem( item );
7322 this.selecting = item;
7323 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
7324 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
7331 * Handle document mouse up events.
7334 * @param {MouseEvent} e Mouse up event
7335 * @return {undefined|boolean} False to prevent default if event is handled
7337 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
7338 this.togglePressed( false );
7339 if ( !this.selecting ) {
7340 const item = this.findTargetItem( e );
7341 if ( item && item.isSelectable() ) {
7342 this.selecting = item;
7345 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
7346 this.pressItem( null );
7347 this.chooseItem( this.selecting );
7348 this.selecting = null;
7351 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
7352 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
7358 * Handle document mouse move events.
7361 * @param {MouseEvent} e Mouse move event
7363 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
7364 if ( !this.isDisabled() && this.pressed ) {
7365 const item = this.findTargetItem( e );
7366 if ( item && item !== this.selecting && item.isSelectable() ) {
7367 this.pressItem( item );
7368 this.selecting = item;
7374 * Handle mouse over events.
7377 * @param {jQuery.Event} e Mouse over event
7378 * @return {undefined|boolean} False to prevent default if event is handled
7380 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
7381 if ( this.blockMouseOverEvents ) {
7384 if ( !this.isDisabled() ) {
7385 const item = this.findTargetItem( e );
7386 this.highlightItem( item && item.isHighlightable() ? item : null );
7392 * Handle mouse leave events.
7395 * @param {jQuery.Event} e Mouse over event
7396 * @return {undefined|boolean} False to prevent default if event is handled
7398 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
7399 if ( !this.isDisabled() ) {
7400 this.highlightItem( null );
7406 * Handle document key down events.
7409 * @param {KeyboardEvent} e Key down event
7411 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7412 let handled = false;
7415 ( this.isVisible() && this.findHighlightedItem() ) ||
7416 ( !this.multiselect && this.findSelectedItem() );
7419 if ( !this.isDisabled() ) {
7420 switch ( e.keyCode ) {
7421 case OO.ui.Keys.ENTER:
7422 if ( currentItem ) {
7423 // Select highlighted item or toggle when multiselect is enabled
7424 this.chooseItem( currentItem );
7429 case OO.ui.Keys.LEFT:
7430 case OO.ui.Keys.DOWN:
7431 case OO.ui.Keys.RIGHT:
7432 this.clearKeyPressBuffer();
7433 nextItem = this.findRelativeSelectableItem(
7435 e.keyCode === OO.ui.Keys.UP || e.keyCode === OO.ui.Keys.LEFT ? -1 : 1,
7437 this.constructor.static.listWrapsAround
7441 case OO.ui.Keys.HOME:
7442 case OO.ui.Keys.END:
7443 if ( this.constructor.static.handleNavigationKeys ) {
7444 this.clearKeyPressBuffer();
7445 nextItem = this.findRelativeSelectableItem(
7447 e.keyCode === OO.ui.Keys.HOME ? 1 : -1,
7449 this.constructor.static.listWrapsAround
7454 case OO.ui.Keys.PAGEUP:
7455 case OO.ui.Keys.PAGEDOWN:
7456 if ( this.constructor.static.handleNavigationKeys ) {
7457 this.clearKeyPressBuffer();
7458 nextItem = this.findRelativeSelectableItem(
7460 e.keyCode === OO.ui.Keys.PAGEUP ? -10 : 10,
7462 this.constructor.static.listWrapsAround
7467 case OO.ui.Keys.ESCAPE:
7468 case OO.ui.Keys.TAB:
7469 if ( currentItem ) {
7470 currentItem.setHighlighted( false );
7472 this.unbindDocumentKeyDownListener();
7473 this.unbindDocumentKeyPressListener();
7474 // Don't prevent tabbing away / defocusing
7480 if ( this.isVisible() && nextItem.constructor.static.highlightable ) {
7481 this.highlightItem( nextItem );
7483 if ( this.screenReaderMode ) {
7484 this.highlightItem( nextItem );
7486 this.chooseItem( nextItem );
7488 this.scrollItemIntoView( nextItem );
7493 e.stopPropagation();
7499 * Bind document key down listener.
7503 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
7504 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
7508 * Unbind document key down listener.
7512 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7513 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
7517 * Scroll item into view, preventing spurious mouse highlight actions from happening.
7519 * @param {OO.ui.OptionWidget} item Item to scroll into view
7521 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
7522 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
7523 // scrolling and around 100-150 ms after it is finished.
7524 this.blockMouseOverEvents++;
7525 item.scrollElementIntoView().done( () => {
7527 this.blockMouseOverEvents--;
7533 * Clear the key-press buffer
7537 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
7538 if ( this.keyPressBufferTimer ) {
7539 clearTimeout( this.keyPressBufferTimer );
7540 this.keyPressBufferTimer = null;
7542 this.keyPressBuffer = '';
7546 * Handle key press events.
7549 * @param {KeyboardEvent} e Key press event
7550 * @return {undefined|boolean} False to prevent default if event is handled
7552 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
7553 if ( !e.charCode ) {
7554 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
7555 this.keyPressBuffer = this.keyPressBuffer.slice( 0, this.keyPressBuffer.length - 1 );
7561 const c = String.fromCodePoint( e.charCode );
7563 if ( this.keyPressBufferTimer ) {
7564 clearTimeout( this.keyPressBufferTimer );
7566 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
7568 let item = ( this.isVisible() && this.findHighlightedItem() ) ||
7569 ( !this.multiselect && this.findSelectedItem() );
7571 if ( this.keyPressBuffer === c ) {
7572 // Common (if weird) special case: typing "xxxx" will cycle through all
7573 // the items beginning with "x".
7575 item = this.findRelativeSelectableItem( item, 1 );
7578 this.keyPressBuffer += c;
7581 const filter = this.getItemMatcher( this.keyPressBuffer, false );
7582 if ( !item || !filter( item ) ) {
7583 item = this.findRelativeSelectableItem( item, 1, filter );
7586 if ( this.isVisible() && item.constructor.static.highlightable ) {
7587 this.highlightItem( item );
7589 if ( this.screenReaderMode ) {
7590 this.highlightItem( item );
7592 this.chooseItem( item );
7594 this.scrollItemIntoView( item );
7598 e.stopPropagation();
7602 * Get a matcher for the specific string
7605 * @param {string} query String to match against items
7606 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
7607 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
7609 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
7610 const normalizeForMatching = this.constructor.static.normalizeForMatching,
7611 normalizedQuery = normalizeForMatching( query );
7613 // Empty string matches everything, except in "exact" mode where it matches nothing
7614 if ( !normalizedQuery ) {
7615 return function () {
7616 return mode !== 'exact';
7620 return function ( item ) {
7621 const matchText = normalizeForMatching( item.getMatchText() );
7624 return matchText === normalizedQuery;
7626 return matchText.indexOf( normalizedQuery ) !== -1;
7629 return matchText.indexOf( normalizedQuery ) === 0;
7635 * Bind document key press listener.
7639 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
7640 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7644 * Unbind document key down listener.
7646 * If you override this, be sure to call this.clearKeyPressBuffer() from your
7651 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7652 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7653 this.clearKeyPressBuffer();
7657 * Visibility change handler
7660 * @param {boolean} visible
7662 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
7664 this.clearKeyPressBuffer();
7669 * Get the closest item to a jQuery.Event.
7672 * @param {jQuery.Event} e
7673 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
7675 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
7676 const $option = $( e.target ).closest( '.oo-ui-optionWidget' );
7677 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
7680 return $option.data( 'oo-ui-optionWidget' ) || null;
7684 * @return {OO.ui.OptionWidget|null} The first (of possibly many) selected item, if any
7686 OO.ui.SelectWidget.prototype.findFirstSelectedItem = function () {
7687 for ( let i = 0; i < this.items.length; i++ ) {
7688 if ( this.items[ i ].isSelected() ) {
7689 return this.items[ i ];
7696 * Find all selected items, if there are any. If the widget allows for multiselect
7697 * it will return an array of selected options. If the widget doesn't allow for
7698 * multiselect, it will return the selected option or null if no item is selected.
7700 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7701 * then return an array of selected items (or empty array),
7702 * if the widget is not multiselect, return a single selected item, or `null`
7703 * if no item is selected
7705 OO.ui.SelectWidget.prototype.findSelectedItems = function () {
7706 if ( !this.multiselect ) {
7707 return this.findFirstSelectedItem();
7710 return this.items.filter( ( item ) => item.isSelected() );
7714 * Find selected item.
7716 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7717 * then return an array of selected items (or empty array),
7718 * if the widget is not multiselect, return a single selected item, or `null`
7719 * if no item is selected
7721 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
7722 return this.findSelectedItems();
7726 * Find highlighted item.
7728 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
7730 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
7731 for ( let i = 0; i < this.items.length; i++ ) {
7732 if ( this.items[ i ].isHighlighted() ) {
7733 return this.items[ i ];
7740 * Toggle pressed state.
7742 * Press is a state that occurs when a user mouses down on an item, but
7743 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7744 * until the user releases the mouse.
7746 * @param {boolean} [pressed] An option is being pressed, omit to toggle
7748 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7749 if ( pressed === undefined ) {
7750 pressed = !this.pressed;
7752 if ( pressed !== this.pressed ) {
7754 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7755 .toggleClass( 'oo-ui-selectWidget-unpressed', !pressed );
7756 this.pressed = pressed;
7761 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7762 * and any existing highlight will be removed. The highlight is mutually exclusive.
7764 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7765 * @fires OO.ui.SelectWidget#highlight
7767 * @return {OO.ui.Widget} The widget, for chaining
7769 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7770 if ( item && item.isHighlighted() ) {
7774 let changed = false;
7776 for ( let i = 0; i < this.items.length; i++ ) {
7777 const highlighted = this.items[ i ] === item;
7778 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7779 this.items[ i ].setHighlighted( highlighted );
7781 // This was the second change; there can only be two, a set and an unset
7784 // Un-highlighting can't fail, but highlighting can
7785 changed = !highlighted || this.items[ i ].isHighlighted();
7791 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7793 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7795 this.emit( 'highlight', item );
7802 * Fetch an item by its label.
7804 * @param {string} label Label of the item to select.
7805 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7806 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7808 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7809 const len = this.items.length;
7811 let filter = this.getItemMatcher( label, 'exact' );
7814 for ( i = 0; i < len; i++ ) {
7815 item = this.items[ i ];
7816 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7823 filter = this.getItemMatcher( label, 'prefix' );
7824 for ( i = 0; i < len; i++ ) {
7825 item = this.items[ i ];
7826 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7842 * Programmatically select an option by its label. If the item does not exist,
7843 * all options will be deselected.
7845 * @param {string} [label] Label of the item to select.
7846 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7847 * @fires OO.ui.SelectWidget#select
7849 * @return {OO.ui.Widget} The widget, for chaining
7851 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7852 const itemFromLabel = this.getItemFromLabel( label, !!prefix );
7853 if ( label === undefined || !itemFromLabel ) {
7854 return this.selectItem();
7856 return this.selectItem( itemFromLabel );
7860 * Programmatically select an option by its data. If the `data` parameter is omitted,
7861 * or if the item does not exist, all options will be deselected.
7863 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7864 * @fires OO.ui.SelectWidget#select
7866 * @return {OO.ui.Widget} The widget, for chaining
7868 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7869 const itemFromData = this.findItemFromData( data );
7870 if ( data === undefined || !itemFromData ) {
7871 return this.selectItem();
7873 return this.selectItem( itemFromData );
7877 * Programmatically unselect an option by its reference. If the widget
7878 * allows for multiple selections, there may be other items still selected;
7879 * otherwise, no items will be selected.
7880 * If no item is given, all selected items will be unselected.
7882 * @param {OO.ui.OptionWidget} [unselectedItem] Item to unselect, or nothing to unselect all
7883 * @fires OO.ui.SelectWidget#select
7885 * @return {OO.ui.Widget} The widget, for chaining
7887 OO.ui.SelectWidget.prototype.unselectItem = function ( unselectedItem ) {
7888 if ( !unselectedItem ) {
7891 } else if ( unselectedItem.isSelected() ) {
7892 unselectedItem.setSelected( false );
7893 // Other items might still be selected in multiselect mode
7894 this.emit( 'select', this.findSelectedItems() );
7901 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7902 * all options will be deselected.
7904 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7905 * @fires OO.ui.SelectWidget#select
7907 * @return {OO.ui.Widget} The widget, for chaining
7909 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7911 if ( item.isSelected() ) {
7913 } else if ( this.multiselect ) {
7914 // We don't care about the state of the other items when multiselect is allowed
7915 item.setSelected( true );
7916 this.emit( 'select', this.findSelectedItems() );
7921 let changed = false;
7923 for ( let i = 0; i < this.items.length; i++ ) {
7924 const selected = this.items[ i ] === item;
7925 if ( this.items[ i ].isSelected() !== selected ) {
7926 this.items[ i ].setSelected( selected );
7927 if ( changed && !this.multiselect ) {
7928 // This was the second change; there can only be two, a set and an unset
7931 // Un-selecting can't fail, but selecting can
7932 changed = !selected || this.items[ i ].isSelected();
7937 // Fall back to the selected instead of the highlighted option (see #highlightItem) only
7938 // when we know highlighting is disabled. Unfortunately we can't know without an item.
7939 // Don't even try when an arbitrary number of options can be selected.
7940 if ( !this.multiselect && item && !item.constructor.static.highlightable ) {
7941 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7943 this.emit( 'select', this.findSelectedItems() );
7952 * Press is a state that occurs when a user mouses down on an item, but has not
7953 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7954 * releases the mouse.
7956 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7957 * @fires OO.ui.SelectWidget#press
7959 * @return {OO.ui.Widget} The widget, for chaining
7961 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7962 if ( item && item.isPressed() ) {
7966 let changed = false;
7968 for ( let i = 0; i < this.items.length; i++ ) {
7969 const pressed = this.items[ i ] === item;
7970 if ( this.items[ i ].isPressed() !== pressed ) {
7971 this.items[ i ].setPressed( pressed );
7973 // This was the second change; there can only be two, a set and an unset
7976 // Un-pressing can't fail, but pressing can
7977 changed = !pressed || this.items[ i ].isPressed();
7982 this.emit( 'press', item );
7989 * Select an item or toggle an item's selection when multiselect is enabled.
7991 * Note that ‘choose’ should never be modified programmatically. A user can choose
7992 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7993 * use the #selectItem method.
7995 * This method is not identical to #selectItem and may vary further in subclasses that take
7996 * additional action when users choose an item with the keyboard or mouse.
7998 * @param {OO.ui.OptionWidget} item Item to choose
7999 * @fires OO.ui.SelectWidget#choose
8001 * @return {OO.ui.Widget} The widget, for chaining
8003 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
8005 if ( this.multiselect && item.isSelected() ) {
8006 this.unselectItem( item );
8008 this.selectItem( item );
8011 this.emit( 'choose', item, item.isSelected() );
8018 * Find an option by its position relative to the specified item (or to the start of the option
8019 * array, if item is `null`). The direction and distance in which to search through the option array
8020 * is specified with a number: e.g. -1 for the previous item (the default) or 1 for the next item,
8021 * or 15 for the 15th next item, etc. The method will return an option, or `null` if there are no
8022 * options in the array.
8024 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
8025 * the beginning of the array.
8026 * @param {number} offset Relative position: negative to move backward, positive to move forward
8027 * @param {Function} [filter] Only consider items for which this function returns
8028 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
8029 * @param {boolean} [wrap=false] Do not wrap around after reaching the last or first item
8030 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
8032 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, offset, filter, wrap ) {
8033 const step = offset > 0 ? 1 : -1,
8034 len = this.items.length;
8035 if ( wrap === undefined ) {
8040 if ( item instanceof OO.ui.OptionWidget ) {
8041 nextIndex = this.items.indexOf( item );
8043 // If no item is selected and moving forward, start at the beginning.
8044 // If moving backward, start at the end.
8045 nextIndex = offset > 0 ? 0 : len - 1;
8049 const previousItem = item;
8050 let nextItem = null;
8051 for ( let i = 0; i < len; i++ ) {
8052 item = this.items[ nextIndex ];
8054 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
8055 ( !filter || filter( item ) )
8060 if ( offset === 0 && nextItem && nextItem !== previousItem ) {
8061 // We walked at least the desired number of steps *and* we've selected a different item.
8062 // This is to ensure that disabled items don't cause us to get stuck or return null.
8067 if ( nextIndex < 0 || nextIndex >= len ) {
8069 nextIndex = ( nextIndex + len ) % len;
8071 // We ran out of the list, return whichever was the last valid item
8075 if ( offset !== 0 ) {
8083 * Find the next selectable item or `null` if there are no selectable items.
8084 * Disabled options and menu-section markers and breaks are not selectable.
8086 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
8088 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
8089 return this.findRelativeSelectableItem( null, 1 );
8093 * Add an array of options to the select. Optionally, an index number can be used to
8094 * specify an insertion point.
8096 * @param {OO.ui.OptionWidget[]} [items] Options to add
8097 * @param {number} [index] Index to insert items after
8098 * @fires OO.ui.SelectWidget#add
8100 * @return {OO.ui.Widget} The widget, for chaining
8102 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
8103 if ( !items || items.length === 0 ) {
8108 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
8110 // Always provide an index, even if it was omitted
8111 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
8117 * Remove the specified array of options from the select. Options will be detached
8118 * from the DOM, not removed, so they can be reused later. To remove all options from
8119 * the select, you may wish to use the #clearItems method instead.
8121 * @param {OO.ui.OptionWidget[]} items Items to remove
8122 * @fires OO.ui.SelectWidget#remove
8124 * @return {OO.ui.Widget} The widget, for chaining
8126 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
8127 // Deselect items being removed
8128 for ( let i = 0; i < items.length; i++ ) {
8129 const item = items[ i ];
8130 if ( item.isSelected() ) {
8131 this.selectItem( null );
8136 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
8138 this.emit( 'remove', items );
8144 * Clear all options from the select. Options will be detached from the DOM, not removed,
8145 * so that they can be reused later. To remove a subset of options from the select, use
8146 * the #removeItems method.
8148 * @fires OO.ui.SelectWidget#remove
8150 * @return {OO.ui.Widget} The widget, for chaining
8152 OO.ui.SelectWidget.prototype.clearItems = function () {
8153 const items = this.items.slice();
8156 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
8159 this.selectItem( null );
8161 this.emit( 'remove', items );
8167 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
8169 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
8172 * @param {jQuery} $focusOwner
8174 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
8175 this.$focusOwner = $focusOwner;
8179 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
8180 * with an {@link OO.ui.mixin.IconElement icon} and/or
8181 * {@link OO.ui.mixin.IndicatorElement indicator}.
8182 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
8183 * options. For more information about options and selects, please see the
8184 * [OOUI documentation on MediaWiki][1].
8186 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8189 * // Decorated options in a select widget.
8190 * const select = new OO.ui.SelectWidget( {
8192 * new OO.ui.DecoratedOptionWidget( {
8194 * label: 'Option with icon',
8197 * new OO.ui.DecoratedOptionWidget( {
8199 * label: 'Option with indicator',
8204 * $( document.body ).append( select.$element );
8207 * @extends OO.ui.OptionWidget
8208 * @mixes OO.ui.mixin.IconElement
8209 * @mixes OO.ui.mixin.IndicatorElement
8212 * @param {Object} [config] Configuration options
8214 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
8215 // Parent constructor
8216 OO.ui.DecoratedOptionWidget.super.call( this, config );
8218 // Mixin constructors
8219 OO.ui.mixin.IconElement.call( this, config );
8220 OO.ui.mixin.IndicatorElement.call( this, config );
8224 .addClass( 'oo-ui-decoratedOptionWidget' )
8225 .prepend( this.$icon )
8226 .append( this.$indicator );
8231 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
8232 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
8233 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
8236 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
8237 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
8238 * the [OOUI documentation on MediaWiki][1] for more information.
8240 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8243 * @extends OO.ui.DecoratedOptionWidget
8246 * @param {Object} [config] Configuration options
8248 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
8249 // Parent constructor
8250 OO.ui.MenuOptionWidget.super.call( this, config );
8253 this.checkIcon = new OO.ui.IconWidget( {
8255 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
8260 .prepend( this.checkIcon.$element )
8261 .addClass( 'oo-ui-menuOptionWidget' );
8266 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
8268 /* Static Properties */
8274 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
8277 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
8278 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
8279 * cannot be highlighted or selected.
8282 * const dropdown = new OO.ui.DropdownWidget( {
8285 * new OO.ui.MenuSectionOptionWidget( {
8288 * new OO.ui.MenuOptionWidget( {
8290 * label: 'Welsh Corgi'
8292 * new OO.ui.MenuOptionWidget( {
8294 * label: 'Standard Poodle'
8296 * new OO.ui.MenuSectionOptionWidget( {
8299 * new OO.ui.MenuOptionWidget( {
8306 * $( document.body ).append( dropdown.$element );
8309 * @extends OO.ui.DecoratedOptionWidget
8312 * @param {Object} [config] Configuration options
8314 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
8315 // Parent constructor
8316 OO.ui.MenuSectionOptionWidget.super.call( this, config );
8320 .addClass( 'oo-ui-menuSectionOptionWidget' )
8321 .removeAttr( 'role aria-selected' );
8322 this.selected = false;
8327 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
8329 /* Static Properties */
8335 OO.ui.MenuSectionOptionWidget.static.selectable = false;
8341 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
8344 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
8345 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
8346 * See {@link OO.ui.DropdownWidget DropdownWidget},
8347 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
8348 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
8349 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
8350 * and customized to be opened, closed, and displayed as needed.
8352 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
8353 * mouse outside the menu.
8355 * Menus also have support for keyboard interaction:
8357 * - Enter/Return key: choose and select a menu option
8358 * - Up-arrow key: highlight the previous menu option
8359 * - Down-arrow key: highlight the next menu option
8360 * - Escape key: hide the menu
8362 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
8364 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8365 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8368 * @extends OO.ui.SelectWidget
8369 * @mixes OO.ui.mixin.ClippableElement
8370 * @mixes OO.ui.mixin.FloatableElement
8373 * @param {Object} [config] Configuration options
8374 * @param {OO.ui.TextInputWidget} [config.input] Text input used to implement option highlighting for menu
8375 * items that match the text the user types. This config is used by
8376 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
8377 * {@link OO.ui.mixin.LookupElement LookupElement}
8378 * @param {jQuery} [config.$input] Text input used to implement option highlighting for menu items that match
8379 * the text the user types. This config is used by
8380 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
8381 * @param {OO.ui.Widget} [config.widget] Widget associated with the menu's active state. If the user clicks
8382 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
8383 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
8384 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
8386 * @param {boolean} [config.autoHide=true] Hide the menu when the mouse is pressed outside the menu.
8387 * @param {jQuery} [config.$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
8388 * @param {boolean} [config.hideOnChoose=true] Hide the menu when the user chooses an option.
8389 * @param {boolean} [config.filterFromInput=false] Filter the displayed options from the input
8390 * @param {boolean} [config.highlightOnFilter=false] Highlight the first result when filtering
8391 * @param {string} [config.filterMode='prefix'] The mode by which the menu filters the results.
8392 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
8393 * @param {number|string} [config.width] Width of the menu as a number of pixels or CSS string with unit
8394 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
8396 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
8397 // Configuration initialization
8398 config = config || {};
8400 // Parent constructor
8401 OO.ui.MenuSelectWidget.super.call( this, config );
8403 // Mixin constructors
8404 OO.ui.mixin.ClippableElement.call( this, Object.assign( { $clippable: this.$group }, config ) );
8405 OO.ui.mixin.FloatableElement.call( this, config );
8407 // Initial vertical positions other than 'center' will result in
8408 // the menu being flipped if there is not enough space in the container.
8409 // Store the original position so we know what to reset to.
8410 this.originalVerticalPosition = this.verticalPosition;
8413 this.autoHide = config.autoHide === undefined || !!config.autoHide;
8414 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
8415 this.filterFromInput = !!config.filterFromInput;
8416 this.previouslySelectedValue = null;
8417 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
8418 this.$widget = config.widget ? config.widget.$element : null;
8419 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
8420 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
8421 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
8422 this.highlightOnFilter = !!config.highlightOnFilter;
8423 this.lastHighlightedItem = null;
8424 this.width = config.width;
8425 this.filterMode = config.filterMode;
8426 this.screenReaderMode = false;
8429 this.$element.addClass( 'oo-ui-menuSelectWidget' );
8430 if ( config.widget ) {
8431 this.setFocusOwner( config.widget.$tabIndexed );
8434 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
8435 // that reference properties not initialized at that time of parent class construction
8436 // TODO: Find a better way to handle post-constructor setup
8437 this.visible = false;
8438 this.$element.addClass( 'oo-ui-element-hidden' );
8439 this.$focusOwner.attr( 'aria-expanded', 'false' );
8444 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
8445 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
8446 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
8451 * The menu is ready: it is visible and has been positioned and clipped.
8453 * @event OO.ui.MenuSelectWidget#ready
8456 /* Static properties */
8458 OO.ui.MenuSelectWidget.static.handleNavigationKeys = true;
8460 OO.ui.MenuSelectWidget.static.listWrapsAround = false;
8463 * Positions to flip to if there isn't room in the container for the
8464 * menu in a specific direction.
8466 * @property {Object.<string,string>}
8468 OO.ui.MenuSelectWidget.static.flippedPositions = {
8478 * Handles document mouse down events.
8481 * @param {MouseEvent} e Mouse down event
8483 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
8487 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
8492 this.toggle( false );
8499 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
8500 let handled = false;
8502 const currentItem = this.findHighlightedItem() || this.findFirstSelectedItem();
8504 if ( !this.isDisabled() && this.getVisibleItems().length ) {
8505 switch ( e.keyCode ) {
8506 case OO.ui.Keys.ENTER:
8507 if ( this.isVisible() ) {
8508 OO.ui.MenuSelectWidget.super.prototype.onDocumentKeyDown.call( this, e );
8511 case OO.ui.Keys.TAB:
8512 if ( this.isVisible() ) {
8513 if ( currentItem && !currentItem.isSelected() ) {
8514 // Was only highlighted, now let's select it. No-op if already selected.
8515 this.chooseItem( currentItem );
8518 this.toggle( false );
8521 case OO.ui.Keys.LEFT:
8522 case OO.ui.Keys.RIGHT:
8523 case OO.ui.Keys.HOME:
8524 case OO.ui.Keys.END:
8525 // Do nothing if a text field is associated, these keys will be handled by the
8527 if ( !this.$input ) {
8528 OO.ui.MenuSelectWidget.super.prototype.onDocumentKeyDown.call( this, e );
8531 case OO.ui.Keys.ESCAPE:
8532 if ( this.isVisible() ) {
8533 if ( currentItem && !this.multiselect ) {
8534 currentItem.setHighlighted( false );
8536 this.toggle( false );
8541 return OO.ui.MenuSelectWidget.super.prototype.onDocumentKeyDown.call( this, e );
8545 e.stopPropagation();
8551 * Return the visible items in the menu.
8553 * @return {OO.ui.MenuOptionWidget[]} Visible items
8555 OO.ui.MenuSelectWidget.prototype.getVisibleItems = function () {
8556 return this.getItems().filter( ( item ) => item.isVisible() );
8560 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
8561 * or after items were added/removed (always).
8565 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
8566 if ( !this.filterFromInput || !this.$input ) {
8571 let anyVisible = false;
8573 const showAll = !this.isVisible() || this.previouslySelectedValue === this.$input.val(),
8574 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
8575 // Hide non-matching options, and also hide section headers if all options
8576 // in their section are hidden.
8578 let section, sectionEmpty;
8579 for ( let i = 0; i < this.items.length; i++ ) {
8580 item = this.items[ i ];
8581 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
8583 // If the previous section was empty, hide its header
8584 section.toggle( showAll || !sectionEmpty );
8587 sectionEmpty = true;
8588 } else if ( item instanceof OO.ui.OptionWidget ) {
8589 const visible = !filter || filter( item );
8590 anyVisible = anyVisible || visible;
8591 sectionEmpty = sectionEmpty && !visible;
8592 item.toggle( visible );
8595 // Process the final section
8597 section.toggle( showAll || !sectionEmpty );
8600 if ( !anyVisible ) {
8601 this.highlightItem( null );
8604 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
8606 if ( this.highlightOnFilter &&
8607 !( this.lastHighlightedItem && this.lastHighlightedItem.isSelectable() ) &&
8610 // Highlight the first selectable item in the list
8611 item = this.findFirstSelectableItem();
8612 this.highlightItem( item );
8613 this.lastHighlightedItem = item;
8616 // Reevaluate clipping
8623 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
8624 if ( this.$input ) {
8625 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
8627 OO.ui.MenuSelectWidget.super.prototype.bindDocumentKeyDownListener.call( this );
8634 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
8635 if ( this.$input ) {
8636 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
8638 OO.ui.MenuSelectWidget.super.prototype.unbindDocumentKeyDownListener.call( this );
8645 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
8646 if ( this.$input ) {
8647 if ( this.filterFromInput ) {
8649 'keydown mouseup cut paste change input select',
8650 this.onInputEditHandler
8652 this.$input.one( 'keypress', () => {
8653 this.previouslySelectedValue = null;
8655 this.previouslySelectedValue = this.$input.val();
8656 this.updateItemVisibility();
8659 OO.ui.MenuSelectWidget.super.prototype.bindDocumentKeyPressListener.call( this );
8666 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
8667 if ( this.$input ) {
8668 if ( this.filterFromInput ) {
8670 'keydown mouseup cut paste change input select',
8671 this.onInputEditHandler
8673 this.updateItemVisibility();
8676 OO.ui.MenuSelectWidget.super.prototype.unbindDocumentKeyPressListener.call( this );
8681 * Select an item or toggle an item's selection when multiselect is enabled.
8683 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
8686 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
8687 * the keyboard or mouse and it becomes selected. To select an item programmatically,
8688 * use the #selectItem method.
8690 * @param {OO.ui.OptionWidget} item Item to choose
8692 * @return {OO.ui.Widget} The widget, for chaining
8694 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
8695 OO.ui.MenuSelectWidget.super.prototype.chooseItem.call( this, item );
8696 if ( this.hideOnChoose ) {
8697 this.toggle( false );
8705 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
8706 if ( !items || items.length === 0 ) {
8711 OO.ui.MenuSelectWidget.super.prototype.addItems.call( this, items, index );
8713 this.updateItemVisibility();
8721 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
8723 OO.ui.MenuSelectWidget.super.prototype.removeItems.call( this, items );
8725 this.updateItemVisibility();
8733 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
8735 OO.ui.MenuSelectWidget.super.prototype.clearItems.call( this );
8737 this.updateItemVisibility();
8743 * Toggle visibility of the menu for screen readers.
8745 * @param {boolean} [screenReaderMode=false]
8747 OO.ui.MenuSelectWidget.prototype.toggleScreenReaderMode = function ( screenReaderMode ) {
8748 screenReaderMode = !!screenReaderMode;
8749 this.screenReaderMode = screenReaderMode;
8751 this.$element.toggleClass( 'oo-ui-menuSelectWidget-screenReaderMode', this.screenReaderMode );
8753 if ( screenReaderMode ) {
8754 this.bindDocumentKeyDownListener();
8755 this.bindDocumentKeyPressListener();
8757 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8758 this.unbindDocumentKeyDownListener();
8759 this.unbindDocumentKeyPressListener();
8764 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
8765 * `.toggle( true )` after its #$element is attached to the DOM.
8767 * Do not show the menu while it is not attached to the DOM. The calculations required to display
8768 * it in the right place and with the right dimensions only work correctly while it is attached.
8769 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
8770 * strictly enforced, so currently it only generates a warning in the browser console.
8772 * @fires OO.ui.MenuSelectWidget#ready
8775 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
8776 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
8777 const change = visible !== this.isVisible();
8779 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
8780 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
8781 this.warnedUnattached = true;
8784 if ( change && visible ) {
8785 // Reset position before showing the popup again. It's possible we no longer need to flip
8786 // (e.g. if the user scrolled).
8787 this.setVerticalPosition( this.originalVerticalPosition );
8791 OO.ui.MenuSelectWidget.super.prototype.toggle.call( this, visible );
8797 this.setIdealSize( this.width );
8798 } else if ( this.$floatableContainer ) {
8799 this.$clippable.css( 'width', 'auto' );
8801 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
8802 // Dropdown is smaller than handle so expand to width
8803 this.$floatableContainer[ 0 ].offsetWidth :
8804 // Dropdown is larger than handle so auto size
8807 this.$clippable.css( 'width', '' );
8810 this.togglePositioning( !!this.$floatableContainer );
8811 this.toggleClipping( true );
8813 if ( !this.screenReaderMode ) {
8814 this.bindDocumentKeyDownListener();
8815 this.bindDocumentKeyPressListener();
8819 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
8820 this.originalVerticalPosition !== 'center'
8822 // If opening the menu in one direction causes it to be clipped, flip it
8823 const originalHeight = this.$element.height();
8824 this.setVerticalPosition(
8825 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
8827 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
8828 // If flipping also causes it to be clipped, open in whichever direction
8829 // we have more space
8830 const flippedHeight = this.$element.height();
8831 if ( originalHeight > flippedHeight ) {
8832 this.setVerticalPosition( this.originalVerticalPosition );
8836 // Note that we do not flip the menu's opening direction if the clipping changes
8837 // later (e.g. after the user scrolls), that seems like it would be annoying
8839 this.$focusOwner.attr( 'aria-expanded', 'true' );
8840 this.$focusOwner.attr( 'aria-owns', this.getElementId() );
8842 const selectedItem = !this.multiselect && this.findSelectedItem();
8843 if ( selectedItem ) {
8844 // TODO: Verify if this is even needed; This is already done on highlight changes
8845 // in SelectWidget#highlightItem, so we should just need to highlight the item
8846 // we need to highlight here and not bother with attr or checking selections.
8847 this.$focusOwner.attr( 'aria-activedescendant', selectedItem.getElementId() );
8848 selectedItem.scrollElementIntoView( { duration: 0 } );
8852 if ( this.autoHide ) {
8853 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8856 this.emit( 'ready' );
8858 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8859 if ( !this.screenReaderMode ) {
8860 this.unbindDocumentKeyDownListener();
8861 this.unbindDocumentKeyPressListener();
8863 this.$focusOwner.attr( 'aria-expanded', 'false' );
8864 this.$focusOwner.removeAttr( 'aria-owns' );
8865 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8866 this.togglePositioning( false );
8867 this.toggleClipping( false );
8868 this.lastHighlightedItem = null;
8876 * Scroll to the top of the menu
8878 OO.ui.MenuSelectWidget.prototype.scrollToTop = function () {
8879 this.$element.scrollTop( 0 );
8883 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8884 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8885 * users can interact with it.
8887 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8888 * OO.ui.DropdownInputWidget instead.
8890 * For more information, please see the [OOUI documentation on MediaWiki][1].
8892 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8895 * // A DropdownWidget with a menu that contains three options.
8896 * const dropdown = new OO.ui.DropdownWidget( {
8897 * label: 'Dropdown menu: Select a menu option',
8900 * new OO.ui.MenuOptionWidget( {
8904 * new OO.ui.MenuOptionWidget( {
8908 * new OO.ui.MenuOptionWidget( {
8916 * $( document.body ).append( dropdown.$element );
8918 * dropdown.getMenu().selectItemByData( 'b' );
8920 * dropdown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8923 * @extends OO.ui.Widget
8924 * @mixes OO.ui.mixin.IconElement
8925 * @mixes OO.ui.mixin.IndicatorElement
8926 * @mixes OO.ui.mixin.LabelElement
8927 * @mixes OO.ui.mixin.TitledElement
8928 * @mixes OO.ui.mixin.TabIndexedElement
8931 * @param {Object} [config] Configuration options
8932 * @param {Object} [config.menu] Configuration options to pass to
8933 * {@link OO.ui.MenuSelectWidget menu select widget}.
8934 * @param {jQuery|boolean} [config.$overlay] Render the menu into a separate layer. This configuration is
8935 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
8936 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
8937 * the menu uses relative positioning. Pass 'true' to use the default overlay.
8938 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8940 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8941 // Configuration initialization
8942 config = Object.assign( { indicator: 'down' }, config );
8944 // Parent constructor
8945 OO.ui.DropdownWidget.super.call( this, config );
8947 // Properties (must be set before TabIndexedElement constructor call)
8948 this.$handle = $( '<span>' );
8949 this.$overlay = ( config.$overlay === true ?
8950 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8952 // Mixin constructors
8953 OO.ui.mixin.IconElement.call( this, config );
8954 OO.ui.mixin.IndicatorElement.call( this, config );
8955 OO.ui.mixin.LabelElement.call( this, config );
8956 OO.ui.mixin.TitledElement.call( this, Object.assign( {
8957 $titled: this.$label
8959 OO.ui.mixin.TabIndexedElement.call( this, Object.assign( {
8960 $tabIndexed: this.$handle
8964 this.menu = new OO.ui.MenuSelectWidget( Object.assign( {
8966 $floatableContainer: this.$element
8971 click: this.onClick.bind( this ),
8972 keydown: this.onKeyDown.bind( this ),
8973 focus: this.onFocus.bind( this ),
8974 blur: this.onBlur.bind( this )
8976 this.menu.connect( this, {
8977 select: 'onMenuSelect',
8978 toggle: 'onMenuToggle'
8982 const labelId = OO.ui.generateElementId();
8983 this.setLabelId( labelId );
8987 'aria-readonly': 'true'
8990 .addClass( 'oo-ui-dropdownWidget-handle' )
8991 .append( this.$icon, this.$label, this.$indicator )
8994 'aria-autocomplete': 'list',
8995 'aria-expanded': 'false',
8996 'aria-haspopup': 'true',
8997 'aria-labelledby': labelId
9000 .addClass( 'oo-ui-dropdownWidget' )
9001 .append( this.$handle );
9002 this.$overlay.append( this.menu.$element );
9007 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
9008 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
9009 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
9010 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
9011 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
9012 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
9019 * @return {OO.ui.MenuSelectWidget} Menu of widget
9021 OO.ui.DropdownWidget.prototype.getMenu = function () {
9026 * Handles menu select events.
9029 * @param {OO.ui.MenuOptionWidget} item Selected menu item
9031 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
9035 this.setLabel( null );
9039 selectedLabel = item.getLabel();
9041 // If the label is a DOM element, clone it, because setLabel will append() it
9042 if ( selectedLabel instanceof $ ) {
9043 selectedLabel = selectedLabel.clone();
9046 this.setLabel( selectedLabel );
9050 * Handle menu toggle events.
9053 * @param {boolean} isVisible Open state of the menu
9055 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
9056 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
9060 * Handle mouse click events.
9063 * @param {jQuery.Event} e Mouse click event
9064 * @return {undefined|boolean} False to prevent default if event is handled
9066 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
9067 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
9074 * Handle key down events.
9077 * @param {jQuery.Event} e Key down event
9078 * @return {undefined|boolean} False to prevent default if event is handled
9080 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
9081 if ( !this.isDisabled() ) {
9082 switch ( e.keyCode ) {
9083 case OO.ui.Keys.ENTER:
9086 case OO.ui.Keys.SPACE:
9087 if ( this.menu.keyPressBuffer === '' ) {
9088 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
9089 // Space only opens the menu is the user is not typing to search.
9099 * Handle focus events.
9102 * @param {jQuery.Event} e Focus event
9104 OO.ui.DropdownWidget.prototype.onFocus = function () {
9105 this.menu.toggleScreenReaderMode( true );
9109 * Handle blur events.
9112 * @param {jQuery.Event} e Blur event
9114 OO.ui.DropdownWidget.prototype.onBlur = function () {
9115 this.menu.toggleScreenReaderMode( false );
9121 OO.ui.DropdownWidget.prototype.setLabelledBy = function ( id ) {
9122 const labelId = this.$label.attr( 'id' );
9125 this.$handle.attr( 'aria-labelledby', id + ' ' + labelId );
9127 this.$handle.attr( 'aria-labelledby', labelId );
9132 * RadioOptionWidget is an option widget that looks like a radio button.
9133 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
9134 * Please see the [OOUI documentation on MediaWiki][1] for more information.
9136 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
9139 * @extends OO.ui.OptionWidget
9142 * @param {Object} [config] Configuration options
9143 * @param {any} [config.data]
9145 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
9146 // Configuration initialization
9147 config = config || {};
9149 // Properties (must be done before parent constructor which calls #setDisabled)
9150 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
9152 // Parent constructor
9153 OO.ui.RadioOptionWidget.super.call( this, config );
9156 // Remove implicit role, we're handling it ourselves
9157 this.radio.$input.attr( 'role', 'presentation' );
9159 .addClass( 'oo-ui-radioOptionWidget' )
9160 .attr( { role: 'radio', 'aria-checked': 'false' } )
9161 .removeAttr( 'aria-selected' )
9162 .prepend( this.radio.$element );
9167 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
9169 /* Static Properties */
9175 OO.ui.RadioOptionWidget.static.highlightable = false;
9181 OO.ui.RadioOptionWidget.static.pressable = false;
9187 OO.ui.RadioOptionWidget.static.tagName = 'label';
9194 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
9195 OO.ui.RadioOptionWidget.super.prototype.setSelected.call( this, state );
9197 this.radio.setSelected( state );
9199 .attr( 'aria-checked', this.selected.toString() )
9200 .removeAttr( 'aria-selected' );
9208 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
9209 OO.ui.RadioOptionWidget.super.prototype.setDisabled.call( this, disabled );
9211 this.radio.setDisabled( this.isDisabled() );
9217 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
9218 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
9219 * an interface for adding, removing and selecting options.
9220 * Please see the [OOUI documentation on MediaWiki][1] for more information.
9222 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
9223 * OO.ui.RadioSelectInputWidget instead.
9225 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
9228 * // A RadioSelectWidget with RadioOptions.
9229 * const option1 = new OO.ui.RadioOptionWidget( {
9231 * label: 'Selected radio option'
9233 * option2 = new OO.ui.RadioOptionWidget( {
9235 * label: 'Unselected radio option'
9237 * radioSelect = new OO.ui.RadioSelectWidget( {
9238 * items: [ option1, option2 ]
9241 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
9242 * radioSelect.selectItem( option1 );
9244 * $( document.body ).append( radioSelect.$element );
9247 * @extends OO.ui.SelectWidget
9248 * @mixes OO.ui.mixin.TabIndexedElement
9251 * @param {Object} [config] Configuration options
9253 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
9254 // Parent constructor
9255 OO.ui.RadioSelectWidget.super.call( this, config );
9257 // Mixin constructors
9258 OO.ui.mixin.TabIndexedElement.call( this, config );
9262 focus: this.bindDocumentKeyDownListener.bind( this ),
9263 blur: this.unbindDocumentKeyDownListener.bind( this )
9268 .addClass( 'oo-ui-radioSelectWidget' )
9269 .attr( 'role', 'radiogroup' )
9270 // Not applicable to 'radiogroup', and it would always be 'false' anyway
9271 .removeAttr( 'aria-multiselectable' );
9276 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
9277 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
9280 * MultioptionWidgets are special elements that can be selected and configured with data. The
9281 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
9282 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
9283 * and examples, please see the [OOUI documentation on MediaWiki][1].
9285 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
9288 * @extends OO.ui.Widget
9289 * @mixes OO.ui.mixin.ItemWidget
9290 * @mixes OO.ui.mixin.LabelElement
9291 * @mixes OO.ui.mixin.TitledElement
9294 * @param {Object} [config] Configuration options
9295 * @param {boolean} [config.selected=false] Whether the option is initially selected
9297 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
9298 // Configuration initialization
9299 config = config || {};
9301 // Parent constructor
9302 OO.ui.MultioptionWidget.super.call( this, config );
9304 // Mixin constructors
9305 OO.ui.mixin.ItemWidget.call( this );
9306 OO.ui.mixin.LabelElement.call( this, config );
9307 OO.ui.mixin.TitledElement.call( this, config );
9310 this.selected = null;
9314 .addClass( 'oo-ui-multioptionWidget' )
9315 .append( this.$label );
9316 this.setSelected( config.selected );
9321 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
9322 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
9323 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
9324 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
9329 * A change event is emitted when the selected state of the option changes.
9331 * @event OO.ui.MultioptionWidget#change
9332 * @param {boolean} selected Whether the option is now selected
9338 * Check if the option is selected.
9340 * @return {boolean} Item is selected
9342 OO.ui.MultioptionWidget.prototype.isSelected = function () {
9343 return this.selected;
9347 * Set the option’s selected state. In general, all modifications to the selection
9348 * should be handled by the SelectWidget’s
9349 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
9351 * @param {boolean} [state=false] Select option
9353 * @return {OO.ui.Widget} The widget, for chaining
9355 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
9357 if ( this.selected !== state ) {
9358 this.selected = state;
9359 this.emit( 'change', state );
9360 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
9366 * MultiselectWidget allows selecting multiple options from a list.
9368 * For more information about menus and options, please see the [OOUI documentation
9371 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
9375 * @extends OO.ui.Widget
9376 * @mixes OO.ui.mixin.GroupWidget
9377 * @mixes OO.ui.mixin.TitledElement
9380 * @param {Object} [config] Configuration options
9381 * @param {OO.ui.MultioptionWidget[]} [config.items] An array of options to add to the multiselect.
9383 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
9384 // Parent constructor
9385 OO.ui.MultiselectWidget.super.call( this, config );
9387 // Configuration initialization
9388 config = config || {};
9390 // Mixin constructors
9391 OO.ui.mixin.GroupWidget.call( this, config );
9392 OO.ui.mixin.TitledElement.call( this, config );
9398 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
9399 // by GroupElement only when items are added/removed
9400 this.connect( this, {
9401 select: [ 'emit', 'change' ]
9405 this.addItems( config.items || [] );
9406 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
9407 this.$element.addClass( 'oo-ui-multiselectWidget' )
9408 .append( this.$group );
9413 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
9414 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
9415 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
9420 * A change event is emitted when the set of items changes, or an item is selected or deselected.
9422 * @event OO.ui.MultiselectWidget#change
9426 * A select event is emitted when an item is selected or deselected.
9428 * @event OO.ui.MultiselectWidget#select
9434 * Find options that are selected.
9436 * @return {OO.ui.MultioptionWidget[]} Selected options
9438 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
9439 return this.items.filter( ( item ) => item.isSelected() );
9443 * Find the data of options that are selected.
9445 * @return {Object[]|string[]} Values of selected options
9447 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
9448 return this.findSelectedItems().map( ( item ) => item.data );
9452 * Select options by reference. Options not mentioned in the `items` array will be deselected.
9454 * @param {OO.ui.MultioptionWidget[]} items Items to select
9456 * @return {OO.ui.Widget} The widget, for chaining
9458 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
9459 const itemsSet = new Set( items );
9460 this.items.forEach( ( item ) => {
9461 const selected = itemsSet.has( item );
9462 item.setSelected( selected );
9468 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
9470 * @param {Object[]|string[]} datas Values of items to select
9472 * @return {OO.ui.Widget} The widget, for chaining
9474 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
9475 const dataHashSet = new Set( datas.map( ( data ) => OO.getHash( data ) ) );
9476 this.items.forEach( ( item ) => {
9477 const selected = dataHashSet.has( OO.getHash( item.getData() ) );
9478 item.setSelected( selected );
9484 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
9485 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
9486 * Please see the [OOUI documentation on MediaWiki][1] for more information.
9488 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
9491 * @extends OO.ui.MultioptionWidget
9494 * @param {Object} [config] Configuration options
9496 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
9497 // Configuration initialization
9498 config = config || {};
9500 // Properties (must be done before parent constructor which calls #setDisabled)
9501 this.checkbox = new OO.ui.CheckboxInputWidget();
9503 // Parent constructor
9504 OO.ui.CheckboxMultioptionWidget.super.call( this, config );
9507 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
9508 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
9512 .addClass( 'oo-ui-checkboxMultioptionWidget' )
9513 .prepend( this.checkbox.$element );
9518 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
9520 /* Static Properties */
9526 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
9531 * Handle checkbox selected state change.
9535 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
9536 this.setSelected( this.checkbox.isSelected() );
9542 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
9543 OO.ui.CheckboxMultioptionWidget.super.prototype.setSelected.call( this, state );
9544 this.checkbox.setSelected( state );
9551 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
9552 OO.ui.CheckboxMultioptionWidget.super.prototype.setDisabled.call( this, disabled );
9553 this.checkbox.setDisabled( this.isDisabled() );
9560 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
9561 this.checkbox.focus();
9565 * Handle key down events.
9568 * @param {jQuery.Event} e
9570 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
9571 const element = this.getElementGroup();
9574 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
9575 nextItem = element.getRelativeFocusableItem( this, -1 );
9576 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
9577 nextItem = element.getRelativeFocusableItem( this, 1 );
9587 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
9588 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
9589 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
9590 * Please see the [OOUI documentation on MediaWiki][1] for more information.
9592 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
9593 * OO.ui.CheckboxMultiselectInputWidget instead.
9595 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
9598 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
9599 * const option1 = new OO.ui.CheckboxMultioptionWidget( {
9602 * label: 'Selected checkbox'
9604 * option2 = new OO.ui.CheckboxMultioptionWidget( {
9606 * label: 'Unselected checkbox'
9608 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
9609 * items: [ option1, option2 ]
9611 * $( document.body ).append( multiselect.$element );
9614 * @extends OO.ui.MultiselectWidget
9617 * @param {Object} [config] Configuration options
9619 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
9620 // Parent constructor
9621 OO.ui.CheckboxMultiselectWidget.super.call( this, config );
9624 this.$lastClicked = null;
9627 this.$group.on( 'click', this.onClick.bind( this ) );
9630 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
9635 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
9640 * Get an option by its position relative to the specified item (or to the start of the
9641 * option array, if item is `null`). The direction in which to search through the option array
9642 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
9643 * return an option, or `null` if there are no options in the array.
9645 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
9646 * `null` to start at the beginning of the array.
9647 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
9648 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
9651 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
9652 const increase = direction > 0 ? 1 : -1,
9653 len = this.items.length;
9657 const currentIndex = this.items.indexOf( item );
9658 nextIndex = ( currentIndex + increase + len ) % len;
9660 // If no item is selected and moving forward, start at the beginning.
9661 // If moving backward, start at the end.
9662 nextIndex = direction > 0 ? 0 : len - 1;
9665 for ( let i = 0; i < len; i++ ) {
9666 item = this.items[ nextIndex ];
9667 if ( item && !item.isDisabled() ) {
9670 nextIndex = ( nextIndex + increase + len ) % len;
9676 * Handle click events on checkboxes.
9678 * @param {jQuery.Event} e
9680 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
9681 const $lastClicked = this.$lastClicked,
9682 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
9683 .not( '.oo-ui-widget-disabled' );
9685 // Allow selecting multiple options at once by Shift-clicking them
9686 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
9687 const $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
9688 const lastClickedIndex = $options.index( $lastClicked );
9689 const nowClickedIndex = $options.index( $nowClicked );
9690 // If it's the same item, either the user is being silly, or it's a fake event generated
9691 // by the browser. In either case we don't need custom handling.
9692 if ( nowClickedIndex !== lastClickedIndex ) {
9693 const items = this.items;
9694 const wasSelected = items[ nowClickedIndex ].isSelected();
9695 const direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
9697 // This depends on the DOM order of the items and the order of the .items array being
9699 for ( let i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
9700 if ( !items[ i ].isDisabled() ) {
9701 items[ i ].setSelected( !wasSelected );
9704 // For the now-clicked element, use immediate timeout to allow the browser to do its own
9705 // handling first, then set our value. The order in which events happen is different for
9706 // clicks on the <input> and on the <label> and there are additional fake clicks fired
9707 // for non-click actions that change the checkboxes.
9710 if ( !items[ nowClickedIndex ].isDisabled() ) {
9711 items[ nowClickedIndex ].setSelected( !wasSelected );
9717 if ( $nowClicked.length ) {
9718 this.$lastClicked = $nowClicked;
9726 * @return {OO.ui.Widget} The widget, for chaining
9728 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
9729 if ( !this.isDisabled() ) {
9730 const item = this.getRelativeFocusableItem( null, 1 );
9741 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
9746 * Progress bars visually display the status of an operation, such as a download,
9747 * and can be either determinate or indeterminate:
9749 * - **determinate** process bars show the percent of an operation that is complete.
9751 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
9752 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
9753 * not use percentages.
9755 * The value of the `progress` configuration determines whether the bar is determinate
9759 * // Examples of determinate and indeterminate progress bars.
9760 * const progressBar1 = new OO.ui.ProgressBarWidget( {
9763 * const progressBar2 = new OO.ui.ProgressBarWidget();
9765 * // Create a FieldsetLayout to layout progress bars.
9766 * const fieldset = new OO.ui.FieldsetLayout;
9767 * fieldset.addItems( [
9768 * new OO.ui.FieldLayout( progressBar1, {
9769 * label: 'Determinate',
9772 * new OO.ui.FieldLayout( progressBar2, {
9773 * label: 'Indeterminate',
9777 * $( document.body ).append( fieldset.$element );
9780 * @extends OO.ui.Widget
9781 * @mixes OO.ui.mixin.PendingElement
9784 * @param {Object} [config] Configuration options
9785 * @param {number|boolean} [config.progress=false] The type of progress bar (determinate or indeterminate).
9786 * To create a determinate progress bar, specify a number that reflects the initial
9788 * By default, the progress bar is indeterminate.
9789 * @param {boolean} [config.inline=false] Use a smaller inline variant on the progress bar
9791 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
9792 // Configuration initialization
9793 config = config || {};
9795 // Parent constructor
9796 OO.ui.ProgressBarWidget.super.call( this, config );
9798 // Mixin constructors
9799 OO.ui.mixin.PendingElement.call( this, config );
9802 this.$bar = $( '<div>' );
9803 this.progress = null;
9806 this.setProgress( config.progress !== undefined ? config.progress : false );
9807 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
9810 role: 'progressbar',
9812 'aria-valuemax': 100
9814 .addClass( 'oo-ui-progressBarWidget' )
9815 .append( this.$bar );
9817 if ( config.inline ) {
9818 this.$element.addClass( 'oo-ui-progressBarWidget-inline' );
9824 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
9825 OO.mixinClass( OO.ui.ProgressBarWidget, OO.ui.mixin.PendingElement );
9827 /* Static Properties */
9833 OO.ui.ProgressBarWidget.static.tagName = 'div';
9838 * Get the percent of the progress that has been completed. Indeterminate progresses will
9841 * @return {number|boolean} Progress percent
9843 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
9844 return this.progress;
9848 * Set the percent of the process completed or `false` for an indeterminate process.
9850 * @param {number|boolean} progress Progress percent or `false` for indeterminate
9852 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
9853 this.progress = progress;
9855 if ( progress !== false ) {
9856 this.$bar.css( 'width', this.progress + '%' );
9857 this.$element.attr( 'aria-valuenow', this.progress );
9859 this.$bar.css( 'width', '' );
9860 this.$element.removeAttr( 'aria-valuenow' );
9862 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
9866 * InputWidget is the base class for all input widgets, which
9867 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
9868 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
9869 * {@link OO.ui.ButtonInputWidget button inputs}.
9870 * See the [OOUI documentation on MediaWiki][1] for more information and examples.
9872 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9876 * @extends OO.ui.Widget
9877 * @mixes OO.ui.mixin.TabIndexedElement
9878 * @mixes OO.ui.mixin.TitledElement
9879 * @mixes OO.ui.mixin.AccessKeyedElement
9882 * @param {Object} [config] Configuration options
9883 * @param {string} [config.name=''] The value of the input’s HTML `name` attribute.
9884 * @param {string} [config.value=''] The value of the input.
9885 * @param {string} [config.dir] The directionality of the input (ltr/rtl).
9886 * @param {string} [config.inputId] The value of the input’s HTML `id` attribute.
9887 * @param {Function} [config.inputFilter] The name of an input filter function. Input filters modify the
9888 * value of an input before it is accepted.
9890 OO.ui.InputWidget = function OoUiInputWidget( config ) {
9891 // Configuration initialization
9892 config = config || {};
9894 // Parent constructor
9895 OO.ui.InputWidget.super.call( this, config );
9898 // See #reusePreInfuseDOM about config.$input
9899 this.$input = config.$input || this.getInputElement( config );
9901 this.inputFilter = config.inputFilter;
9903 // Mixin constructors
9904 OO.ui.mixin.TabIndexedElement.call( this, Object.assign( {
9905 $tabIndexed: this.$input
9907 OO.ui.mixin.TitledElement.call( this, Object.assign( {
9908 $titled: this.$input
9910 OO.ui.mixin.AccessKeyedElement.call( this, Object.assign( {
9911 $accessKeyed: this.$input
9915 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9919 .addClass( 'oo-ui-inputWidget-input' )
9920 .attr( 'name', config.name )
9921 .prop( 'disabled', this.isDisabled() );
9923 .addClass( 'oo-ui-inputWidget' )
9924 .append( this.$input );
9925 this.setValue( config.value );
9927 this.setDir( config.dir );
9929 if ( config.inputId !== undefined ) {
9930 this.setInputId( config.inputId );
9936 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9937 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9938 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9939 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9941 /* Static Methods */
9946 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9947 const $input = $( node ).find( '.oo-ui-inputWidget-input' );
9948 config = OO.ui.InputWidget.super.static.reusePreInfuseDOM( node, config );
9949 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9950 if ( $input.length ) {
9951 config.$input = $input;
9959 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9960 const state = OO.ui.InputWidget.super.static.gatherPreInfuseState( node, config );
9961 if ( config.$input ) {
9962 state.value = config.$input.val();
9963 // Might be better in TabIndexedElement, but it's awkward to do there because
9964 // mixins are awkward
9965 state.focus = config.$input.is( ':focus' );
9973 * A change event is emitted when the value of the input changes.
9975 * @event OO.ui.InputWidget#change
9976 * @param {string} value
9982 * Get input element.
9984 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9985 * different circumstances. The element must have a `value` property (like form elements).
9988 * @param {Object} config Configuration options
9989 * @return {jQuery} Input element
9991 OO.ui.InputWidget.prototype.getInputElement = function () {
9992 return $( '<input>' );
9996 * Handle potentially value-changing events.
9999 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
10001 OO.ui.InputWidget.prototype.onEdit = function () {
10002 if ( !this.isDisabled() ) {
10003 this.setValue( this.$input.val() );
10004 // Allow the stack to clear so the value will be updated
10005 // TODO: This appears to only be used by TextInputWidget, and in current browsers
10006 // they always the value immediately, however it is mostly harmless so this can be
10007 // left in until more thoroughly tested.
10008 setTimeout( () => {
10009 this.setValue( this.$input.val() );
10015 * Get the value of the input.
10017 * @return {string} Input value
10019 OO.ui.InputWidget.prototype.getValue = function () {
10020 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
10021 // it, and we won't know unless they're kind enough to trigger a 'change' event.
10022 const value = this.$input.val();
10023 if ( this.value !== value ) {
10024 this.setValue( value );
10030 * Set the directionality of the input.
10032 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
10034 * @return {OO.ui.Widget} The widget, for chaining
10036 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
10037 this.$input.prop( 'dir', dir );
10042 * Set the value of the input.
10044 * @param {string} value New value
10045 * @fires OO.ui.InputWidget#change
10047 * @return {OO.ui.Widget} The widget, for chaining
10049 OO.ui.InputWidget.prototype.setValue = function ( value ) {
10050 value = this.cleanUpValue( value );
10051 // Update the DOM if it has changed. Note that with cleanUpValue, it
10052 // is possible for the DOM value to change without this.value changing.
10053 if ( this.$input.val() !== value ) {
10054 this.$input.val( value );
10056 if ( this.value !== value ) {
10057 this.value = value;
10058 this.emit( 'change', this.value );
10060 // The first time that the value is set (probably while constructing the widget),
10061 // remember it in defaultValue. This property can be later used to check whether
10062 // the value of the input has been changed since it was created.
10063 if ( this.defaultValue === undefined ) {
10064 this.defaultValue = this.value;
10065 this.$input[ 0 ].defaultValue = this.defaultValue;
10071 * Clean up incoming value.
10073 * Ensures value is a string, and converts undefined and null to empty string.
10076 * @param {string} value Original value
10077 * @return {string} Cleaned up value
10079 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
10080 if ( value === undefined || value === null ) {
10082 } else if ( this.inputFilter ) {
10083 return this.inputFilter( String( value ) );
10085 return String( value );
10092 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
10093 OO.ui.InputWidget.super.prototype.setDisabled.call( this, state );
10094 if ( this.$input ) {
10095 this.$input.prop( 'disabled', this.isDisabled() );
10101 * Set the 'id' attribute of the `<input>` element.
10103 * @param {string} id
10105 * @return {OO.ui.Widget} The widget, for chaining
10107 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
10108 this.$input.attr( 'id', id );
10115 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
10116 OO.ui.InputWidget.super.prototype.restorePreInfuseState.call( this, state );
10117 if ( state.value !== undefined && state.value !== this.getValue() ) {
10118 this.setValue( state.value );
10120 if ( state.focus ) {
10126 * Data widget intended for creating `<input type="hidden">` inputs.
10129 * @extends OO.ui.Widget
10132 * @param {Object} [config] Configuration options
10133 * @param {string} [config.value=''] The value of the input.
10134 * @param {string} [config.name=''] The value of the input’s HTML `name` attribute.
10136 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
10137 // Configuration initialization
10138 config = Object.assign( { value: '', name: '' }, config );
10140 // Parent constructor
10141 OO.ui.HiddenInputWidget.super.call( this, config );
10144 this.$element.attr( {
10146 value: config.value,
10149 this.$element.removeAttr( 'aria-disabled' );
10154 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
10156 /* Static Properties */
10162 OO.ui.HiddenInputWidget.static.tagName = 'input';
10165 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
10166 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
10167 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
10168 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
10169 * [OOUI documentation on MediaWiki][1] for more information.
10171 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
10174 * // A ButtonInputWidget rendered as an HTML button, the default.
10175 * const button = new OO.ui.ButtonInputWidget( {
10176 * label: 'Input button',
10180 * $( document.body ).append( button.$element );
10183 * @extends OO.ui.InputWidget
10184 * @mixes OO.ui.mixin.ButtonElement
10185 * @mixes OO.ui.mixin.IconElement
10186 * @mixes OO.ui.mixin.IndicatorElement
10187 * @mixes OO.ui.mixin.LabelElement
10188 * @mixes OO.ui.mixin.FlaggedElement
10191 * @param {Object} [config] Configuration options
10192 * @param {string} [config.type='button'] The value of the HTML `'type'` attribute:
10193 * 'button', 'submit' or 'reset'.
10194 * @param {boolean} [config.useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the
10195 * default. Widgets configured to be an `<input>` do not support {@link OO.ui.ButtonInputWidget#icon icons} and
10196 * {@link OO.ui.ButtonInputWidget#indicator indicators}, non-plaintext {@link OO.ui.ButtonInputWidget#label labels}, or {@link OO.ui.ButtonInputWidget#value values}. In
10197 * general, useInputTag should only be set to `true` when there’s need to support IE 6 in a form
10198 * with multiple buttons.
10199 * @param {boolean} [config.formNoValidate=false] Whether to use `formnovalidate` attribute.
10201 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
10202 // Configuration initialization
10203 config = Object.assign( { type: 'button', useInputTag: false, formNoValidate: false }, config );
10205 // See InputWidget#reusePreInfuseDOM about config.$input
10206 if ( config.$input ) {
10207 config.$input.empty();
10210 // Properties (must be set before parent constructor, which calls #setValue)
10211 this.useInputTag = config.useInputTag;
10213 // Parent constructor
10214 OO.ui.ButtonInputWidget.super.call( this, config );
10216 // Mixin constructors
10217 OO.ui.mixin.ButtonElement.call( this, Object.assign( {
10218 $button: this.$input
10220 OO.ui.mixin.IconElement.call( this, config );
10221 OO.ui.mixin.IndicatorElement.call( this, config );
10222 OO.ui.mixin.LabelElement.call( this, config );
10223 OO.ui.mixin.FlaggedElement.call( this, config );
10226 if ( !config.useInputTag ) {
10227 this.$input.append( this.$icon, this.$label, this.$indicator );
10230 if ( config.formNoValidate ) {
10231 this.$input.attr( 'formnovalidate', 'formnovalidate' );
10234 this.$element.addClass( 'oo-ui-buttonInputWidget' );
10239 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
10240 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
10241 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
10242 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
10243 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
10244 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.FlaggedElement );
10246 /* Static Properties */
10252 OO.ui.ButtonInputWidget.static.tagName = 'span';
10260 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
10261 const type = config.type === 'submit' || config.type === 'reset' ? config.type : 'button';
10262 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
10268 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
10270 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
10271 * text, or `null` for no label
10273 * @return {OO.ui.Widget} The widget, for chaining
10275 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
10276 label = OO.ui.resolveMsg( label );
10278 if ( this.useInputTag ) {
10279 // Discard non-plaintext labels
10280 if ( typeof label !== 'string' ) {
10284 this.$input.val( label );
10287 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
10291 * Set the value of the input.
10293 * This method is disabled for button inputs configured as {@link OO.ui.ButtonInputWidget#useInputTag <input> tags}, as
10294 * they do not support {@link OO.ui.ButtonInputWidget#value values}.
10296 * @param {string} value New value
10298 * @return {OO.ui.Widget} The widget, for chaining
10300 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
10301 if ( !this.useInputTag ) {
10302 OO.ui.ButtonInputWidget.super.prototype.setValue.call( this, value );
10310 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
10311 // Disable generating `<label>` elements for buttons. One would very rarely need additional
10312 // label for a button, and it's already a big clickable target, and it causes
10313 // unexpected rendering.
10318 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
10319 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
10320 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
10321 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
10323 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10325 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10328 * // An example of selected, unselected, and disabled checkbox inputs.
10329 * const checkbox1 = new OO.ui.CheckboxInputWidget( {
10333 * checkbox2 = new OO.ui.CheckboxInputWidget( {
10336 * checkbox3 = new OO.ui.CheckboxInputWidget( {
10340 * // Create a fieldset layout with fields for each checkbox.
10341 * fieldset = new OO.ui.FieldsetLayout( {
10342 * label: 'Checkboxes'
10344 * fieldset.addItems( [
10345 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
10346 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
10347 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
10349 * $( document.body ).append( fieldset.$element );
10352 * @extends OO.ui.InputWidget
10355 * @param {Object} [config] Configuration options
10356 * @param {boolean} [config.selected=false] Select the checkbox initially. By default, the checkbox is
10358 * @param {boolean} [config.indeterminate=false] Whether the checkbox is in the indeterminate state.
10360 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
10361 // Configuration initialization
10362 config = config || {};
10364 // Parent constructor
10365 OO.ui.CheckboxInputWidget.super.call( this, config );
10367 // Mixin constructors
10368 OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
10369 // TODO: Display the required indicator somewhere
10370 indicatorElement: null
10374 this.checkIcon = new OO.ui.IconWidget( {
10376 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
10381 .addClass( 'oo-ui-checkboxInputWidget' )
10382 // Required for pretty styling in WikimediaUI theme
10383 .append( this.checkIcon.$element );
10384 this.setSelected( config.selected !== undefined ? config.selected : false );
10385 this.setIndeterminate( config.indeterminate !== undefined ? config.indeterminate : false );
10390 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
10391 OO.mixinClass( OO.ui.CheckboxInputWidget, OO.ui.mixin.RequiredElement );
10396 * A change event is emitted when the state of the input changes.
10398 * @event OO.ui.CheckboxInputWidget#change
10399 * @param {boolean} selected
10400 * @param {boolean} indeterminate
10403 /* Static Properties */
10408 OO.ui.CheckboxInputWidget.static.tagName = 'span';
10410 /* Static Methods */
10415 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10416 const state = OO.ui.CheckboxInputWidget.super.static.gatherPreInfuseState( node, config );
10417 if ( config.$input ) {
10418 state.checked = config.$input.prop( 'checked' );
10429 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
10430 return $( '<input>' ).attr( 'type', 'checkbox' );
10436 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
10437 if ( !this.isDisabled() ) {
10438 // Allow the stack to clear so the value will be updated
10439 setTimeout( () => {
10440 this.setSelected( this.$input.prop( 'checked' ) );
10441 this.setIndeterminate( this.$input.prop( 'indeterminate' ) );
10447 * Set selection state of this checkbox.
10449 * @param {boolean} [state=false] Selected state
10450 * @param {boolean} [internal=false] Used for internal calls to suppress events
10452 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
10454 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state, internal ) {
10456 if ( this.selected !== state ) {
10457 this.selected = state;
10458 this.$input.prop( 'checked', this.selected );
10460 this.setIndeterminate( false, true );
10461 this.emit( 'change', this.selected, this.indeterminate );
10464 // The first time that the selection state is set (probably while constructing the widget),
10465 // remember it in defaultSelected. This property can be later used to check whether
10466 // the selection state of the input has been changed since it was created.
10467 if ( this.defaultSelected === undefined ) {
10468 this.defaultSelected = this.selected;
10469 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10475 * Check if this checkbox is selected.
10477 * @return {boolean} Checkbox is selected
10479 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
10480 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
10481 // it, and we won't know unless they're kind enough to trigger a 'change' event.
10482 const selected = this.$input.prop( 'checked' );
10483 if ( this.selected !== selected ) {
10484 this.setSelected( selected );
10486 return this.selected;
10490 * Set indeterminate state of this checkbox.
10492 * @param {boolean} [state=false] Indeterminate state
10493 * @param {boolean} [internal=false] Used for internal calls to suppress events
10495 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
10497 OO.ui.CheckboxInputWidget.prototype.setIndeterminate = function ( state, internal ) {
10499 if ( this.indeterminate !== state ) {
10500 this.indeterminate = state;
10501 this.$input.prop( 'indeterminate', this.indeterminate );
10503 this.setSelected( false, true );
10504 this.emit( 'change', this.selected, this.indeterminate );
10511 * Check if this checkbox is selected.
10513 * @return {boolean} Checkbox is selected
10515 OO.ui.CheckboxInputWidget.prototype.isIndeterminate = function () {
10516 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
10517 // it, and we won't know unless they're kind enough to trigger a 'change' event.
10518 const indeterminate = this.$input.prop( 'indeterminate' );
10519 if ( this.indeterminate !== indeterminate ) {
10520 this.setIndeterminate( indeterminate );
10522 return this.indeterminate;
10528 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
10529 if ( !this.isDisabled() ) {
10530 this.$handle.trigger( 'click' );
10538 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
10539 OO.ui.CheckboxInputWidget.super.prototype.restorePreInfuseState.call( this, state );
10540 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10541 this.setSelected( state.checked );
10546 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
10547 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
10548 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10549 * more information about input widgets.
10551 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
10552 * are no options. If no `value` configuration option is provided, the first option is selected.
10553 * If you need a state representing no value (no option being selected), use a DropdownWidget.
10555 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
10557 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10560 * // A DropdownInputWidget with three options.
10561 * const dropdownInput = new OO.ui.DropdownInputWidget( {
10563 * { data: 'a', label: 'First' },
10564 * { data: 'b', label: 'Second', disabled: true },
10565 * { optgroup: 'Group label' },
10566 * { data: 'c', label: 'First sub-item)' }
10569 * $( document.body ).append( dropdownInput.$element );
10572 * @extends OO.ui.InputWidget
10575 * @param {Object} [config] Configuration options
10576 * @param {Object[]} [config.options=[]] Array of menu options in the format described above.
10577 * @param {Object} [config.dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
10578 * @param {jQuery|boolean} [config.$overlay] Render the menu into a separate layer. This configuration is
10579 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
10580 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
10581 * the menu uses relative positioning. Pass 'true' to use the default overlay.
10582 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
10584 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
10585 // Configuration initialization
10586 config = config || {};
10588 // Properties (must be done before parent constructor which calls #setDisabled)
10589 this.dropdownWidget = new OO.ui.DropdownWidget( Object.assign(
10591 $overlay: config.$overlay
10595 // Set up the options before parent constructor, which uses them to validate config.value.
10596 // Use this instead of setOptions() because this.$input is not set up yet.
10597 this.setOptionsData( config.options || [] );
10599 // Parent constructor
10600 OO.ui.DropdownInputWidget.super.call( this, config );
10602 // Mixin constructors
10603 OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
10604 // TODO: Display the required indicator somewhere
10605 indicatorElement: null
10609 this.dropdownWidget.getMenu().connect( this, {
10610 select: 'onMenuSelect'
10615 .addClass( 'oo-ui-dropdownInputWidget' )
10616 .append( this.dropdownWidget.$element );
10617 if ( OO.ui.isMobile() ) {
10618 this.$element.addClass( 'oo-ui-isMobile' );
10620 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
10621 this.setTitledElement( this.dropdownWidget.$handle );
10626 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
10627 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.RequiredElement );
10635 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
10636 return $( '<select>' ).addClass( 'oo-ui-indicator-down' );
10640 * Handles menu select events.
10643 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
10645 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
10646 this.setValue( item ? item.getData() : '' );
10652 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
10653 value = this.cleanUpValue( value );
10654 // Only allow setting values that are actually present in the dropdown
10655 const selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
10656 this.dropdownWidget.getMenu().findFirstSelectableItem();
10657 this.dropdownWidget.getMenu().selectItem( selected );
10658 value = selected ? selected.getData() : '';
10659 OO.ui.DropdownInputWidget.super.prototype.setValue.call( this, value );
10660 if ( this.optionsDirty ) {
10661 // We reached this from the constructor or from #setOptions.
10662 // We have to update the <select> element.
10663 this.updateOptionsInterface();
10671 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
10672 this.dropdownWidget.setDisabled( state );
10673 OO.ui.DropdownInputWidget.super.prototype.setDisabled.call( this, state );
10678 * Set the options available for this input.
10680 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10682 * @return {OO.ui.Widget} The widget, for chaining
10684 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
10685 const value = this.getValue();
10687 this.setOptionsData( options );
10689 // Re-set the value to update the visible interface (DropdownWidget and <select>).
10690 // In case the previous value is no longer an available option, select the first valid one.
10691 this.setValue( value );
10697 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10699 * This method may be called before the parent constructor, so various properties may not be
10702 * @param {Object[]} options Array of menu options (see #constructor for details).
10705 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
10706 this.optionsDirty = true;
10708 // Go through all the supplied option configs and create either
10709 // MenuSectionOption or MenuOption widgets from each.
10710 const optionWidgets = [];
10711 let previousOptgroup;
10712 for ( let optIndex = 0; optIndex < options.length; optIndex++ ) {
10713 const opt = options[ optIndex ];
10716 if ( opt.optgroup !== undefined ) {
10717 // Create a <optgroup> menu item.
10718 optionWidget = this.createMenuSectionOptionWidget( opt.optgroup );
10719 previousOptgroup = optionWidget;
10722 // Create a normal <option> menu item.
10723 const optValue = this.cleanUpValue( opt.data );
10724 optionWidget = this.createMenuOptionWidget(
10726 opt.label !== undefined ? opt.label : optValue
10730 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
10732 opt.disabled !== undefined ||
10733 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
10734 previousOptgroup.isDisabled()
10736 optionWidget.setDisabled( true );
10739 optionWidgets.push( optionWidget );
10742 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
10746 * Create a menu option widget.
10749 * @param {string} data Item data
10750 * @param {string} label Item label
10751 * @return {OO.ui.MenuOptionWidget} Option widget
10753 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
10754 return new OO.ui.MenuOptionWidget( {
10761 * Create a menu section option widget.
10764 * @param {string} label Section item label
10765 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
10767 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
10768 return new OO.ui.MenuSectionOptionWidget( {
10774 * Update the user-visible interface to match the internal list of options and value.
10776 * This method must only be called after the parent constructor.
10780 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
10781 let $optionsContainer = this.$input;
10783 const defaultValue = this.defaultValue;
10785 this.$input.empty();
10787 this.dropdownWidget.getMenu().getItems().forEach( ( optionWidget ) => {
10790 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
10791 $optionNode = $( '<option>' )
10792 .attr( 'value', optionWidget.getData() )
10793 .text( optionWidget.getLabel() );
10795 // Remember original selection state. This property can be later used to check whether
10796 // the selection state of the input has been changed since it was created.
10797 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
10799 $optionsContainer.append( $optionNode );
10801 $optionNode = $( '<optgroup>' )
10802 .attr( 'label', optionWidget.getLabel() );
10803 this.$input.append( $optionNode );
10804 $optionsContainer = $optionNode;
10807 // Disable the option or optgroup if required.
10808 if ( optionWidget.isDisabled() ) {
10809 $optionNode.prop( 'disabled', true );
10813 this.optionsDirty = false;
10819 OO.ui.DropdownInputWidget.prototype.focus = function () {
10820 this.dropdownWidget.focus();
10827 OO.ui.DropdownInputWidget.prototype.blur = function () {
10828 this.dropdownWidget.blur();
10835 OO.ui.DropdownInputWidget.prototype.setLabelledBy = function ( id ) {
10836 this.dropdownWidget.setLabelledBy( id );
10840 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
10841 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
10842 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
10843 * please see the [OOUI documentation on MediaWiki][1].
10845 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10847 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10850 * // An example of selected, unselected, and disabled radio inputs
10851 * const radio1 = new OO.ui.RadioInputWidget( {
10855 * const radio2 = new OO.ui.RadioInputWidget( {
10858 * const radio3 = new OO.ui.RadioInputWidget( {
10862 * // Create a fieldset layout with fields for each radio button.
10863 * const fieldset = new OO.ui.FieldsetLayout( {
10864 * label: 'Radio inputs'
10866 * fieldset.addItems( [
10867 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
10868 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
10869 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
10871 * $( document.body ).append( fieldset.$element );
10874 * @extends OO.ui.InputWidget
10877 * @param {Object} [config] Configuration options
10878 * @param {boolean} [config.selected=false] Select the radio button initially. By default, the radio button
10881 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
10882 // Configuration initialization
10883 config = config || {};
10885 // Parent constructor
10886 OO.ui.RadioInputWidget.super.call( this, config );
10888 // Mixin constructors
10889 OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
10890 // TODO: Display the required indicator somewhere
10891 indicatorElement: null
10896 .addClass( 'oo-ui-radioInputWidget' )
10897 // Required for pretty styling in WikimediaUI theme
10898 .append( $( '<span>' ) );
10899 this.setSelected( config.selected !== undefined ? config.selected : false );
10904 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
10905 OO.mixinClass( OO.ui.RadioInputWidget, OO.ui.mixin.RequiredElement );
10907 /* Static Properties */
10913 OO.ui.RadioInputWidget.static.tagName = 'span';
10915 /* Static Methods */
10920 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10921 const state = OO.ui.RadioInputWidget.super.static.gatherPreInfuseState( node, config );
10922 if ( config.$input ) {
10923 state.checked = config.$input.prop( 'checked' );
10934 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
10935 return $( '<input>' ).attr( 'type', 'radio' );
10941 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10942 // RadioInputWidget doesn't track its state.
10946 * Set selection state of this radio button.
10948 * @param {boolean} state `true` for selected
10950 * @return {OO.ui.Widget} The widget, for chaining
10952 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10953 // RadioInputWidget doesn't track its state.
10954 this.$input.prop( 'checked', state );
10955 // The first time that the selection state is set (probably while constructing the widget),
10956 // remember it in defaultSelected. This property can be later used to check whether
10957 // the selection state of the input has been changed since it was created.
10958 if ( this.defaultSelected === undefined ) {
10959 this.defaultSelected = state;
10960 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10966 * Check if this radio button is selected.
10968 * @return {boolean} Radio is selected
10970 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10971 return this.$input.prop( 'checked' );
10977 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10978 if ( !this.isDisabled() ) {
10979 this.$input.trigger( 'click' );
10987 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10988 OO.ui.RadioInputWidget.super.prototype.restorePreInfuseState.call( this, state );
10989 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10990 this.setSelected( state.checked );
10995 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10996 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10997 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10998 * more information about input widgets.
11000 * This and OO.ui.DropdownInputWidget support similar configuration options.
11002 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11005 * // A RadioSelectInputWidget with three options
11006 * const radioSelectInput = new OO.ui.RadioSelectInputWidget( {
11008 * { data: 'a', label: 'First' },
11009 * { data: 'b', label: 'Second'},
11010 * { data: 'c', label: 'Third' }
11013 * $( document.body ).append( radioSelectInput.$element );
11016 * @extends OO.ui.InputWidget
11019 * @param {Object} [config] Configuration options
11020 * @param {Object[]} [config.options=[]] Array of menu options in the format `{ data: …, label: … }`
11022 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
11023 // Configuration initialization
11024 config = config || {};
11026 // Properties (must be done before parent constructor which calls #setDisabled)
11027 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
11028 // Set up the options before parent constructor, which uses them to validate config.value.
11029 // Use this instead of setOptions() because this.$input is not set up yet
11030 this.setOptionsData( config.options || [] );
11032 // Parent constructor
11033 OO.ui.RadioSelectInputWidget.super.call( this, config );
11036 this.radioSelectWidget.connect( this, {
11037 select: 'onMenuSelect'
11042 .addClass( 'oo-ui-radioSelectInputWidget' )
11043 .append( this.radioSelectWidget.$element );
11044 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
11049 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
11051 /* Static Methods */
11056 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11057 const state = OO.ui.RadioSelectInputWidget.super.static.gatherPreInfuseState( node, config );
11058 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
11065 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
11066 config = OO.ui.RadioSelectInputWidget.super.static.reusePreInfuseDOM( node, config );
11067 // Cannot reuse the `<input type=radio>` set
11068 delete config.$input;
11078 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
11079 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
11080 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
11081 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
11085 * Handles menu select events.
11088 * @param {OO.ui.RadioOptionWidget} item Selected menu item
11090 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
11091 this.setValue( item.getData() );
11097 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
11098 value = this.cleanUpValue( value );
11099 // Only allow setting values that are actually present in the dropdown
11100 const selected = this.radioSelectWidget.findItemFromData( value ) ||
11101 this.radioSelectWidget.findFirstSelectableItem();
11102 this.radioSelectWidget.selectItem( selected );
11103 value = selected ? selected.getData() : '';
11104 OO.ui.RadioSelectInputWidget.super.prototype.setValue.call( this, value );
11111 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
11112 this.radioSelectWidget.setDisabled( state );
11113 OO.ui.RadioSelectInputWidget.super.prototype.setDisabled.call( this, state );
11118 * Set the options available for this input.
11120 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11122 * @return {OO.ui.Widget} The widget, for chaining
11124 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
11125 const value = this.getValue();
11127 this.setOptionsData( options );
11129 // Re-set the value to update the visible interface (RadioSelectWidget).
11130 // In case the previous value is no longer an available option, select the first valid one.
11131 this.setValue( value );
11137 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
11139 * This method may be called before the parent constructor, so various properties may not be
11142 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11145 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
11146 this.radioSelectWidget
11148 .addItems( options.map( ( opt ) => {
11149 const optValue = this.cleanUpValue( opt.data );
11150 return new OO.ui.RadioOptionWidget( {
11152 label: opt.label !== undefined ? opt.label : optValue
11160 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
11161 this.radioSelectWidget.focus();
11168 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
11169 this.radioSelectWidget.blur();
11174 * CheckboxMultiselectInputWidget is a
11175 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
11176 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
11177 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
11178 * more information about input widgets.
11180 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11183 * // A CheckboxMultiselectInputWidget with three options.
11184 * const multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
11186 * { data: 'a', label: 'First' },
11187 * { data: 'b', label: 'Second' },
11188 * { data: 'c', label: 'Third' }
11191 * $( document.body ).append( multiselectInput.$element );
11194 * @extends OO.ui.InputWidget
11197 * @param {Object} [config] Configuration options
11198 * @param {Object[]} [config.options=[]] Array of menu options in the format
11199 * `{ data: …, label: …, disabled: … }`
11201 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
11202 // Configuration initialization
11203 config = config || {};
11205 // Properties (must be done before parent constructor which calls #setDisabled)
11206 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
11207 // Must be set before the #setOptionsData call below
11208 this.inputName = config.name;
11209 // Set up the options before parent constructor, which uses them to validate config.value.
11210 // Use this instead of setOptions() because this.$input is not set up yet
11211 this.setOptionsData( config.options || [] );
11213 // Parent constructor
11214 OO.ui.CheckboxMultiselectInputWidget.super.call( this, config );
11217 // HACK: When selecting multiple items, the 'select' event is fired after every item, and our
11218 // handler performs a linear-time operation (validating every selected value), so selecting
11219 // multiple items becomes quadratic (T335082#8815547). Debounce it, so that it only executes
11220 // once at the end of the selecting, making it linear again. This will make the internal state
11221 // momentarily inconsistent while the selecting is ongoing, but that's probably fine.
11222 this.onCheckboxesSelectHandler = OO.ui.debounce( this.onCheckboxesSelect );
11223 this.checkboxMultiselectWidget.connect( this, {
11224 select: 'onCheckboxesSelectHandler'
11229 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
11230 .append( this.checkboxMultiselectWidget.$element );
11231 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
11232 this.$input.detach();
11237 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
11239 /* Static Methods */
11244 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11245 const state = OO.ui.CheckboxMultiselectInputWidget.super.static.gatherPreInfuseState(
11248 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
11249 .toArray().map( ( el ) => el.value );
11256 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
11257 config = OO.ui.CheckboxMultiselectInputWidget.super.static.reusePreInfuseDOM( node, config );
11258 // Cannot reuse the `<input type=checkbox>` set
11259 delete config.$input;
11269 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
11271 return $( '<unused>' );
11275 * Handles CheckboxMultiselectWidget select events.
11279 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
11280 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
11286 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
11287 const value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
11288 .toArray().map( ( el ) => el.value );
11289 if ( !OO.compare( this.value, value ) ) {
11290 this.setValue( value );
11298 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
11299 value = this.cleanUpValue( value );
11300 this.checkboxMultiselectWidget.selectItemsByData( value );
11301 OO.ui.CheckboxMultiselectInputWidget.super.prototype.setValue.call( this, value );
11302 if ( this.optionsDirty ) {
11303 // We reached this from the constructor or from #setOptions.
11304 // We have to update the <select> element.
11305 this.updateOptionsInterface();
11311 * Clean up incoming value.
11313 * @param {string[]} value Original value
11314 * @return {string[]} Cleaned up value
11316 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
11317 const cleanValue = [];
11318 if ( !Array.isArray( value ) ) {
11321 const dataHashSet = new Set( this.checkboxMultiselectWidget.getItems().map( ( item ) => OO.getHash( item.getData() ) ) );
11322 for ( let i = 0; i < value.length; i++ ) {
11323 const singleValue = OO.ui.CheckboxMultiselectInputWidget.super.prototype.cleanUpValue
11324 .call( this, value[ i ] );
11325 // Remove options that we don't have here
11326 if ( !dataHashSet.has( OO.getHash( singleValue ) ) ) {
11329 cleanValue.push( singleValue );
11337 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
11338 this.checkboxMultiselectWidget.setDisabled( state );
11339 OO.ui.CheckboxMultiselectInputWidget.super.prototype.setDisabled.call( this, state );
11344 * Set the options available for this input.
11346 * @param {Object[]} options Array of menu options in the format
11347 * `{ data: …, label: …, disabled: … }`
11349 * @return {OO.ui.Widget} The widget, for chaining
11351 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
11352 const value = this.getValue();
11354 this.setOptionsData( options );
11356 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
11357 // This will also get rid of any stale options that we just removed.
11358 this.setValue( value );
11364 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
11366 * This method may be called before the parent constructor, so various properties may not be
11369 * @param {Object[]} options Array of menu options in the format
11370 * `{ data: …, label: … }`
11373 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
11374 this.optionsDirty = true;
11376 this.checkboxMultiselectWidget
11378 .addItems( options.map( ( opt ) => {
11379 const optValue = OO.ui.CheckboxMultiselectInputWidget.super.prototype.cleanUpValue
11380 .call( this, opt.data );
11381 const optDisabled = opt.disabled !== undefined ? opt.disabled : false;
11382 const item = new OO.ui.CheckboxMultioptionWidget( {
11384 label: opt.label !== undefined ? opt.label : optValue,
11385 disabled: optDisabled
11387 // Set the 'name' and 'value' for form submission
11388 item.checkbox.$input.attr( 'name', this.inputName );
11389 item.checkbox.setValue( optValue );
11395 * Update the user-visible interface to match the internal list of options and value.
11397 * This method must only be called after the parent constructor.
11401 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
11402 const defaultValueSet = new Set( this.defaultValue );
11404 this.checkboxMultiselectWidget.getItems().forEach( ( item ) => {
11405 // Remember original selection state. This property can be later used to check whether
11406 // the selection state of the input has been changed since it was created.
11407 const isDefault = defaultValueSet.has( item.getData() );
11408 item.checkbox.defaultSelected = isDefault;
11409 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
11412 this.optionsDirty = false;
11418 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
11419 this.checkboxMultiselectWidget.focus();
11424 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
11425 * size of the field as well as its presentation. In addition, these widgets can be configured
11426 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
11427 * optional validation-pattern (used to determine if an input value is valid or not) and an input
11428 * filter, which modifies incoming values rather than validating them.
11429 * Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
11431 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11433 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11436 * // A TextInputWidget.
11437 * const textInput = new OO.ui.TextInputWidget( {
11438 * value: 'Text input'
11440 * $( document.body ).append( textInput.$element );
11443 * @extends OO.ui.InputWidget
11444 * @mixes OO.ui.mixin.IconElement
11445 * @mixes OO.ui.mixin.IndicatorElement
11446 * @mixes OO.ui.mixin.PendingElement
11447 * @mixes OO.ui.mixin.LabelElement
11448 * @mixes OO.ui.mixin.FlaggedElement
11451 * @param {Object} [config] Configuration options
11452 * @param {string} [config.type='text'] The value of the HTML `type` attribute: 'text', 'password'
11453 * 'email', 'url' or 'number'. Subclasses might support other types.
11454 * @param {string} [config.placeholder] Placeholder text
11455 * @param {boolean} [config.autofocus=false] Use an HTML `autofocus` attribute to
11456 * instruct the browser to focus this widget.
11457 * @param {boolean} [config.readOnly=false] Prevent changes to the value of the text input.
11458 * @param {number} [config.maxLength] Maximum number of characters allowed in the input.
11459 * @param {number} [config.minLength] Minimum number of characters allowed in the input.
11461 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
11462 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
11463 * many emojis) count as 2 characters each.
11464 * @param {string} [config.labelPosition='after'] The position of the inline label relative to that of
11465 * the value or placeholder text: `'before'` or `'after'`
11466 * @param {boolean|string} [config.autocomplete] Should the browser support autocomplete for this field?
11467 * Type hints such as 'email' are also allowed.
11468 * @param {boolean} [config.spellcheck] Should the browser support spellcheck for this field (`undefined`
11469 * means leaving it up to the browser).
11470 * @param {RegExp|Function|string} [config.validate] Validation pattern: when string, a symbolic name of a
11471 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
11472 * (the value must contain only numbers); when RegExp, a regular expression that must match the
11473 * value for it to be considered valid; when Function, a function receiving the value as parameter
11474 * that must return true, or promise resolving to true, for it to be considered valid.
11476 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
11477 // Configuration initialization
11478 config = Object.assign( {
11479 labelPosition: 'after'
11481 config.type = this.getValidType( config );
11482 if ( config.autocomplete === false ) {
11483 config.autocomplete = 'off';
11484 } else if ( config.autocomplete === true ) {
11485 config.autocomplete = 'on';
11488 // Parent constructor
11489 OO.ui.TextInputWidget.super.call( this, config );
11491 // Mixin constructors
11492 OO.ui.mixin.IconElement.call( this, config );
11493 OO.ui.mixin.IndicatorElement.call( this, config );
11494 OO.ui.mixin.PendingElement.call( this, Object.assign( { $pending: this.$input }, config ) );
11495 OO.ui.mixin.LabelElement.call( this, config );
11496 OO.ui.mixin.FlaggedElement.call( this, config );
11497 OO.ui.mixin.RequiredElement.call( this, config );
11500 this.type = config.type;
11501 this.readOnly = false;
11502 this.validate = null;
11503 this.scrollWidth = null;
11505 this.setValidation( config.validate );
11506 this.setLabelPosition( config.labelPosition );
11510 keypress: this.onKeyPress.bind( this ),
11511 blur: this.onBlur.bind( this ),
11512 focus: this.onFocus.bind( this )
11514 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
11515 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
11516 this.on( 'labelChange', this.updatePosition.bind( this ) );
11517 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
11521 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + config.type )
11522 .append( this.$icon, this.$indicator );
11523 this.setReadOnly( !!config.readOnly );
11524 if ( config.placeholder !== undefined ) {
11525 this.$input.attr( 'placeholder', config.placeholder );
11527 if ( config.maxLength !== undefined ) {
11528 this.$input.attr( 'maxlength', config.maxLength );
11530 if ( config.minLength !== undefined ) {
11531 this.$input.attr( 'minlength', config.minLength );
11533 if ( config.autofocus ) {
11534 this.$input.attr( 'autofocus', 'autofocus' );
11536 if ( config.autocomplete !== null && config.autocomplete !== undefined ) {
11537 this.$input.attr( 'autocomplete', config.autocomplete );
11538 if ( config.autocomplete === 'off' ) {
11539 // Turning off autocompletion also disables "form caching" when the user navigates to a
11540 // different page and then clicks "Back". Re-enable it when leaving.
11541 // Borrowed from jQuery UI.
11543 beforeunload: function () {
11544 this.$input.removeAttr( 'autocomplete' );
11546 pageshow: function () {
11547 // Browsers don't seem to actually fire this event on "Back", they instead just
11548 // reload the whole page... it shouldn't hurt, though.
11549 this.$input.attr( 'autocomplete', 'off' );
11554 if ( config.spellcheck !== undefined ) {
11555 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
11557 if ( this.label ) {
11558 this.isWaitingToBeAttached = true;
11559 this.installParentChangeDetector();
11565 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
11566 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
11567 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
11568 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
11569 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
11570 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.FlaggedElement );
11571 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.RequiredElement );
11573 /* Static Properties */
11575 OO.ui.TextInputWidget.static.validationPatterns = {
11583 * An `enter` event is emitted when the user presses Enter key inside the text box.
11585 * @event OO.ui.TextInputWidget#enter
11591 * Focus the input element when clicking on the icon.
11594 * @param {jQuery.Event} e Mouse down event
11595 * @return {undefined|boolean} False to prevent default if event is handled
11597 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
11598 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11605 * Focus the input element when clicking on the indicator. This default implementation is
11606 * effectively only suitable for the 'required' indicator. If you are looking for functional 'clear'
11607 * or 'down' indicators, you might want to use the {@link OO.ui.SearchInputWidget SearchInputWidget}
11608 * or {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} subclasses.
11611 * @param {jQuery.Event} e Mouse down event
11612 * @return {undefined|boolean} False to prevent default if event is handled
11614 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11615 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11622 * Handle key press events.
11625 * @param {jQuery.Event} e Key press event
11626 * @fires OO.ui.TextInputWidget#enter If Enter key is pressed
11628 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
11629 if ( e.which === OO.ui.Keys.ENTER ) {
11630 this.emit( 'enter', e );
11635 * Handle blur events.
11638 * @param {jQuery.Event} e Blur event
11640 OO.ui.TextInputWidget.prototype.onBlur = function () {
11641 this.setValidityFlag();
11645 * Handle focus events.
11648 * @param {jQuery.Event} e Focus event
11650 OO.ui.TextInputWidget.prototype.onFocus = function () {
11651 if ( this.isWaitingToBeAttached ) {
11652 // If we've received focus, then we must be attached to the document, and if
11653 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
11654 this.onElementAttach();
11656 this.setValidityFlag( true );
11660 * Handle element attach events.
11663 * @param {jQuery.Event} e Element attach event
11665 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
11666 this.isWaitingToBeAttached = false;
11667 // Any previously calculated size is now probably invalid if we reattached elsewhere
11668 this.valCache = null;
11669 this.positionLabel();
11673 * Handle debounced change events.
11675 * @param {string} value
11678 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
11679 this.setValidityFlag();
11683 * Check if the input is {@link OO.ui.TextInputWidget#readOnly read-only}.
11685 * @return {boolean}
11687 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
11688 return this.readOnly;
11692 * Set the {@link OO.ui.TextInputWidget#readOnly read-only} state of the input.
11694 * @param {boolean} [state=false] Make input read-only
11696 * @return {OO.ui.Widget} The widget, for chaining
11698 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
11699 this.readOnly = !!state;
11700 this.$input.prop( 'readOnly', this.readOnly );
11705 * Support function for making #onElementAttach work.
11707 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
11708 this.connectDetectorNode = document.createElement( 'ooui-connect-detector' );
11709 this.connectDetectorNode.onConnectOOUI = () => {
11710 if ( this.isElementAttached() ) {
11711 this.onElementAttach();
11715 this.$element.append( this.connectDetectorNode );
11722 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
11723 const $input = $( '<input>' ).attr( 'type', config.type );
11725 if ( config.type === 'number' ) {
11726 $input.attr( 'step', 'any' );
11733 * Get sanitized value for 'type' for given config. Subclasses might support other types.
11735 * @param {Object} config Configuration options
11736 * @param {string} [config.type='text']
11740 OO.ui.TextInputWidget.prototype.getValidType = function ( config ) {
11741 const allowedTypes = [
11748 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
11752 * Focus the input and select a specified range within the text.
11754 * @param {number} from Select from offset
11755 * @param {number} [to=from] Select to offset
11757 * @return {OO.ui.Widget} The widget, for chaining
11759 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
11760 const input = this.$input[ 0 ];
11764 const isBackwards = to < from,
11765 start = isBackwards ? to : from,
11766 end = isBackwards ? from : to;
11771 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
11773 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
11774 // Rather than expensively check if the input is attached every time, just check
11775 // if it was the cause of an error being thrown. If not, rethrow the error.
11776 if ( this.getElementDocument().body.contains( input ) ) {
11784 * Get an object describing the current selection range in a directional manner
11786 * @return {Object} Object containing 'from' and 'to' offsets
11788 OO.ui.TextInputWidget.prototype.getRange = function () {
11789 const input = this.$input[ 0 ],
11790 start = input.selectionStart,
11791 end = input.selectionEnd,
11792 isBackwards = input.selectionDirection === 'backward';
11795 from: isBackwards ? end : start,
11796 to: isBackwards ? start : end
11801 * Get the length of the text input value.
11803 * This could differ from the length of #getValue if the
11804 * value gets filtered
11806 * @return {number} Input length
11808 OO.ui.TextInputWidget.prototype.getInputLength = function () {
11809 return this.$input[ 0 ].value.length;
11813 * Focus the input and select the entire text.
11816 * @return {OO.ui.Widget} The widget, for chaining
11818 OO.ui.TextInputWidget.prototype.select = function () {
11819 return this.selectRange( 0, this.getInputLength() );
11823 * Focus the input and move the cursor to the start.
11826 * @return {OO.ui.Widget} The widget, for chaining
11828 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
11829 return this.selectRange( 0 );
11833 * Focus the input and move the cursor to the end.
11836 * @return {OO.ui.Widget} The widget, for chaining
11838 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
11839 return this.selectRange( this.getInputLength() );
11843 * Insert new content into the input.
11845 * @param {string} content Content to be inserted
11847 * @return {OO.ui.Widget} The widget, for chaining
11849 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
11850 const value = this.getValue(),
11851 range = this.getRange(),
11852 start = Math.min( range.from, range.to ),
11853 end = Math.max( range.from, range.to );
11855 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11856 this.selectRange( start + content.length );
11861 * Insert new content either side of a selection.
11863 * @param {string} pre Content to be inserted before the selection
11864 * @param {string} post Content to be inserted after the selection
11866 * @return {OO.ui.Widget} The widget, for chaining
11868 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11869 const offset = pre.length,
11870 range = this.getRange(),
11871 start = Math.min( range.from, range.to ),
11872 end = Math.max( range.from, range.to );
11874 this.selectRange( start ).insertContent( pre );
11875 this.selectRange( offset + end ).insertContent( post );
11877 this.selectRange( offset + start, offset + end );
11882 * Set the validation pattern.
11884 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11885 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11886 * value must contain only numbers).
11888 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11889 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11891 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11892 this.validate = validate instanceof RegExp || validate instanceof Function ?
11894 this.constructor.static.validationPatterns[ validate ];
11898 * Sets the 'invalid' flag appropriately.
11900 * @param {boolean} [isValid] Optionally override validation result
11902 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11903 const setFlag = ( valid ) => {
11905 this.$input.attr( 'aria-invalid', 'true' );
11907 this.$input.removeAttr( 'aria-invalid' );
11909 this.setFlags( { invalid: !valid } );
11912 if ( isValid !== undefined ) {
11913 setFlag( isValid );
11915 this.getValidity().then( () => {
11924 * Get the validity of current value.
11926 * This method returns a promise that resolves if the value is valid and rejects if
11927 * it isn't. Uses the {@link OO.ui.TextInputWidget#validate validation pattern} to check for validity.
11929 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11931 OO.ui.TextInputWidget.prototype.getValidity = function () {
11932 function rejectOrResolve( valid ) {
11933 const deferred = $.Deferred(),
11934 promise = valid ? deferred.resolve() : deferred.reject();
11935 return promise.promise();
11938 // Check browser validity and reject if it is invalid
11939 if ( this.$input[ 0 ].checkValidity && this.$input[ 0 ].checkValidity() === false ) {
11940 return rejectOrResolve( false );
11943 if ( !this.validate ) {
11944 return rejectOrResolve( true );
11947 // Run our checks if the browser thinks the field is valid
11949 if ( this.validate instanceof Function ) {
11950 result = this.validate( this.getValue() );
11951 if ( result && typeof result.promise === 'function' ) {
11952 return result.promise().then( ( valid ) => rejectOrResolve( valid ) );
11955 // The only other type we accept is a RegExp, see #setValidation
11956 result = this.validate.test( this.getValue() );
11958 return rejectOrResolve( result );
11962 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11964 * @param {string} labelPosition Label position, 'before' or 'after'
11966 * @return {OO.ui.Widget} The widget, for chaining
11968 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11969 this.labelPosition = labelPosition;
11970 if ( this.label ) {
11971 // If there is no label and we only change the position, #updatePosition is a no-op,
11972 // but it takes really a lot of work to do nothing.
11973 this.updatePosition();
11979 * Update the position of the inline label.
11981 * This method is called by #setLabelPosition, and can also be called on its own if
11982 * something causes the label to be mispositioned.
11985 * @return {OO.ui.Widget} The widget, for chaining
11987 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11988 const after = this.labelPosition === 'after';
11991 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11992 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11994 this.valCache = null;
11995 this.scrollWidth = null;
11996 this.positionLabel();
12002 * Position the label by setting the correct padding on the input.
12006 * @return {OO.ui.Widget} The widget, for chaining
12008 OO.ui.TextInputWidget.prototype.positionLabel = function () {
12009 if ( this.isWaitingToBeAttached ) {
12010 // #onElementAttach will be called soon, which calls this method
12015 'padding-right': '',
12019 if ( this.label ) {
12020 this.$element.append( this.$label );
12022 this.$label.detach();
12023 // Clear old values if present
12024 this.$input.css( newCss );
12028 const after = this.labelPosition === 'after',
12029 rtl = this.$element.css( 'direction' ) === 'rtl',
12030 property = after === rtl ? 'padding-left' : 'padding-right';
12032 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
12033 // We have to clear the padding on the other side, in case the element direction changed
12034 this.$input.css( newCss );
12040 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
12041 * {@link OO.ui.mixin.IconElement 'search' icon} as well as a functional
12042 * {@link OO.ui.mixin.IndicatorElement 'clear' indicator} by default.
12043 * Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
12045 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
12048 * @extends OO.ui.TextInputWidget
12051 * @param {Object} [config] Configuration options
12053 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
12054 config = Object.assign( {
12058 // Parent constructor
12059 OO.ui.SearchInputWidget.super.call( this, config );
12062 this.connect( this, {
12065 this.$indicator.on( 'click', this.onIndicatorClick.bind( this ) );
12066 this.$indicator.on( 'keydown', this.onIndicatorKeyDown.bind( this ) );
12069 this.updateSearchIndicator();
12070 this.connect( this, {
12071 disable: 'onDisable'
12082 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
12090 OO.ui.SearchInputWidget.prototype.getValidType = function () {
12095 * Clear and focus the input element when pressing enter on the 'clear' indicator.
12097 * @param {jQuery.Event} e KeyDown event
12098 * @return {boolean}
12100 OO.ui.SearchInputWidget.prototype.onIndicatorKeyDown = function ( e ) {
12101 if ( e.keyCode === OO.ui.Keys.ENTER ) {
12102 // Clear the text field
12103 this.setValue( '' );
12110 * Clear and focus the input element when clicking on the 'clear' indicator.
12112 * @param {jQuery.Event} e Click event
12113 * @return {boolean}
12115 OO.ui.SearchInputWidget.prototype.onIndicatorClick = function ( e ) {
12116 if ( e.which === OO.ui.MouseButtons.LEFT ) {
12117 // Clear the text field
12118 this.setValue( '' );
12125 * Update the 'clear' indicator displayed on type: 'search' text
12126 * fields, hiding it when the field is already empty or when it's not
12129 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
12130 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
12131 this.setIndicator( null );
12133 this.setIndicator( 'clear' );
12134 this.$indicator.attr( 'aria-label', OO.ui.msg( 'ooui-item-remove' ) );
12139 * Handle change events.
12143 OO.ui.SearchInputWidget.prototype.onChange = function () {
12144 this.updateSearchIndicator();
12148 * Handle disable events.
12150 * @param {boolean} disabled Element is disabled
12153 OO.ui.SearchInputWidget.prototype.onDisable = function () {
12154 this.updateSearchIndicator();
12160 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
12161 OO.ui.SearchInputWidget.super.prototype.setReadOnly.call( this, state );
12162 this.updateSearchIndicator();
12167 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
12168 * configure number of rows visible. In addition, these widgets can be autosized to fit user
12169 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
12170 * {@link OO.ui.mixin.IndicatorElement indicators}.
12171 * Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
12173 * MultilineTextInputWidgets can also be used when a single line string is required, but
12174 * we want to display it to the user over mulitple lines (wrapped). This is done by setting
12175 * the `allowLinebreaks` config to `false`.
12177 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
12179 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
12182 * // A MultilineTextInputWidget.
12183 * const multilineTextInput = new OO.ui.MultilineTextInputWidget( {
12184 * value: 'Text input on multiple lines'
12186 * $( document.body ).append( multilineTextInput.$element );
12189 * @extends OO.ui.TextInputWidget
12192 * @param {Object} [config] Configuration options
12193 * @param {number} [config.rows] Number of visible lines in textarea. If used with `autosize`,
12194 * specifies minimum number of rows to display.
12195 * @param {boolean} [config.autosize=false] Automatically resize the text input to fit its content.
12196 * Use the #maxRows config to specify a maximum number of displayed rows.
12197 * @param {number} [config.maxRows] Maximum number of rows to display when #autosize is set to true.
12198 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
12199 * @param {boolean} [config.allowLinebreaks=true] Whether to allow the user to add line breaks.
12201 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
12202 config = Object.assign( {
12206 // This property needs to exist before setValue in the parent constructor,
12207 // otherwise any linebreaks in the initial value won't be stripped by
12209 this.allowLinebreaks = config.allowLinebreaks !== undefined ? config.allowLinebreaks : true;
12211 // Parent constructor
12212 OO.ui.MultilineTextInputWidget.super.call( this, config );
12215 this.autosize = !!config.autosize;
12216 this.styleHeight = null;
12217 this.minRows = config.rows !== undefined ? config.rows : '';
12218 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
12220 // Clone for resizing
12221 if ( this.autosize ) {
12222 this.$clone = this.$input
12224 .removeAttr( 'id' )
12225 .removeAttr( 'name' )
12226 .insertAfter( this.$input )
12227 .attr( 'aria-hidden', 'true' )
12228 // Exclude scrollbars when calculating new size (T297963)
12229 .css( 'overflow', 'hidden' )
12230 .addClass( 'oo-ui-element-hidden' );
12234 this.connect( this, {
12239 if ( config.rows ) {
12240 this.$input.attr( 'rows', config.rows );
12242 if ( this.autosize ) {
12243 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
12244 this.isWaitingToBeAttached = true;
12245 this.installParentChangeDetector();
12251 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
12256 * An `resize` event is emitted when the widget changes size via the autosize functionality.
12258 * @event OO.ui.MultilineTextInputWidget#resize
12261 /* Static Methods */
12266 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
12267 const state = OO.ui.MultilineTextInputWidget.super.static.gatherPreInfuseState( node, config );
12268 if ( config.$input ) {
12269 state.scrollTop = config.$input.scrollTop();
12279 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
12280 OO.ui.MultilineTextInputWidget.super.prototype.onElementAttach.call( this );
12285 * Handle change events.
12289 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
12296 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
12297 OO.ui.MultilineTextInputWidget.super.prototype.updatePosition.call( this );
12304 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
12306 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
12307 if ( !this.allowLinebreaks ) {
12308 // In this mode we're pretending to be a single-line input, so we
12309 // prevent adding newlines and react to enter in the same way as
12310 // TextInputWidget:
12311 if ( e.which === OO.ui.Keys.ENTER ) {
12312 e.preventDefault();
12314 return OO.ui.TextInputWidget.prototype.onKeyPress.call( this, e );
12317 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
12318 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
12321 this.emit( 'enter', e );
12328 OO.ui.MultilineTextInputWidget.prototype.cleanUpValue = function ( value ) {
12329 // Parent method will guarantee we're dealing with a string, and apply inputFilter:
12330 value = OO.ui.MultilineTextInputWidget.super.prototype.cleanUpValue( value );
12331 if ( !this.allowLinebreaks ) {
12332 // If we're forbidding linebreaks then clean them out of the incoming
12333 // value to avoid a confusing situation
12334 // TODO: Better handle a paste with linebreaks by using the paste event, as when
12335 // we use input filtering the cursor is always reset to the end of the input.
12336 value = value.replace( /\r?\n/g, ' ' );
12342 * Automatically adjust the size of the text input.
12344 * This only affects multiline inputs that are {@link OO.ui.MultilineTextInputWidget#autosize autosized}.
12347 * @param {boolean} [force=false] Force an update, even if the value hasn't changed
12348 * @return {OO.ui.Widget} The widget, for chaining
12349 * @fires OO.ui.MultilineTextInputWidget#resize
12351 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function ( force ) {
12352 if ( force || this.$input.val() !== this.valCache ) {
12353 if ( this.autosize ) {
12355 .val( this.$input.val() )
12356 .attr( 'rows', this.minRows )
12357 // Set inline height property to 0 to measure scroll height
12358 .css( 'height', 0 )
12359 .removeClass( 'oo-ui-element-hidden' );
12361 this.valCache = this.$input.val();
12363 // https://bugzilla.mozilla.org/show_bug.cgi?id=1799404
12364 // eslint-disable-next-line no-unused-expressions
12365 this.$clone[ 0 ].scrollHeight;
12366 const scrollHeight = this.$clone[ 0 ].scrollHeight;
12368 // Remove inline height property to measure natural heights
12369 this.$clone.css( 'height', '' );
12370 const innerHeight = this.$clone.innerHeight();
12371 const outerHeight = this.$clone.outerHeight();
12373 // Measure max rows height
12375 .attr( 'rows', this.maxRows )
12376 .css( 'height', 'auto' )
12378 const maxInnerHeight = this.$clone.innerHeight();
12380 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
12381 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
12382 const measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
12383 const idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
12385 this.$clone.addClass( 'oo-ui-element-hidden' );
12387 // Only apply inline height when expansion beyond natural height is needed
12388 // Use the difference between the inner and outer height as a buffer
12389 const newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
12390 if ( newHeight !== this.styleHeight ) {
12391 this.$input.css( 'height', newHeight );
12392 this.styleHeight = newHeight;
12393 this.emit( 'resize' );
12396 const scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
12397 if ( scrollWidth !== this.scrollWidth ) {
12398 const property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
12400 this.$label.css( { right: '', left: '' } );
12401 this.$indicator.css( { right: '', left: '' } );
12403 if ( scrollWidth ) {
12404 this.$indicator.css( property, scrollWidth );
12405 if ( this.labelPosition === 'after' ) {
12406 this.$label.css( property, scrollWidth );
12410 this.scrollWidth = scrollWidth;
12411 this.positionLabel();
12421 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
12422 return $( '<textarea>' );
12426 * Check if the input automatically adjusts its size.
12428 * @return {boolean}
12430 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
12431 return !!this.autosize;
12437 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
12438 OO.ui.MultilineTextInputWidget.super.prototype.restorePreInfuseState.call( this, state );
12439 if ( state.scrollTop !== undefined ) {
12440 this.$input.scrollTop( state.scrollTop );
12445 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12446 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
12447 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
12449 * - by typing a value in the text input field. If the value exactly matches the value of a menu
12450 * option, that option will appear to be selected.
12451 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
12454 * After the user chooses an option, its `data` will be used as a new value for the widget.
12455 * A `label` also can be specified for each option: if given, it will be shown instead of the
12456 * `data` in the dropdown menu.
12458 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
12460 * For more information about menus and options, please see the
12461 * [OOUI documentation on MediaWiki][1].
12463 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
12466 * // A ComboBoxInputWidget.
12467 * const comboBox = new OO.ui.ComboBoxInputWidget( {
12468 * value: 'Option 1',
12470 * { data: 'Option 1' },
12471 * { data: 'Option 2' },
12472 * { data: 'Option 3' }
12475 * $( document.body ).append( comboBox.$element );
12477 * @example <caption>A ComboBoxInputWidget can have additional option labels:</caption>
12478 * const comboBox = new OO.ui.ComboBoxInputWidget( {
12479 * value: 'Option 1',
12482 * data: 'Option 1',
12483 * label: 'Option One'
12486 * data: 'Option 2',
12487 * label: 'Option Two'
12490 * data: 'Option 3',
12491 * label: 'Option Three'
12495 * $( document.body ).append( comboBox.$element );
12498 * @extends OO.ui.TextInputWidget
12501 * @param {Object} [config] Configuration options
12502 * @param {Object[]} [config.options=[]] Array of menu options in the format `{ data: …, label: … }`
12503 * @param {Object} [config.menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
12505 * @param {jQuery} [config.$overlay] Render the menu into a separate layer. This configuration is useful
12506 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
12507 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
12508 * uses relative positioning.
12509 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12511 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
12512 // Configuration initialization
12513 config = Object.assign( {
12514 autocomplete: false
12517 // See InputWidget#reusePreInfuseDOM about `config.$input`
12518 if ( config.$input ) {
12519 config.$input.removeAttr( 'list' );
12522 // Parent constructor
12523 OO.ui.ComboBoxInputWidget.super.call( this, config );
12526 this.$overlay = ( config.$overlay === true ?
12527 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
12528 this.dropdownButton = new OO.ui.ButtonWidget( {
12529 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
12530 label: OO.ui.msg( 'ooui-combobox-button-label' ),
12532 invisibleLabel: true,
12533 disabled: this.disabled
12535 this.menu = new OO.ui.MenuSelectWidget( Object.assign(
12539 $floatableContainer: this.$element,
12540 disabled: this.isDisabled()
12546 this.connect( this, {
12547 change: 'onInputChange',
12548 enter: 'onInputEnter'
12550 this.dropdownButton.connect( this, {
12551 click: 'onDropdownButtonClick'
12553 this.menu.connect( this, {
12554 choose: 'onMenuChoose',
12555 add: 'onMenuItemsChange',
12556 remove: 'onMenuItemsChange',
12557 toggle: 'onMenuToggle'
12561 this.$input.attr( {
12563 'aria-owns': this.menu.getElementId(),
12564 'aria-autocomplete': 'list'
12566 this.dropdownButton.$button.attr( {
12567 'aria-controls': this.menu.getElementId()
12569 // Do not override options set via config.menu.items
12570 if ( config.options !== undefined ) {
12571 this.setOptions( config.options );
12573 this.$field = $( '<div>' )
12574 .addClass( 'oo-ui-comboBoxInputWidget-field' )
12575 .append( this.$input, this.dropdownButton.$element );
12577 .addClass( 'oo-ui-comboBoxInputWidget' )
12578 .append( this.$field );
12579 this.$overlay.append( this.menu.$element );
12580 this.onMenuItemsChange();
12585 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
12590 * Get the combobox's menu.
12592 * @return {OO.ui.MenuSelectWidget} Menu widget
12594 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
12599 * Get the combobox's text input widget.
12601 * @return {OO.ui.TextInputWidget} Text input widget
12603 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
12610 OO.ui.ComboBoxInputWidget.prototype.onEdit = function ( event ) {
12612 OO.ui.ComboBoxInputWidget.super.prototype.onEdit.apply( this, arguments );
12614 if ( this.menu.isVisible() || this.isDisabled() || !this.isVisible() ) {
12618 if ( event.type === 'input' || event.type === 'mouseup' || ( event.type === 'keydown' && (
12619 event.keyCode === OO.ui.Keys.ENTER ||
12620 event.keyCode === OO.ui.Keys.UP ||
12621 event.keyCode === OO.ui.Keys.DOWN
12623 this.menu.toggle( true );
12628 * Handle input change events.
12631 * @param {string} value New value
12633 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
12634 const match = this.menu.findItemFromData( value );
12636 this.menu.selectItem( match );
12637 if ( this.menu.findHighlightedItem() ) {
12638 this.menu.highlightItem( match );
12643 * Handle input enter events.
12647 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
12648 if ( !this.isDisabled() ) {
12649 this.menu.toggle( false );
12654 * Handle button click events.
12658 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
12659 this.menu.toggle();
12664 * Handle menu choose events.
12667 * @param {OO.ui.OptionWidget} item Chosen item
12669 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
12670 this.setValue( item.getData() );
12674 * Handle menu item change events.
12678 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
12679 const match = this.menu.findItemFromData( this.getValue() );
12680 this.menu.selectItem( match );
12681 if ( this.menu.findHighlightedItem() ) {
12682 this.menu.highlightItem( match );
12684 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
12688 * Handle menu toggle events.
12691 * @param {boolean} isVisible Open state of the menu
12693 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
12694 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
12698 * Update the disabled state of the controls
12702 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
12704 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
12705 const disabled = this.isDisabled() || this.isReadOnly();
12706 if ( this.dropdownButton ) {
12707 this.dropdownButton.setDisabled( disabled );
12710 this.menu.setDisabled( disabled );
12718 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
12720 OO.ui.ComboBoxInputWidget.super.prototype.setDisabled.apply( this, arguments );
12721 this.updateControlsDisabled();
12728 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
12730 OO.ui.ComboBoxInputWidget.super.prototype.setReadOnly.apply( this, arguments );
12731 this.updateControlsDisabled();
12736 * Set the options available for this input.
12738 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
12740 * @return {OO.ui.Widget} The widget, for chaining
12742 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
12745 .addItems( options.map( ( opt ) => new OO.ui.MenuOptionWidget( {
12747 label: opt.label !== undefined ? opt.label : opt.data
12754 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
12755 * which is a widget that is specified by reference before any optional configuration settings.
12757 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
12760 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12761 * A left-alignment is used for forms with many fields.
12762 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12763 * A right-alignment is used for long but familiar forms which users tab through,
12764 * verifying the current field with a quick glance at the label.
12765 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12766 * that users fill out from top to bottom.
12767 * - **inline**: The label is placed after the field-widget and aligned to the left.
12768 * An inline-alignment is best used with checkboxes or radio buttons.
12770 * Help text can either be:
12772 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
12774 * - shown as a subtle explanation below the label.
12776 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
12777 * If it is long or not essential, leave `helpInline` to its default, `false`.
12779 * Please see the [OOUI documentation on MediaWiki][1] for examples and more information.
12781 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12784 * @extends OO.ui.Layout
12785 * @mixes OO.ui.mixin.LabelElement
12786 * @mixes OO.ui.mixin.TitledElement
12789 * @param {OO.ui.Widget} fieldWidget Field widget
12790 * @param {Object} [config] Configuration options
12791 * @param {string} [config.align='left'] Alignment of the label: 'left', 'right', 'top'
12793 * @param {Array} [config.errors] Error messages about the widget, which will be
12794 * displayed below the widget.
12795 * @param {Array} [config.warnings] Warning messages about the widget, which will be
12796 * displayed below the widget.
12797 * @param {Array} [config.successMessages] Success messages on user interactions with the widget,
12798 * which will be displayed below the widget.
12799 * The array may contain strings or OO.ui.HtmlSnippet instances.
12800 * @param {Array} [config.notices] Notices about the widget, which will be displayed
12801 * below the widget.
12802 * The array may contain strings or OO.ui.HtmlSnippet instances.
12803 * These are more visible than `help` messages when `helpInline` is set, and so
12804 * might be good for transient messages.
12805 * @param {string|OO.ui.HtmlSnippet} [config.help] Help text. When help text is specified
12806 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
12807 * corner of the rendered field; clicking it will display the text in a popup.
12808 * If `helpInline` is `true`, then a subtle description will be shown after the
12810 * @param {boolean} [config.helpInline=false] Whether or not the help should be inline,
12811 * or shown when the "help" icon is clicked.
12812 * @param {jQuery} [config.$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
12814 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12816 * @throws {Error} An error is thrown if no widget is specified
12818 * @property {OO.ui.Widget} fieldWidget
12820 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
12821 // Allow passing positional parameters inside the config object
12822 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12823 config = fieldWidget;
12824 fieldWidget = config.fieldWidget;
12827 // Make sure we have required constructor arguments
12828 if ( fieldWidget === undefined ) {
12829 throw new Error( 'Widget not found' );
12832 // Configuration initialization
12833 config = Object.assign( { align: 'left', helpInline: false }, config );
12835 if ( config.help && !config.label ) {
12836 // Add an empty label. For some combinations of 'helpInline' and 'align'
12837 // there would be no space in the interface to display the help text otherwise.
12838 config.label = ' ';
12841 // Parent constructor
12842 OO.ui.FieldLayout.super.call( this, config );
12844 // Mixin constructors
12845 OO.ui.mixin.LabelElement.call( this, Object.assign( {
12846 $label: $( '<label>' )
12848 OO.ui.mixin.TitledElement.call( this, Object.assign( { $titled: this.$label }, config ) );
12851 this.fieldWidget = fieldWidget;
12853 this.warnings = [];
12854 this.successMessages = [];
12856 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12857 this.$messages = $( '<div>' );
12858 this.$header = $( '<span>' );
12859 this.$body = $( '<div>' );
12861 this.helpInline = config.helpInline;
12864 this.fieldWidget.connect( this, {
12865 disable: 'onFieldDisable'
12869 this.$help = config.help ?
12870 this.createHelpElement( config.help, config.$overlay ) :
12872 if ( this.fieldWidget.getInputId() ) {
12873 this.$label.attr( 'for', this.fieldWidget.getInputId() );
12874 if ( this.helpInline ) {
12875 this.$help.attr( 'for', this.fieldWidget.getInputId() );
12878 // We can't use `label for` with non-form elements, use `aria-labelledby` instead
12879 const id = OO.ui.generateElementId();
12880 this.$label.attr( 'id', id );
12881 this.fieldWidget.setLabelledBy( id );
12883 // Forward clicks on the label to the widget, like `label for` would do
12884 this.$label.on( 'click', this.onLabelClick.bind( this ) );
12885 if ( this.helpInline ) {
12886 this.$help.on( 'click', this.onLabelClick.bind( this ) );
12890 .addClass( 'oo-ui-fieldLayout' )
12891 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
12892 .append( this.$body );
12893 this.$body.addClass( 'oo-ui-fieldLayout-body' );
12894 this.$header.addClass( 'oo-ui-fieldLayout-header' );
12895 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
12897 .addClass( 'oo-ui-fieldLayout-field' )
12898 .append( this.fieldWidget.$element );
12900 this.setErrors( config.errors || [] );
12901 this.setWarnings( config.warnings || [] );
12902 this.setSuccess( config.successMessages || [] );
12903 this.setNotices( config.notices || [] );
12904 this.setAlignment( config.align );
12905 // Call this again to take into account the widget's accessKey
12906 this.updateTitle();
12911 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
12912 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
12913 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
12918 * Handle field disable events.
12921 * @param {boolean} value Field is disabled
12923 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
12924 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
12928 * Handle click events on the field label, or inline help
12930 * @param {jQuery.Event} event
12932 OO.ui.FieldLayout.prototype.onLabelClick = function () {
12933 this.fieldWidget.simulateLabelClick();
12937 * Get the widget contained by the field.
12939 * @return {OO.ui.Widget} Field widget
12941 OO.ui.FieldLayout.prototype.getField = function () {
12942 return this.fieldWidget;
12946 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12947 * #setAlignment). Return `false` if it can't or if this can't be determined.
12949 * @return {boolean}
12951 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12952 // This is very simplistic, but should be good enough.
12953 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12958 * @param {string} kind 'error' or 'notice'
12959 * @param {string|OO.ui.HtmlSnippet} text
12962 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12963 return new OO.ui.MessageWidget( {
12971 * Set the field alignment mode.
12974 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12976 * @return {OO.ui.BookletLayout} The layout, for chaining
12978 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12979 if ( value !== this.align ) {
12980 // Default to 'left'
12981 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12985 if ( value === 'inline' && !this.isFieldInline() ) {
12988 // Reorder elements
12990 if ( this.helpInline ) {
12991 if ( value === 'top' ) {
12992 this.$header.append( this.$label );
12993 this.$body.append( this.$header, this.$field, this.$help );
12994 } else if ( value === 'inline' ) {
12995 this.$header.append( this.$label, this.$help );
12996 this.$body.append( this.$field, this.$header );
12998 this.$header.append( this.$label, this.$help );
12999 this.$body.append( this.$header, this.$field );
13002 if ( value === 'top' ) {
13003 this.$header.append( this.$help, this.$label );
13004 this.$body.append( this.$header, this.$field );
13005 } else if ( value === 'inline' ) {
13006 this.$header.append( this.$help, this.$label );
13007 this.$body.append( this.$field, this.$header );
13009 this.$header.append( this.$label );
13010 this.$body.append( this.$header, this.$help, this.$field );
13013 // Set classes. The following classes can be used here:
13014 // * oo-ui-fieldLayout-align-left
13015 // * oo-ui-fieldLayout-align-right
13016 // * oo-ui-fieldLayout-align-top
13017 // * oo-ui-fieldLayout-align-inline
13018 if ( this.align ) {
13019 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
13021 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
13022 this.align = value;
13029 * Set the list of error messages.
13031 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
13032 * The array may contain strings or OO.ui.HtmlSnippet instances.
13034 * @return {OO.ui.BookletLayout} The layout, for chaining
13036 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
13037 this.errors = errors.slice();
13038 this.updateMessages();
13043 * Set the list of warning messages.
13045 * @param {Array} warnings Warning messages about the widget, which will be displayed below
13047 * The array may contain strings or OO.ui.HtmlSnippet instances.
13049 * @return {OO.ui.BookletLayout} The layout, for chaining
13051 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
13052 this.warnings = warnings.slice();
13053 this.updateMessages();
13058 * Set the list of success messages.
13060 * @param {Array} successMessages Success messages about the widget, which will be displayed below
13062 * The array may contain strings or OO.ui.HtmlSnippet instances.
13064 * @return {OO.ui.BookletLayout} The layout, for chaining
13066 OO.ui.FieldLayout.prototype.setSuccess = function ( successMessages ) {
13067 this.successMessages = successMessages.slice();
13068 this.updateMessages();
13073 * Set the list of notice messages.
13075 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
13076 * The array may contain strings or OO.ui.HtmlSnippet instances.
13078 * @return {OO.ui.BookletLayout} The layout, for chaining
13080 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
13081 this.notices = notices.slice();
13082 this.updateMessages();
13087 * Update the rendering of error, warning, success and notice messages.
13091 OO.ui.FieldLayout.prototype.updateMessages = function () {
13092 this.$messages.empty();
13095 this.errors.length ||
13096 this.warnings.length ||
13097 this.successMessages.length ||
13098 this.notices.length
13100 this.$body.after( this.$messages );
13102 this.$messages.remove();
13107 for ( i = 0; i < this.errors.length; i++ ) {
13108 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
13110 for ( i = 0; i < this.warnings.length; i++ ) {
13111 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
13113 for ( i = 0; i < this.successMessages.length; i++ ) {
13114 this.$messages.append( this.makeMessage( 'success', this.successMessages[ i ] ) );
13116 for ( i = 0; i < this.notices.length; i++ ) {
13117 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
13122 * Include information about the widget's accessKey in our title. TitledElement calls this method.
13123 * (This is a bit of a hack.)
13126 * @param {string} title Tooltip label for 'title' attribute
13129 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
13130 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
13131 return this.fieldWidget.formatTitleWithAccessKey( title );
13137 * Creates and returns the help element. Also sets the `aria-describedby`
13138 * attribute on the main element of the `fieldWidget`.
13141 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
13142 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
13143 * @return {jQuery} The element that should become `this.$help`.
13145 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
13146 let helpId, helpWidget;
13148 if ( this.helpInline ) {
13149 helpWidget = new OO.ui.LabelWidget( {
13151 classes: [ 'oo-ui-inline-help' ]
13154 helpId = helpWidget.getElementId();
13156 helpWidget = new OO.ui.PopupButtonWidget( {
13157 $overlay: $overlay,
13161 classes: [ 'oo-ui-fieldLayout-help' ],
13164 label: OO.ui.msg( 'ooui-field-help' ),
13165 invisibleLabel: true
13168 helpWidget.popup.on( 'ready', () => {
13169 const $popupElement = helpWidget.popup.$element;
13170 $popupElement.attr( 'tabindex', 0 );
13171 $popupElement.trigger( 'focus' );
13174 helpWidget.popup.on( 'closing', () => {
13175 helpWidget.$button.trigger( 'focus' );
13178 if ( help instanceof OO.ui.HtmlSnippet ) {
13179 helpWidget.getPopup().$body.html( help.toString() );
13181 helpWidget.getPopup().$body.text( help );
13184 helpId = helpWidget.getPopup().getBodyId();
13187 // Set the 'aria-describedby' attribute on the fieldWidget
13188 // Preference given to an input or a button
13190 this.fieldWidget.$input ||
13191 ( this.fieldWidget.input && this.fieldWidget.input.$input ) ||
13192 this.fieldWidget.$button ||
13193 this.fieldWidget.$element
13194 ).attr( 'aria-describedby', helpId );
13196 return helpWidget.$element;
13200 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
13201 * a button, and an optional label and/or help text. The field-widget (e.g., a
13202 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
13203 * configuration settings.
13205 * Labels can be aligned in one of four ways:
13207 * - **left**: The label is placed before the field-widget and aligned with the left margin.
13208 * A left-alignment is used for forms with many fields.
13209 * - **right**: The label is placed before the field-widget and aligned to the right margin.
13210 * A right-alignment is used for long but familiar forms which users tab through,
13211 * verifying the current field with a quick glance at the label.
13212 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
13213 * that users fill out from top to bottom.
13214 * - **inline**: The label is placed after the field-widget and aligned to the left.
13215 * An inline-alignment is best used with checkboxes or radio buttons.
13217 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
13218 * field layout when help text is specified.
13221 * // Example of an ActionFieldLayout
13222 * const actionFieldLayout = new OO.ui.ActionFieldLayout(
13223 * new OO.ui.TextInputWidget( {
13224 * placeholder: 'Field widget'
13226 * new OO.ui.ButtonWidget( {
13230 * label: 'An ActionFieldLayout. This label is aligned top',
13232 * help: 'This is help text'
13236 * $( document.body ).append( actionFieldLayout.$element );
13239 * @extends OO.ui.FieldLayout
13242 * @param {OO.ui.Widget} fieldWidget Field widget
13243 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
13244 * @param {Object} config
13246 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
13247 // Allow passing positional parameters inside the config object
13248 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
13249 config = fieldWidget;
13250 fieldWidget = config.fieldWidget;
13251 buttonWidget = config.buttonWidget;
13254 // Parent constructor
13255 OO.ui.ActionFieldLayout.super.call( this, fieldWidget, config );
13258 this.buttonWidget = buttonWidget;
13259 this.$button = $( '<span>' );
13260 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
13263 this.$element.addClass( 'oo-ui-actionFieldLayout' );
13265 .addClass( 'oo-ui-actionFieldLayout-button' )
13266 .append( this.buttonWidget.$element );
13268 .addClass( 'oo-ui-actionFieldLayout-input' )
13269 .append( this.fieldWidget.$element );
13270 this.$field.append( this.$input, this.$button );
13275 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
13278 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
13279 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
13280 * configured with a label as well. For more information and examples,
13281 * please see the [OOUI documentation on MediaWiki][1].
13284 * // Example of a fieldset layout
13285 * const input1 = new OO.ui.TextInputWidget( {
13286 * placeholder: 'A text input field'
13289 * const input2 = new OO.ui.TextInputWidget( {
13290 * placeholder: 'A text input field'
13293 * const fieldset = new OO.ui.FieldsetLayout( {
13294 * label: 'Example of a fieldset layout'
13297 * fieldset.addItems( [
13298 * new OO.ui.FieldLayout( input1, {
13299 * label: 'Field One'
13301 * new OO.ui.FieldLayout( input2, {
13302 * label: 'Field Two'
13305 * $( document.body ).append( fieldset.$element );
13307 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
13310 * @extends OO.ui.Layout
13311 * @mixes OO.ui.mixin.IconElement
13312 * @mixes OO.ui.mixin.LabelElement
13313 * @mixes OO.ui.mixin.GroupElement
13316 * @param {Object} [config] Configuration options
13317 * @param {OO.ui.FieldLayout[]} [config.items] An array of fields to add to the fieldset.
13318 * See OO.ui.FieldLayout for more information about fields.
13319 * @param {string|OO.ui.HtmlSnippet} [config.help] Help text. When help text is specified
13320 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
13321 * corner of the rendered field; clicking it will display the text in a popup.
13322 * If `helpInline` is `true`, then a subtle description will be shown after the
13324 * For feedback messages, you are advised to use `notices`.
13325 * @param {boolean} [config.helpInline=false] Whether or not the help should be inline,
13326 * or shown when the "help" icon is clicked.
13327 * @param {jQuery} [config.$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
13328 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
13330 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
13331 // Configuration initialization
13332 config = config || {};
13334 // Parent constructor
13335 OO.ui.FieldsetLayout.super.call( this, config );
13337 // Mixin constructors
13338 OO.ui.mixin.IconElement.call( this, config );
13339 OO.ui.mixin.LabelElement.call( this, config );
13340 OO.ui.mixin.GroupElement.call( this, config );
13343 this.$header = $( '<legend>' );
13347 .addClass( 'oo-ui-fieldsetLayout-header' )
13348 .append( this.$icon, this.$label );
13349 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
13351 .addClass( 'oo-ui-fieldsetLayout' )
13352 .prepend( this.$header, this.$group );
13355 if ( config.help ) {
13356 if ( config.helpInline ) {
13357 const inlineHelpWidget = new OO.ui.LabelWidget( {
13358 label: config.help,
13359 classes: [ 'oo-ui-inline-help' ]
13361 this.$element.prepend( this.$header, inlineHelpWidget.$element, this.$group );
13363 const helpWidget = new OO.ui.PopupButtonWidget( {
13364 $overlay: config.$overlay,
13368 classes: [ 'oo-ui-fieldsetLayout-help' ],
13371 label: OO.ui.msg( 'ooui-field-help' ),
13372 invisibleLabel: true
13374 if ( config.help instanceof OO.ui.HtmlSnippet ) {
13375 helpWidget.getPopup().$body.html( config.help.toString() );
13377 helpWidget.getPopup().$body.text( config.help );
13379 this.$header.append( helpWidget.$element );
13382 this.addItems( config.items || [] );
13387 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
13388 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
13389 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
13390 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
13392 /* Static Properties */
13398 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
13401 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
13402 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
13403 * can be configured with an HTML form action, an encoding type, and a method using the #action,
13404 * #enctype, and #method configs, respectively.
13405 * See the [OOUI documentation on MediaWiki][1] for more information and examples.
13407 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
13408 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
13409 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
13410 * some fancier controls. Some controls have both regular and InputWidget variants, for example
13411 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
13412 * often have simplified APIs to match the capabilities of HTML forms.
13413 * See the [OOUI documentation on MediaWiki][2] for more information about InputWidgets.
13415 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
13416 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
13419 * // Example of a form layout that wraps a fieldset layout.
13420 * const input1 = new OO.ui.TextInputWidget( {
13421 * placeholder: 'Username'
13423 * input2 = new OO.ui.TextInputWidget( {
13424 * placeholder: 'Password',
13427 * submit = new OO.ui.ButtonInputWidget( {
13430 * fieldset = new OO.ui.FieldsetLayout( {
13431 * label: 'A form layout'
13434 * fieldset.addItems( [
13435 * new OO.ui.FieldLayout( input1, {
13436 * label: 'Username',
13439 * new OO.ui.FieldLayout( input2, {
13440 * label: 'Password',
13443 * new OO.ui.FieldLayout( submit )
13445 * const form = new OO.ui.FormLayout( {
13446 * items: [ fieldset ],
13447 * action: '/api/formhandler',
13450 * $( document.body ).append( form.$element );
13453 * @extends OO.ui.Layout
13454 * @mixes OO.ui.mixin.GroupElement
13457 * @param {Object} [config] Configuration options
13458 * @param {string} [config.method] HTML form `method` attribute
13459 * @param {string} [config.action] HTML form `action` attribute
13460 * @param {string} [config.enctype] HTML form `enctype` attribute
13461 * @param {OO.ui.FieldsetLayout[]} [config.items] Fieldset layouts to add to the form layout.
13463 OO.ui.FormLayout = function OoUiFormLayout( config ) {
13464 // Configuration initialization
13465 config = config || {};
13467 // Parent constructor
13468 OO.ui.FormLayout.super.call( this, config );
13470 // Mixin constructors
13471 OO.ui.mixin.GroupElement.call( this, Object.assign( { $group: this.$element }, config ) );
13474 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
13476 // Make sure the action is safe
13477 let action = config.action;
13478 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
13479 action = './' + action;
13484 .addClass( 'oo-ui-formLayout' )
13486 method: config.method,
13488 enctype: config.enctype
13490 this.addItems( config.items || [] );
13495 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
13496 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
13501 * A 'submit' event is emitted when the form is submitted.
13503 * @event OO.ui.FormLayout#submit
13506 /* Static Properties */
13512 OO.ui.FormLayout.static.tagName = 'form';
13517 * Handle form submit events.
13520 * @param {jQuery.Event} e Submit event
13521 * @fires OO.ui.FormLayout#submit
13522 * @return {OO.ui.FormLayout} The layout, for chaining
13524 OO.ui.FormLayout.prototype.onFormSubmit = function () {
13525 if ( this.emit( 'submit' ) ) {
13531 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
13532 * scrolling, padding, and a frame, and are often used together with
13533 * {@link OO.ui.StackLayout StackLayouts}.
13536 * // Example of a panel layout
13537 * const panel = new OO.ui.PanelLayout( {
13541 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
13543 * $( document.body ).append( panel.$element );
13546 * @extends OO.ui.Layout
13549 * @param {Object} [config] Configuration options
13550 * @param {boolean} [config.scrollable=false] Allow vertical scrolling
13551 * @param {boolean} [config.padded=false] Add padding between the content and the edges of the panel.
13552 * @param {boolean} [config.expanded=true] Expand the panel to fill the entire parent element.
13553 * @param {boolean} [config.framed=false] Render the panel with a frame to visually separate it from outside
13556 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
13557 // Configuration initialization
13558 config = Object.assign( {
13565 // Parent constructor
13566 OO.ui.PanelLayout.super.call( this, config );
13569 this.$element.addClass( 'oo-ui-panelLayout' );
13570 if ( config.scrollable ) {
13571 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
13573 if ( config.padded ) {
13574 this.$element.addClass( 'oo-ui-panelLayout-padded' );
13576 if ( config.expanded ) {
13577 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
13579 if ( config.framed ) {
13580 this.$element.addClass( 'oo-ui-panelLayout-framed' );
13586 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
13588 /* Static Methods */
13593 OO.ui.PanelLayout.static.reusePreInfuseDOM = function ( node, config ) {
13594 config = OO.ui.PanelLayout.super.static.reusePreInfuseDOM( node, config );
13595 if ( config.preserveContent !== false ) {
13596 config.$content = $( node ).contents();
13604 * Focus the panel layout
13606 * The default implementation just focuses the first focusable element in the panel
13608 OO.ui.PanelLayout.prototype.focus = function () {
13609 OO.ui.findFocusable( this.$element ).focus();
13613 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
13614 * items), with small margins between them. Convenient when you need to put a number of block-level
13615 * widgets on a single line next to each other.
13617 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
13620 * // HorizontalLayout with a text input and a label.
13621 * const layout = new OO.ui.HorizontalLayout( {
13623 * new OO.ui.LabelWidget( { label: 'Label' } ),
13624 * new OO.ui.TextInputWidget( { value: 'Text' } )
13627 * $( document.body ).append( layout.$element );
13630 * @extends OO.ui.Layout
13631 * @mixes OO.ui.mixin.GroupElement
13634 * @param {Object} [config] Configuration options
13635 * @param {OO.ui.Widget[]|OO.ui.Layout[]} [config.items] Widgets or other layouts to add to the layout.
13637 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
13638 // Configuration initialization
13639 config = config || {};
13641 // Parent constructor
13642 OO.ui.HorizontalLayout.super.call( this, config );
13644 // Mixin constructors
13645 OO.ui.mixin.GroupElement.call( this, Object.assign( { $group: this.$element }, config ) );
13648 this.$element.addClass( 'oo-ui-horizontalLayout' );
13649 this.addItems( config.items || [] );
13654 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
13655 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
13658 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
13659 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
13660 * (to adjust the value in increments) to allow the user to enter a number.
13663 * // A NumberInputWidget.
13664 * const numberInput = new OO.ui.NumberInputWidget( {
13665 * label: 'NumberInputWidget',
13666 * input: { value: 5 },
13670 * $( document.body ).append( numberInput.$element );
13673 * @extends OO.ui.TextInputWidget
13676 * @param {Object} [config] Configuration options
13677 * @param {Object} [config.minusButton] Configuration options to pass to the
13678 * {@link OO.ui.ButtonWidget decrementing button widget}.
13679 * @param {Object} [config.plusButton] Configuration options to pass to the
13680 * {@link OO.ui.ButtonWidget incrementing button widget}.
13681 * @param {number} [config.min=-Infinity] Minimum allowed value
13682 * @param {number} [config.max=Infinity] Maximum allowed value
13683 * @param {number|null} [config.step] If specified, the field only accepts values that are multiples of this.
13684 * @param {number} [config.buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
13685 * Defaults to `step` if specified, otherwise `1`.
13686 * @param {number} [config.pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
13687 * Defaults to 10 times `buttonStep`.
13688 * @param {boolean} [config.showButtons=true] Whether to show the plus and minus buttons.
13690 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
13691 const $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
13693 // Configuration initialization
13694 config = Object.assign( {
13700 // For backward compatibility
13701 Object.assign( config, config.input );
13704 // Parent constructor
13705 OO.ui.NumberInputWidget.super.call( this, Object.assign( config, {
13709 if ( config.showButtons ) {
13710 this.minusButton = new OO.ui.ButtonWidget( Object.assign(
13712 disabled: this.isDisabled(),
13714 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
13719 this.minusButton.$element.attr( 'aria-hidden', 'true' );
13720 this.plusButton = new OO.ui.ButtonWidget( Object.assign(
13722 disabled: this.isDisabled(),
13724 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
13729 this.plusButton.$element.attr( 'aria-hidden', 'true' );
13734 keydown: this.onKeyDown.bind( this ),
13735 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
13737 if ( config.showButtons ) {
13738 this.plusButton.connect( this, {
13739 click: [ 'onButtonClick', +1 ]
13741 this.minusButton.connect( this, {
13742 click: [ 'onButtonClick', -1 ]
13747 $field.append( this.$input );
13748 if ( config.showButtons ) {
13750 .prepend( this.minusButton.$element )
13751 .append( this.plusButton.$element );
13755 if ( config.allowInteger || config.isInteger ) {
13756 // Backward compatibility
13759 this.setRange( config.min, config.max );
13760 this.setStep( config.buttonStep, config.pageStep, config.step );
13761 // Set the validation method after we set step and range
13762 // so that it doesn't immediately call setValidityFlag
13763 this.setValidation( this.validateNumber.bind( this ) );
13766 .addClass( 'oo-ui-numberInputWidget' )
13767 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
13773 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
13777 // Backward compatibility
13778 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
13779 this.setStep( flag ? 1 : null );
13781 // Backward compatibility
13782 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
13784 // Backward compatibility
13785 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
13786 return this.step === 1;
13788 // Backward compatibility
13789 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
13792 * Set the range of allowed values
13794 * @param {number} min Minimum allowed value
13795 * @param {number} max Maximum allowed value
13797 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
13799 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
13803 this.$input.attr( { min: this.min, max: this.max } );
13804 this.setValidityFlag();
13808 * Get the current range
13810 * @return {number[]} Minimum and maximum values
13812 OO.ui.NumberInputWidget.prototype.getRange = function () {
13813 return [ this.min, this.max ];
13817 * Set the stepping deltas
13819 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
13820 * Defaults to `step` if specified, otherwise `1`.
13821 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
13822 * Defaults to 10 times `buttonStep`.
13823 * @param {number|null} [step] If specified, the field only accepts values that are multiples
13826 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
13827 if ( buttonStep === undefined ) {
13828 buttonStep = step || 1;
13830 if ( pageStep === undefined ) {
13831 pageStep = 10 * buttonStep;
13833 if ( step !== null && step <= 0 ) {
13834 throw new Error( 'Step value, if given, must be positive' );
13836 if ( buttonStep <= 0 ) {
13837 throw new Error( 'Button step value must be positive' );
13839 if ( pageStep <= 0 ) {
13840 throw new Error( 'Page step value must be positive' );
13843 this.buttonStep = buttonStep;
13844 this.pageStep = pageStep;
13845 this.$input.attr( 'step', this.step || 'any' );
13846 this.setValidityFlag();
13852 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
13853 if ( value === '' ) {
13854 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
13855 // so here we make sure an 'empty' value is actually displayed as such.
13856 this.$input.val( '' );
13858 return OO.ui.NumberInputWidget.super.prototype.setValue.call( this, value );
13862 * Get the current stepping values
13864 * @return {number[]} Button step, page step, and validity step
13866 OO.ui.NumberInputWidget.prototype.getStep = function () {
13867 return [ this.buttonStep, this.pageStep, this.step ];
13871 * Get the current value of the widget as a number
13873 * @return {number} May be NaN, or an invalid number
13875 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
13876 return +this.getValue();
13880 * Adjust the value of the widget
13882 * @param {number} delta Adjustment amount
13884 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
13885 const v = this.getNumericValue();
13888 if ( isNaN( delta ) || !isFinite( delta ) ) {
13889 throw new Error( 'Delta must be a finite number' );
13893 if ( isNaN( v ) ) {
13897 n = Math.max( Math.min( n, this.max ), this.min );
13899 n = Math.round( n / this.step ) * this.step;
13904 this.setValue( n );
13911 * @param {string} value Field value
13912 * @return {boolean}
13914 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
13916 if ( value === '' ) {
13917 return !this.isRequired();
13920 if ( isNaN( n ) || !isFinite( n ) ) {
13924 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
13928 if ( n < this.min || n > this.max ) {
13936 * Handle mouse click events.
13939 * @param {number} dir +1 or -1
13941 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
13942 this.adjustValue( dir * this.buttonStep );
13946 * Handle mouse wheel events.
13949 * @param {jQuery.Event} event
13950 * @return {undefined|boolean} False to prevent default if event is handled
13952 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13955 if ( this.isDisabled() || this.isReadOnly() ) {
13959 if ( this.$input.is( ':focus' ) ) {
13960 // Standard 'wheel' event
13961 if ( event.originalEvent.deltaMode !== undefined ) {
13962 this.sawWheelEvent = true;
13964 if ( event.originalEvent.deltaY ) {
13965 delta = -event.originalEvent.deltaY;
13966 } else if ( event.originalEvent.deltaX ) {
13967 delta = event.originalEvent.deltaX;
13970 // Non-standard events
13971 if ( !this.sawWheelEvent ) {
13972 if ( event.originalEvent.wheelDeltaX ) {
13973 delta = -event.originalEvent.wheelDeltaX;
13974 } else if ( event.originalEvent.wheelDeltaY ) {
13975 delta = event.originalEvent.wheelDeltaY;
13976 } else if ( event.originalEvent.wheelDelta ) {
13977 delta = event.originalEvent.wheelDelta;
13978 } else if ( event.originalEvent.detail ) {
13979 delta = -event.originalEvent.detail;
13984 delta = delta < 0 ? -1 : 1;
13985 this.adjustValue( delta * this.buttonStep );
13993 * Handle key down events.
13996 * @param {jQuery.Event} e Key down event
13997 * @return {undefined|boolean} False to prevent default if event is handled
13999 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
14000 if ( this.isDisabled() || this.isReadOnly() ) {
14004 switch ( e.which ) {
14005 case OO.ui.Keys.UP:
14006 this.adjustValue( this.buttonStep );
14008 case OO.ui.Keys.DOWN:
14009 this.adjustValue( -this.buttonStep );
14011 case OO.ui.Keys.PAGEUP:
14012 this.adjustValue( this.pageStep );
14014 case OO.ui.Keys.PAGEDOWN:
14015 this.adjustValue( -this.pageStep );
14021 * Update the disabled state of the controls
14025 * @return {OO.ui.NumberInputWidget} The widget, for chaining
14027 OO.ui.NumberInputWidget.prototype.updateControlsDisabled = function () {
14028 const disabled = this.isDisabled() || this.isReadOnly();
14029 if ( this.minusButton ) {
14030 this.minusButton.setDisabled( disabled );
14032 if ( this.plusButton ) {
14033 this.plusButton.setDisabled( disabled );
14041 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
14043 OO.ui.NumberInputWidget.super.prototype.setDisabled.call( this, disabled );
14044 this.updateControlsDisabled();
14051 OO.ui.NumberInputWidget.prototype.setReadOnly = function () {
14053 OO.ui.NumberInputWidget.super.prototype.setReadOnly.apply( this, arguments );
14054 this.updateControlsDisabled();
14059 * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
14060 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
14061 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
14062 * Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
14064 * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
14066 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
14069 * // A file select input widget.
14070 * const selectFile = new OO.ui.SelectFileInputWidget();
14071 * $( document.body ).append( selectFile.$element );
14074 * @extends OO.ui.InputWidget
14075 * @mixes OO.ui.mixin.RequiredElement
14076 * @mixes OO.ui.mixin.PendingElement
14079 * @param {Object} [config] Configuration options
14080 * @param {string[]|null} [config.accept=null] MIME types to accept. null accepts all types.
14081 * @param {boolean} [config.multiple=false] Allow multiple files to be selected.
14082 * @param {string} [config.placeholder] Text to display when no file is selected.
14083 * @param {Object} [config.button] Config to pass to select file button.
14084 * @param {Object|string|null} [config.icon=null] Icon to show next to file info
14085 * @param {boolean} [config.droppable=true] Whether to accept files by drag and drop.
14086 * @param {boolean} [config.buttonOnly=false] Show only the select file button, no info field.
14087 * Requires showDropTarget to be false.
14088 * @param {boolean} [config.showDropTarget=false] Whether to show a drop target. Requires droppable
14090 * @param {number} [config.thumbnailSizeLimit=20] File size limit in MiB above which to not try and
14091 * show a preview (for performance).
14093 OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
14094 config = config || {};
14096 // Construct buttons before parent method is called (calling setDisabled)
14097 this.selectButton = new OO.ui.ButtonWidget( Object.assign( {
14098 $element: $( '<label>' ),
14099 classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
14102 'ooui-selectfile-button-select-multiple' :
14103 'ooui-selectfile-button-select'
14105 }, config.button ) );
14107 // Configuration initialization
14108 config = Object.assign( {
14110 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
14111 $tabIndexed: this.selectButton.$tabIndexed,
14114 showDropTarget: false,
14115 thumbnailSizeLimit: 20
14118 this.canSetFiles = true;
14119 // Support: Safari < 14
14121 // eslint-disable-next-line no-new
14122 new DataTransfer();
14124 this.canSetFiles = false;
14125 config.droppable = false;
14128 this.info = new OO.ui.SearchInputWidget( {
14129 classes: [ 'oo-ui-selectFileInputWidget-info' ],
14130 placeholder: config.placeholder,
14131 // Pass an empty collection so that .focus() always does nothing
14132 $tabIndexed: $( [] )
14133 } ).setIcon( config.icon );
14134 // Set tabindex manually on $input as $tabIndexed has been overridden.
14135 // Prevents field from becoming focused while tabbing.
14136 // We will also set the disabled attribute on $input, but that is done in #setDisabled.
14137 this.info.$input.attr( 'tabindex', -1 );
14138 // This indicator serves as the only way to clear the file, so it must be keyboard-accessible
14139 this.info.$indicator.attr( 'tabindex', 0 );
14141 // Parent constructor
14142 OO.ui.SelectFileInputWidget.super.call( this, config );
14144 // Mixin constructors
14145 OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
14146 // TODO: Display the required indicator somewhere
14147 indicatorElement: null
14149 OO.ui.mixin.PendingElement.call( this );
14152 this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
14153 if ( Array.isArray( config.accept ) ) {
14154 this.accept = config.accept;
14156 this.accept = null;
14158 this.multiple = !!config.multiple;
14159 this.showDropTarget = config.droppable && config.showDropTarget;
14160 this.thumbnailSizeLimit = config.thumbnailSizeLimit;
14163 this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
14168 // this.selectButton is tabindexed
14170 // Infused input may have previously by
14171 // TabIndexed, so remove aria-disabled attr.
14172 'aria-disabled': null
14175 if ( this.accept ) {
14176 this.$input.attr( 'accept', this.accept.join( ', ' ) );
14178 if ( this.multiple ) {
14179 this.$input.attr( 'multiple', '' );
14181 this.selectButton.$button.append( this.$input );
14184 .addClass( 'oo-ui-selectFileInputWidget oo-ui-selectFileWidget' )
14185 .append( this.fieldLayout.$element );
14187 if ( this.showDropTarget ) {
14188 this.selectButton.setIcon( 'upload' );
14190 .addClass( 'oo-ui-selectFileInputWidget-dropTarget oo-ui-selectFileWidget-dropTarget' )
14192 click: this.onDropTargetClick.bind( this )
14195 this.info.$element,
14196 this.selectButton.$element,
14198 .addClass( 'oo-ui-selectFileInputWidget-dropLabel oo-ui-selectFileWidget-dropLabel' )
14201 'ooui-selectfile-dragdrop-placeholder-multiple' :
14202 'ooui-selectfile-dragdrop-placeholder'
14205 if ( !this.multiple ) {
14206 this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileInputWidget-thumbnail oo-ui-selectFileWidget-thumbnail' );
14207 this.setPendingElement( this.$thumbnail );
14209 .addClass( 'oo-ui-selectFileInputWidget-withThumbnail oo-ui-selectFileWidget-withThumbnail' )
14210 .prepend( this.$thumbnail );
14212 this.fieldLayout.$element.remove();
14213 } else if ( config.buttonOnly ) {
14214 // Copy over any classes that may have been added already.
14215 // Ensure no events are bound to this.$element before here.
14216 this.selectButton.$element
14217 .addClass( this.$element.attr( 'class' ) )
14218 .addClass( 'oo-ui-selectFileInputWidget-buttonOnly oo-ui-selectFileWidget-buttonOnly' );
14219 // Set this.$element to just be the button
14220 this.$element = this.selectButton.$element;
14224 this.info.connect( this, { change: 'onInfoChange' } );
14225 this.selectButton.$button.on( {
14226 keypress: this.onKeyPress.bind( this )
14229 change: this.onFileSelected.bind( this ),
14230 click: function ( e ) {
14231 // Prevents dropTarget getting clicked which calls
14232 // a click on this input
14233 e.stopPropagation();
14237 this.connect( this, { change: 'updateUI' } );
14238 if ( config.droppable ) {
14239 const dragHandler = this.onDragEnterOrOver.bind( this );
14240 this.$element.on( {
14241 dragenter: dragHandler,
14242 dragover: dragHandler,
14243 dragleave: this.onDragLeave.bind( this ),
14244 drop: this.onDrop.bind( this )
14253 OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
14254 OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.RequiredElement );
14255 OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.PendingElement );
14260 * A change event is emitted when the currently selected files change
14262 * @event OO.ui.SelectFileInputWidget#change
14263 * @param {File[]} currentFiles Current file list
14266 /* Static Properties */
14268 // Set empty title so that browser default tooltips like "No file chosen" don't appear.
14269 OO.ui.SelectFileInputWidget.static.title = '';
14274 * Get the current value of the field
14276 * For single file widgets returns a File or null.
14277 * For multiple file widgets returns a list of Files.
14279 * @return {File|File[]|null}
14281 OO.ui.SelectFileInputWidget.prototype.getValue = function () {
14282 return this.multiple ? this.currentFiles : this.currentFiles[ 0 ];
14286 * Set the current file list
14288 * Can only be set to a non-null/non-empty value if this.canSetFiles is true,
14289 * or if the widget has been set natively and we are just updating the internal
14292 * @param {File[]|null} files Files to select
14294 * @return {OO.ui.SelectFileInputWidget} The widget, for chaining
14296 OO.ui.SelectFileInputWidget.prototype.setValue = function ( files ) {
14297 if ( files === undefined || typeof files === 'string' ) {
14298 // Called during init, don't replace value if just infusing.
14302 if ( files && !this.multiple ) {
14303 files = files.slice( 0, 1 );
14306 function comparableFile( file ) {
14307 // Use extend to convert to plain objects so they can be compared.
14308 // File objects contains name, size, timestamp and mime type which
14309 // should be unique.
14310 return Object.assign( {}, file );
14314 files && files.map( comparableFile ),
14315 this.currentFiles && this.currentFiles.map( comparableFile )
14317 this.currentFiles = files || [];
14318 this.emit( 'change', this.currentFiles );
14321 if ( this.canSetFiles ) {
14322 // Convert File[] array back to FileList for setting DOM value
14323 const dataTransfer = new DataTransfer();
14324 Array.prototype.forEach.call( this.currentFiles || [], ( file ) => {
14325 dataTransfer.items.add( file );
14327 this.$input[ 0 ].files = dataTransfer.files;
14329 if ( !files || !files.length ) {
14330 // We're allowed to set the input value to empty string
14332 OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
14334 // Otherwise we assume the caller was just calling setValue with the
14335 // current state of .files in the DOM.
14342 * Get the filename of the currently selected file.
14344 * @return {string} Filename
14346 OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
14347 return this.currentFiles.map( ( file ) => file.name ).join( ', ' );
14351 * Handle file selection from the input.
14354 * @param {jQuery.Event} e
14356 OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
14357 const files = this.filterFiles( e.target.files || [] );
14358 this.setValue( files );
14362 * Disable InputWidget#onEdit listener, onFileSelected is used instead.
14366 OO.ui.SelectFileInputWidget.prototype.onEdit = function () {};
14369 * Update the user interface when a file is selected or unselected.
14373 OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
14375 if ( !this.selectButton ) {
14379 this.info.setValue( this.getFilename() );
14381 if ( this.currentFiles.length ) {
14382 this.$element.removeClass( 'oo-ui-selectFileInputWidget-empty' );
14384 if ( this.showDropTarget ) {
14385 if ( !this.multiple ) {
14386 this.pushPending();
14387 this.loadAndGetImageUrl( this.currentFiles[ 0 ] ).done( ( url ) => {
14388 this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
14390 this.$thumbnail.append(
14391 new OO.ui.IconWidget( {
14392 icon: 'attachment',
14393 classes: [ 'oo-ui-selectFileInputWidget-noThumbnail-icon oo-ui-selectFileWidget-noThumbnail-icon' ]
14396 } ).always( () => {
14400 this.$element.off( 'click' );
14403 if ( this.showDropTarget ) {
14404 this.$element.off( 'click' );
14405 this.$element.on( {
14406 click: this.onDropTargetClick.bind( this )
14408 if ( !this.multiple ) {
14411 .css( 'background-image', '' );
14414 this.$element.addClass( 'oo-ui-selectFileInputWidget-empty' );
14419 * If the selected file is an image, get its URL and load it.
14421 * @param {File} file File
14422 * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
14424 OO.ui.SelectFileInputWidget.prototype.loadAndGetImageUrl = function ( file ) {
14425 const deferred = $.Deferred(),
14426 reader = new FileReader();
14429 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
14430 file.size < this.thumbnailSizeLimit * 1024 * 1024
14432 reader.onload = function ( event ) {
14433 const img = document.createElement( 'img' );
14434 img.addEventListener( 'load', () => {
14436 img.naturalWidth === 0 ||
14437 img.naturalHeight === 0 ||
14438 img.complete === false
14442 deferred.resolve( event.target.result );
14445 img.src = event.target.result;
14447 reader.readAsDataURL( file );
14452 return deferred.promise();
14456 * Determine if we should accept this file.
14459 * @param {FileList|File[]} files Files to filter
14460 * @return {File[]} Filter files
14462 OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
14463 const accept = this.accept;
14465 function mimeAllowed( file ) {
14466 const mimeType = file.type;
14468 if ( !accept || !mimeType ) {
14472 for ( let i = 0; i < accept.length; i++ ) {
14473 let mimeTest = accept[ i ];
14474 if ( mimeTest === mimeType ) {
14476 } else if ( mimeTest.slice( -2 ) === '/*' ) {
14477 mimeTest = mimeTest.slice( 0, mimeTest.length - 1 );
14478 if ( mimeType.slice( 0, mimeTest.length ) === mimeTest ) {
14486 return Array.prototype.filter.call( files, mimeAllowed );
14490 * Handle info input change events
14492 * The info widget can only be changed by the user
14493 * with the clear button.
14496 * @param {string} value
14498 OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
14499 if ( value === '' ) {
14500 this.setValue( null );
14505 * Handle key press events.
14508 * @param {jQuery.Event} e Key press event
14509 * @return {undefined|boolean} False to prevent default if event is handled
14511 OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
14512 if ( !this.isDisabled() && this.$input &&
14513 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
14515 // Emit a click to open the file selector.
14516 this.$input.trigger( 'click' );
14517 // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
14518 this.selectButton.onDocumentKeyUp( e );
14526 OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
14528 OO.ui.SelectFileInputWidget.super.prototype.setDisabled.call( this, disabled );
14530 this.selectButton.setDisabled( disabled );
14531 this.info.setDisabled( disabled );
14533 // Always make the input element disabled, so that it can't be found and focused,
14534 // e.g. by OO.ui.findFocusable.
14535 // The SearchInputWidget can otherwise be enabled normally.
14536 this.info.$input.attr( 'disabled', true );
14542 * Handle drop target click events.
14545 * @param {jQuery.Event} e Key press event
14546 * @return {undefined|boolean} False to prevent default if event is handled
14548 OO.ui.SelectFileInputWidget.prototype.onDropTargetClick = function () {
14549 if ( !this.isDisabled() && this.$input ) {
14550 this.$input.trigger( 'click' );
14556 * Handle drag enter and over events
14559 * @param {jQuery.Event} e Drag event
14560 * @return {undefined|boolean} False to prevent default if event is handled
14562 OO.ui.SelectFileInputWidget.prototype.onDragEnterOrOver = function ( e ) {
14563 let hasDroppableFile = false;
14565 const dt = e.originalEvent.dataTransfer;
14567 e.preventDefault();
14568 e.stopPropagation();
14570 if ( this.isDisabled() ) {
14571 this.$element.removeClass( [
14572 'oo-ui-selectFileInputWidget-canDrop',
14573 'oo-ui-selectFileWidget-canDrop',
14574 'oo-ui-selectFileInputWidget-cantDrop'
14576 dt.dropEffect = 'none';
14580 // DataTransferItem and File both have a type property, but in Chrome files
14581 // have no information at this point.
14582 const itemsOrFiles = dt.items || dt.files;
14583 const hasFiles = !!( itemsOrFiles && itemsOrFiles.length );
14585 if ( this.filterFiles( itemsOrFiles ).length ) {
14586 hasDroppableFile = true;
14588 // dt.types is Array-like, but not an Array
14589 } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
14590 // File information is not available at this point for security so just assume
14591 // it is acceptable for now.
14592 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
14593 hasDroppableFile = true;
14596 this.$element.toggleClass( 'oo-ui-selectFileInputWidget-canDrop oo-ui-selectFileWidget-canDrop', hasDroppableFile );
14597 this.$element.toggleClass( 'oo-ui-selectFileInputWidget-cantDrop', !hasDroppableFile && hasFiles );
14598 if ( !hasDroppableFile ) {
14599 dt.dropEffect = 'none';
14606 * Handle drag leave events
14609 * @param {jQuery.Event} e Drag event
14611 OO.ui.SelectFileInputWidget.prototype.onDragLeave = function () {
14612 this.$element.removeClass( [
14613 'oo-ui-selectFileInputWidget-canDrop',
14614 'oo-ui-selectFileWidget-canDrop',
14615 'oo-ui-selectFileInputWidget-cantDrop'
14620 * Handle drop events
14623 * @param {jQuery.Event} e Drop event
14624 * @return {undefined|boolean} False to prevent default if event is handled
14626 OO.ui.SelectFileInputWidget.prototype.onDrop = function ( e ) {
14627 const dt = e.originalEvent.dataTransfer;
14629 e.preventDefault();
14630 e.stopPropagation();
14631 this.$element.removeClass( [
14632 'oo-ui-selectFileInputWidget-canDrop',
14633 'oo-ui-selectFileWidget-canDrop',
14634 'oo-ui-selectFileInputWidget-cantDrop'
14637 if ( this.isDisabled() ) {
14641 const files = this.filterFiles( dt.files || [] );
14642 this.setValue( files );
14647 // Deprecated alias
14648 OO.ui.SelectFileWidget = function OoUiSelectFileWidget() {
14649 OO.ui.warnDeprecation( 'SelectFileWidget: Deprecated alias, use SelectFileInputWidget instead.' );
14650 OO.ui.SelectFileWidget.super.apply( this, arguments );
14653 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.SelectFileInputWidget );
14657 //# sourceMappingURL=oojs-ui-core.js.map.json