Merge "maintenance: Document secondary purpose of --server"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOUI v0.26.3
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2018 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2018-04-10T22:15:39Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
15 /**
16 * Namespace for all classes, static methods and static properties.
17 *
18 * @class
19 * @singleton
20 */
21 OO.ui = {};
22
23 OO.ui.bind = $.proxy;
24
25 /**
26 * @property {Object}
27 */
28 OO.ui.Keys = {
29 UNDEFINED: 0,
30 BACKSPACE: 8,
31 DELETE: 46,
32 LEFT: 37,
33 RIGHT: 39,
34 UP: 38,
35 DOWN: 40,
36 ENTER: 13,
37 END: 35,
38 HOME: 36,
39 TAB: 9,
40 PAGEUP: 33,
41 PAGEDOWN: 34,
42 ESCAPE: 27,
43 SHIFT: 16,
44 SPACE: 32
45 };
46
47 /**
48 * Constants for MouseEvent.which
49 *
50 * @property {Object}
51 */
52 OO.ui.MouseButtons = {
53 LEFT: 1,
54 MIDDLE: 2,
55 RIGHT: 3
56 };
57
58 /**
59 * @property {number}
60 * @private
61 */
62 OO.ui.elementId = 0;
63
64 /**
65 * Generate a unique ID for element
66 *
67 * @return {string} ID
68 */
69 OO.ui.generateElementId = function () {
70 OO.ui.elementId++;
71 return 'ooui-' + OO.ui.elementId;
72 };
73
74 /**
75 * Check if an element is focusable.
76 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 *
78 * @param {jQuery} $element Element to test
79 * @return {boolean} Element is focusable
80 */
81 OO.ui.isFocusableElement = function ( $element ) {
82 var nodeName,
83 element = $element[ 0 ];
84
85 // Anything disabled is not focusable
86 if ( element.disabled ) {
87 return false;
88 }
89
90 // Check if the element is visible
91 if ( !(
92 // This is quicker than calling $element.is( ':visible' )
93 $.expr.pseudos.visible( element ) &&
94 // Check that all parents are visible
95 !$element.parents().addBack().filter( function () {
96 return $.css( this, 'visibility' ) === 'hidden';
97 } ).length
98 ) ) {
99 return false;
100 }
101
102 // Check if the element is ContentEditable, which is the string 'true'
103 if ( element.contentEditable === 'true' ) {
104 return true;
105 }
106
107 // Anything with a non-negative numeric tabIndex is focusable.
108 // Use .prop to avoid browser bugs
109 if ( $element.prop( 'tabIndex' ) >= 0 ) {
110 return true;
111 }
112
113 // Some element types are naturally focusable
114 // (indexOf is much faster than regex in Chrome and about the
115 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
116 nodeName = element.nodeName.toLowerCase();
117 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
118 return true;
119 }
120
121 // Links and areas are focusable if they have an href
122 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
123 return true;
124 }
125
126 return false;
127 };
128
129 /**
130 * Find a focusable child
131 *
132 * @param {jQuery} $container Container to search in
133 * @param {boolean} [backwards] Search backwards
134 * @return {jQuery} Focusable child, or an empty jQuery object if none found
135 */
136 OO.ui.findFocusable = function ( $container, backwards ) {
137 var $focusable = $( [] ),
138 // $focusableCandidates is a superset of things that
139 // could get matched by isFocusableElement
140 $focusableCandidates = $container
141 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142
143 if ( backwards ) {
144 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 }
146
147 $focusableCandidates.each( function () {
148 var $this = $( this );
149 if ( OO.ui.isFocusableElement( $this ) ) {
150 $focusable = $this;
151 return false;
152 }
153 } );
154 return $focusable;
155 };
156
157 /**
158 * Get the user's language and any fallback languages.
159 *
160 * These language codes are used to localize user interface elements in the user's language.
161 *
162 * In environments that provide a localization system, this function should be overridden to
163 * return the user's language(s). The default implementation returns English (en) only.
164 *
165 * @return {string[]} Language codes, in descending order of priority
166 */
167 OO.ui.getUserLanguages = function () {
168 return [ 'en' ];
169 };
170
171 /**
172 * Get a value in an object keyed by language code.
173 *
174 * @param {Object.<string,Mixed>} obj Object keyed by language code
175 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
176 * @param {string} [fallback] Fallback code, used if no matching language can be found
177 * @return {Mixed} Local value
178 */
179 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
180 var i, len, langs;
181
182 // Requested language
183 if ( obj[ lang ] ) {
184 return obj[ lang ];
185 }
186 // Known user language
187 langs = OO.ui.getUserLanguages();
188 for ( i = 0, len = langs.length; i < len; i++ ) {
189 lang = langs[ i ];
190 if ( obj[ lang ] ) {
191 return obj[ lang ];
192 }
193 }
194 // Fallback language
195 if ( obj[ fallback ] ) {
196 return obj[ fallback ];
197 }
198 // First existing language
199 for ( lang in obj ) {
200 return obj[ lang ];
201 }
202
203 return undefined;
204 };
205
206 /**
207 * Check if a node is contained within another node
208 *
209 * Similar to jQuery#contains except a list of containers can be supplied
210 * and a boolean argument allows you to include the container in the match list
211 *
212 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
213 * @param {HTMLElement} contained Node to find
214 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
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.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
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
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
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 );
273 }
274 };
275
276 /**
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.
280 *
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
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @return {OO.ui.Element}
337 * The `OO.ui.Element` corresponding to this (infusable) document node.
338 */
339 OO.ui.infuse = function ( idOrNode ) {
340 return OO.ui.Element.static.infuse( idOrNode );
341 };
342
343 ( function () {
344 /**
345 * Message store for the default implementation of OO.ui.msg
346 *
347 * Environments that provide a localization system should not use this, but should override
348 * OO.ui.msg altogether.
349 *
350 * @private
351 */
352 var messages = {
353 // Tool tip for a button that moves items in a list down one place
354 'ooui-outline-control-move-down': 'Move item down',
355 // Tool tip for a button that moves items in a list up one place
356 'ooui-outline-control-move-up': 'Move item up',
357 // Tool tip for a button that removes items from a list
358 'ooui-outline-control-remove': 'Remove item',
359 // Label for the toolbar group that contains a list of all other available tools
360 'ooui-toolbar-more': 'More',
361 // Label for the fake tool that expands the full list of tools in a toolbar group
362 'ooui-toolgroup-expand': 'More',
363 // Label for the fake tool that collapses the full list of tools in a toolbar group
364 'ooui-toolgroup-collapse': 'Fewer',
365 // Default label for the tooltip for the button that removes a tag item
366 'ooui-item-remove': 'Remove',
367 // Default label for the accept button of a confirmation dialog
368 'ooui-dialog-message-accept': 'OK',
369 // Default label for the reject button of a confirmation dialog
370 'ooui-dialog-message-reject': 'Cancel',
371 // Title for process dialog error description
372 'ooui-dialog-process-error': 'Something went wrong',
373 // Label for process dialog dismiss error button, visible when describing errors
374 'ooui-dialog-process-dismiss': 'Dismiss',
375 // Label for process dialog retry action button, visible when describing only recoverable errors
376 'ooui-dialog-process-retry': 'Try again',
377 // Label for process dialog retry action button, visible when describing only warnings
378 'ooui-dialog-process-continue': 'Continue',
379 // Label for the file selection widget's select file button
380 'ooui-selectfile-button-select': 'Select a file',
381 // Label for the file selection widget if file selection is not supported
382 'ooui-selectfile-not-supported': 'File selection is not supported',
383 // Label for the file selection widget when no file is currently selected
384 'ooui-selectfile-placeholder': 'No file is selected',
385 // Label for the file selection widget's drop target
386 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
387 };
388
389 /**
390 * Get a localized message.
391 *
392 * After the message key, message parameters may optionally be passed. In the default implementation,
393 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
394 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
395 * they support unnamed, ordered message parameters.
396 *
397 * In environments that provide a localization system, this function should be overridden to
398 * return the message translated in the user's language. The default implementation always returns
399 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
400 * follows.
401 *
402 * @example
403 * var i, iLen, button,
404 * messagePath = 'oojs-ui/dist/i18n/',
405 * languages = [ $.i18n().locale, 'ur', 'en' ],
406 * languageMap = {};
407 *
408 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
409 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
410 * }
411 *
412 * $.i18n().load( languageMap ).done( function() {
413 * // Replace the built-in `msg` only once we've loaded the internationalization.
414 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
415 * // you put off creating any widgets until this promise is complete, no English
416 * // will be displayed.
417 * OO.ui.msg = $.i18n;
418 *
419 * // A button displaying "OK" in the default locale
420 * button = new OO.ui.ButtonWidget( {
421 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
422 * icon: 'check'
423 * } );
424 * $( 'body' ).append( button.$element );
425 *
426 * // A button displaying "OK" in Urdu
427 * $.i18n().locale = 'ur';
428 * button = new OO.ui.ButtonWidget( {
429 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
430 * icon: 'check'
431 * } );
432 * $( 'body' ).append( button.$element );
433 * } );
434 *
435 * @param {string} key Message key
436 * @param {...Mixed} [params] Message parameters
437 * @return {string} Translated message with parameters substituted
438 */
439 OO.ui.msg = function ( key ) {
440 var message = messages[ key ],
441 params = Array.prototype.slice.call( arguments, 1 );
442 if ( typeof message === 'string' ) {
443 // Perform $1 substitution
444 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
445 var i = parseInt( n, 10 );
446 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
447 } );
448 } else {
449 // Return placeholder if message not found
450 message = '[' + key + ']';
451 }
452 return message;
453 };
454 }() );
455
456 /**
457 * Package a message and arguments for deferred resolution.
458 *
459 * Use this when you are statically specifying a message and the message may not yet be present.
460 *
461 * @param {string} key Message key
462 * @param {...Mixed} [params] Message parameters
463 * @return {Function} Function that returns the resolved message when executed
464 */
465 OO.ui.deferMsg = function () {
466 var args = arguments;
467 return function () {
468 return OO.ui.msg.apply( OO.ui, args );
469 };
470 };
471
472 /**
473 * Resolve a message.
474 *
475 * If the message is a function it will be executed, otherwise it will pass through directly.
476 *
477 * @param {Function|string} msg Deferred message, or message text
478 * @return {string} Resolved message
479 */
480 OO.ui.resolveMsg = function ( msg ) {
481 if ( $.isFunction( msg ) ) {
482 return msg();
483 }
484 return msg;
485 };
486
487 /**
488 * @param {string} url
489 * @return {boolean}
490 */
491 OO.ui.isSafeUrl = function ( url ) {
492 // Keep this function in sync with php/Tag.php
493 var i, protocolWhitelist;
494
495 function stringStartsWith( haystack, needle ) {
496 return haystack.substr( 0, needle.length ) === needle;
497 }
498
499 protocolWhitelist = [
500 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
501 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
502 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
503 ];
504
505 if ( url === '' ) {
506 return true;
507 }
508
509 for ( i = 0; i < protocolWhitelist.length; i++ ) {
510 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
511 return true;
512 }
513 }
514
515 // This matches '//' too
516 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
517 return true;
518 }
519 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
520 return true;
521 }
522
523 return false;
524 };
525
526 /**
527 * Check if the user has a 'mobile' device.
528 *
529 * For our purposes this means the user is primarily using an
530 * on-screen keyboard, touch input instead of a mouse and may
531 * have a physically small display.
532 *
533 * It is left up to implementors to decide how to compute this
534 * so the default implementation always returns false.
535 *
536 * @return {boolean} User is on a mobile device
537 */
538 OO.ui.isMobile = function () {
539 return false;
540 };
541
542 /**
543 * Get the additional spacing that should be taken into account when displaying elements that are
544 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
545 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
546 *
547 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
548 * the extra spacing from that edge of viewport (in pixels)
549 */
550 OO.ui.getViewportSpacing = function () {
551 return {
552 top: 0,
553 right: 0,
554 bottom: 0,
555 left: 0
556 };
557 };
558
559 /**
560 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
561 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
562 *
563 * @return {jQuery} Default overlay node
564 */
565 OO.ui.getDefaultOverlay = function () {
566 if ( !OO.ui.$defaultOverlay ) {
567 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
568 $( 'body' ).append( OO.ui.$defaultOverlay );
569 }
570 return OO.ui.$defaultOverlay;
571 };
572
573 /*!
574 * Mixin namespace.
575 */
576
577 /**
578 * Namespace for OOUI mixins.
579 *
580 * Mixins are named according to the type of object they are intended to
581 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
582 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
583 * is intended to be mixed in to an instance of OO.ui.Widget.
584 *
585 * @class
586 * @singleton
587 */
588 OO.ui.mixin = {};
589
590 /**
591 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
592 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
593 * connected to them and can't be interacted with.
594 *
595 * @abstract
596 * @class
597 *
598 * @constructor
599 * @param {Object} [config] Configuration options
600 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
601 * to the top level (e.g., the outermost div) of the element. See the [OOUI documentation on MediaWiki][2]
602 * for an example.
603 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
604 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
605 * @cfg {string} [text] Text to insert
606 * @cfg {Array} [content] An array of content elements to append (after #text).
607 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
608 * Instances of OO.ui.Element will have their $element appended.
609 * @cfg {jQuery} [$content] Content elements to append (after #text).
610 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
611 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
612 * Data can also be specified with the #setData method.
613 */
614 OO.ui.Element = function OoUiElement( config ) {
615 if ( OO.ui.isDemo ) {
616 this.initialConfig = config;
617 }
618 // Configuration initialization
619 config = config || {};
620
621 // Properties
622 this.$ = $;
623 this.elementId = null;
624 this.visible = true;
625 this.data = config.data;
626 this.$element = config.$element ||
627 $( document.createElement( this.getTagName() ) );
628 this.elementGroup = null;
629
630 // Initialization
631 if ( Array.isArray( config.classes ) ) {
632 this.$element.addClass( config.classes.join( ' ' ) );
633 }
634 if ( config.id ) {
635 this.setElementId( config.id );
636 }
637 if ( config.text ) {
638 this.$element.text( config.text );
639 }
640 if ( config.content ) {
641 // The `content` property treats plain strings as text; use an
642 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
643 // appropriate $element appended.
644 this.$element.append( config.content.map( function ( v ) {
645 if ( typeof v === 'string' ) {
646 // Escape string so it is properly represented in HTML.
647 return document.createTextNode( v );
648 } else if ( v instanceof OO.ui.HtmlSnippet ) {
649 // Bypass escaping.
650 return v.toString();
651 } else if ( v instanceof OO.ui.Element ) {
652 return v.$element;
653 }
654 return v;
655 } ) );
656 }
657 if ( config.$content ) {
658 // The `$content` property treats plain strings as HTML.
659 this.$element.append( config.$content );
660 }
661 };
662
663 /* Setup */
664
665 OO.initClass( OO.ui.Element );
666
667 /* Static Properties */
668
669 /**
670 * The name of the HTML tag used by the element.
671 *
672 * The static value may be ignored if the #getTagName method is overridden.
673 *
674 * @static
675 * @inheritable
676 * @property {string}
677 */
678 OO.ui.Element.static.tagName = 'div';
679
680 /* Static Methods */
681
682 /**
683 * Reconstitute a JavaScript object corresponding to a widget created
684 * by the PHP implementation.
685 *
686 * @param {string|HTMLElement|jQuery} idOrNode
687 * A DOM id (if a string) or node for the widget to infuse.
688 * @return {OO.ui.Element}
689 * The `OO.ui.Element` corresponding to this (infusable) document node.
690 * For `Tag` objects emitted on the HTML side (used occasionally for content)
691 * the value returned is a newly-created Element wrapping around the existing
692 * DOM node.
693 */
694 OO.ui.Element.static.infuse = function ( idOrNode ) {
695 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
696 // Verify that the type matches up.
697 // FIXME: uncomment after T89721 is fixed, see T90929.
698 /*
699 if ( !( obj instanceof this['class'] ) ) {
700 throw new Error( 'Infusion type mismatch!' );
701 }
702 */
703 return obj;
704 };
705
706 /**
707 * Implementation helper for `infuse`; skips the type check and has an
708 * extra property so that only the top-level invocation touches the DOM.
709 *
710 * @private
711 * @param {string|HTMLElement|jQuery} idOrNode
712 * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
713 * when the top-level widget of this infusion is inserted into DOM,
714 * replacing the original node; or false for top-level invocation.
715 * @return {OO.ui.Element}
716 */
717 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
718 // look for a cached result of a previous infusion.
719 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
720 if ( typeof idOrNode === 'string' ) {
721 id = idOrNode;
722 $elem = $( document.getElementById( id ) );
723 } else {
724 $elem = $( idOrNode );
725 id = $elem.attr( 'id' );
726 }
727 if ( !$elem.length ) {
728 if ( typeof idOrNode === 'string' ) {
729 error = 'Widget not found: ' + idOrNode;
730 } else if ( idOrNode && idOrNode.selector ) {
731 error = 'Widget not found: ' + idOrNode.selector;
732 } else {
733 error = 'Widget not found';
734 }
735 throw new Error( error );
736 }
737 if ( $elem[ 0 ].oouiInfused ) {
738 $elem = $elem[ 0 ].oouiInfused;
739 }
740 data = $elem.data( 'ooui-infused' );
741 if ( data ) {
742 // cached!
743 if ( data === true ) {
744 throw new Error( 'Circular dependency! ' + id );
745 }
746 if ( domPromise ) {
747 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
748 state = data.constructor.static.gatherPreInfuseState( $elem, data );
749 // restore dynamic state after the new element is re-inserted into DOM under infused parent
750 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
751 infusedChildren = $elem.data( 'ooui-infused-children' );
752 if ( infusedChildren && infusedChildren.length ) {
753 infusedChildren.forEach( function ( data ) {
754 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
755 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
756 } );
757 }
758 }
759 return data;
760 }
761 data = $elem.attr( 'data-ooui' );
762 if ( !data ) {
763 throw new Error( 'No infusion data found: ' + id );
764 }
765 try {
766 data = JSON.parse( data );
767 } catch ( _ ) {
768 data = null;
769 }
770 if ( !( data && data._ ) ) {
771 throw new Error( 'No valid infusion data found: ' + id );
772 }
773 if ( data._ === 'Tag' ) {
774 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
775 return new OO.ui.Element( { $element: $elem } );
776 }
777 parts = data._.split( '.' );
778 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
779 if ( cls === undefined ) {
780 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
781 }
782
783 // Verify that we're creating an OO.ui.Element instance
784 parent = cls.parent;
785
786 while ( parent !== undefined ) {
787 if ( parent === OO.ui.Element ) {
788 // Safe
789 break;
790 }
791
792 parent = parent.parent;
793 }
794
795 if ( parent !== OO.ui.Element ) {
796 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
797 }
798
799 if ( domPromise === false ) {
800 top = $.Deferred();
801 domPromise = top.promise();
802 }
803 $elem.data( 'ooui-infused', true ); // prevent loops
804 data.id = id; // implicit
805 infusedChildren = [];
806 data = OO.copy( data, null, function deserialize( value ) {
807 var infused;
808 if ( OO.isPlainObject( value ) ) {
809 if ( value.tag ) {
810 infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
811 infusedChildren.push( infused );
812 // Flatten the structure
813 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
814 infused.$element.removeData( 'ooui-infused-children' );
815 return infused;
816 }
817 if ( value.html !== undefined ) {
818 return new OO.ui.HtmlSnippet( value.html );
819 }
820 }
821 } );
822 // allow widgets to reuse parts of the DOM
823 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
824 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
825 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
826 // rebuild widget
827 // eslint-disable-next-line new-cap
828 obj = new cls( data );
829 // If anyone is holding a reference to the old DOM element,
830 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
831 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
832 $elem[ 0 ].oouiInfused = obj.$element;
833 // now replace old DOM with this new DOM.
834 if ( top ) {
835 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
836 // so only mutate the DOM if we need to.
837 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
838 $elem.replaceWith( obj.$element );
839 }
840 top.resolve();
841 }
842 obj.$element.data( 'ooui-infused', obj );
843 obj.$element.data( 'ooui-infused-children', infusedChildren );
844 // set the 'data-ooui' attribute so we can identify infused widgets
845 obj.$element.attr( 'data-ooui', '' );
846 // restore dynamic state after the new element is inserted into DOM
847 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
848 return obj;
849 };
850
851 /**
852 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
853 *
854 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
855 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
856 * constructor, which will be given the enhanced config.
857 *
858 * @protected
859 * @param {HTMLElement} node
860 * @param {Object} config
861 * @return {Object}
862 */
863 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
864 return config;
865 };
866
867 /**
868 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
869 * (and its children) that represent an Element of the same class and the given configuration,
870 * generated by the PHP implementation.
871 *
872 * This method is called just before `node` is detached from the DOM. The return value of this
873 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
874 * is inserted into DOM to replace `node`.
875 *
876 * @protected
877 * @param {HTMLElement} node
878 * @param {Object} config
879 * @return {Object}
880 */
881 OO.ui.Element.static.gatherPreInfuseState = function () {
882 return {};
883 };
884
885 /**
886 * Get a jQuery function within a specific document.
887 *
888 * @static
889 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
890 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
891 * not in an iframe
892 * @return {Function} Bound jQuery function
893 */
894 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
895 function wrapper( selector ) {
896 return $( selector, wrapper.context );
897 }
898
899 wrapper.context = this.getDocument( context );
900
901 if ( $iframe ) {
902 wrapper.$iframe = $iframe;
903 }
904
905 return wrapper;
906 };
907
908 /**
909 * Get the document of an element.
910 *
911 * @static
912 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
913 * @return {HTMLDocument|null} Document object
914 */
915 OO.ui.Element.static.getDocument = function ( obj ) {
916 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
917 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
918 // Empty jQuery selections might have a context
919 obj.context ||
920 // HTMLElement
921 obj.ownerDocument ||
922 // Window
923 obj.document ||
924 // HTMLDocument
925 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
926 null;
927 };
928
929 /**
930 * Get the window of an element or document.
931 *
932 * @static
933 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
934 * @return {Window} Window object
935 */
936 OO.ui.Element.static.getWindow = function ( obj ) {
937 var doc = this.getDocument( obj );
938 return doc.defaultView;
939 };
940
941 /**
942 * Get the direction of an element or document.
943 *
944 * @static
945 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
946 * @return {string} Text direction, either 'ltr' or 'rtl'
947 */
948 OO.ui.Element.static.getDir = function ( obj ) {
949 var isDoc, isWin;
950
951 if ( obj instanceof jQuery ) {
952 obj = obj[ 0 ];
953 }
954 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
955 isWin = obj.document !== undefined;
956 if ( isDoc || isWin ) {
957 if ( isWin ) {
958 obj = obj.document;
959 }
960 obj = obj.body;
961 }
962 return $( obj ).css( 'direction' );
963 };
964
965 /**
966 * Get the offset between two frames.
967 *
968 * TODO: Make this function not use recursion.
969 *
970 * @static
971 * @param {Window} from Window of the child frame
972 * @param {Window} [to=window] Window of the parent frame
973 * @param {Object} [offset] Offset to start with, used internally
974 * @return {Object} Offset object, containing left and top properties
975 */
976 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
977 var i, len, frames, frame, rect;
978
979 if ( !to ) {
980 to = window;
981 }
982 if ( !offset ) {
983 offset = { top: 0, left: 0 };
984 }
985 if ( from.parent === from ) {
986 return offset;
987 }
988
989 // Get iframe element
990 frames = from.parent.document.getElementsByTagName( 'iframe' );
991 for ( i = 0, len = frames.length; i < len; i++ ) {
992 if ( frames[ i ].contentWindow === from ) {
993 frame = frames[ i ];
994 break;
995 }
996 }
997
998 // Recursively accumulate offset values
999 if ( frame ) {
1000 rect = frame.getBoundingClientRect();
1001 offset.left += rect.left;
1002 offset.top += rect.top;
1003 if ( from !== to ) {
1004 this.getFrameOffset( from.parent, offset );
1005 }
1006 }
1007 return offset;
1008 };
1009
1010 /**
1011 * Get the offset between two elements.
1012 *
1013 * The two elements may be in a different frame, but in that case the frame $element is in must
1014 * be contained in the frame $anchor is in.
1015 *
1016 * @static
1017 * @param {jQuery} $element Element whose position to get
1018 * @param {jQuery} $anchor Element to get $element's position relative to
1019 * @return {Object} Translated position coordinates, containing top and left properties
1020 */
1021 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1022 var iframe, iframePos,
1023 pos = $element.offset(),
1024 anchorPos = $anchor.offset(),
1025 elementDocument = this.getDocument( $element ),
1026 anchorDocument = this.getDocument( $anchor );
1027
1028 // If $element isn't in the same document as $anchor, traverse up
1029 while ( elementDocument !== anchorDocument ) {
1030 iframe = elementDocument.defaultView.frameElement;
1031 if ( !iframe ) {
1032 throw new Error( '$element frame is not contained in $anchor frame' );
1033 }
1034 iframePos = $( iframe ).offset();
1035 pos.left += iframePos.left;
1036 pos.top += iframePos.top;
1037 elementDocument = iframe.ownerDocument;
1038 }
1039 pos.left -= anchorPos.left;
1040 pos.top -= anchorPos.top;
1041 return pos;
1042 };
1043
1044 /**
1045 * Get element border sizes.
1046 *
1047 * @static
1048 * @param {HTMLElement} el Element to measure
1049 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1050 */
1051 OO.ui.Element.static.getBorders = function ( el ) {
1052 var doc = el.ownerDocument,
1053 win = doc.defaultView,
1054 style = win.getComputedStyle( el, null ),
1055 $el = $( el ),
1056 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1057 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1058 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1059 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1060
1061 return {
1062 top: top,
1063 left: left,
1064 bottom: bottom,
1065 right: right
1066 };
1067 };
1068
1069 /**
1070 * Get dimensions of an element or window.
1071 *
1072 * @static
1073 * @param {HTMLElement|Window} el Element to measure
1074 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1075 */
1076 OO.ui.Element.static.getDimensions = function ( el ) {
1077 var $el, $win,
1078 doc = el.ownerDocument || el.document,
1079 win = doc.defaultView;
1080
1081 if ( win === el || el === doc.documentElement ) {
1082 $win = $( win );
1083 return {
1084 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1085 scroll: {
1086 top: $win.scrollTop(),
1087 left: $win.scrollLeft()
1088 },
1089 scrollbar: { right: 0, bottom: 0 },
1090 rect: {
1091 top: 0,
1092 left: 0,
1093 bottom: $win.innerHeight(),
1094 right: $win.innerWidth()
1095 }
1096 };
1097 } else {
1098 $el = $( el );
1099 return {
1100 borders: this.getBorders( el ),
1101 scroll: {
1102 top: $el.scrollTop(),
1103 left: $el.scrollLeft()
1104 },
1105 scrollbar: {
1106 right: $el.innerWidth() - el.clientWidth,
1107 bottom: $el.innerHeight() - el.clientHeight
1108 },
1109 rect: el.getBoundingClientRect()
1110 };
1111 }
1112 };
1113
1114 /**
1115 * Get the number of pixels that an element's content is scrolled to the left.
1116 *
1117 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1118 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1119 *
1120 * This function smooths out browser inconsistencies (nicely described in the README at
1121 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1122 * with Firefox's 'scrollLeft', which seems the sanest.
1123 *
1124 * @static
1125 * @method
1126 * @param {HTMLElement|Window} el Element to measure
1127 * @return {number} Scroll position from the left.
1128 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1129 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1130 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1131 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1132 */
1133 OO.ui.Element.static.getScrollLeft = ( function () {
1134 var rtlScrollType = null;
1135
1136 function test() {
1137 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1138 definer = $definer[ 0 ];
1139
1140 $definer.appendTo( 'body' );
1141 if ( definer.scrollLeft > 0 ) {
1142 // Safari, Chrome
1143 rtlScrollType = 'default';
1144 } else {
1145 definer.scrollLeft = 1;
1146 if ( definer.scrollLeft === 0 ) {
1147 // Firefox, old Opera
1148 rtlScrollType = 'negative';
1149 } else {
1150 // Internet Explorer, Edge
1151 rtlScrollType = 'reverse';
1152 }
1153 }
1154 $definer.remove();
1155 }
1156
1157 return function getScrollLeft( el ) {
1158 var isRoot = el.window === el ||
1159 el === el.ownerDocument.body ||
1160 el === el.ownerDocument.documentElement,
1161 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1162 // All browsers use the correct scroll type ('negative') on the root, so don't
1163 // do any fixups when looking at the root element
1164 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1165
1166 if ( direction === 'rtl' ) {
1167 if ( rtlScrollType === null ) {
1168 test();
1169 }
1170 if ( rtlScrollType === 'reverse' ) {
1171 scrollLeft = -scrollLeft;
1172 } else if ( rtlScrollType === 'default' ) {
1173 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1174 }
1175 }
1176
1177 return scrollLeft;
1178 };
1179 }() );
1180
1181 /**
1182 * Get the root scrollable element of given element's document.
1183 *
1184 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1185 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1186 * lets us use 'body' or 'documentElement' based on what is working.
1187 *
1188 * https://code.google.com/p/chromium/issues/detail?id=303131
1189 *
1190 * @static
1191 * @param {HTMLElement} el Element to find root scrollable parent for
1192 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1193 * depending on browser
1194 */
1195 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1196 var scrollTop, body;
1197
1198 if ( OO.ui.scrollableElement === undefined ) {
1199 body = el.ownerDocument.body;
1200 scrollTop = body.scrollTop;
1201 body.scrollTop = 1;
1202
1203 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1204 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1205 if ( Math.round( body.scrollTop ) === 1 ) {
1206 body.scrollTop = scrollTop;
1207 OO.ui.scrollableElement = 'body';
1208 } else {
1209 OO.ui.scrollableElement = 'documentElement';
1210 }
1211 }
1212
1213 return el.ownerDocument[ OO.ui.scrollableElement ];
1214 };
1215
1216 /**
1217 * Get closest scrollable container.
1218 *
1219 * Traverses up until either a scrollable element or the root is reached, in which case the root
1220 * scrollable element will be returned (see #getRootScrollableElement).
1221 *
1222 * @static
1223 * @param {HTMLElement} el Element to find scrollable container for
1224 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1225 * @return {HTMLElement} Closest scrollable container
1226 */
1227 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1228 var i, val,
1229 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1230 // 'overflow-y' have different values, so we need to check the separate properties.
1231 props = [ 'overflow-x', 'overflow-y' ],
1232 $parent = $( el ).parent();
1233
1234 if ( dimension === 'x' || dimension === 'y' ) {
1235 props = [ 'overflow-' + dimension ];
1236 }
1237
1238 // Special case for the document root (which doesn't really have any scrollable container, since
1239 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1240 if ( $( el ).is( 'html, body' ) ) {
1241 return this.getRootScrollableElement( el );
1242 }
1243
1244 while ( $parent.length ) {
1245 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1246 return $parent[ 0 ];
1247 }
1248 i = props.length;
1249 while ( i-- ) {
1250 val = $parent.css( props[ i ] );
1251 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1252 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1253 // unintentionally perform a scroll in such case even if the application doesn't scroll
1254 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1255 // This could cause funny issues...
1256 if ( val === 'auto' || val === 'scroll' ) {
1257 return $parent[ 0 ];
1258 }
1259 }
1260 $parent = $parent.parent();
1261 }
1262 // The element is unattached... return something mostly sane
1263 return this.getRootScrollableElement( el );
1264 };
1265
1266 /**
1267 * Scroll element into view.
1268 *
1269 * @static
1270 * @param {HTMLElement} el Element to scroll into view
1271 * @param {Object} [config] Configuration options
1272 * @param {string} [config.duration='fast'] jQuery animation duration value
1273 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1274 * to scroll in both directions
1275 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1276 */
1277 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1278 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1279 deferred = $.Deferred();
1280
1281 // Configuration initialization
1282 config = config || {};
1283
1284 animations = {};
1285 container = this.getClosestScrollableContainer( el, config.direction );
1286 $container = $( container );
1287 elementDimensions = this.getDimensions( el );
1288 containerDimensions = this.getDimensions( container );
1289 $window = $( this.getWindow( el ) );
1290
1291 // Compute the element's position relative to the container
1292 if ( $container.is( 'html, body' ) ) {
1293 // If the scrollable container is the root, this is easy
1294 position = {
1295 top: elementDimensions.rect.top,
1296 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1297 left: elementDimensions.rect.left,
1298 right: $window.innerWidth() - elementDimensions.rect.right
1299 };
1300 } else {
1301 // Otherwise, we have to subtract el's coordinates from container's coordinates
1302 position = {
1303 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1304 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1305 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1306 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1307 };
1308 }
1309
1310 if ( !config.direction || config.direction === 'y' ) {
1311 if ( position.top < 0 ) {
1312 animations.scrollTop = containerDimensions.scroll.top + position.top;
1313 } else if ( position.top > 0 && position.bottom < 0 ) {
1314 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1315 }
1316 }
1317 if ( !config.direction || config.direction === 'x' ) {
1318 if ( position.left < 0 ) {
1319 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1320 } else if ( position.left > 0 && position.right < 0 ) {
1321 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1322 }
1323 }
1324 if ( !$.isEmptyObject( animations ) ) {
1325 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1326 $container.queue( function ( next ) {
1327 deferred.resolve();
1328 next();
1329 } );
1330 } else {
1331 deferred.resolve();
1332 }
1333 return deferred.promise();
1334 };
1335
1336 /**
1337 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1338 * and reserve space for them, because it probably doesn't.
1339 *
1340 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1341 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1342 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1343 * and then reattach (or show) them back.
1344 *
1345 * @static
1346 * @param {HTMLElement} el Element to reconsider the scrollbars on
1347 */
1348 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1349 var i, len, scrollLeft, scrollTop, nodes = [];
1350 // Save scroll position
1351 scrollLeft = el.scrollLeft;
1352 scrollTop = el.scrollTop;
1353 // Detach all children
1354 while ( el.firstChild ) {
1355 nodes.push( el.firstChild );
1356 el.removeChild( el.firstChild );
1357 }
1358 // Force reflow
1359 void el.offsetHeight;
1360 // Reattach all children
1361 for ( i = 0, len = nodes.length; i < len; i++ ) {
1362 el.appendChild( nodes[ i ] );
1363 }
1364 // Restore scroll position (no-op if scrollbars disappeared)
1365 el.scrollLeft = scrollLeft;
1366 el.scrollTop = scrollTop;
1367 };
1368
1369 /* Methods */
1370
1371 /**
1372 * Toggle visibility of an element.
1373 *
1374 * @param {boolean} [show] Make element visible, omit to toggle visibility
1375 * @fires visible
1376 * @chainable
1377 */
1378 OO.ui.Element.prototype.toggle = function ( show ) {
1379 show = show === undefined ? !this.visible : !!show;
1380
1381 if ( show !== this.isVisible() ) {
1382 this.visible = show;
1383 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1384 this.emit( 'toggle', show );
1385 }
1386
1387 return this;
1388 };
1389
1390 /**
1391 * Check if element is visible.
1392 *
1393 * @return {boolean} element is visible
1394 */
1395 OO.ui.Element.prototype.isVisible = function () {
1396 return this.visible;
1397 };
1398
1399 /**
1400 * Get element data.
1401 *
1402 * @return {Mixed} Element data
1403 */
1404 OO.ui.Element.prototype.getData = function () {
1405 return this.data;
1406 };
1407
1408 /**
1409 * Set element data.
1410 *
1411 * @param {Mixed} data Element data
1412 * @chainable
1413 */
1414 OO.ui.Element.prototype.setData = function ( data ) {
1415 this.data = data;
1416 return this;
1417 };
1418
1419 /**
1420 * Set the element has an 'id' attribute.
1421 *
1422 * @param {string} id
1423 * @chainable
1424 */
1425 OO.ui.Element.prototype.setElementId = function ( id ) {
1426 this.elementId = id;
1427 this.$element.attr( 'id', id );
1428 return this;
1429 };
1430
1431 /**
1432 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1433 * and return its value.
1434 *
1435 * @return {string}
1436 */
1437 OO.ui.Element.prototype.getElementId = function () {
1438 if ( this.elementId === null ) {
1439 this.setElementId( OO.ui.generateElementId() );
1440 }
1441 return this.elementId;
1442 };
1443
1444 /**
1445 * Check if element supports one or more methods.
1446 *
1447 * @param {string|string[]} methods Method or list of methods to check
1448 * @return {boolean} All methods are supported
1449 */
1450 OO.ui.Element.prototype.supports = function ( methods ) {
1451 var i, len,
1452 support = 0;
1453
1454 methods = Array.isArray( methods ) ? methods : [ methods ];
1455 for ( i = 0, len = methods.length; i < len; i++ ) {
1456 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1457 support++;
1458 }
1459 }
1460
1461 return methods.length === support;
1462 };
1463
1464 /**
1465 * Update the theme-provided classes.
1466 *
1467 * @localdoc This is called in element mixins and widget classes any time state changes.
1468 * Updating is debounced, minimizing overhead of changing multiple attributes and
1469 * guaranteeing that theme updates do not occur within an element's constructor
1470 */
1471 OO.ui.Element.prototype.updateThemeClasses = function () {
1472 OO.ui.theme.queueUpdateElementClasses( this );
1473 };
1474
1475 /**
1476 * Get the HTML tag name.
1477 *
1478 * Override this method to base the result on instance information.
1479 *
1480 * @return {string} HTML tag name
1481 */
1482 OO.ui.Element.prototype.getTagName = function () {
1483 return this.constructor.static.tagName;
1484 };
1485
1486 /**
1487 * Check if the element is attached to the DOM
1488 *
1489 * @return {boolean} The element is attached to the DOM
1490 */
1491 OO.ui.Element.prototype.isElementAttached = function () {
1492 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1493 };
1494
1495 /**
1496 * Get the DOM document.
1497 *
1498 * @return {HTMLDocument} Document object
1499 */
1500 OO.ui.Element.prototype.getElementDocument = function () {
1501 // Don't cache this in other ways either because subclasses could can change this.$element
1502 return OO.ui.Element.static.getDocument( this.$element );
1503 };
1504
1505 /**
1506 * Get the DOM window.
1507 *
1508 * @return {Window} Window object
1509 */
1510 OO.ui.Element.prototype.getElementWindow = function () {
1511 return OO.ui.Element.static.getWindow( this.$element );
1512 };
1513
1514 /**
1515 * Get closest scrollable container.
1516 *
1517 * @return {HTMLElement} Closest scrollable container
1518 */
1519 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1520 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1521 };
1522
1523 /**
1524 * Get group element is in.
1525 *
1526 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1527 */
1528 OO.ui.Element.prototype.getElementGroup = function () {
1529 return this.elementGroup;
1530 };
1531
1532 /**
1533 * Set group element is in.
1534 *
1535 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1536 * @chainable
1537 */
1538 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1539 this.elementGroup = group;
1540 return this;
1541 };
1542
1543 /**
1544 * Scroll element into view.
1545 *
1546 * @param {Object} [config] Configuration options
1547 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1548 */
1549 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1550 if (
1551 !this.isElementAttached() ||
1552 !this.isVisible() ||
1553 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1554 ) {
1555 return $.Deferred().resolve();
1556 }
1557 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1558 };
1559
1560 /**
1561 * Restore the pre-infusion dynamic state for this widget.
1562 *
1563 * This method is called after #$element has been inserted into DOM. The parameter is the return
1564 * value of #gatherPreInfuseState.
1565 *
1566 * @protected
1567 * @param {Object} state
1568 */
1569 OO.ui.Element.prototype.restorePreInfuseState = function () {
1570 };
1571
1572 /**
1573 * Wraps an HTML snippet for use with configuration values which default
1574 * to strings. This bypasses the default html-escaping done to string
1575 * values.
1576 *
1577 * @class
1578 *
1579 * @constructor
1580 * @param {string} [content] HTML content
1581 */
1582 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1583 // Properties
1584 this.content = content;
1585 };
1586
1587 /* Setup */
1588
1589 OO.initClass( OO.ui.HtmlSnippet );
1590
1591 /* Methods */
1592
1593 /**
1594 * Render into HTML.
1595 *
1596 * @return {string} Unchanged HTML snippet.
1597 */
1598 OO.ui.HtmlSnippet.prototype.toString = function () {
1599 return this.content;
1600 };
1601
1602 /**
1603 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1604 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1605 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1606 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1607 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1608 *
1609 * @abstract
1610 * @class
1611 * @extends OO.ui.Element
1612 * @mixins OO.EventEmitter
1613 *
1614 * @constructor
1615 * @param {Object} [config] Configuration options
1616 */
1617 OO.ui.Layout = function OoUiLayout( config ) {
1618 // Configuration initialization
1619 config = config || {};
1620
1621 // Parent constructor
1622 OO.ui.Layout.parent.call( this, config );
1623
1624 // Mixin constructors
1625 OO.EventEmitter.call( this );
1626
1627 // Initialization
1628 this.$element.addClass( 'oo-ui-layout' );
1629 };
1630
1631 /* Setup */
1632
1633 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1634 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1635
1636 /**
1637 * Widgets are compositions of one or more OOUI elements that users can both view
1638 * and interact with. All widgets can be configured and modified via a standard API,
1639 * and their state can change dynamically according to a model.
1640 *
1641 * @abstract
1642 * @class
1643 * @extends OO.ui.Element
1644 * @mixins OO.EventEmitter
1645 *
1646 * @constructor
1647 * @param {Object} [config] Configuration options
1648 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1649 * appearance reflects this state.
1650 */
1651 OO.ui.Widget = function OoUiWidget( config ) {
1652 // Initialize config
1653 config = $.extend( { disabled: false }, config );
1654
1655 // Parent constructor
1656 OO.ui.Widget.parent.call( this, config );
1657
1658 // Mixin constructors
1659 OO.EventEmitter.call( this );
1660
1661 // Properties
1662 this.disabled = null;
1663 this.wasDisabled = null;
1664
1665 // Initialization
1666 this.$element.addClass( 'oo-ui-widget' );
1667 this.setDisabled( !!config.disabled );
1668 };
1669
1670 /* Setup */
1671
1672 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1673 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1674
1675 /* Events */
1676
1677 /**
1678 * @event disable
1679 *
1680 * A 'disable' event is emitted when the disabled state of the widget changes
1681 * (i.e. on disable **and** enable).
1682 *
1683 * @param {boolean} disabled Widget is disabled
1684 */
1685
1686 /**
1687 * @event toggle
1688 *
1689 * A 'toggle' event is emitted when the visibility of the widget changes.
1690 *
1691 * @param {boolean} visible Widget is visible
1692 */
1693
1694 /* Methods */
1695
1696 /**
1697 * Check if the widget is disabled.
1698 *
1699 * @return {boolean} Widget is disabled
1700 */
1701 OO.ui.Widget.prototype.isDisabled = function () {
1702 return this.disabled;
1703 };
1704
1705 /**
1706 * Set the 'disabled' state of the widget.
1707 *
1708 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1709 *
1710 * @param {boolean} disabled Disable widget
1711 * @chainable
1712 */
1713 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1714 var isDisabled;
1715
1716 this.disabled = !!disabled;
1717 isDisabled = this.isDisabled();
1718 if ( isDisabled !== this.wasDisabled ) {
1719 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1720 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1721 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1722 this.emit( 'disable', isDisabled );
1723 this.updateThemeClasses();
1724 }
1725 this.wasDisabled = isDisabled;
1726
1727 return this;
1728 };
1729
1730 /**
1731 * Update the disabled state, in case of changes in parent widget.
1732 *
1733 * @chainable
1734 */
1735 OO.ui.Widget.prototype.updateDisabled = function () {
1736 this.setDisabled( this.disabled );
1737 return this;
1738 };
1739
1740 /**
1741 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1742 * value.
1743 *
1744 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1745 * instead.
1746 *
1747 * @return {string|null} The ID of the labelable element
1748 */
1749 OO.ui.Widget.prototype.getInputId = function () {
1750 return null;
1751 };
1752
1753 /**
1754 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1755 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1756 * override this method to provide intuitive, accessible behavior.
1757 *
1758 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1759 * Individual widgets may override it too.
1760 *
1761 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1762 * directly.
1763 */
1764 OO.ui.Widget.prototype.simulateLabelClick = function () {
1765 };
1766
1767 /**
1768 * Theme logic.
1769 *
1770 * @abstract
1771 * @class
1772 *
1773 * @constructor
1774 */
1775 OO.ui.Theme = function OoUiTheme() {
1776 this.elementClassesQueue = [];
1777 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1778 };
1779
1780 /* Setup */
1781
1782 OO.initClass( OO.ui.Theme );
1783
1784 /* Methods */
1785
1786 /**
1787 * Get a list of classes to be applied to a widget.
1788 *
1789 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1790 * otherwise state transitions will not work properly.
1791 *
1792 * @param {OO.ui.Element} element Element for which to get classes
1793 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1794 */
1795 OO.ui.Theme.prototype.getElementClasses = function () {
1796 return { on: [], off: [] };
1797 };
1798
1799 /**
1800 * Update CSS classes provided by the theme.
1801 *
1802 * For elements with theme logic hooks, this should be called any time there's a state change.
1803 *
1804 * @param {OO.ui.Element} element Element for which to update classes
1805 */
1806 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1807 var $elements = $( [] ),
1808 classes = this.getElementClasses( element );
1809
1810 if ( element.$icon ) {
1811 $elements = $elements.add( element.$icon );
1812 }
1813 if ( element.$indicator ) {
1814 $elements = $elements.add( element.$indicator );
1815 }
1816
1817 $elements
1818 .removeClass( classes.off.join( ' ' ) )
1819 .addClass( classes.on.join( ' ' ) );
1820 };
1821
1822 /**
1823 * @private
1824 */
1825 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1826 var i;
1827 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1828 this.updateElementClasses( this.elementClassesQueue[ i ] );
1829 }
1830 // Clear the queue
1831 this.elementClassesQueue = [];
1832 };
1833
1834 /**
1835 * Queue #updateElementClasses to be called for this element.
1836 *
1837 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1838 * to make them synchronous.
1839 *
1840 * @param {OO.ui.Element} element Element for which to update classes
1841 */
1842 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1843 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1844 // the most common case (this method is often called repeatedly for the same element).
1845 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1846 return;
1847 }
1848 this.elementClassesQueue.push( element );
1849 this.debouncedUpdateQueuedElementClasses();
1850 };
1851
1852 /**
1853 * Get the transition duration in milliseconds for dialogs opening/closing
1854 *
1855 * The dialog should be fully rendered this many milliseconds after the
1856 * ready process has executed.
1857 *
1858 * @return {number} Transition duration in milliseconds
1859 */
1860 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1861 return 0;
1862 };
1863
1864 /**
1865 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1866 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1867 * order in which users will navigate through the focusable elements via the "tab" key.
1868 *
1869 * @example
1870 * // TabIndexedElement is mixed into the ButtonWidget class
1871 * // to provide a tabIndex property.
1872 * var button1 = new OO.ui.ButtonWidget( {
1873 * label: 'fourth',
1874 * tabIndex: 4
1875 * } );
1876 * var button2 = new OO.ui.ButtonWidget( {
1877 * label: 'second',
1878 * tabIndex: 2
1879 * } );
1880 * var button3 = new OO.ui.ButtonWidget( {
1881 * label: 'third',
1882 * tabIndex: 3
1883 * } );
1884 * var button4 = new OO.ui.ButtonWidget( {
1885 * label: 'first',
1886 * tabIndex: 1
1887 * } );
1888 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1889 *
1890 * @abstract
1891 * @class
1892 *
1893 * @constructor
1894 * @param {Object} [config] Configuration options
1895 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1896 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1897 * functionality will be applied to it instead.
1898 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1899 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1900 * to remove the element from the tab-navigation flow.
1901 */
1902 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1903 // Configuration initialization
1904 config = $.extend( { tabIndex: 0 }, config );
1905
1906 // Properties
1907 this.$tabIndexed = null;
1908 this.tabIndex = null;
1909
1910 // Events
1911 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1912
1913 // Initialization
1914 this.setTabIndex( config.tabIndex );
1915 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1916 };
1917
1918 /* Setup */
1919
1920 OO.initClass( OO.ui.mixin.TabIndexedElement );
1921
1922 /* Methods */
1923
1924 /**
1925 * Set the element that should use the tabindex functionality.
1926 *
1927 * This method is used to retarget a tabindex mixin so that its functionality applies
1928 * to the specified element. If an element is currently using the functionality, the mixin’s
1929 * effect on that element is removed before the new element is set up.
1930 *
1931 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1932 * @chainable
1933 */
1934 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1935 var tabIndex = this.tabIndex;
1936 // Remove attributes from old $tabIndexed
1937 this.setTabIndex( null );
1938 // Force update of new $tabIndexed
1939 this.$tabIndexed = $tabIndexed;
1940 this.tabIndex = tabIndex;
1941 return this.updateTabIndex();
1942 };
1943
1944 /**
1945 * Set the value of the tabindex.
1946 *
1947 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1948 * @chainable
1949 */
1950 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1951 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1952
1953 if ( this.tabIndex !== tabIndex ) {
1954 this.tabIndex = tabIndex;
1955 this.updateTabIndex();
1956 }
1957
1958 return this;
1959 };
1960
1961 /**
1962 * Update the `tabindex` attribute, in case of changes to tab index or
1963 * disabled state.
1964 *
1965 * @private
1966 * @chainable
1967 */
1968 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1969 if ( this.$tabIndexed ) {
1970 if ( this.tabIndex !== null ) {
1971 // Do not index over disabled elements
1972 this.$tabIndexed.attr( {
1973 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1974 // Support: ChromeVox and NVDA
1975 // These do not seem to inherit aria-disabled from parent elements
1976 'aria-disabled': this.isDisabled().toString()
1977 } );
1978 } else {
1979 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1980 }
1981 }
1982 return this;
1983 };
1984
1985 /**
1986 * Handle disable events.
1987 *
1988 * @private
1989 * @param {boolean} disabled Element is disabled
1990 */
1991 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1992 this.updateTabIndex();
1993 };
1994
1995 /**
1996 * Get the value of the tabindex.
1997 *
1998 * @return {number|null} Tabindex value
1999 */
2000 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2001 return this.tabIndex;
2002 };
2003
2004 /**
2005 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2006 *
2007 * If the element already has an ID then that is returned, otherwise unique ID is
2008 * generated, set on the element, and returned.
2009 *
2010 * @return {string|null} The ID of the focusable element
2011 */
2012 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2013 var id;
2014
2015 if ( !this.$tabIndexed ) {
2016 return null;
2017 }
2018 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2019 return null;
2020 }
2021
2022 id = this.$tabIndexed.attr( 'id' );
2023 if ( id === undefined ) {
2024 id = OO.ui.generateElementId();
2025 this.$tabIndexed.attr( 'id', id );
2026 }
2027
2028 return id;
2029 };
2030
2031 /**
2032 * Whether the node is 'labelable' according to the HTML spec
2033 * (i.e., whether it can be interacted with through a `<label for="…">`).
2034 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2035 *
2036 * @private
2037 * @param {jQuery} $node
2038 * @return {boolean}
2039 */
2040 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2041 var
2042 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2043 tagName = $node.prop( 'tagName' ).toLowerCase();
2044
2045 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2046 return true;
2047 }
2048 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2049 return true;
2050 }
2051 return false;
2052 };
2053
2054 /**
2055 * Focus this element.
2056 *
2057 * @chainable
2058 */
2059 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2060 if ( !this.isDisabled() ) {
2061 this.$tabIndexed.focus();
2062 }
2063 return this;
2064 };
2065
2066 /**
2067 * Blur this element.
2068 *
2069 * @chainable
2070 */
2071 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2072 this.$tabIndexed.blur();
2073 return this;
2074 };
2075
2076 /**
2077 * @inheritdoc OO.ui.Widget
2078 */
2079 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2080 this.focus();
2081 };
2082
2083 /**
2084 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2085 * interface element that can be configured with access keys for accessibility.
2086 * See the [OOUI documentation on MediaWiki] [1] for examples.
2087 *
2088 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2089 *
2090 * @abstract
2091 * @class
2092 *
2093 * @constructor
2094 * @param {Object} [config] Configuration options
2095 * @cfg {jQuery} [$button] The button element created by the class.
2096 * If this configuration is omitted, the button element will use a generated `<a>`.
2097 * @cfg {boolean} [framed=true] Render the button with a frame
2098 */
2099 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2100 // Configuration initialization
2101 config = config || {};
2102
2103 // Properties
2104 this.$button = null;
2105 this.framed = null;
2106 this.active = config.active !== undefined && config.active;
2107 this.onMouseUpHandler = this.onMouseUp.bind( this );
2108 this.onMouseDownHandler = this.onMouseDown.bind( this );
2109 this.onKeyDownHandler = this.onKeyDown.bind( this );
2110 this.onKeyUpHandler = this.onKeyUp.bind( this );
2111 this.onClickHandler = this.onClick.bind( this );
2112 this.onKeyPressHandler = this.onKeyPress.bind( this );
2113
2114 // Initialization
2115 this.$element.addClass( 'oo-ui-buttonElement' );
2116 this.toggleFramed( config.framed === undefined || config.framed );
2117 this.setButtonElement( config.$button || $( '<a>' ) );
2118 };
2119
2120 /* Setup */
2121
2122 OO.initClass( OO.ui.mixin.ButtonElement );
2123
2124 /* Static Properties */
2125
2126 /**
2127 * Cancel mouse down events.
2128 *
2129 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2130 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2131 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2132 * parent widget.
2133 *
2134 * @static
2135 * @inheritable
2136 * @property {boolean}
2137 */
2138 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2139
2140 /* Events */
2141
2142 /**
2143 * A 'click' event is emitted when the button element is clicked.
2144 *
2145 * @event click
2146 */
2147
2148 /* Methods */
2149
2150 /**
2151 * Set the button element.
2152 *
2153 * This method is used to retarget a button mixin so that its functionality applies to
2154 * the specified button element instead of the one created by the class. If a button element
2155 * is already set, the method will remove the mixin’s effect on that element.
2156 *
2157 * @param {jQuery} $button Element to use as button
2158 */
2159 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2160 if ( this.$button ) {
2161 this.$button
2162 .removeClass( 'oo-ui-buttonElement-button' )
2163 .removeAttr( 'role accesskey' )
2164 .off( {
2165 mousedown: this.onMouseDownHandler,
2166 keydown: this.onKeyDownHandler,
2167 click: this.onClickHandler,
2168 keypress: this.onKeyPressHandler
2169 } );
2170 }
2171
2172 this.$button = $button
2173 .addClass( 'oo-ui-buttonElement-button' )
2174 .on( {
2175 mousedown: this.onMouseDownHandler,
2176 keydown: this.onKeyDownHandler,
2177 click: this.onClickHandler,
2178 keypress: this.onKeyPressHandler
2179 } );
2180
2181 // Add `role="button"` on `<a>` elements, where it's needed
2182 // `toUppercase()` is added for XHTML documents
2183 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2184 this.$button.attr( 'role', 'button' );
2185 }
2186 };
2187
2188 /**
2189 * Handles mouse down events.
2190 *
2191 * @protected
2192 * @param {jQuery.Event} e Mouse down event
2193 */
2194 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2195 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2196 return;
2197 }
2198 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2199 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2200 // reliably remove the pressed class
2201 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2202 // Prevent change of focus unless specifically configured otherwise
2203 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2204 return false;
2205 }
2206 };
2207
2208 /**
2209 * Handles mouse up events.
2210 *
2211 * @protected
2212 * @param {MouseEvent} e Mouse up event
2213 */
2214 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2215 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2216 return;
2217 }
2218 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2219 // Stop listening for mouseup, since we only needed this once
2220 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2221 };
2222
2223 /**
2224 * Handles mouse click events.
2225 *
2226 * @protected
2227 * @param {jQuery.Event} e Mouse click event
2228 * @fires click
2229 */
2230 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2231 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2232 if ( this.emit( 'click' ) ) {
2233 return false;
2234 }
2235 }
2236 };
2237
2238 /**
2239 * Handles key down events.
2240 *
2241 * @protected
2242 * @param {jQuery.Event} e Key down event
2243 */
2244 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2245 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2246 return;
2247 }
2248 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2249 // Run the keyup handler no matter where the key is when the button is let go, so we can
2250 // reliably remove the pressed class
2251 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2252 };
2253
2254 /**
2255 * Handles key up events.
2256 *
2257 * @protected
2258 * @param {KeyboardEvent} e Key up event
2259 */
2260 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2261 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2262 return;
2263 }
2264 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2265 // Stop listening for keyup, since we only needed this once
2266 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2267 };
2268
2269 /**
2270 * Handles key press events.
2271 *
2272 * @protected
2273 * @param {jQuery.Event} e Key press event
2274 * @fires click
2275 */
2276 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2277 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2278 if ( this.emit( 'click' ) ) {
2279 return false;
2280 }
2281 }
2282 };
2283
2284 /**
2285 * Check if button has a frame.
2286 *
2287 * @return {boolean} Button is framed
2288 */
2289 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2290 return this.framed;
2291 };
2292
2293 /**
2294 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2295 *
2296 * @param {boolean} [framed] Make button framed, omit to toggle
2297 * @chainable
2298 */
2299 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2300 framed = framed === undefined ? !this.framed : !!framed;
2301 if ( framed !== this.framed ) {
2302 this.framed = framed;
2303 this.$element
2304 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2305 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2306 this.updateThemeClasses();
2307 }
2308
2309 return this;
2310 };
2311
2312 /**
2313 * Set the button's active state.
2314 *
2315 * The active state can be set on:
2316 *
2317 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2318 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2319 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2320 *
2321 * @protected
2322 * @param {boolean} value Make button active
2323 * @chainable
2324 */
2325 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2326 this.active = !!value;
2327 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2328 this.updateThemeClasses();
2329 return this;
2330 };
2331
2332 /**
2333 * Check if the button is active
2334 *
2335 * @protected
2336 * @return {boolean} The button is active
2337 */
2338 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2339 return this.active;
2340 };
2341
2342 /**
2343 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2344 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2345 * items from the group is done through the interface the class provides.
2346 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2347 *
2348 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2349 *
2350 * @abstract
2351 * @mixins OO.EmitterList
2352 * @class
2353 *
2354 * @constructor
2355 * @param {Object} [config] Configuration options
2356 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2357 * is omitted, the group element will use a generated `<div>`.
2358 */
2359 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2360 // Configuration initialization
2361 config = config || {};
2362
2363 // Mixin constructors
2364 OO.EmitterList.call( this, config );
2365
2366 // Properties
2367 this.$group = null;
2368
2369 // Initialization
2370 this.setGroupElement( config.$group || $( '<div>' ) );
2371 };
2372
2373 /* Setup */
2374
2375 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2376
2377 /* Events */
2378
2379 /**
2380 * @event change
2381 *
2382 * A change event is emitted when the set of selected items changes.
2383 *
2384 * @param {OO.ui.Element[]} items Items currently in the group
2385 */
2386
2387 /* Methods */
2388
2389 /**
2390 * Set the group element.
2391 *
2392 * If an element is already set, items will be moved to the new element.
2393 *
2394 * @param {jQuery} $group Element to use as group
2395 */
2396 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2397 var i, len;
2398
2399 this.$group = $group;
2400 for ( i = 0, len = this.items.length; i < len; i++ ) {
2401 this.$group.append( this.items[ i ].$element );
2402 }
2403 };
2404
2405 /**
2406 * Find an item by its data.
2407 *
2408 * Only the first item with matching data will be returned. To return all matching items,
2409 * use the #findItemsFromData method.
2410 *
2411 * @param {Object} data Item data to search for
2412 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2413 */
2414 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2415 var i, len, item,
2416 hash = OO.getHash( data );
2417
2418 for ( i = 0, len = this.items.length; i < len; i++ ) {
2419 item = this.items[ i ];
2420 if ( hash === OO.getHash( item.getData() ) ) {
2421 return item;
2422 }
2423 }
2424
2425 return null;
2426 };
2427
2428 /**
2429 * Get an item by its data.
2430 *
2431 * @deprecated Since v0.25.0; use {@link #findItemFromData} instead.
2432 * @param {Object} data Item data to search for
2433 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2434 */
2435 OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
2436 OO.ui.warnDeprecation( 'GroupElement#getItemFromData. Deprecated function. Use findItemFromData instead. See T76630' );
2437 return this.findItemFromData( data );
2438 };
2439
2440 /**
2441 * Find items by their data.
2442 *
2443 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2444 *
2445 * @param {Object} data Item data to search for
2446 * @return {OO.ui.Element[]} Items with equivalent data
2447 */
2448 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2449 var i, len, item,
2450 hash = OO.getHash( data ),
2451 items = [];
2452
2453 for ( i = 0, len = this.items.length; i < len; i++ ) {
2454 item = this.items[ i ];
2455 if ( hash === OO.getHash( item.getData() ) ) {
2456 items.push( item );
2457 }
2458 }
2459
2460 return items;
2461 };
2462
2463 /**
2464 * Find items by their data.
2465 *
2466 * @deprecated Since v0.25.0; use {@link #findItemsFromData} instead.
2467 * @param {Object} data Item data to search for
2468 * @return {OO.ui.Element[]} Items with equivalent data
2469 */
2470 OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
2471 OO.ui.warnDeprecation( 'GroupElement#getItemsFromData. Deprecated function. Use findItemsFromData instead. See T76630' );
2472 return this.findItemsFromData( data );
2473 };
2474
2475 /**
2476 * Add items to the group.
2477 *
2478 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2479 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2480 *
2481 * @param {OO.ui.Element[]} items An array of items to add to the group
2482 * @param {number} [index] Index of the insertion point
2483 * @chainable
2484 */
2485 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2486 // Mixin method
2487 OO.EmitterList.prototype.addItems.call( this, items, index );
2488
2489 this.emit( 'change', this.getItems() );
2490 return this;
2491 };
2492
2493 /**
2494 * @inheritdoc
2495 */
2496 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2497 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2498 this.insertItemElements( items, newIndex );
2499
2500 // Mixin method
2501 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2502
2503 return newIndex;
2504 };
2505
2506 /**
2507 * @inheritdoc
2508 */
2509 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2510 item.setElementGroup( this );
2511 this.insertItemElements( item, index );
2512
2513 // Mixin method
2514 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2515
2516 return index;
2517 };
2518
2519 /**
2520 * Insert elements into the group
2521 *
2522 * @private
2523 * @param {OO.ui.Element} itemWidget Item to insert
2524 * @param {number} index Insertion index
2525 */
2526 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2527 if ( index === undefined || index < 0 || index >= this.items.length ) {
2528 this.$group.append( itemWidget.$element );
2529 } else if ( index === 0 ) {
2530 this.$group.prepend( itemWidget.$element );
2531 } else {
2532 this.items[ index ].$element.before( itemWidget.$element );
2533 }
2534 };
2535
2536 /**
2537 * Remove the specified items from a group.
2538 *
2539 * Removed items are detached (not removed) from the DOM so that they may be reused.
2540 * To remove all items from a group, you may wish to use the #clearItems method instead.
2541 *
2542 * @param {OO.ui.Element[]} items An array of items to remove
2543 * @chainable
2544 */
2545 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2546 var i, len, item, index;
2547
2548 // Remove specific items elements
2549 for ( i = 0, len = items.length; i < len; i++ ) {
2550 item = items[ i ];
2551 index = this.items.indexOf( item );
2552 if ( index !== -1 ) {
2553 item.setElementGroup( null );
2554 item.$element.detach();
2555 }
2556 }
2557
2558 // Mixin method
2559 OO.EmitterList.prototype.removeItems.call( this, items );
2560
2561 this.emit( 'change', this.getItems() );
2562 return this;
2563 };
2564
2565 /**
2566 * Clear all items from the group.
2567 *
2568 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2569 * To remove only a subset of items from a group, use the #removeItems method.
2570 *
2571 * @chainable
2572 */
2573 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2574 var i, len;
2575
2576 // Remove all item elements
2577 for ( i = 0, len = this.items.length; i < len; i++ ) {
2578 this.items[ i ].setElementGroup( null );
2579 this.items[ i ].$element.detach();
2580 }
2581
2582 // Mixin method
2583 OO.EmitterList.prototype.clearItems.call( this );
2584
2585 this.emit( 'change', this.getItems() );
2586 return this;
2587 };
2588
2589 /**
2590 * IconElement is often mixed into other classes to generate an icon.
2591 * Icons are graphics, about the size of normal text. They are used to aid the user
2592 * in locating a control or to convey information in a space-efficient way. See the
2593 * [OOUI documentation on MediaWiki] [1] for a list of icons
2594 * included in the library.
2595 *
2596 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2597 *
2598 * @abstract
2599 * @class
2600 *
2601 * @constructor
2602 * @param {Object} [config] Configuration options
2603 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2604 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2605 * the icon element be set to an existing icon instead of the one generated by this class, set a
2606 * value using a jQuery selection. For example:
2607 *
2608 * // Use a <div> tag instead of a <span>
2609 * $icon: $("<div>")
2610 * // Use an existing icon element instead of the one generated by the class
2611 * $icon: this.$element
2612 * // Use an icon element from a child widget
2613 * $icon: this.childwidget.$element
2614 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2615 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2616 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2617 * by the user's language.
2618 *
2619 * Example of an i18n map:
2620 *
2621 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2622 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2623 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2624 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2625 * text. The icon title is displayed when users move the mouse over the icon.
2626 */
2627 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2628 // Configuration initialization
2629 config = config || {};
2630
2631 // Properties
2632 this.$icon = null;
2633 this.icon = null;
2634 this.iconTitle = null;
2635
2636 // Initialization
2637 this.setIcon( config.icon || this.constructor.static.icon );
2638 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2639 this.setIconElement( config.$icon || $( '<span>' ) );
2640 };
2641
2642 /* Setup */
2643
2644 OO.initClass( OO.ui.mixin.IconElement );
2645
2646 /* Static Properties */
2647
2648 /**
2649 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2650 * for i18n purposes and contains a `default` icon name and additional names keyed by
2651 * language code. The `default` name is used when no icon is keyed by the user's language.
2652 *
2653 * Example of an i18n map:
2654 *
2655 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2656 *
2657 * Note: the static property will be overridden if the #icon configuration is used.
2658 *
2659 * @static
2660 * @inheritable
2661 * @property {Object|string}
2662 */
2663 OO.ui.mixin.IconElement.static.icon = null;
2664
2665 /**
2666 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2667 * function that returns title text, or `null` for no title.
2668 *
2669 * The static property will be overridden if the #iconTitle configuration is used.
2670 *
2671 * @static
2672 * @inheritable
2673 * @property {string|Function|null}
2674 */
2675 OO.ui.mixin.IconElement.static.iconTitle = null;
2676
2677 /* Methods */
2678
2679 /**
2680 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2681 * applies to the specified icon element instead of the one created by the class. If an icon
2682 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2683 * and mixin methods will no longer affect the element.
2684 *
2685 * @param {jQuery} $icon Element to use as icon
2686 */
2687 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2688 if ( this.$icon ) {
2689 this.$icon
2690 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2691 .removeAttr( 'title' );
2692 }
2693
2694 this.$icon = $icon
2695 .addClass( 'oo-ui-iconElement-icon' )
2696 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2697 if ( this.iconTitle !== null ) {
2698 this.$icon.attr( 'title', this.iconTitle );
2699 }
2700
2701 this.updateThemeClasses();
2702 };
2703
2704 /**
2705 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2706 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2707 * for an example.
2708 *
2709 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2710 * by language code, or `null` to remove the icon.
2711 * @chainable
2712 */
2713 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2714 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2715 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2716
2717 if ( this.icon !== icon ) {
2718 if ( this.$icon ) {
2719 if ( this.icon !== null ) {
2720 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2721 }
2722 if ( icon !== null ) {
2723 this.$icon.addClass( 'oo-ui-icon-' + icon );
2724 }
2725 }
2726 this.icon = icon;
2727 }
2728
2729 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2730 this.updateThemeClasses();
2731
2732 return this;
2733 };
2734
2735 /**
2736 * Set the icon title. Use `null` to remove the title.
2737 *
2738 * @param {string|Function|null} iconTitle A text string used as the icon title,
2739 * a function that returns title text, or `null` for no title.
2740 * @chainable
2741 */
2742 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2743 iconTitle =
2744 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2745 OO.ui.resolveMsg( iconTitle ) : null;
2746
2747 if ( this.iconTitle !== iconTitle ) {
2748 this.iconTitle = iconTitle;
2749 if ( this.$icon ) {
2750 if ( this.iconTitle !== null ) {
2751 this.$icon.attr( 'title', iconTitle );
2752 } else {
2753 this.$icon.removeAttr( 'title' );
2754 }
2755 }
2756 }
2757
2758 return this;
2759 };
2760
2761 /**
2762 * Get the symbolic name of the icon.
2763 *
2764 * @return {string} Icon name
2765 */
2766 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2767 return this.icon;
2768 };
2769
2770 /**
2771 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2772 *
2773 * @return {string} Icon title text
2774 */
2775 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2776 return this.iconTitle;
2777 };
2778
2779 /**
2780 * IndicatorElement is often mixed into other classes to generate an indicator.
2781 * Indicators are small graphics that are generally used in two ways:
2782 *
2783 * - To draw attention to the status of an item. For example, an indicator might be
2784 * used to show that an item in a list has errors that need to be resolved.
2785 * - To clarify the function of a control that acts in an exceptional way (a button
2786 * that opens a menu instead of performing an action directly, for example).
2787 *
2788 * For a list of indicators included in the library, please see the
2789 * [OOUI documentation on MediaWiki] [1].
2790 *
2791 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2792 *
2793 * @abstract
2794 * @class
2795 *
2796 * @constructor
2797 * @param {Object} [config] Configuration options
2798 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2799 * configuration is omitted, the indicator element will use a generated `<span>`.
2800 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2801 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
2802 * in the library.
2803 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2804 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2805 * or a function that returns title text. The indicator title is displayed when users move
2806 * the mouse over the indicator.
2807 */
2808 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2809 // Configuration initialization
2810 config = config || {};
2811
2812 // Properties
2813 this.$indicator = null;
2814 this.indicator = null;
2815 this.indicatorTitle = null;
2816
2817 // Initialization
2818 this.setIndicator( config.indicator || this.constructor.static.indicator );
2819 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2820 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2821 };
2822
2823 /* Setup */
2824
2825 OO.initClass( OO.ui.mixin.IndicatorElement );
2826
2827 /* Static Properties */
2828
2829 /**
2830 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2831 * The static property will be overridden if the #indicator configuration is used.
2832 *
2833 * @static
2834 * @inheritable
2835 * @property {string|null}
2836 */
2837 OO.ui.mixin.IndicatorElement.static.indicator = null;
2838
2839 /**
2840 * A text string used as the indicator title, a function that returns title text, or `null`
2841 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2842 *
2843 * @static
2844 * @inheritable
2845 * @property {string|Function|null}
2846 */
2847 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2848
2849 /* Methods */
2850
2851 /**
2852 * Set the indicator element.
2853 *
2854 * If an element is already set, it will be cleaned up before setting up the new element.
2855 *
2856 * @param {jQuery} $indicator Element to use as indicator
2857 */
2858 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2859 if ( this.$indicator ) {
2860 this.$indicator
2861 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2862 .removeAttr( 'title' );
2863 }
2864
2865 this.$indicator = $indicator
2866 .addClass( 'oo-ui-indicatorElement-indicator' )
2867 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2868 if ( this.indicatorTitle !== null ) {
2869 this.$indicator.attr( 'title', this.indicatorTitle );
2870 }
2871
2872 this.updateThemeClasses();
2873 };
2874
2875 /**
2876 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
2877 *
2878 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2879 * @chainable
2880 */
2881 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2882 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2883
2884 if ( this.indicator !== indicator ) {
2885 if ( this.$indicator ) {
2886 if ( this.indicator !== null ) {
2887 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2888 }
2889 if ( indicator !== null ) {
2890 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2891 }
2892 }
2893 this.indicator = indicator;
2894 }
2895
2896 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2897 this.updateThemeClasses();
2898
2899 return this;
2900 };
2901
2902 /**
2903 * Set the indicator title.
2904 *
2905 * The title is displayed when a user moves the mouse over the indicator.
2906 *
2907 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2908 * `null` for no indicator title
2909 * @chainable
2910 */
2911 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2912 indicatorTitle =
2913 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2914 OO.ui.resolveMsg( indicatorTitle ) : null;
2915
2916 if ( this.indicatorTitle !== indicatorTitle ) {
2917 this.indicatorTitle = indicatorTitle;
2918 if ( this.$indicator ) {
2919 if ( this.indicatorTitle !== null ) {
2920 this.$indicator.attr( 'title', indicatorTitle );
2921 } else {
2922 this.$indicator.removeAttr( 'title' );
2923 }
2924 }
2925 }
2926
2927 return this;
2928 };
2929
2930 /**
2931 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2932 *
2933 * @return {string} Symbolic name of indicator
2934 */
2935 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2936 return this.indicator;
2937 };
2938
2939 /**
2940 * Get the indicator title.
2941 *
2942 * The title is displayed when a user moves the mouse over the indicator.
2943 *
2944 * @return {string} Indicator title text
2945 */
2946 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2947 return this.indicatorTitle;
2948 };
2949
2950 /**
2951 * LabelElement is often mixed into other classes to generate a label, which
2952 * helps identify the function of an interface element.
2953 * See the [OOUI documentation on MediaWiki] [1] for more information.
2954 *
2955 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2956 *
2957 * @abstract
2958 * @class
2959 *
2960 * @constructor
2961 * @param {Object} [config] Configuration options
2962 * @cfg {jQuery} [$label] The label element created by the class. If this
2963 * configuration is omitted, the label element will use a generated `<span>`.
2964 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2965 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2966 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2967 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2968 */
2969 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2970 // Configuration initialization
2971 config = config || {};
2972
2973 // Properties
2974 this.$label = null;
2975 this.label = null;
2976
2977 // Initialization
2978 this.setLabel( config.label || this.constructor.static.label );
2979 this.setLabelElement( config.$label || $( '<span>' ) );
2980 };
2981
2982 /* Setup */
2983
2984 OO.initClass( OO.ui.mixin.LabelElement );
2985
2986 /* Events */
2987
2988 /**
2989 * @event labelChange
2990 * @param {string} value
2991 */
2992
2993 /* Static Properties */
2994
2995 /**
2996 * The label text. The label can be specified as a plaintext string, a function that will
2997 * produce a string in the future, or `null` for no label. The static value will
2998 * be overridden if a label is specified with the #label config option.
2999 *
3000 * @static
3001 * @inheritable
3002 * @property {string|Function|null}
3003 */
3004 OO.ui.mixin.LabelElement.static.label = null;
3005
3006 /* Static methods */
3007
3008 /**
3009 * Highlight the first occurrence of the query in the given text
3010 *
3011 * @param {string} text Text
3012 * @param {string} query Query to find
3013 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3014 * @return {jQuery} Text with the first match of the query
3015 * sub-string wrapped in highlighted span
3016 */
3017 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
3018 var i, tLen, qLen,
3019 offset = -1,
3020 $result = $( '<span>' );
3021
3022 if ( compare ) {
3023 tLen = text.length;
3024 qLen = query.length;
3025 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
3026 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
3027 offset = i;
3028 }
3029 }
3030 } else {
3031 offset = text.toLowerCase().indexOf( query.toLowerCase() );
3032 }
3033
3034 if ( !query.length || offset === -1 ) {
3035 $result.text( text );
3036 } else {
3037 $result.append(
3038 document.createTextNode( text.slice( 0, offset ) ),
3039 $( '<span>' )
3040 .addClass( 'oo-ui-labelElement-label-highlight' )
3041 .text( text.slice( offset, offset + query.length ) ),
3042 document.createTextNode( text.slice( offset + query.length ) )
3043 );
3044 }
3045 return $result.contents();
3046 };
3047
3048 /* Methods */
3049
3050 /**
3051 * Set the label element.
3052 *
3053 * If an element is already set, it will be cleaned up before setting up the new element.
3054 *
3055 * @param {jQuery} $label Element to use as label
3056 */
3057 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3058 if ( this.$label ) {
3059 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3060 }
3061
3062 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3063 this.setLabelContent( this.label );
3064 };
3065
3066 /**
3067 * Set the label.
3068 *
3069 * An empty string will result in the label being hidden. A string containing only whitespace will
3070 * be converted to a single `&nbsp;`.
3071 *
3072 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3073 * text; or null for no label
3074 * @chainable
3075 */
3076 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3077 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3078 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3079
3080 if ( this.label !== label ) {
3081 if ( this.$label ) {
3082 this.setLabelContent( label );
3083 }
3084 this.label = label;
3085 this.emit( 'labelChange' );
3086 }
3087
3088 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3089
3090 return this;
3091 };
3092
3093 /**
3094 * Set the label as plain text with a highlighted query
3095 *
3096 * @param {string} text Text label to set
3097 * @param {string} query Substring of text to highlight
3098 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3099 * @chainable
3100 */
3101 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3102 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3103 };
3104
3105 /**
3106 * Get the label.
3107 *
3108 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3109 * text; or null for no label
3110 */
3111 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3112 return this.label;
3113 };
3114
3115 /**
3116 * Set the content of the label.
3117 *
3118 * Do not call this method until after the label element has been set by #setLabelElement.
3119 *
3120 * @private
3121 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3122 * text; or null for no label
3123 */
3124 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3125 if ( typeof label === 'string' ) {
3126 if ( label.match( /^\s*$/ ) ) {
3127 // Convert whitespace only string to a single non-breaking space
3128 this.$label.html( '&nbsp;' );
3129 } else {
3130 this.$label.text( label );
3131 }
3132 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3133 this.$label.html( label.toString() );
3134 } else if ( label instanceof jQuery ) {
3135 this.$label.empty().append( label );
3136 } else {
3137 this.$label.empty();
3138 }
3139 };
3140
3141 /**
3142 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3143 * additional functionality to an element created by another class. The class provides
3144 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3145 * which are used to customize the look and feel of a widget to better describe its
3146 * importance and functionality.
3147 *
3148 * The library currently contains the following styling flags for general use:
3149 *
3150 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3151 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3152 *
3153 * The flags affect the appearance of the buttons:
3154 *
3155 * @example
3156 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3157 * var button1 = new OO.ui.ButtonWidget( {
3158 * label: 'Progressive',
3159 * flags: 'progressive'
3160 * } );
3161 * var button2 = new OO.ui.ButtonWidget( {
3162 * label: 'Destructive',
3163 * flags: 'destructive'
3164 * } );
3165 * $( 'body' ).append( button1.$element, button2.$element );
3166 *
3167 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3168 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3169 *
3170 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3171 *
3172 * @abstract
3173 * @class
3174 *
3175 * @constructor
3176 * @param {Object} [config] Configuration options
3177 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3178 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3179 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3180 * @cfg {jQuery} [$flagged] The flagged element. By default,
3181 * the flagged functionality is applied to the element created by the class ($element).
3182 * If a different element is specified, the flagged functionality will be applied to it instead.
3183 */
3184 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3185 // Configuration initialization
3186 config = config || {};
3187
3188 // Properties
3189 this.flags = {};
3190 this.$flagged = null;
3191
3192 // Initialization
3193 this.setFlags( config.flags );
3194 this.setFlaggedElement( config.$flagged || this.$element );
3195 };
3196
3197 /* Events */
3198
3199 /**
3200 * @event flag
3201 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3202 * parameter contains the name of each modified flag and indicates whether it was
3203 * added or removed.
3204 *
3205 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3206 * that the flag was added, `false` that the flag was removed.
3207 */
3208
3209 /* Methods */
3210
3211 /**
3212 * Set the flagged element.
3213 *
3214 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3215 * If an element is already set, the method will remove the mixin’s effect on that element.
3216 *
3217 * @param {jQuery} $flagged Element that should be flagged
3218 */
3219 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3220 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3221 return 'oo-ui-flaggedElement-' + flag;
3222 } ).join( ' ' );
3223
3224 if ( this.$flagged ) {
3225 this.$flagged.removeClass( classNames );
3226 }
3227
3228 this.$flagged = $flagged.addClass( classNames );
3229 };
3230
3231 /**
3232 * Check if the specified flag is set.
3233 *
3234 * @param {string} flag Name of flag
3235 * @return {boolean} The flag is set
3236 */
3237 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3238 // This may be called before the constructor, thus before this.flags is set
3239 return this.flags && ( flag in this.flags );
3240 };
3241
3242 /**
3243 * Get the names of all flags set.
3244 *
3245 * @return {string[]} Flag names
3246 */
3247 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3248 // This may be called before the constructor, thus before this.flags is set
3249 return Object.keys( this.flags || {} );
3250 };
3251
3252 /**
3253 * Clear all flags.
3254 *
3255 * @chainable
3256 * @fires flag
3257 */
3258 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3259 var flag, className,
3260 changes = {},
3261 remove = [],
3262 classPrefix = 'oo-ui-flaggedElement-';
3263
3264 for ( flag in this.flags ) {
3265 className = classPrefix + flag;
3266 changes[ flag ] = false;
3267 delete this.flags[ flag ];
3268 remove.push( className );
3269 }
3270
3271 if ( this.$flagged ) {
3272 this.$flagged.removeClass( remove.join( ' ' ) );
3273 }
3274
3275 this.updateThemeClasses();
3276 this.emit( 'flag', changes );
3277
3278 return this;
3279 };
3280
3281 /**
3282 * Add one or more flags.
3283 *
3284 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3285 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3286 * be added (`true`) or removed (`false`).
3287 * @chainable
3288 * @fires flag
3289 */
3290 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3291 var i, len, flag, className,
3292 changes = {},
3293 add = [],
3294 remove = [],
3295 classPrefix = 'oo-ui-flaggedElement-';
3296
3297 if ( typeof flags === 'string' ) {
3298 className = classPrefix + flags;
3299 // Set
3300 if ( !this.flags[ flags ] ) {
3301 this.flags[ flags ] = true;
3302 add.push( className );
3303 }
3304 } else if ( Array.isArray( flags ) ) {
3305 for ( i = 0, len = flags.length; i < len; i++ ) {
3306 flag = flags[ i ];
3307 className = classPrefix + flag;
3308 // Set
3309 if ( !this.flags[ flag ] ) {
3310 changes[ flag ] = true;
3311 this.flags[ flag ] = true;
3312 add.push( className );
3313 }
3314 }
3315 } else if ( OO.isPlainObject( flags ) ) {
3316 for ( flag in flags ) {
3317 className = classPrefix + flag;
3318 if ( flags[ flag ] ) {
3319 // Set
3320 if ( !this.flags[ flag ] ) {
3321 changes[ flag ] = true;
3322 this.flags[ flag ] = true;
3323 add.push( className );
3324 }
3325 } else {
3326 // Remove
3327 if ( this.flags[ flag ] ) {
3328 changes[ flag ] = false;
3329 delete this.flags[ flag ];
3330 remove.push( className );
3331 }
3332 }
3333 }
3334 }
3335
3336 if ( this.$flagged ) {
3337 this.$flagged
3338 .addClass( add.join( ' ' ) )
3339 .removeClass( remove.join( ' ' ) );
3340 }
3341
3342 this.updateThemeClasses();
3343 this.emit( 'flag', changes );
3344
3345 return this;
3346 };
3347
3348 /**
3349 * TitledElement is mixed into other classes to provide a `title` attribute.
3350 * Titles are rendered by the browser and are made visible when the user moves
3351 * the mouse over the element. Titles are not visible on touch devices.
3352 *
3353 * @example
3354 * // TitledElement provides a 'title' attribute to the
3355 * // ButtonWidget class
3356 * var button = new OO.ui.ButtonWidget( {
3357 * label: 'Button with Title',
3358 * title: 'I am a button'
3359 * } );
3360 * $( 'body' ).append( button.$element );
3361 *
3362 * @abstract
3363 * @class
3364 *
3365 * @constructor
3366 * @param {Object} [config] Configuration options
3367 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3368 * If this config is omitted, the title functionality is applied to $element, the
3369 * element created by the class.
3370 * @cfg {string|Function} [title] The title text or a function that returns text. If
3371 * this config is omitted, the value of the {@link #static-title static title} property is used.
3372 */
3373 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3374 // Configuration initialization
3375 config = config || {};
3376
3377 // Properties
3378 this.$titled = null;
3379 this.title = null;
3380
3381 // Initialization
3382 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3383 this.setTitledElement( config.$titled || this.$element );
3384 };
3385
3386 /* Setup */
3387
3388 OO.initClass( OO.ui.mixin.TitledElement );
3389
3390 /* Static Properties */
3391
3392 /**
3393 * The title text, a function that returns text, or `null` for no title. The value of the static property
3394 * is overridden if the #title config option is used.
3395 *
3396 * @static
3397 * @inheritable
3398 * @property {string|Function|null}
3399 */
3400 OO.ui.mixin.TitledElement.static.title = null;
3401
3402 /* Methods */
3403
3404 /**
3405 * Set the titled element.
3406 *
3407 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3408 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3409 *
3410 * @param {jQuery} $titled Element that should use the 'titled' functionality
3411 */
3412 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3413 if ( this.$titled ) {
3414 this.$titled.removeAttr( 'title' );
3415 }
3416
3417 this.$titled = $titled;
3418 if ( this.title ) {
3419 this.updateTitle();
3420 }
3421 };
3422
3423 /**
3424 * Set title.
3425 *
3426 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3427 * @chainable
3428 */
3429 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3430 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3431 title = ( typeof title === 'string' && title.length ) ? title : null;
3432
3433 if ( this.title !== title ) {
3434 this.title = title;
3435 this.updateTitle();
3436 }
3437
3438 return this;
3439 };
3440
3441 /**
3442 * Update the title attribute, in case of changes to title or accessKey.
3443 *
3444 * @protected
3445 * @chainable
3446 */
3447 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3448 var title = this.getTitle();
3449 if ( this.$titled ) {
3450 if ( title !== null ) {
3451 // Only if this is an AccessKeyedElement
3452 if ( this.formatTitleWithAccessKey ) {
3453 title = this.formatTitleWithAccessKey( title );
3454 }
3455 this.$titled.attr( 'title', title );
3456 } else {
3457 this.$titled.removeAttr( 'title' );
3458 }
3459 }
3460 return this;
3461 };
3462
3463 /**
3464 * Get title.
3465 *
3466 * @return {string} Title string
3467 */
3468 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3469 return this.title;
3470 };
3471
3472 /**
3473 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3474 * Accesskeys allow an user to go to a specific element by using
3475 * a shortcut combination of a browser specific keys + the key
3476 * set to the field.
3477 *
3478 * @example
3479 * // AccessKeyedElement provides an 'accesskey' attribute to the
3480 * // ButtonWidget class
3481 * var button = new OO.ui.ButtonWidget( {
3482 * label: 'Button with Accesskey',
3483 * accessKey: 'k'
3484 * } );
3485 * $( 'body' ).append( button.$element );
3486 *
3487 * @abstract
3488 * @class
3489 *
3490 * @constructor
3491 * @param {Object} [config] Configuration options
3492 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3493 * If this config is omitted, the accesskey functionality is applied to $element, the
3494 * element created by the class.
3495 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3496 * this config is omitted, no accesskey will be added.
3497 */
3498 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3499 // Configuration initialization
3500 config = config || {};
3501
3502 // Properties
3503 this.$accessKeyed = null;
3504 this.accessKey = null;
3505
3506 // Initialization
3507 this.setAccessKey( config.accessKey || null );
3508 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3509
3510 // If this is also a TitledElement and it initialized before we did, we may have
3511 // to update the title with the access key
3512 if ( this.updateTitle ) {
3513 this.updateTitle();
3514 }
3515 };
3516
3517 /* Setup */
3518
3519 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3520
3521 /* Static Properties */
3522
3523 /**
3524 * The access key, a function that returns a key, or `null` for no accesskey.
3525 *
3526 * @static
3527 * @inheritable
3528 * @property {string|Function|null}
3529 */
3530 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3531
3532 /* Methods */
3533
3534 /**
3535 * Set the accesskeyed element.
3536 *
3537 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3538 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3539 *
3540 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3541 */
3542 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3543 if ( this.$accessKeyed ) {
3544 this.$accessKeyed.removeAttr( 'accesskey' );
3545 }
3546
3547 this.$accessKeyed = $accessKeyed;
3548 if ( this.accessKey ) {
3549 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3550 }
3551 };
3552
3553 /**
3554 * Set accesskey.
3555 *
3556 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3557 * @chainable
3558 */
3559 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3560 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3561
3562 if ( this.accessKey !== accessKey ) {
3563 if ( this.$accessKeyed ) {
3564 if ( accessKey !== null ) {
3565 this.$accessKeyed.attr( 'accesskey', accessKey );
3566 } else {
3567 this.$accessKeyed.removeAttr( 'accesskey' );
3568 }
3569 }
3570 this.accessKey = accessKey;
3571
3572 // Only if this is a TitledElement
3573 if ( this.updateTitle ) {
3574 this.updateTitle();
3575 }
3576 }
3577
3578 return this;
3579 };
3580
3581 /**
3582 * Get accesskey.
3583 *
3584 * @return {string} accessKey string
3585 */
3586 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3587 return this.accessKey;
3588 };
3589
3590 /**
3591 * Add information about the access key to the element's tooltip label.
3592 * (This is only public for hacky usage in FieldLayout.)
3593 *
3594 * @param {string} title Tooltip label for `title` attribute
3595 * @return {string}
3596 */
3597 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3598 var accessKey;
3599
3600 if ( !this.$accessKeyed ) {
3601 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3602 return title;
3603 }
3604 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3605 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3606 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3607 } else {
3608 accessKey = this.getAccessKey();
3609 }
3610 if ( accessKey ) {
3611 title += ' [' + accessKey + ']';
3612 }
3613 return title;
3614 };
3615
3616 /**
3617 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3618 * feels, and functionality can be customized via the class’s configuration options
3619 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3620 * and examples.
3621 *
3622 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3623 *
3624 * @example
3625 * // A button widget
3626 * var button = new OO.ui.ButtonWidget( {
3627 * label: 'Button with Icon',
3628 * icon: 'trash',
3629 * iconTitle: 'Remove'
3630 * } );
3631 * $( 'body' ).append( button.$element );
3632 *
3633 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3634 *
3635 * @class
3636 * @extends OO.ui.Widget
3637 * @mixins OO.ui.mixin.ButtonElement
3638 * @mixins OO.ui.mixin.IconElement
3639 * @mixins OO.ui.mixin.IndicatorElement
3640 * @mixins OO.ui.mixin.LabelElement
3641 * @mixins OO.ui.mixin.TitledElement
3642 * @mixins OO.ui.mixin.FlaggedElement
3643 * @mixins OO.ui.mixin.TabIndexedElement
3644 * @mixins OO.ui.mixin.AccessKeyedElement
3645 *
3646 * @constructor
3647 * @param {Object} [config] Configuration options
3648 * @cfg {boolean} [active=false] Whether button should be shown as active
3649 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3650 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3651 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3652 */
3653 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3654 // Configuration initialization
3655 config = config || {};
3656
3657 // Parent constructor
3658 OO.ui.ButtonWidget.parent.call( this, config );
3659
3660 // Mixin constructors
3661 OO.ui.mixin.ButtonElement.call( this, config );
3662 OO.ui.mixin.IconElement.call( this, config );
3663 OO.ui.mixin.IndicatorElement.call( this, config );
3664 OO.ui.mixin.LabelElement.call( this, config );
3665 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3666 OO.ui.mixin.FlaggedElement.call( this, config );
3667 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3668 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3669
3670 // Properties
3671 this.href = null;
3672 this.target = null;
3673 this.noFollow = false;
3674
3675 // Events
3676 this.connect( this, { disable: 'onDisable' } );
3677
3678 // Initialization
3679 this.$button.append( this.$icon, this.$label, this.$indicator );
3680 this.$element
3681 .addClass( 'oo-ui-buttonWidget' )
3682 .append( this.$button );
3683 this.setActive( config.active );
3684 this.setHref( config.href );
3685 this.setTarget( config.target );
3686 this.setNoFollow( config.noFollow );
3687 };
3688
3689 /* Setup */
3690
3691 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3692 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3693 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3694 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3695 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3696 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3697 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3698 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3699 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3700
3701 /* Static Properties */
3702
3703 /**
3704 * @static
3705 * @inheritdoc
3706 */
3707 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3708
3709 /**
3710 * @static
3711 * @inheritdoc
3712 */
3713 OO.ui.ButtonWidget.static.tagName = 'span';
3714
3715 /* Methods */
3716
3717 /**
3718 * Get hyperlink location.
3719 *
3720 * @return {string} Hyperlink location
3721 */
3722 OO.ui.ButtonWidget.prototype.getHref = function () {
3723 return this.href;
3724 };
3725
3726 /**
3727 * Get hyperlink target.
3728 *
3729 * @return {string} Hyperlink target
3730 */
3731 OO.ui.ButtonWidget.prototype.getTarget = function () {
3732 return this.target;
3733 };
3734
3735 /**
3736 * Get search engine traversal hint.
3737 *
3738 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3739 */
3740 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3741 return this.noFollow;
3742 };
3743
3744 /**
3745 * Set hyperlink location.
3746 *
3747 * @param {string|null} href Hyperlink location, null to remove
3748 */
3749 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3750 href = typeof href === 'string' ? href : null;
3751 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3752 href = './' + href;
3753 }
3754
3755 if ( href !== this.href ) {
3756 this.href = href;
3757 this.updateHref();
3758 }
3759
3760 return this;
3761 };
3762
3763 /**
3764 * Update the `href` attribute, in case of changes to href or
3765 * disabled state.
3766 *
3767 * @private
3768 * @chainable
3769 */
3770 OO.ui.ButtonWidget.prototype.updateHref = function () {
3771 if ( this.href !== null && !this.isDisabled() ) {
3772 this.$button.attr( 'href', this.href );
3773 } else {
3774 this.$button.removeAttr( 'href' );
3775 }
3776
3777 return this;
3778 };
3779
3780 /**
3781 * Handle disable events.
3782 *
3783 * @private
3784 * @param {boolean} disabled Element is disabled
3785 */
3786 OO.ui.ButtonWidget.prototype.onDisable = function () {
3787 this.updateHref();
3788 };
3789
3790 /**
3791 * Set hyperlink target.
3792 *
3793 * @param {string|null} target Hyperlink target, null to remove
3794 */
3795 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3796 target = typeof target === 'string' ? target : null;
3797
3798 if ( target !== this.target ) {
3799 this.target = target;
3800 if ( target !== null ) {
3801 this.$button.attr( 'target', target );
3802 } else {
3803 this.$button.removeAttr( 'target' );
3804 }
3805 }
3806
3807 return this;
3808 };
3809
3810 /**
3811 * Set search engine traversal hint.
3812 *
3813 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3814 */
3815 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3816 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3817
3818 if ( noFollow !== this.noFollow ) {
3819 this.noFollow = noFollow;
3820 if ( noFollow ) {
3821 this.$button.attr( 'rel', 'nofollow' );
3822 } else {
3823 this.$button.removeAttr( 'rel' );
3824 }
3825 }
3826
3827 return this;
3828 };
3829
3830 // Override method visibility hints from ButtonElement
3831 /**
3832 * @method setActive
3833 * @inheritdoc
3834 */
3835 /**
3836 * @method isActive
3837 * @inheritdoc
3838 */
3839
3840 /**
3841 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3842 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3843 * removed, and cleared from the group.
3844 *
3845 * @example
3846 * // Example: A ButtonGroupWidget with two buttons
3847 * var button1 = new OO.ui.PopupButtonWidget( {
3848 * label: 'Select a category',
3849 * icon: 'menu',
3850 * popup: {
3851 * $content: $( '<p>List of categories...</p>' ),
3852 * padded: true,
3853 * align: 'left'
3854 * }
3855 * } );
3856 * var button2 = new OO.ui.ButtonWidget( {
3857 * label: 'Add item'
3858 * });
3859 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3860 * items: [button1, button2]
3861 * } );
3862 * $( 'body' ).append( buttonGroup.$element );
3863 *
3864 * @class
3865 * @extends OO.ui.Widget
3866 * @mixins OO.ui.mixin.GroupElement
3867 *
3868 * @constructor
3869 * @param {Object} [config] Configuration options
3870 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3871 */
3872 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3873 // Configuration initialization
3874 config = config || {};
3875
3876 // Parent constructor
3877 OO.ui.ButtonGroupWidget.parent.call( this, config );
3878
3879 // Mixin constructors
3880 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3881
3882 // Initialization
3883 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3884 if ( Array.isArray( config.items ) ) {
3885 this.addItems( config.items );
3886 }
3887 };
3888
3889 /* Setup */
3890
3891 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3892 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3893
3894 /* Static Properties */
3895
3896 /**
3897 * @static
3898 * @inheritdoc
3899 */
3900 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3901
3902 /* Methods */
3903
3904 /**
3905 * Focus the widget
3906 *
3907 * @chainable
3908 */
3909 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3910 if ( !this.isDisabled() ) {
3911 if ( this.items[ 0 ] ) {
3912 this.items[ 0 ].focus();
3913 }
3914 }
3915 return this;
3916 };
3917
3918 /**
3919 * @inheritdoc
3920 */
3921 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3922 this.focus();
3923 };
3924
3925 /**
3926 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3927 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
3928 * for a list of icons included in the library.
3929 *
3930 * @example
3931 * // An icon widget with a label
3932 * var myIcon = new OO.ui.IconWidget( {
3933 * icon: 'help',
3934 * iconTitle: 'Help'
3935 * } );
3936 * // Create a label.
3937 * var iconLabel = new OO.ui.LabelWidget( {
3938 * label: 'Help'
3939 * } );
3940 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3941 *
3942 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3943 *
3944 * @class
3945 * @extends OO.ui.Widget
3946 * @mixins OO.ui.mixin.IconElement
3947 * @mixins OO.ui.mixin.TitledElement
3948 * @mixins OO.ui.mixin.FlaggedElement
3949 *
3950 * @constructor
3951 * @param {Object} [config] Configuration options
3952 */
3953 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3954 // Configuration initialization
3955 config = config || {};
3956
3957 // Parent constructor
3958 OO.ui.IconWidget.parent.call( this, config );
3959
3960 // Mixin constructors
3961 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3962 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3963 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3964
3965 // Initialization
3966 this.$element.addClass( 'oo-ui-iconWidget' );
3967 };
3968
3969 /* Setup */
3970
3971 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3972 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3973 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3974 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3975
3976 /* Static Properties */
3977
3978 /**
3979 * @static
3980 * @inheritdoc
3981 */
3982 OO.ui.IconWidget.static.tagName = 'span';
3983
3984 /**
3985 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3986 * attention to the status of an item or to clarify the function within a control. For a list of
3987 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
3988 *
3989 * @example
3990 * // Example of an indicator widget
3991 * var indicator1 = new OO.ui.IndicatorWidget( {
3992 * indicator: 'required'
3993 * } );
3994 *
3995 * // Create a fieldset layout to add a label
3996 * var fieldset = new OO.ui.FieldsetLayout();
3997 * fieldset.addItems( [
3998 * new OO.ui.FieldLayout( indicator1, { label: 'A required indicator:' } )
3999 * ] );
4000 * $( 'body' ).append( fieldset.$element );
4001 *
4002 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4003 *
4004 * @class
4005 * @extends OO.ui.Widget
4006 * @mixins OO.ui.mixin.IndicatorElement
4007 * @mixins OO.ui.mixin.TitledElement
4008 *
4009 * @constructor
4010 * @param {Object} [config] Configuration options
4011 */
4012 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4013 // Configuration initialization
4014 config = config || {};
4015
4016 // Parent constructor
4017 OO.ui.IndicatorWidget.parent.call( this, config );
4018
4019 // Mixin constructors
4020 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4021 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4022
4023 // Initialization
4024 this.$element.addClass( 'oo-ui-indicatorWidget' );
4025 };
4026
4027 /* Setup */
4028
4029 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4030 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4031 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4032
4033 /* Static Properties */
4034
4035 /**
4036 * @static
4037 * @inheritdoc
4038 */
4039 OO.ui.IndicatorWidget.static.tagName = 'span';
4040
4041 /**
4042 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4043 * be configured with a `label` option that is set to a string, a label node, or a function:
4044 *
4045 * - String: a plaintext string
4046 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4047 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4048 * - Function: a function that will produce a string in the future. Functions are used
4049 * in cases where the value of the label is not currently defined.
4050 *
4051 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4052 * will come into focus when the label is clicked.
4053 *
4054 * @example
4055 * // Examples of LabelWidgets
4056 * var label1 = new OO.ui.LabelWidget( {
4057 * label: 'plaintext label'
4058 * } );
4059 * var label2 = new OO.ui.LabelWidget( {
4060 * label: $( '<a href="default.html">jQuery label</a>' )
4061 * } );
4062 * // Create a fieldset layout with fields for each example
4063 * var fieldset = new OO.ui.FieldsetLayout();
4064 * fieldset.addItems( [
4065 * new OO.ui.FieldLayout( label1 ),
4066 * new OO.ui.FieldLayout( label2 )
4067 * ] );
4068 * $( 'body' ).append( fieldset.$element );
4069 *
4070 * @class
4071 * @extends OO.ui.Widget
4072 * @mixins OO.ui.mixin.LabelElement
4073 * @mixins OO.ui.mixin.TitledElement
4074 *
4075 * @constructor
4076 * @param {Object} [config] Configuration options
4077 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4078 * Clicking the label will focus the specified input field.
4079 */
4080 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4081 // Configuration initialization
4082 config = config || {};
4083
4084 // Parent constructor
4085 OO.ui.LabelWidget.parent.call( this, config );
4086
4087 // Mixin constructors
4088 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4089 OO.ui.mixin.TitledElement.call( this, config );
4090
4091 // Properties
4092 this.input = config.input;
4093
4094 // Initialization
4095 if ( this.input ) {
4096 if ( this.input.getInputId() ) {
4097 this.$element.attr( 'for', this.input.getInputId() );
4098 } else {
4099 this.$label.on( 'click', function () {
4100 this.input.simulateLabelClick();
4101 }.bind( this ) );
4102 }
4103 }
4104 this.$element.addClass( 'oo-ui-labelWidget' );
4105 };
4106
4107 /* Setup */
4108
4109 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4110 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4111 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4112
4113 /* Static Properties */
4114
4115 /**
4116 * @static
4117 * @inheritdoc
4118 */
4119 OO.ui.LabelWidget.static.tagName = 'label';
4120
4121 /**
4122 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4123 * and that they should wait before proceeding. The pending state is visually represented with a pending
4124 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4125 * field of a {@link OO.ui.TextInputWidget text input widget}.
4126 *
4127 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4128 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4129 * in process dialogs.
4130 *
4131 * @example
4132 * function MessageDialog( config ) {
4133 * MessageDialog.parent.call( this, config );
4134 * }
4135 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4136 *
4137 * MessageDialog.static.name = 'myMessageDialog';
4138 * MessageDialog.static.actions = [
4139 * { action: 'save', label: 'Done', flags: 'primary' },
4140 * { label: 'Cancel', flags: 'safe' }
4141 * ];
4142 *
4143 * MessageDialog.prototype.initialize = function () {
4144 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4145 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4146 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
4147 * this.$body.append( this.content.$element );
4148 * };
4149 * MessageDialog.prototype.getBodyHeight = function () {
4150 * return 100;
4151 * }
4152 * MessageDialog.prototype.getActionProcess = function ( action ) {
4153 * var dialog = this;
4154 * if ( action === 'save' ) {
4155 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4156 * return new OO.ui.Process()
4157 * .next( 1000 )
4158 * .next( function () {
4159 * dialog.getActions().get({actions: 'save'})[0].popPending();
4160 * } );
4161 * }
4162 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4163 * };
4164 *
4165 * var windowManager = new OO.ui.WindowManager();
4166 * $( 'body' ).append( windowManager.$element );
4167 *
4168 * var dialog = new MessageDialog();
4169 * windowManager.addWindows( [ dialog ] );
4170 * windowManager.openWindow( dialog );
4171 *
4172 * @abstract
4173 * @class
4174 *
4175 * @constructor
4176 * @param {Object} [config] Configuration options
4177 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4178 */
4179 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4180 // Configuration initialization
4181 config = config || {};
4182
4183 // Properties
4184 this.pending = 0;
4185 this.$pending = null;
4186
4187 // Initialisation
4188 this.setPendingElement( config.$pending || this.$element );
4189 };
4190
4191 /* Setup */
4192
4193 OO.initClass( OO.ui.mixin.PendingElement );
4194
4195 /* Methods */
4196
4197 /**
4198 * Set the pending element (and clean up any existing one).
4199 *
4200 * @param {jQuery} $pending The element to set to pending.
4201 */
4202 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4203 if ( this.$pending ) {
4204 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4205 }
4206
4207 this.$pending = $pending;
4208 if ( this.pending > 0 ) {
4209 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4210 }
4211 };
4212
4213 /**
4214 * Check if an element is pending.
4215 *
4216 * @return {boolean} Element is pending
4217 */
4218 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4219 return !!this.pending;
4220 };
4221
4222 /**
4223 * Increase the pending counter. The pending state will remain active until the counter is zero
4224 * (i.e., the number of calls to #pushPending and #popPending is the same).
4225 *
4226 * @chainable
4227 */
4228 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4229 if ( this.pending === 0 ) {
4230 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4231 this.updateThemeClasses();
4232 }
4233 this.pending++;
4234
4235 return this;
4236 };
4237
4238 /**
4239 * Decrease the pending counter. The pending state will remain active until the counter is zero
4240 * (i.e., the number of calls to #pushPending and #popPending is the same).
4241 *
4242 * @chainable
4243 */
4244 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4245 if ( this.pending === 1 ) {
4246 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4247 this.updateThemeClasses();
4248 }
4249 this.pending = Math.max( 0, this.pending - 1 );
4250
4251 return this;
4252 };
4253
4254 /**
4255 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4256 * in the document (for example, in an OO.ui.Window's $overlay).
4257 *
4258 * The elements's position is automatically calculated and maintained when window is resized or the
4259 * page is scrolled. If you reposition the container manually, you have to call #position to make
4260 * sure the element is still placed correctly.
4261 *
4262 * As positioning is only possible when both the element and the container are attached to the DOM
4263 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4264 * the #toggle method to display a floating popup, for example.
4265 *
4266 * @abstract
4267 * @class
4268 *
4269 * @constructor
4270 * @param {Object} [config] Configuration options
4271 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4272 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4273 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4274 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4275 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4276 * 'top': Align the top edge with $floatableContainer's top edge
4277 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4278 * 'center': Vertically align the center with $floatableContainer's center
4279 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4280 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4281 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4282 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4283 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4284 * 'center': Horizontally align the center with $floatableContainer's center
4285 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4286 * is out of view
4287 */
4288 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4289 // Configuration initialization
4290 config = config || {};
4291
4292 // Properties
4293 this.$floatable = null;
4294 this.$floatableContainer = null;
4295 this.$floatableWindow = null;
4296 this.$floatableClosestScrollable = null;
4297 this.floatableOutOfView = false;
4298 this.onFloatableScrollHandler = this.position.bind( this );
4299 this.onFloatableWindowResizeHandler = this.position.bind( this );
4300
4301 // Initialization
4302 this.setFloatableContainer( config.$floatableContainer );
4303 this.setFloatableElement( config.$floatable || this.$element );
4304 this.setVerticalPosition( config.verticalPosition || 'below' );
4305 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4306 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4307 };
4308
4309 /* Methods */
4310
4311 /**
4312 * Set floatable element.
4313 *
4314 * If an element is already set, it will be cleaned up before setting up the new element.
4315 *
4316 * @param {jQuery} $floatable Element to make floatable
4317 */
4318 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4319 if ( this.$floatable ) {
4320 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4321 this.$floatable.css( { left: '', top: '' } );
4322 }
4323
4324 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4325 this.position();
4326 };
4327
4328 /**
4329 * Set floatable container.
4330 *
4331 * The element will be positioned relative to the specified container.
4332 *
4333 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4334 */
4335 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4336 this.$floatableContainer = $floatableContainer;
4337 if ( this.$floatable ) {
4338 this.position();
4339 }
4340 };
4341
4342 /**
4343 * Change how the element is positioned vertically.
4344 *
4345 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4346 */
4347 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4348 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4349 throw new Error( 'Invalid value for vertical position: ' + position );
4350 }
4351 if ( this.verticalPosition !== position ) {
4352 this.verticalPosition = position;
4353 if ( this.$floatable ) {
4354 this.position();
4355 }
4356 }
4357 };
4358
4359 /**
4360 * Change how the element is positioned horizontally.
4361 *
4362 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4363 */
4364 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4365 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4366 throw new Error( 'Invalid value for horizontal position: ' + position );
4367 }
4368 if ( this.horizontalPosition !== position ) {
4369 this.horizontalPosition = position;
4370 if ( this.$floatable ) {
4371 this.position();
4372 }
4373 }
4374 };
4375
4376 /**
4377 * Toggle positioning.
4378 *
4379 * Do not turn positioning on until after the element is attached to the DOM and visible.
4380 *
4381 * @param {boolean} [positioning] Enable positioning, omit to toggle
4382 * @chainable
4383 */
4384 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4385 var closestScrollableOfContainer;
4386
4387 if ( !this.$floatable || !this.$floatableContainer ) {
4388 return this;
4389 }
4390
4391 positioning = positioning === undefined ? !this.positioning : !!positioning;
4392
4393 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4394 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4395 this.warnedUnattached = true;
4396 }
4397
4398 if ( this.positioning !== positioning ) {
4399 this.positioning = positioning;
4400
4401 this.needsCustomPosition =
4402 this.verticalPostion !== 'below' ||
4403 this.horizontalPosition !== 'start' ||
4404 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4405
4406 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4407 // If the scrollable is the root, we have to listen to scroll events
4408 // on the window because of browser inconsistencies.
4409 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4410 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4411 }
4412
4413 if ( positioning ) {
4414 this.$floatableWindow = $( this.getElementWindow() );
4415 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4416
4417 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4418 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4419
4420 // Initial position after visible
4421 this.position();
4422 } else {
4423 if ( this.$floatableWindow ) {
4424 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4425 this.$floatableWindow = null;
4426 }
4427
4428 if ( this.$floatableClosestScrollable ) {
4429 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4430 this.$floatableClosestScrollable = null;
4431 }
4432
4433 this.$floatable.css( { left: '', right: '', top: '' } );
4434 }
4435 }
4436
4437 return this;
4438 };
4439
4440 /**
4441 * Check whether the bottom edge of the given element is within the viewport of the given container.
4442 *
4443 * @private
4444 * @param {jQuery} $element
4445 * @param {jQuery} $container
4446 * @return {boolean}
4447 */
4448 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4449 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4450 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4451 direction = $element.css( 'direction' );
4452
4453 elemRect = $element[ 0 ].getBoundingClientRect();
4454 if ( $container[ 0 ] === window ) {
4455 viewportSpacing = OO.ui.getViewportSpacing();
4456 contRect = {
4457 top: 0,
4458 left: 0,
4459 right: document.documentElement.clientWidth,
4460 bottom: document.documentElement.clientHeight
4461 };
4462 contRect.top += viewportSpacing.top;
4463 contRect.left += viewportSpacing.left;
4464 contRect.right -= viewportSpacing.right;
4465 contRect.bottom -= viewportSpacing.bottom;
4466 } else {
4467 contRect = $container[ 0 ].getBoundingClientRect();
4468 }
4469
4470 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4471 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4472 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4473 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4474 if ( direction === 'rtl' ) {
4475 startEdgeInBounds = rightEdgeInBounds;
4476 endEdgeInBounds = leftEdgeInBounds;
4477 } else {
4478 startEdgeInBounds = leftEdgeInBounds;
4479 endEdgeInBounds = rightEdgeInBounds;
4480 }
4481
4482 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4483 return false;
4484 }
4485 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4486 return false;
4487 }
4488 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4489 return false;
4490 }
4491 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4492 return false;
4493 }
4494
4495 // The other positioning values are all about being inside the container,
4496 // so in those cases all we care about is that any part of the container is visible.
4497 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4498 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4499 };
4500
4501 /**
4502 * Check if the floatable is hidden to the user because it was offscreen.
4503 *
4504 * @return {boolean} Floatable is out of view
4505 */
4506 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4507 return this.floatableOutOfView;
4508 };
4509
4510 /**
4511 * Position the floatable below its container.
4512 *
4513 * This should only be done when both of them are attached to the DOM and visible.
4514 *
4515 * @chainable
4516 */
4517 OO.ui.mixin.FloatableElement.prototype.position = function () {
4518 if ( !this.positioning ) {
4519 return this;
4520 }
4521
4522 if ( !(
4523 // To continue, some things need to be true:
4524 // The element must actually be in the DOM
4525 this.isElementAttached() && (
4526 // The closest scrollable is the current window
4527 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4528 // OR is an element in the element's DOM
4529 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4530 )
4531 ) ) {
4532 // Abort early if important parts of the widget are no longer attached to the DOM
4533 return this;
4534 }
4535
4536 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4537 if ( this.floatableOutOfView ) {
4538 this.$floatable.addClass( 'oo-ui-element-hidden' );
4539 return this;
4540 } else {
4541 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4542 }
4543
4544 if ( !this.needsCustomPosition ) {
4545 return this;
4546 }
4547
4548 this.$floatable.css( this.computePosition() );
4549
4550 // We updated the position, so re-evaluate the clipping state.
4551 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4552 // will not notice the need to update itself.)
4553 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4554 // it not listen to the right events in the right places?
4555 if ( this.clip ) {
4556 this.clip();
4557 }
4558
4559 return this;
4560 };
4561
4562 /**
4563 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4564 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4565 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4566 *
4567 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4568 */
4569 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4570 var isBody, scrollableX, scrollableY, containerPos,
4571 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4572 newPos = { top: '', left: '', bottom: '', right: '' },
4573 direction = this.$floatableContainer.css( 'direction' ),
4574 $offsetParent = this.$floatable.offsetParent();
4575
4576 if ( $offsetParent.is( 'html' ) ) {
4577 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4578 // <html> element, but they do work on the <body>
4579 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4580 }
4581 isBody = $offsetParent.is( 'body' );
4582 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4583 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4584
4585 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4586 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4587 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4588 // or if it isn't scrollable
4589 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4590 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4591
4592 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4593 // if the <body> has a margin
4594 containerPos = isBody ?
4595 this.$floatableContainer.offset() :
4596 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4597 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4598 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4599 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4600 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4601
4602 if ( this.verticalPosition === 'below' ) {
4603 newPos.top = containerPos.bottom;
4604 } else if ( this.verticalPosition === 'above' ) {
4605 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4606 } else if ( this.verticalPosition === 'top' ) {
4607 newPos.top = containerPos.top;
4608 } else if ( this.verticalPosition === 'bottom' ) {
4609 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4610 } else if ( this.verticalPosition === 'center' ) {
4611 newPos.top = containerPos.top +
4612 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4613 }
4614
4615 if ( this.horizontalPosition === 'before' ) {
4616 newPos.end = containerPos.start;
4617 } else if ( this.horizontalPosition === 'after' ) {
4618 newPos.start = containerPos.end;
4619 } else if ( this.horizontalPosition === 'start' ) {
4620 newPos.start = containerPos.start;
4621 } else if ( this.horizontalPosition === 'end' ) {
4622 newPos.end = containerPos.end;
4623 } else if ( this.horizontalPosition === 'center' ) {
4624 newPos.left = containerPos.left +
4625 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4626 }
4627
4628 if ( newPos.start !== undefined ) {
4629 if ( direction === 'rtl' ) {
4630 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4631 } else {
4632 newPos.left = newPos.start;
4633 }
4634 delete newPos.start;
4635 }
4636 if ( newPos.end !== undefined ) {
4637 if ( direction === 'rtl' ) {
4638 newPos.left = newPos.end;
4639 } else {
4640 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4641 }
4642 delete newPos.end;
4643 }
4644
4645 // Account for scroll position
4646 if ( newPos.top !== '' ) {
4647 newPos.top += scrollTop;
4648 }
4649 if ( newPos.bottom !== '' ) {
4650 newPos.bottom -= scrollTop;
4651 }
4652 if ( newPos.left !== '' ) {
4653 newPos.left += scrollLeft;
4654 }
4655 if ( newPos.right !== '' ) {
4656 newPos.right -= scrollLeft;
4657 }
4658
4659 // Account for scrollbar gutter
4660 if ( newPos.bottom !== '' ) {
4661 newPos.bottom -= horizScrollbarHeight;
4662 }
4663 if ( direction === 'rtl' ) {
4664 if ( newPos.left !== '' ) {
4665 newPos.left -= vertScrollbarWidth;
4666 }
4667 } else {
4668 if ( newPos.right !== '' ) {
4669 newPos.right -= vertScrollbarWidth;
4670 }
4671 }
4672
4673 return newPos;
4674 };
4675
4676 /**
4677 * Element that can be automatically clipped to visible boundaries.
4678 *
4679 * Whenever the element's natural height changes, you have to call
4680 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4681 * clipping correctly.
4682 *
4683 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4684 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4685 * then #$clippable will be given a fixed reduced height and/or width and will be made
4686 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4687 * but you can build a static footer by setting #$clippableContainer to an element that contains
4688 * #$clippable and the footer.
4689 *
4690 * @abstract
4691 * @class
4692 *
4693 * @constructor
4694 * @param {Object} [config] Configuration options
4695 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4696 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4697 * omit to use #$clippable
4698 */
4699 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4700 // Configuration initialization
4701 config = config || {};
4702
4703 // Properties
4704 this.$clippable = null;
4705 this.$clippableContainer = null;
4706 this.clipping = false;
4707 this.clippedHorizontally = false;
4708 this.clippedVertically = false;
4709 this.$clippableScrollableContainer = null;
4710 this.$clippableScroller = null;
4711 this.$clippableWindow = null;
4712 this.idealWidth = null;
4713 this.idealHeight = null;
4714 this.onClippableScrollHandler = this.clip.bind( this );
4715 this.onClippableWindowResizeHandler = this.clip.bind( this );
4716
4717 // Initialization
4718 if ( config.$clippableContainer ) {
4719 this.setClippableContainer( config.$clippableContainer );
4720 }
4721 this.setClippableElement( config.$clippable || this.$element );
4722 };
4723
4724 /* Methods */
4725
4726 /**
4727 * Set clippable element.
4728 *
4729 * If an element is already set, it will be cleaned up before setting up the new element.
4730 *
4731 * @param {jQuery} $clippable Element to make clippable
4732 */
4733 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4734 if ( this.$clippable ) {
4735 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4736 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4737 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4738 }
4739
4740 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4741 this.clip();
4742 };
4743
4744 /**
4745 * Set clippable container.
4746 *
4747 * This is the container that will be measured when deciding whether to clip. When clipping,
4748 * #$clippable will be resized in order to keep the clippable container fully visible.
4749 *
4750 * If the clippable container is unset, #$clippable will be used.
4751 *
4752 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4753 */
4754 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4755 this.$clippableContainer = $clippableContainer;
4756 if ( this.$clippable ) {
4757 this.clip();
4758 }
4759 };
4760
4761 /**
4762 * Toggle clipping.
4763 *
4764 * Do not turn clipping on until after the element is attached to the DOM and visible.
4765 *
4766 * @param {boolean} [clipping] Enable clipping, omit to toggle
4767 * @chainable
4768 */
4769 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4770 clipping = clipping === undefined ? !this.clipping : !!clipping;
4771
4772 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4773 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4774 this.warnedUnattached = true;
4775 }
4776
4777 if ( this.clipping !== clipping ) {
4778 this.clipping = clipping;
4779 if ( clipping ) {
4780 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4781 // If the clippable container is the root, we have to listen to scroll events and check
4782 // jQuery.scrollTop on the window because of browser inconsistencies
4783 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4784 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4785 this.$clippableScrollableContainer;
4786 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4787 this.$clippableWindow = $( this.getElementWindow() )
4788 .on( 'resize', this.onClippableWindowResizeHandler );
4789 // Initial clip after visible
4790 this.clip();
4791 } else {
4792 this.$clippable.css( {
4793 width: '',
4794 height: '',
4795 maxWidth: '',
4796 maxHeight: '',
4797 overflowX: '',
4798 overflowY: ''
4799 } );
4800 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4801
4802 this.$clippableScrollableContainer = null;
4803 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4804 this.$clippableScroller = null;
4805 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4806 this.$clippableWindow = null;
4807 }
4808 }
4809
4810 return this;
4811 };
4812
4813 /**
4814 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4815 *
4816 * @return {boolean} Element will be clipped to the visible area
4817 */
4818 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4819 return this.clipping;
4820 };
4821
4822 /**
4823 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4824 *
4825 * @return {boolean} Part of the element is being clipped
4826 */
4827 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4828 return this.clippedHorizontally || this.clippedVertically;
4829 };
4830
4831 /**
4832 * Check if the right of the element is being clipped by the nearest scrollable container.
4833 *
4834 * @return {boolean} Part of the element is being clipped
4835 */
4836 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4837 return this.clippedHorizontally;
4838 };
4839
4840 /**
4841 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4842 *
4843 * @return {boolean} Part of the element is being clipped
4844 */
4845 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4846 return this.clippedVertically;
4847 };
4848
4849 /**
4850 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4851 *
4852 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4853 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4854 */
4855 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4856 this.idealWidth = width;
4857 this.idealHeight = height;
4858
4859 if ( !this.clipping ) {
4860 // Update dimensions
4861 this.$clippable.css( { width: width, height: height } );
4862 }
4863 // While clipping, idealWidth and idealHeight are not considered
4864 };
4865
4866 /**
4867 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4868 * ClippableElement will clip the opposite side when reducing element's width.
4869 *
4870 * Classes that mix in ClippableElement should override this to return 'right' if their
4871 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4872 * If your class also mixes in FloatableElement, this is handled automatically.
4873 *
4874 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4875 * always in pixels, even if they were unset or set to 'auto'.)
4876 *
4877 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4878 *
4879 * @return {string} 'left' or 'right'
4880 */
4881 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4882 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4883 return 'right';
4884 }
4885 return 'left';
4886 };
4887
4888 /**
4889 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4890 * ClippableElement will clip the opposite side when reducing element's width.
4891 *
4892 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4893 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4894 * If your class also mixes in FloatableElement, this is handled automatically.
4895 *
4896 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4897 * always in pixels, even if they were unset or set to 'auto'.)
4898 *
4899 * When in doubt, 'top' is a sane fallback.
4900 *
4901 * @return {string} 'top' or 'bottom'
4902 */
4903 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
4904 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
4905 return 'bottom';
4906 }
4907 return 'top';
4908 };
4909
4910 /**
4911 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4912 * when the element's natural height changes.
4913 *
4914 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4915 * overlapped by, the visible area of the nearest scrollable container.
4916 *
4917 * Because calling clip() when the natural height changes isn't always possible, we also set
4918 * max-height when the element isn't being clipped. This means that if the element tries to grow
4919 * beyond the edge, something reasonable will happen before clip() is called.
4920 *
4921 * @chainable
4922 */
4923 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4924 var extraHeight, extraWidth, viewportSpacing,
4925 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4926 naturalWidth, naturalHeight, clipWidth, clipHeight,
4927 $item, itemRect, $viewport, viewportRect, availableRect,
4928 direction, vertScrollbarWidth, horizScrollbarHeight,
4929 // Extra tolerance so that the sloppy code below doesn't result in results that are off
4930 // by one or two pixels. (And also so that we have space to display drop shadows.)
4931 // Chosen by fair dice roll.
4932 buffer = 7;
4933
4934 if ( !this.clipping ) {
4935 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4936 return this;
4937 }
4938
4939 function rectIntersection( a, b ) {
4940 var out = {};
4941 out.top = Math.max( a.top, b.top );
4942 out.left = Math.max( a.left, b.left );
4943 out.bottom = Math.min( a.bottom, b.bottom );
4944 out.right = Math.min( a.right, b.right );
4945 return out;
4946 }
4947
4948 viewportSpacing = OO.ui.getViewportSpacing();
4949
4950 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4951 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
4952 // Dimensions of the browser window, rather than the element!
4953 viewportRect = {
4954 top: 0,
4955 left: 0,
4956 right: document.documentElement.clientWidth,
4957 bottom: document.documentElement.clientHeight
4958 };
4959 viewportRect.top += viewportSpacing.top;
4960 viewportRect.left += viewportSpacing.left;
4961 viewportRect.right -= viewportSpacing.right;
4962 viewportRect.bottom -= viewportSpacing.bottom;
4963 } else {
4964 $viewport = this.$clippableScrollableContainer;
4965 viewportRect = $viewport[ 0 ].getBoundingClientRect();
4966 // Convert into a plain object
4967 viewportRect = $.extend( {}, viewportRect );
4968 }
4969
4970 // Account for scrollbar gutter
4971 direction = $viewport.css( 'direction' );
4972 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
4973 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
4974 viewportRect.bottom -= horizScrollbarHeight;
4975 if ( direction === 'rtl' ) {
4976 viewportRect.left += vertScrollbarWidth;
4977 } else {
4978 viewportRect.right -= vertScrollbarWidth;
4979 }
4980
4981 // Add arbitrary tolerance
4982 viewportRect.top += buffer;
4983 viewportRect.left += buffer;
4984 viewportRect.right -= buffer;
4985 viewportRect.bottom -= buffer;
4986
4987 $item = this.$clippableContainer || this.$clippable;
4988
4989 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
4990 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
4991
4992 itemRect = $item[ 0 ].getBoundingClientRect();
4993 // Convert into a plain object
4994 itemRect = $.extend( {}, itemRect );
4995
4996 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
4997 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
4998 if ( this.getHorizontalAnchorEdge() === 'right' ) {
4999 itemRect.left = viewportRect.left;
5000 } else {
5001 itemRect.right = viewportRect.right;
5002 }
5003 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5004 itemRect.top = viewportRect.top;
5005 } else {
5006 itemRect.bottom = viewportRect.bottom;
5007 }
5008
5009 availableRect = rectIntersection( viewportRect, itemRect );
5010
5011 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5012 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5013 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5014 desiredWidth = Math.min( desiredWidth,
5015 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5016 desiredHeight = Math.min( desiredHeight,
5017 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5018 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5019 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5020 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5021 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5022 clipWidth = allotedWidth < naturalWidth;
5023 clipHeight = allotedHeight < naturalHeight;
5024
5025 if ( clipWidth ) {
5026 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5027 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5028 this.$clippable.css( 'overflowX', 'scroll' );
5029 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5030 this.$clippable.css( {
5031 width: Math.max( 0, allotedWidth ),
5032 maxWidth: ''
5033 } );
5034 } else {
5035 this.$clippable.css( {
5036 overflowX: '',
5037 width: this.idealWidth || '',
5038 maxWidth: Math.max( 0, allotedWidth )
5039 } );
5040 }
5041 if ( clipHeight ) {
5042 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5043 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5044 this.$clippable.css( 'overflowY', 'scroll' );
5045 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5046 this.$clippable.css( {
5047 height: Math.max( 0, allotedHeight ),
5048 maxHeight: ''
5049 } );
5050 } else {
5051 this.$clippable.css( {
5052 overflowY: '',
5053 height: this.idealHeight || '',
5054 maxHeight: Math.max( 0, allotedHeight )
5055 } );
5056 }
5057
5058 // If we stopped clipping in at least one of the dimensions
5059 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5060 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5061 }
5062
5063 this.clippedHorizontally = clipWidth;
5064 this.clippedVertically = clipHeight;
5065
5066 return this;
5067 };
5068
5069 /**
5070 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5071 * By default, each popup has an anchor that points toward its origin.
5072 * Please see the [OOUI documentation on Mediawiki] [1] for more information and examples.
5073 *
5074 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5075 *
5076 * @example
5077 * // A popup widget.
5078 * var popup = new OO.ui.PopupWidget( {
5079 * $content: $( '<p>Hi there!</p>' ),
5080 * padded: true,
5081 * width: 300
5082 * } );
5083 *
5084 * $( 'body' ).append( popup.$element );
5085 * // To display the popup, toggle the visibility to 'true'.
5086 * popup.toggle( true );
5087 *
5088 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5089 *
5090 * @class
5091 * @extends OO.ui.Widget
5092 * @mixins OO.ui.mixin.LabelElement
5093 * @mixins OO.ui.mixin.ClippableElement
5094 * @mixins OO.ui.mixin.FloatableElement
5095 *
5096 * @constructor
5097 * @param {Object} [config] Configuration options
5098 * @cfg {number} [width=320] Width of popup in pixels
5099 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
5100 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5101 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5102 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5103 * of $floatableContainer
5104 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5105 * of $floatableContainer
5106 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5107 * endwards (right/left) to the vertical center of $floatableContainer
5108 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5109 * startwards (left/right) to the vertical center of $floatableContainer
5110 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5111 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5112 * as possible while still keeping the anchor within the popup;
5113 * if position is before/after, move the popup as far downwards as possible.
5114 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5115 * as possible while still keeping the anchor within the popup;
5116 * if position in before/after, move the popup as far upwards as possible.
5117 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5118 * of the popup with the center of $floatableContainer.
5119 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5120 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5121 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5122 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5123 * desired direction to display the popup without clipping
5124 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5125 * See the [OOUI docs on MediaWiki][3] for an example.
5126 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5127 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5128 * @cfg {jQuery} [$content] Content to append to the popup's body
5129 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5130 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5131 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5132 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5133 * for an example.
5134 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5135 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5136 * button.
5137 * @cfg {boolean} [padded=false] Add padding to the popup's body
5138 */
5139 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5140 // Configuration initialization
5141 config = config || {};
5142
5143 // Parent constructor
5144 OO.ui.PopupWidget.parent.call( this, config );
5145
5146 // Properties (must be set before ClippableElement constructor call)
5147 this.$body = $( '<div>' );
5148 this.$popup = $( '<div>' );
5149
5150 // Mixin constructors
5151 OO.ui.mixin.LabelElement.call( this, config );
5152 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5153 $clippable: this.$body,
5154 $clippableContainer: this.$popup
5155 } ) );
5156 OO.ui.mixin.FloatableElement.call( this, config );
5157
5158 // Properties
5159 this.$anchor = $( '<div>' );
5160 // If undefined, will be computed lazily in computePosition()
5161 this.$container = config.$container;
5162 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5163 this.autoClose = !!config.autoClose;
5164 this.$autoCloseIgnore = config.$autoCloseIgnore;
5165 this.transitionTimeout = null;
5166 this.anchored = false;
5167 this.width = config.width !== undefined ? config.width : 320;
5168 this.height = config.height !== undefined ? config.height : null;
5169 this.onMouseDownHandler = this.onMouseDown.bind( this );
5170 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5171
5172 // Initialization
5173 this.toggleAnchor( config.anchor === undefined || config.anchor );
5174 this.setAlignment( config.align || 'center' );
5175 this.setPosition( config.position || 'below' );
5176 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5177 this.$body.addClass( 'oo-ui-popupWidget-body' );
5178 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5179 this.$popup
5180 .addClass( 'oo-ui-popupWidget-popup' )
5181 .append( this.$body );
5182 this.$element
5183 .addClass( 'oo-ui-popupWidget' )
5184 .append( this.$popup, this.$anchor );
5185 // Move content, which was added to #$element by OO.ui.Widget, to the body
5186 // FIXME This is gross, we should use '$body' or something for the config
5187 if ( config.$content instanceof jQuery ) {
5188 this.$body.append( config.$content );
5189 }
5190
5191 if ( config.padded ) {
5192 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5193 }
5194
5195 if ( config.head ) {
5196 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5197 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5198 this.$head = $( '<div>' )
5199 .addClass( 'oo-ui-popupWidget-head' )
5200 .append( this.$label, this.closeButton.$element );
5201 this.$popup.prepend( this.$head );
5202 }
5203
5204 if ( config.$footer ) {
5205 this.$footer = $( '<div>' )
5206 .addClass( 'oo-ui-popupWidget-footer' )
5207 .append( config.$footer );
5208 this.$popup.append( this.$footer );
5209 }
5210
5211 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5212 // that reference properties not initialized at that time of parent class construction
5213 // TODO: Find a better way to handle post-constructor setup
5214 this.visible = false;
5215 this.$element.addClass( 'oo-ui-element-hidden' );
5216 };
5217
5218 /* Setup */
5219
5220 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5221 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5222 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5223 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5224
5225 /* Events */
5226
5227 /**
5228 * @event ready
5229 *
5230 * The popup is ready: it is visible and has been positioned and clipped.
5231 */
5232
5233 /* Methods */
5234
5235 /**
5236 * Handles mouse down events.
5237 *
5238 * @private
5239 * @param {MouseEvent} e Mouse down event
5240 */
5241 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5242 if (
5243 this.isVisible() &&
5244 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5245 ) {
5246 this.toggle( false );
5247 }
5248 };
5249
5250 /**
5251 * Bind mouse down listener.
5252 *
5253 * @private
5254 */
5255 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5256 // Capture clicks outside popup
5257 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5258 };
5259
5260 /**
5261 * Handles close button click events.
5262 *
5263 * @private
5264 */
5265 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5266 if ( this.isVisible() ) {
5267 this.toggle( false );
5268 }
5269 };
5270
5271 /**
5272 * Unbind mouse down listener.
5273 *
5274 * @private
5275 */
5276 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5277 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5278 };
5279
5280 /**
5281 * Handles key down events.
5282 *
5283 * @private
5284 * @param {KeyboardEvent} e Key down event
5285 */
5286 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5287 if (
5288 e.which === OO.ui.Keys.ESCAPE &&
5289 this.isVisible()
5290 ) {
5291 this.toggle( false );
5292 e.preventDefault();
5293 e.stopPropagation();
5294 }
5295 };
5296
5297 /**
5298 * Bind key down listener.
5299 *
5300 * @private
5301 */
5302 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5303 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5304 };
5305
5306 /**
5307 * Unbind key down listener.
5308 *
5309 * @private
5310 */
5311 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5312 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5313 };
5314
5315 /**
5316 * Show, hide, or toggle the visibility of the anchor.
5317 *
5318 * @param {boolean} [show] Show anchor, omit to toggle
5319 */
5320 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5321 show = show === undefined ? !this.anchored : !!show;
5322
5323 if ( this.anchored !== show ) {
5324 if ( show ) {
5325 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5326 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5327 } else {
5328 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5329 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5330 }
5331 this.anchored = show;
5332 }
5333 };
5334
5335 /**
5336 * Change which edge the anchor appears on.
5337 *
5338 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5339 */
5340 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5341 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5342 throw new Error( 'Invalid value for edge: ' + edge );
5343 }
5344 if ( this.anchorEdge !== null ) {
5345 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5346 }
5347 this.anchorEdge = edge;
5348 if ( this.anchored ) {
5349 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5350 }
5351 };
5352
5353 /**
5354 * Check if the anchor is visible.
5355 *
5356 * @return {boolean} Anchor is visible
5357 */
5358 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5359 return this.anchored;
5360 };
5361
5362 /**
5363 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5364 * `.toggle( true )` after its #$element is attached to the DOM.
5365 *
5366 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5367 * it in the right place and with the right dimensions only work correctly while it is attached.
5368 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5369 * strictly enforced, so currently it only generates a warning in the browser console.
5370 *
5371 * @fires ready
5372 * @inheritdoc
5373 */
5374 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5375 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5376 show = show === undefined ? !this.isVisible() : !!show;
5377
5378 change = show !== this.isVisible();
5379
5380 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5381 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5382 this.warnedUnattached = true;
5383 }
5384 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5385 // Fall back to the parent node if the floatableContainer is not set
5386 this.setFloatableContainer( this.$element.parent() );
5387 }
5388
5389 if ( change && show && this.autoFlip ) {
5390 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5391 // (e.g. if the user scrolled).
5392 this.isAutoFlipped = false;
5393 }
5394
5395 // Parent method
5396 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5397
5398 if ( change ) {
5399 this.togglePositioning( show && !!this.$floatableContainer );
5400
5401 if ( show ) {
5402 if ( this.autoClose ) {
5403 this.bindMouseDownListener();
5404 this.bindKeyDownListener();
5405 }
5406 this.updateDimensions();
5407 this.toggleClipping( true );
5408
5409 if ( this.autoFlip ) {
5410 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5411 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5412 // If opening the popup in the normal direction causes it to be clipped, open
5413 // in the opposite one instead
5414 normalHeight = this.$element.height();
5415 this.isAutoFlipped = !this.isAutoFlipped;
5416 this.position();
5417 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5418 // If that also causes it to be clipped, open in whichever direction
5419 // we have more space
5420 oppositeHeight = this.$element.height();
5421 if ( oppositeHeight < normalHeight ) {
5422 this.isAutoFlipped = !this.isAutoFlipped;
5423 this.position();
5424 }
5425 }
5426 }
5427 }
5428 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5429 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5430 // If opening the popup in the normal direction causes it to be clipped, open
5431 // in the opposite one instead
5432 normalWidth = this.$element.width();
5433 this.isAutoFlipped = !this.isAutoFlipped;
5434 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5435 // which causes positioning to be off. Toggle clipping back and fort to work around.
5436 this.toggleClipping( false );
5437 this.position();
5438 this.toggleClipping( true );
5439 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5440 // If that also causes it to be clipped, open in whichever direction
5441 // we have more space
5442 oppositeWidth = this.$element.width();
5443 if ( oppositeWidth < normalWidth ) {
5444 this.isAutoFlipped = !this.isAutoFlipped;
5445 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5446 // which causes positioning to be off. Toggle clipping back and fort to work around.
5447 this.toggleClipping( false );
5448 this.position();
5449 this.toggleClipping( true );
5450 }
5451 }
5452 }
5453 }
5454 }
5455
5456 this.emit( 'ready' );
5457 } else {
5458 this.toggleClipping( false );
5459 if ( this.autoClose ) {
5460 this.unbindMouseDownListener();
5461 this.unbindKeyDownListener();
5462 }
5463 }
5464 }
5465
5466 return this;
5467 };
5468
5469 /**
5470 * Set the size of the popup.
5471 *
5472 * Changing the size may also change the popup's position depending on the alignment.
5473 *
5474 * @param {number} width Width in pixels
5475 * @param {number} height Height in pixels
5476 * @param {boolean} [transition=false] Use a smooth transition
5477 * @chainable
5478 */
5479 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5480 this.width = width;
5481 this.height = height !== undefined ? height : null;
5482 if ( this.isVisible() ) {
5483 this.updateDimensions( transition );
5484 }
5485 };
5486
5487 /**
5488 * Update the size and position.
5489 *
5490 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5491 * be called automatically.
5492 *
5493 * @param {boolean} [transition=false] Use a smooth transition
5494 * @chainable
5495 */
5496 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5497 var widget = this;
5498
5499 // Prevent transition from being interrupted
5500 clearTimeout( this.transitionTimeout );
5501 if ( transition ) {
5502 // Enable transition
5503 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5504 }
5505
5506 this.position();
5507
5508 if ( transition ) {
5509 // Prevent transitioning after transition is complete
5510 this.transitionTimeout = setTimeout( function () {
5511 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5512 }, 200 );
5513 } else {
5514 // Prevent transitioning immediately
5515 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5516 }
5517 };
5518
5519 /**
5520 * @inheritdoc
5521 */
5522 OO.ui.PopupWidget.prototype.computePosition = function () {
5523 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5524 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5525 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5526 popupPos = {},
5527 anchorCss = { left: '', right: '', top: '', bottom: '' },
5528 popupPositionOppositeMap = {
5529 above: 'below',
5530 below: 'above',
5531 before: 'after',
5532 after: 'before'
5533 },
5534 alignMap = {
5535 ltr: {
5536 'force-left': 'backwards',
5537 'force-right': 'forwards'
5538 },
5539 rtl: {
5540 'force-left': 'forwards',
5541 'force-right': 'backwards'
5542 }
5543 },
5544 anchorEdgeMap = {
5545 above: 'bottom',
5546 below: 'top',
5547 before: 'end',
5548 after: 'start'
5549 },
5550 hPosMap = {
5551 forwards: 'start',
5552 center: 'center',
5553 backwards: this.anchored ? 'before' : 'end'
5554 },
5555 vPosMap = {
5556 forwards: 'top',
5557 center: 'center',
5558 backwards: 'bottom'
5559 };
5560
5561 if ( !this.$container ) {
5562 // Lazy-initialize $container if not specified in constructor
5563 this.$container = $( this.getClosestScrollableElementContainer() );
5564 }
5565 direction = this.$container.css( 'direction' );
5566
5567 // Set height and width before we do anything else, since it might cause our measurements
5568 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5569 this.$popup.css( {
5570 width: this.width,
5571 height: this.height !== null ? this.height : 'auto'
5572 } );
5573
5574 align = alignMap[ direction ][ this.align ] || this.align;
5575 popupPosition = this.popupPosition;
5576 if ( this.isAutoFlipped ) {
5577 popupPosition = popupPositionOppositeMap[ popupPosition ];
5578 }
5579
5580 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5581 vertical = popupPosition === 'before' || popupPosition === 'after';
5582 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5583 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5584 near = vertical ? 'top' : 'left';
5585 far = vertical ? 'bottom' : 'right';
5586 sizeProp = vertical ? 'Height' : 'Width';
5587 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5588
5589 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5590 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5591 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5592
5593 // Parent method
5594 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5595 // Find out which property FloatableElement used for positioning, and adjust that value
5596 positionProp = vertical ?
5597 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5598 ( parentPosition.left !== '' ? 'left' : 'right' );
5599
5600 // Figure out where the near and far edges of the popup and $floatableContainer are
5601 floatablePos = this.$floatableContainer.offset();
5602 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5603 // Measure where the offsetParent is and compute our position based on that and parentPosition
5604 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5605 { top: 0, left: 0 } :
5606 this.$element.offsetParent().offset();
5607
5608 if ( positionProp === near ) {
5609 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5610 popupPos[ far ] = popupPos[ near ] + popupSize;
5611 } else {
5612 popupPos[ far ] = offsetParentPos[ near ] +
5613 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5614 popupPos[ near ] = popupPos[ far ] - popupSize;
5615 }
5616
5617 if ( this.anchored ) {
5618 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5619 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5620 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5621
5622 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5623 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5624 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5625 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5626 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5627 // Not enough space for the anchor on the start side; pull the popup startwards
5628 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5629 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5630 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5631 // Not enough space for the anchor on the end side; pull the popup endwards
5632 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5633 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5634 } else {
5635 positionAdjustment = 0;
5636 }
5637 } else {
5638 positionAdjustment = 0;
5639 }
5640
5641 // Check if the popup will go beyond the edge of this.$container
5642 containerPos = this.$container[ 0 ] === document.documentElement ?
5643 { top: 0, left: 0 } :
5644 this.$container.offset();
5645 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5646 if ( this.$container[ 0 ] === document.documentElement ) {
5647 viewportSpacing = OO.ui.getViewportSpacing();
5648 containerPos[ near ] += viewportSpacing[ near ];
5649 containerPos[ far ] -= viewportSpacing[ far ];
5650 }
5651 // Take into account how much the popup will move because of the adjustments we're going to make
5652 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5653 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5654 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5655 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5656 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5657 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5658 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5659 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5660 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5661 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5662 }
5663
5664 if ( this.anchored ) {
5665 // Adjust anchorOffset for positionAdjustment
5666 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5667
5668 // Position the anchor
5669 anchorCss[ start ] = anchorOffset;
5670 this.$anchor.css( anchorCss );
5671 }
5672
5673 // Move the popup if needed
5674 parentPosition[ positionProp ] += positionAdjustment;
5675
5676 return parentPosition;
5677 };
5678
5679 /**
5680 * Set popup alignment
5681 *
5682 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5683 * `backwards` or `forwards`.
5684 */
5685 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5686 // Validate alignment
5687 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5688 this.align = align;
5689 } else {
5690 this.align = 'center';
5691 }
5692 this.position();
5693 };
5694
5695 /**
5696 * Get popup alignment
5697 *
5698 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5699 * `backwards` or `forwards`.
5700 */
5701 OO.ui.PopupWidget.prototype.getAlignment = function () {
5702 return this.align;
5703 };
5704
5705 /**
5706 * Change the positioning of the popup.
5707 *
5708 * @param {string} position 'above', 'below', 'before' or 'after'
5709 */
5710 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5711 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5712 position = 'below';
5713 }
5714 this.popupPosition = position;
5715 this.position();
5716 };
5717
5718 /**
5719 * Get popup positioning.
5720 *
5721 * @return {string} 'above', 'below', 'before' or 'after'
5722 */
5723 OO.ui.PopupWidget.prototype.getPosition = function () {
5724 return this.popupPosition;
5725 };
5726
5727 /**
5728 * Set popup auto-flipping.
5729 *
5730 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5731 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5732 * desired direction to display the popup without clipping
5733 */
5734 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5735 autoFlip = !!autoFlip;
5736
5737 if ( this.autoFlip !== autoFlip ) {
5738 this.autoFlip = autoFlip;
5739 }
5740 };
5741
5742 /**
5743 * Get an ID of the body element, this can be used as the
5744 * `aria-describedby` attribute for an input field.
5745 *
5746 * @return {string} The ID of the body element
5747 */
5748 OO.ui.PopupWidget.prototype.getBodyId = function () {
5749 var id = this.$body.attr( 'id' );
5750 if ( id === undefined ) {
5751 id = OO.ui.generateElementId();
5752 this.$body.attr( 'id', id );
5753 }
5754 return id;
5755 };
5756
5757 /**
5758 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5759 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5760 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5761 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5762 *
5763 * @abstract
5764 * @class
5765 *
5766 * @constructor
5767 * @param {Object} [config] Configuration options
5768 * @cfg {Object} [popup] Configuration to pass to popup
5769 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5770 */
5771 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5772 // Configuration initialization
5773 config = config || {};
5774
5775 // Properties
5776 this.popup = new OO.ui.PopupWidget( $.extend(
5777 {
5778 autoClose: true,
5779 $floatableContainer: this.$element
5780 },
5781 config.popup,
5782 {
5783 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5784 }
5785 ) );
5786 };
5787
5788 /* Methods */
5789
5790 /**
5791 * Get popup.
5792 *
5793 * @return {OO.ui.PopupWidget} Popup widget
5794 */
5795 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5796 return this.popup;
5797 };
5798
5799 /**
5800 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5801 * which is used to display additional information or options.
5802 *
5803 * @example
5804 * // Example of a popup button.
5805 * var popupButton = new OO.ui.PopupButtonWidget( {
5806 * label: 'Popup button with options',
5807 * icon: 'menu',
5808 * popup: {
5809 * $content: $( '<p>Additional options here.</p>' ),
5810 * padded: true,
5811 * align: 'force-left'
5812 * }
5813 * } );
5814 * // Append the button to the DOM.
5815 * $( 'body' ).append( popupButton.$element );
5816 *
5817 * @class
5818 * @extends OO.ui.ButtonWidget
5819 * @mixins OO.ui.mixin.PopupElement
5820 *
5821 * @constructor
5822 * @param {Object} [config] Configuration options
5823 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5824 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5825 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5826 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5827 */
5828 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5829 // Configuration initialization
5830 config = config || {};
5831
5832 // Parent constructor
5833 OO.ui.PopupButtonWidget.parent.call( this, config );
5834
5835 // Mixin constructors
5836 OO.ui.mixin.PopupElement.call( this, config );
5837
5838 // Properties
5839 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5840
5841 // Events
5842 this.connect( this, { click: 'onAction' } );
5843
5844 // Initialization
5845 this.$element
5846 .addClass( 'oo-ui-popupButtonWidget' )
5847 .attr( 'aria-haspopup', 'true' );
5848 this.popup.$element
5849 .addClass( 'oo-ui-popupButtonWidget-popup' )
5850 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5851 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5852 this.$overlay.append( this.popup.$element );
5853 };
5854
5855 /* Setup */
5856
5857 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5858 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5859
5860 /* Methods */
5861
5862 /**
5863 * Handle the button action being triggered.
5864 *
5865 * @private
5866 */
5867 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5868 this.popup.toggle();
5869 };
5870
5871 /**
5872 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5873 *
5874 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5875 *
5876 * @private
5877 * @abstract
5878 * @class
5879 * @mixins OO.ui.mixin.GroupElement
5880 *
5881 * @constructor
5882 * @param {Object} [config] Configuration options
5883 */
5884 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5885 // Mixin constructors
5886 OO.ui.mixin.GroupElement.call( this, config );
5887 };
5888
5889 /* Setup */
5890
5891 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5892
5893 /* Methods */
5894
5895 /**
5896 * Set the disabled state of the widget.
5897 *
5898 * This will also update the disabled state of child widgets.
5899 *
5900 * @param {boolean} disabled Disable widget
5901 * @chainable
5902 */
5903 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5904 var i, len;
5905
5906 // Parent method
5907 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5908 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5909
5910 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5911 if ( this.items ) {
5912 for ( i = 0, len = this.items.length; i < len; i++ ) {
5913 this.items[ i ].updateDisabled();
5914 }
5915 }
5916
5917 return this;
5918 };
5919
5920 /**
5921 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5922 *
5923 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5924 * allows bidirectional communication.
5925 *
5926 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5927 *
5928 * @private
5929 * @abstract
5930 * @class
5931 *
5932 * @constructor
5933 */
5934 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5935 //
5936 };
5937
5938 /* Methods */
5939
5940 /**
5941 * Check if widget is disabled.
5942 *
5943 * Checks parent if present, making disabled state inheritable.
5944 *
5945 * @return {boolean} Widget is disabled
5946 */
5947 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5948 return this.disabled ||
5949 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5950 };
5951
5952 /**
5953 * Set group element is in.
5954 *
5955 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5956 * @chainable
5957 */
5958 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5959 // Parent method
5960 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5961 OO.ui.Element.prototype.setElementGroup.call( this, group );
5962
5963 // Initialize item disabled states
5964 this.updateDisabled();
5965
5966 return this;
5967 };
5968
5969 /**
5970 * OptionWidgets are special elements that can be selected and configured with data. The
5971 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5972 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5973 * and examples, please see the [OOUI documentation on MediaWiki][1].
5974 *
5975 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
5976 *
5977 * @class
5978 * @extends OO.ui.Widget
5979 * @mixins OO.ui.mixin.ItemWidget
5980 * @mixins OO.ui.mixin.LabelElement
5981 * @mixins OO.ui.mixin.FlaggedElement
5982 * @mixins OO.ui.mixin.AccessKeyedElement
5983 *
5984 * @constructor
5985 * @param {Object} [config] Configuration options
5986 */
5987 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5988 // Configuration initialization
5989 config = config || {};
5990
5991 // Parent constructor
5992 OO.ui.OptionWidget.parent.call( this, config );
5993
5994 // Mixin constructors
5995 OO.ui.mixin.ItemWidget.call( this );
5996 OO.ui.mixin.LabelElement.call( this, config );
5997 OO.ui.mixin.FlaggedElement.call( this, config );
5998 OO.ui.mixin.AccessKeyedElement.call( this, config );
5999
6000 // Properties
6001 this.selected = false;
6002 this.highlighted = false;
6003 this.pressed = false;
6004
6005 // Initialization
6006 this.$element
6007 .data( 'oo-ui-optionWidget', this )
6008 // Allow programmatic focussing (and by accesskey), but not tabbing
6009 .attr( 'tabindex', '-1' )
6010 .attr( 'role', 'option' )
6011 .attr( 'aria-selected', 'false' )
6012 .addClass( 'oo-ui-optionWidget' )
6013 .append( this.$label );
6014 };
6015
6016 /* Setup */
6017
6018 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6019 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6020 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6021 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6022 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6023
6024 /* Static Properties */
6025
6026 /**
6027 * Whether this option can be selected. See #setSelected.
6028 *
6029 * @static
6030 * @inheritable
6031 * @property {boolean}
6032 */
6033 OO.ui.OptionWidget.static.selectable = true;
6034
6035 /**
6036 * Whether this option can be highlighted. See #setHighlighted.
6037 *
6038 * @static
6039 * @inheritable
6040 * @property {boolean}
6041 */
6042 OO.ui.OptionWidget.static.highlightable = true;
6043
6044 /**
6045 * Whether this option can be pressed. See #setPressed.
6046 *
6047 * @static
6048 * @inheritable
6049 * @property {boolean}
6050 */
6051 OO.ui.OptionWidget.static.pressable = true;
6052
6053 /**
6054 * Whether this option will be scrolled into view when it is selected.
6055 *
6056 * @static
6057 * @inheritable
6058 * @property {boolean}
6059 */
6060 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6061
6062 /* Methods */
6063
6064 /**
6065 * Check if the option can be selected.
6066 *
6067 * @return {boolean} Item is selectable
6068 */
6069 OO.ui.OptionWidget.prototype.isSelectable = function () {
6070 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6071 };
6072
6073 /**
6074 * Check if the option can be highlighted. A highlight indicates that the option
6075 * may be selected when a user presses enter or clicks. Disabled items cannot
6076 * be highlighted.
6077 *
6078 * @return {boolean} Item is highlightable
6079 */
6080 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6081 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6082 };
6083
6084 /**
6085 * Check if the option can be pressed. The pressed state occurs when a user mouses
6086 * down on an item, but has not yet let go of the mouse.
6087 *
6088 * @return {boolean} Item is pressable
6089 */
6090 OO.ui.OptionWidget.prototype.isPressable = function () {
6091 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6092 };
6093
6094 /**
6095 * Check if the option is selected.
6096 *
6097 * @return {boolean} Item is selected
6098 */
6099 OO.ui.OptionWidget.prototype.isSelected = function () {
6100 return this.selected;
6101 };
6102
6103 /**
6104 * Check if the option is highlighted. A highlight indicates that the
6105 * item may be selected when a user presses enter or clicks.
6106 *
6107 * @return {boolean} Item is highlighted
6108 */
6109 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6110 return this.highlighted;
6111 };
6112
6113 /**
6114 * Check if the option is pressed. The pressed state occurs when a user mouses
6115 * down on an item, but has not yet let go of the mouse. The item may appear
6116 * selected, but it will not be selected until the user releases the mouse.
6117 *
6118 * @return {boolean} Item is pressed
6119 */
6120 OO.ui.OptionWidget.prototype.isPressed = function () {
6121 return this.pressed;
6122 };
6123
6124 /**
6125 * Set the option’s selected state. In general, all modifications to the selection
6126 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6127 * method instead of this method.
6128 *
6129 * @param {boolean} [state=false] Select option
6130 * @chainable
6131 */
6132 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6133 if ( this.constructor.static.selectable ) {
6134 this.selected = !!state;
6135 this.$element
6136 .toggleClass( 'oo-ui-optionWidget-selected', state )
6137 .attr( 'aria-selected', state.toString() );
6138 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6139 this.scrollElementIntoView();
6140 }
6141 this.updateThemeClasses();
6142 }
6143 return this;
6144 };
6145
6146 /**
6147 * Set the option’s highlighted state. In general, all programmatic
6148 * modifications to the highlight should be handled by the
6149 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6150 * method instead of this method.
6151 *
6152 * @param {boolean} [state=false] Highlight option
6153 * @chainable
6154 */
6155 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6156 if ( this.constructor.static.highlightable ) {
6157 this.highlighted = !!state;
6158 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6159 this.updateThemeClasses();
6160 }
6161 return this;
6162 };
6163
6164 /**
6165 * Set the option’s pressed state. In general, all
6166 * programmatic modifications to the pressed state should be handled by the
6167 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6168 * method instead of this method.
6169 *
6170 * @param {boolean} [state=false] Press option
6171 * @chainable
6172 */
6173 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6174 if ( this.constructor.static.pressable ) {
6175 this.pressed = !!state;
6176 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6177 this.updateThemeClasses();
6178 }
6179 return this;
6180 };
6181
6182 /**
6183 * Get text to match search strings against.
6184 *
6185 * The default implementation returns the label text, but subclasses
6186 * can override this to provide more complex behavior.
6187 *
6188 * @return {string|boolean} String to match search string against
6189 */
6190 OO.ui.OptionWidget.prototype.getMatchText = function () {
6191 var label = this.getLabel();
6192 return typeof label === 'string' ? label : this.$label.text();
6193 };
6194
6195 /**
6196 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6197 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6198 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6199 * menu selects}.
6200 *
6201 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6202 * information, please see the [OOUI documentation on MediaWiki][1].
6203 *
6204 * @example
6205 * // Example of a select widget with three options
6206 * var select = new OO.ui.SelectWidget( {
6207 * items: [
6208 * new OO.ui.OptionWidget( {
6209 * data: 'a',
6210 * label: 'Option One',
6211 * } ),
6212 * new OO.ui.OptionWidget( {
6213 * data: 'b',
6214 * label: 'Option Two',
6215 * } ),
6216 * new OO.ui.OptionWidget( {
6217 * data: 'c',
6218 * label: 'Option Three',
6219 * } )
6220 * ]
6221 * } );
6222 * $( 'body' ).append( select.$element );
6223 *
6224 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6225 *
6226 * @abstract
6227 * @class
6228 * @extends OO.ui.Widget
6229 * @mixins OO.ui.mixin.GroupWidget
6230 *
6231 * @constructor
6232 * @param {Object} [config] Configuration options
6233 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6234 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6235 * the [OOUI documentation on MediaWiki] [2] for examples.
6236 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6237 */
6238 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6239 // Configuration initialization
6240 config = config || {};
6241
6242 // Parent constructor
6243 OO.ui.SelectWidget.parent.call( this, config );
6244
6245 // Mixin constructors
6246 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6247
6248 // Properties
6249 this.pressed = false;
6250 this.selecting = null;
6251 this.onMouseUpHandler = this.onMouseUp.bind( this );
6252 this.onMouseMoveHandler = this.onMouseMove.bind( this );
6253 this.onKeyDownHandler = this.onKeyDown.bind( this );
6254 this.onKeyPressHandler = this.onKeyPress.bind( this );
6255 this.keyPressBuffer = '';
6256 this.keyPressBufferTimer = null;
6257 this.blockMouseOverEvents = 0;
6258
6259 // Events
6260 this.connect( this, {
6261 toggle: 'onToggle'
6262 } );
6263 this.$element.on( {
6264 focusin: this.onFocus.bind( this ),
6265 mousedown: this.onMouseDown.bind( this ),
6266 mouseover: this.onMouseOver.bind( this ),
6267 mouseleave: this.onMouseLeave.bind( this )
6268 } );
6269
6270 // Initialization
6271 this.$element
6272 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6273 .attr( 'role', 'listbox' );
6274 this.setFocusOwner( this.$element );
6275 if ( Array.isArray( config.items ) ) {
6276 this.addItems( config.items );
6277 }
6278 };
6279
6280 /* Setup */
6281
6282 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6283 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6284
6285 /* Events */
6286
6287 /**
6288 * @event highlight
6289 *
6290 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6291 *
6292 * @param {OO.ui.OptionWidget|null} item Highlighted item
6293 */
6294
6295 /**
6296 * @event press
6297 *
6298 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6299 * pressed state of an option.
6300 *
6301 * @param {OO.ui.OptionWidget|null} item Pressed item
6302 */
6303
6304 /**
6305 * @event select
6306 *
6307 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6308 *
6309 * @param {OO.ui.OptionWidget|null} item Selected item
6310 */
6311
6312 /**
6313 * @event choose
6314 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6315 * @param {OO.ui.OptionWidget} item Chosen item
6316 */
6317
6318 /**
6319 * @event add
6320 *
6321 * An `add` event is emitted when options are added to the select with the #addItems method.
6322 *
6323 * @param {OO.ui.OptionWidget[]} items Added items
6324 * @param {number} index Index of insertion point
6325 */
6326
6327 /**
6328 * @event remove
6329 *
6330 * A `remove` event is emitted when options are removed from the select with the #clearItems
6331 * or #removeItems methods.
6332 *
6333 * @param {OO.ui.OptionWidget[]} items Removed items
6334 */
6335
6336 /* Methods */
6337
6338 /**
6339 * Handle focus events
6340 *
6341 * @private
6342 * @param {jQuery.Event} event
6343 */
6344 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6345 var item;
6346 if ( event.target === this.$element[ 0 ] ) {
6347 // This widget was focussed, e.g. by the user tabbing to it.
6348 // The styles for focus state depend on one of the items being selected.
6349 if ( !this.findSelectedItem() ) {
6350 item = this.findFirstSelectableItem();
6351 }
6352 } else {
6353 if ( event.target.tabIndex === -1 ) {
6354 // One of the options got focussed (and the event bubbled up here).
6355 // They can't be tabbed to, but they can be activated using accesskeys.
6356 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6357 item = this.findTargetItem( event );
6358 } else {
6359 // There is something actually user-focusable in one of the labels of the options, and the
6360 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6361 return;
6362 }
6363 }
6364
6365 if ( item ) {
6366 if ( item.constructor.static.highlightable ) {
6367 this.highlightItem( item );
6368 } else {
6369 this.selectItem( item );
6370 }
6371 }
6372
6373 if ( event.target !== this.$element[ 0 ] ) {
6374 this.$focusOwner.focus();
6375 }
6376 };
6377
6378 /**
6379 * Handle mouse down events.
6380 *
6381 * @private
6382 * @param {jQuery.Event} e Mouse down event
6383 */
6384 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6385 var item;
6386
6387 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6388 this.togglePressed( true );
6389 item = this.findTargetItem( e );
6390 if ( item && item.isSelectable() ) {
6391 this.pressItem( item );
6392 this.selecting = item;
6393 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6394 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6395 }
6396 }
6397 return false;
6398 };
6399
6400 /**
6401 * Handle mouse up events.
6402 *
6403 * @private
6404 * @param {MouseEvent} e Mouse up event
6405 */
6406 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6407 var item;
6408
6409 this.togglePressed( false );
6410 if ( !this.selecting ) {
6411 item = this.findTargetItem( e );
6412 if ( item && item.isSelectable() ) {
6413 this.selecting = item;
6414 }
6415 }
6416 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6417 this.pressItem( null );
6418 this.chooseItem( this.selecting );
6419 this.selecting = null;
6420 }
6421
6422 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6423 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6424
6425 return false;
6426 };
6427
6428 /**
6429 * Handle mouse move events.
6430 *
6431 * @private
6432 * @param {MouseEvent} e Mouse move event
6433 */
6434 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6435 var item;
6436
6437 if ( !this.isDisabled() && this.pressed ) {
6438 item = this.findTargetItem( e );
6439 if ( item && item !== this.selecting && item.isSelectable() ) {
6440 this.pressItem( item );
6441 this.selecting = item;
6442 }
6443 }
6444 };
6445
6446 /**
6447 * Handle mouse over events.
6448 *
6449 * @private
6450 * @param {jQuery.Event} e Mouse over event
6451 */
6452 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6453 var item;
6454 if ( this.blockMouseOverEvents ) {
6455 return;
6456 }
6457 if ( !this.isDisabled() ) {
6458 item = this.findTargetItem( e );
6459 this.highlightItem( item && item.isHighlightable() ? item : null );
6460 }
6461 return false;
6462 };
6463
6464 /**
6465 * Handle mouse leave events.
6466 *
6467 * @private
6468 * @param {jQuery.Event} e Mouse over event
6469 */
6470 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6471 if ( !this.isDisabled() ) {
6472 this.highlightItem( null );
6473 }
6474 return false;
6475 };
6476
6477 /**
6478 * Handle key down events.
6479 *
6480 * @protected
6481 * @param {KeyboardEvent} e Key down event
6482 */
6483 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6484 var nextItem,
6485 handled = false,
6486 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6487
6488 if ( !this.isDisabled() && this.isVisible() ) {
6489 switch ( e.keyCode ) {
6490 case OO.ui.Keys.ENTER:
6491 if ( currentItem && currentItem.constructor.static.highlightable ) {
6492 // Was only highlighted, now let's select it. No-op if already selected.
6493 this.chooseItem( currentItem );
6494 handled = true;
6495 }
6496 break;
6497 case OO.ui.Keys.UP:
6498 case OO.ui.Keys.LEFT:
6499 this.clearKeyPressBuffer();
6500 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6501 handled = true;
6502 break;
6503 case OO.ui.Keys.DOWN:
6504 case OO.ui.Keys.RIGHT:
6505 this.clearKeyPressBuffer();
6506 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6507 handled = true;
6508 break;
6509 case OO.ui.Keys.ESCAPE:
6510 case OO.ui.Keys.TAB:
6511 if ( currentItem && currentItem.constructor.static.highlightable ) {
6512 currentItem.setHighlighted( false );
6513 }
6514 this.unbindKeyDownListener();
6515 this.unbindKeyPressListener();
6516 // Don't prevent tabbing away / defocusing
6517 handled = false;
6518 break;
6519 }
6520
6521 if ( nextItem ) {
6522 if ( nextItem.constructor.static.highlightable ) {
6523 this.highlightItem( nextItem );
6524 } else {
6525 this.chooseItem( nextItem );
6526 }
6527 this.scrollItemIntoView( nextItem );
6528 }
6529
6530 if ( handled ) {
6531 e.preventDefault();
6532 e.stopPropagation();
6533 }
6534 }
6535 };
6536
6537 /**
6538 * Bind key down listener.
6539 *
6540 * @protected
6541 */
6542 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6543 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6544 };
6545
6546 /**
6547 * Unbind key down listener.
6548 *
6549 * @protected
6550 */
6551 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6552 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6553 };
6554
6555 /**
6556 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6557 *
6558 * @param {OO.ui.OptionWidget} item Item to scroll into view
6559 */
6560 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6561 var widget = this;
6562 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6563 // and around 100-150 ms after it is finished.
6564 this.blockMouseOverEvents++;
6565 item.scrollElementIntoView().done( function () {
6566 setTimeout( function () {
6567 widget.blockMouseOverEvents--;
6568 }, 200 );
6569 } );
6570 };
6571
6572 /**
6573 * Clear the key-press buffer
6574 *
6575 * @protected
6576 */
6577 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6578 if ( this.keyPressBufferTimer ) {
6579 clearTimeout( this.keyPressBufferTimer );
6580 this.keyPressBufferTimer = null;
6581 }
6582 this.keyPressBuffer = '';
6583 };
6584
6585 /**
6586 * Handle key press events.
6587 *
6588 * @protected
6589 * @param {KeyboardEvent} e Key press event
6590 */
6591 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6592 var c, filter, item;
6593
6594 if ( !e.charCode ) {
6595 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6596 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6597 return false;
6598 }
6599 return;
6600 }
6601 if ( String.fromCodePoint ) {
6602 c = String.fromCodePoint( e.charCode );
6603 } else {
6604 c = String.fromCharCode( e.charCode );
6605 }
6606
6607 if ( this.keyPressBufferTimer ) {
6608 clearTimeout( this.keyPressBufferTimer );
6609 }
6610 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6611
6612 item = this.findHighlightedItem() || this.findSelectedItem();
6613
6614 if ( this.keyPressBuffer === c ) {
6615 // Common (if weird) special case: typing "xxxx" will cycle through all
6616 // the items beginning with "x".
6617 if ( item ) {
6618 item = this.findRelativeSelectableItem( item, 1 );
6619 }
6620 } else {
6621 this.keyPressBuffer += c;
6622 }
6623
6624 filter = this.getItemMatcher( this.keyPressBuffer, false );
6625 if ( !item || !filter( item ) ) {
6626 item = this.findRelativeSelectableItem( item, 1, filter );
6627 }
6628 if ( item ) {
6629 if ( this.isVisible() && item.constructor.static.highlightable ) {
6630 this.highlightItem( item );
6631 } else {
6632 this.chooseItem( item );
6633 }
6634 this.scrollItemIntoView( item );
6635 }
6636
6637 e.preventDefault();
6638 e.stopPropagation();
6639 };
6640
6641 /**
6642 * Get a matcher for the specific string
6643 *
6644 * @protected
6645 * @param {string} s String to match against items
6646 * @param {boolean} [exact=false] Only accept exact matches
6647 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6648 */
6649 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6650 var re;
6651
6652 if ( s.normalize ) {
6653 s = s.normalize();
6654 }
6655 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6656 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6657 if ( exact ) {
6658 re += '\\s*$';
6659 }
6660 re = new RegExp( re, 'i' );
6661 return function ( item ) {
6662 var matchText = item.getMatchText();
6663 if ( matchText.normalize ) {
6664 matchText = matchText.normalize();
6665 }
6666 return re.test( matchText );
6667 };
6668 };
6669
6670 /**
6671 * Bind key press listener.
6672 *
6673 * @protected
6674 */
6675 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6676 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6677 };
6678
6679 /**
6680 * Unbind key down listener.
6681 *
6682 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6683 * implementation.
6684 *
6685 * @protected
6686 */
6687 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6688 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6689 this.clearKeyPressBuffer();
6690 };
6691
6692 /**
6693 * Visibility change handler
6694 *
6695 * @protected
6696 * @param {boolean} visible
6697 */
6698 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6699 if ( !visible ) {
6700 this.clearKeyPressBuffer();
6701 }
6702 };
6703
6704 /**
6705 * Get the closest item to a jQuery.Event.
6706 *
6707 * @private
6708 * @param {jQuery.Event} e
6709 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6710 */
6711 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6712 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6713 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6714 return null;
6715 }
6716 return $option.data( 'oo-ui-optionWidget' ) || null;
6717 };
6718
6719 /**
6720 * Find selected item.
6721 *
6722 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6723 */
6724 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6725 var i, len;
6726
6727 for ( i = 0, len = this.items.length; i < len; i++ ) {
6728 if ( this.items[ i ].isSelected() ) {
6729 return this.items[ i ];
6730 }
6731 }
6732 return null;
6733 };
6734
6735 /**
6736 * Get selected item.
6737 *
6738 * @deprecated Since v0.25.0; use {@link #findSelectedItem} instead.
6739 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6740 */
6741 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6742 OO.ui.warnDeprecation( 'SelectWidget#getSelectedItem: Deprecated function. Use findSelectedItem instead. See T76630.' );
6743 return this.findSelectedItem();
6744 };
6745
6746 /**
6747 * Find highlighted item.
6748 *
6749 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6750 */
6751 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6752 var i, len;
6753
6754 for ( i = 0, len = this.items.length; i < len; i++ ) {
6755 if ( this.items[ i ].isHighlighted() ) {
6756 return this.items[ i ];
6757 }
6758 }
6759 return null;
6760 };
6761
6762 /**
6763 * Toggle pressed state.
6764 *
6765 * Press is a state that occurs when a user mouses down on an item, but
6766 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6767 * until the user releases the mouse.
6768 *
6769 * @param {boolean} pressed An option is being pressed
6770 */
6771 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6772 if ( pressed === undefined ) {
6773 pressed = !this.pressed;
6774 }
6775 if ( pressed !== this.pressed ) {
6776 this.$element
6777 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6778 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6779 this.pressed = pressed;
6780 }
6781 };
6782
6783 /**
6784 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6785 * and any existing highlight will be removed. The highlight is mutually exclusive.
6786 *
6787 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6788 * @fires highlight
6789 * @chainable
6790 */
6791 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6792 var i, len, highlighted,
6793 changed = false;
6794
6795 for ( i = 0, len = this.items.length; i < len; i++ ) {
6796 highlighted = this.items[ i ] === item;
6797 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6798 this.items[ i ].setHighlighted( highlighted );
6799 changed = true;
6800 }
6801 }
6802 if ( changed ) {
6803 if ( item ) {
6804 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6805 } else {
6806 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6807 }
6808 this.emit( 'highlight', item );
6809 }
6810
6811 return this;
6812 };
6813
6814 /**
6815 * Fetch an item by its label.
6816 *
6817 * @param {string} label Label of the item to select.
6818 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6819 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6820 */
6821 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6822 var i, item, found,
6823 len = this.items.length,
6824 filter = this.getItemMatcher( label, true );
6825
6826 for ( i = 0; i < len; i++ ) {
6827 item = this.items[ i ];
6828 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6829 return item;
6830 }
6831 }
6832
6833 if ( prefix ) {
6834 found = null;
6835 filter = this.getItemMatcher( label, false );
6836 for ( i = 0; i < len; i++ ) {
6837 item = this.items[ i ];
6838 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6839 if ( found ) {
6840 return null;
6841 }
6842 found = item;
6843 }
6844 }
6845 if ( found ) {
6846 return found;
6847 }
6848 }
6849
6850 return null;
6851 };
6852
6853 /**
6854 * Programmatically select an option by its label. If the item does not exist,
6855 * all options will be deselected.
6856 *
6857 * @param {string} [label] Label of the item to select.
6858 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6859 * @fires select
6860 * @chainable
6861 */
6862 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6863 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6864 if ( label === undefined || !itemFromLabel ) {
6865 return this.selectItem();
6866 }
6867 return this.selectItem( itemFromLabel );
6868 };
6869
6870 /**
6871 * Programmatically select an option by its data. If the `data` parameter is omitted,
6872 * or if the item does not exist, all options will be deselected.
6873 *
6874 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6875 * @fires select
6876 * @chainable
6877 */
6878 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6879 var itemFromData = this.findItemFromData( data );
6880 if ( data === undefined || !itemFromData ) {
6881 return this.selectItem();
6882 }
6883 return this.selectItem( itemFromData );
6884 };
6885
6886 /**
6887 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6888 * all options will be deselected.
6889 *
6890 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6891 * @fires select
6892 * @chainable
6893 */
6894 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6895 var i, len, selected,
6896 changed = false;
6897
6898 for ( i = 0, len = this.items.length; i < len; i++ ) {
6899 selected = this.items[ i ] === item;
6900 if ( this.items[ i ].isSelected() !== selected ) {
6901 this.items[ i ].setSelected( selected );
6902 changed = true;
6903 }
6904 }
6905 if ( changed ) {
6906 if ( item && !item.constructor.static.highlightable ) {
6907 if ( item ) {
6908 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6909 } else {
6910 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6911 }
6912 }
6913 this.emit( 'select', item );
6914 }
6915
6916 return this;
6917 };
6918
6919 /**
6920 * Press an item.
6921 *
6922 * Press is a state that occurs when a user mouses down on an item, but has not
6923 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6924 * releases the mouse.
6925 *
6926 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6927 * @fires press
6928 * @chainable
6929 */
6930 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6931 var i, len, pressed,
6932 changed = false;
6933
6934 for ( i = 0, len = this.items.length; i < len; i++ ) {
6935 pressed = this.items[ i ] === item;
6936 if ( this.items[ i ].isPressed() !== pressed ) {
6937 this.items[ i ].setPressed( pressed );
6938 changed = true;
6939 }
6940 }
6941 if ( changed ) {
6942 this.emit( 'press', item );
6943 }
6944
6945 return this;
6946 };
6947
6948 /**
6949 * Choose an item.
6950 *
6951 * Note that ‘choose’ should never be modified programmatically. A user can choose
6952 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6953 * use the #selectItem method.
6954 *
6955 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6956 * when users choose an item with the keyboard or mouse.
6957 *
6958 * @param {OO.ui.OptionWidget} item Item to choose
6959 * @fires choose
6960 * @chainable
6961 */
6962 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6963 if ( item ) {
6964 this.selectItem( item );
6965 this.emit( 'choose', item );
6966 }
6967
6968 return this;
6969 };
6970
6971 /**
6972 * Find an option by its position relative to the specified item (or to the start of the option array,
6973 * if item is `null`). The direction in which to search through the option array is specified with a
6974 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6975 * `null` if there are no options in the array.
6976 *
6977 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6978 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6979 * @param {Function} [filter] Only consider items for which this function returns
6980 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6981 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6982 */
6983 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6984 var currentIndex, nextIndex, i,
6985 increase = direction > 0 ? 1 : -1,
6986 len = this.items.length;
6987
6988 if ( item instanceof OO.ui.OptionWidget ) {
6989 currentIndex = this.items.indexOf( item );
6990 nextIndex = ( currentIndex + increase + len ) % len;
6991 } else {
6992 // If no item is selected and moving forward, start at the beginning.
6993 // If moving backward, start at the end.
6994 nextIndex = direction > 0 ? 0 : len - 1;
6995 }
6996
6997 for ( i = 0; i < len; i++ ) {
6998 item = this.items[ nextIndex ];
6999 if (
7000 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7001 ( !filter || filter( item ) )
7002 ) {
7003 return item;
7004 }
7005 nextIndex = ( nextIndex + increase + len ) % len;
7006 }
7007 return null;
7008 };
7009
7010 /**
7011 * Find the next selectable item or `null` if there are no selectable items.
7012 * Disabled options and menu-section markers and breaks are not selectable.
7013 *
7014 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7015 */
7016 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7017 return this.findRelativeSelectableItem( null, 1 );
7018 };
7019
7020 /**
7021 * Add an array of options to the select. Optionally, an index number can be used to
7022 * specify an insertion point.
7023 *
7024 * @param {OO.ui.OptionWidget[]} items Items to add
7025 * @param {number} [index] Index to insert items after
7026 * @fires add
7027 * @chainable
7028 */
7029 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7030 // Mixin method
7031 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7032
7033 // Always provide an index, even if it was omitted
7034 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7035
7036 return this;
7037 };
7038
7039 /**
7040 * Remove the specified array of options from the select. Options will be detached
7041 * from the DOM, not removed, so they can be reused later. To remove all options from
7042 * the select, you may wish to use the #clearItems method instead.
7043 *
7044 * @param {OO.ui.OptionWidget[]} items Items to remove
7045 * @fires remove
7046 * @chainable
7047 */
7048 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7049 var i, len, item;
7050
7051 // Deselect items being removed
7052 for ( i = 0, len = items.length; i < len; i++ ) {
7053 item = items[ i ];
7054 if ( item.isSelected() ) {
7055 this.selectItem( null );
7056 }
7057 }
7058
7059 // Mixin method
7060 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7061
7062 this.emit( 'remove', items );
7063
7064 return this;
7065 };
7066
7067 /**
7068 * Clear all options from the select. Options will be detached from the DOM, not removed,
7069 * so that they can be reused later. To remove a subset of options from the select, use
7070 * the #removeItems method.
7071 *
7072 * @fires remove
7073 * @chainable
7074 */
7075 OO.ui.SelectWidget.prototype.clearItems = function () {
7076 var items = this.items.slice();
7077
7078 // Mixin method
7079 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7080
7081 // Clear selection
7082 this.selectItem( null );
7083
7084 this.emit( 'remove', items );
7085
7086 return this;
7087 };
7088
7089 /**
7090 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7091 *
7092 * Currently this is just used to set `aria-activedescendant` on it.
7093 *
7094 * @protected
7095 * @param {jQuery} $focusOwner
7096 */
7097 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7098 this.$focusOwner = $focusOwner;
7099 };
7100
7101 /**
7102 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7103 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7104 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7105 * options. For more information about options and selects, please see the
7106 * [OOUI documentation on MediaWiki][1].
7107 *
7108 * @example
7109 * // Decorated options in a select widget
7110 * var select = new OO.ui.SelectWidget( {
7111 * items: [
7112 * new OO.ui.DecoratedOptionWidget( {
7113 * data: 'a',
7114 * label: 'Option with icon',
7115 * icon: 'help'
7116 * } ),
7117 * new OO.ui.DecoratedOptionWidget( {
7118 * data: 'b',
7119 * label: 'Option with indicator',
7120 * indicator: 'next'
7121 * } )
7122 * ]
7123 * } );
7124 * $( 'body' ).append( select.$element );
7125 *
7126 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7127 *
7128 * @class
7129 * @extends OO.ui.OptionWidget
7130 * @mixins OO.ui.mixin.IconElement
7131 * @mixins OO.ui.mixin.IndicatorElement
7132 *
7133 * @constructor
7134 * @param {Object} [config] Configuration options
7135 */
7136 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7137 // Parent constructor
7138 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7139
7140 // Mixin constructors
7141 OO.ui.mixin.IconElement.call( this, config );
7142 OO.ui.mixin.IndicatorElement.call( this, config );
7143
7144 // Initialization
7145 this.$element
7146 .addClass( 'oo-ui-decoratedOptionWidget' )
7147 .prepend( this.$icon )
7148 .append( this.$indicator );
7149 };
7150
7151 /* Setup */
7152
7153 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7154 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7155 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7156
7157 /**
7158 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7159 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7160 * the [OOUI documentation on MediaWiki] [1] for more information.
7161 *
7162 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7163 *
7164 * @class
7165 * @extends OO.ui.DecoratedOptionWidget
7166 *
7167 * @constructor
7168 * @param {Object} [config] Configuration options
7169 */
7170 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7171 // Parent constructor
7172 OO.ui.MenuOptionWidget.parent.call( this, config );
7173
7174 // Properties
7175 this.checkIcon = new OO.ui.IconWidget( {
7176 icon: 'check',
7177 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7178 } );
7179
7180 // Initialization
7181 this.$element
7182 .prepend( this.checkIcon.$element )
7183 .addClass( 'oo-ui-menuOptionWidget' );
7184 };
7185
7186 /* Setup */
7187
7188 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7189
7190 /* Static Properties */
7191
7192 /**
7193 * @static
7194 * @inheritdoc
7195 */
7196 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7197
7198 /**
7199 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7200 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7201 *
7202 * @example
7203 * var myDropdown = new OO.ui.DropdownWidget( {
7204 * menu: {
7205 * items: [
7206 * new OO.ui.MenuSectionOptionWidget( {
7207 * label: 'Dogs'
7208 * } ),
7209 * new OO.ui.MenuOptionWidget( {
7210 * data: 'corgi',
7211 * label: 'Welsh Corgi'
7212 * } ),
7213 * new OO.ui.MenuOptionWidget( {
7214 * data: 'poodle',
7215 * label: 'Standard Poodle'
7216 * } ),
7217 * new OO.ui.MenuSectionOptionWidget( {
7218 * label: 'Cats'
7219 * } ),
7220 * new OO.ui.MenuOptionWidget( {
7221 * data: 'lion',
7222 * label: 'Lion'
7223 * } )
7224 * ]
7225 * }
7226 * } );
7227 * $( 'body' ).append( myDropdown.$element );
7228 *
7229 * @class
7230 * @extends OO.ui.DecoratedOptionWidget
7231 *
7232 * @constructor
7233 * @param {Object} [config] Configuration options
7234 */
7235 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7236 // Parent constructor
7237 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7238
7239 // Initialization
7240 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7241 .removeAttr( 'role aria-selected' );
7242 };
7243
7244 /* Setup */
7245
7246 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7247
7248 /* Static Properties */
7249
7250 /**
7251 * @static
7252 * @inheritdoc
7253 */
7254 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7255
7256 /**
7257 * @static
7258 * @inheritdoc
7259 */
7260 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7261
7262 /**
7263 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7264 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7265 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7266 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7267 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7268 * and customized to be opened, closed, and displayed as needed.
7269 *
7270 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7271 * mouse outside the menu.
7272 *
7273 * Menus also have support for keyboard interaction:
7274 *
7275 * - Enter/Return key: choose and select a menu option
7276 * - Up-arrow key: highlight the previous menu option
7277 * - Down-arrow key: highlight the next menu option
7278 * - Esc key: hide the menu
7279 *
7280 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7281 *
7282 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7283 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7284 *
7285 * @class
7286 * @extends OO.ui.SelectWidget
7287 * @mixins OO.ui.mixin.ClippableElement
7288 * @mixins OO.ui.mixin.FloatableElement
7289 *
7290 * @constructor
7291 * @param {Object} [config] Configuration options
7292 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7293 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7294 * and {@link OO.ui.mixin.LookupElement LookupElement}
7295 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7296 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7297 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7298 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7299 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7300 * that button, unless the button (or its parent widget) is passed in here.
7301 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7302 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7303 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7304 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7305 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7306 * @cfg {number} [width] Width of the menu
7307 */
7308 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7309 // Configuration initialization
7310 config = config || {};
7311
7312 // Parent constructor
7313 OO.ui.MenuSelectWidget.parent.call( this, config );
7314
7315 // Mixin constructors
7316 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7317 OO.ui.mixin.FloatableElement.call( this, config );
7318
7319 // Properties
7320 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7321 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7322 this.filterFromInput = !!config.filterFromInput;
7323 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7324 this.$widget = config.widget ? config.widget.$element : null;
7325 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7326 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7327 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7328 this.highlightOnFilter = !!config.highlightOnFilter;
7329 this.width = config.width;
7330
7331 // Initialization
7332 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7333 if ( config.widget ) {
7334 this.setFocusOwner( config.widget.$tabIndexed );
7335 }
7336
7337 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7338 // that reference properties not initialized at that time of parent class construction
7339 // TODO: Find a better way to handle post-constructor setup
7340 this.visible = false;
7341 this.$element.addClass( 'oo-ui-element-hidden' );
7342 };
7343
7344 /* Setup */
7345
7346 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7347 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7348 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7349
7350 /* Events */
7351
7352 /**
7353 * @event ready
7354 *
7355 * The menu is ready: it is visible and has been positioned and clipped.
7356 */
7357
7358 /* Methods */
7359
7360 /**
7361 * Handles document mouse down events.
7362 *
7363 * @protected
7364 * @param {MouseEvent} e Mouse down event
7365 */
7366 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7367 if (
7368 this.isVisible() &&
7369 !OO.ui.contains(
7370 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7371 e.target,
7372 true
7373 )
7374 ) {
7375 this.toggle( false );
7376 }
7377 };
7378
7379 /**
7380 * @inheritdoc
7381 */
7382 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7383 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7384
7385 if ( !this.isDisabled() && this.isVisible() ) {
7386 switch ( e.keyCode ) {
7387 case OO.ui.Keys.LEFT:
7388 case OO.ui.Keys.RIGHT:
7389 // Do nothing if a text field is associated, arrow keys will be handled natively
7390 if ( !this.$input ) {
7391 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7392 }
7393 break;
7394 case OO.ui.Keys.ESCAPE:
7395 case OO.ui.Keys.TAB:
7396 if ( currentItem ) {
7397 currentItem.setHighlighted( false );
7398 }
7399 this.toggle( false );
7400 // Don't prevent tabbing away, prevent defocusing
7401 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7402 e.preventDefault();
7403 e.stopPropagation();
7404 }
7405 break;
7406 default:
7407 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7408 return;
7409 }
7410 }
7411 };
7412
7413 /**
7414 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7415 * or after items were added/removed (always).
7416 *
7417 * @protected
7418 */
7419 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7420 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7421 firstItemFound = false,
7422 anyVisible = false,
7423 len = this.items.length,
7424 showAll = !this.isVisible(),
7425 exactMatch = false;
7426
7427 if ( this.$input && this.filterFromInput ) {
7428 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7429 exactFilter = this.getItemMatcher( this.$input.val(), true );
7430
7431 // Hide non-matching options, and also hide section headers if all options
7432 // in their section are hidden.
7433 for ( i = 0; i < len; i++ ) {
7434 item = this.items[ i ];
7435 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7436 if ( section ) {
7437 // If the previous section was empty, hide its header
7438 section.toggle( showAll || !sectionEmpty );
7439 }
7440 section = item;
7441 sectionEmpty = true;
7442 } else if ( item instanceof OO.ui.OptionWidget ) {
7443 visible = showAll || filter( item );
7444 exactMatch = exactMatch || exactFilter( item );
7445 anyVisible = anyVisible || visible;
7446 sectionEmpty = sectionEmpty && !visible;
7447 item.toggle( visible );
7448 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7449 // Highlight the first item in the list
7450 this.highlightItem( item );
7451 firstItemFound = true;
7452 }
7453 }
7454 }
7455 // Process the final section
7456 if ( section ) {
7457 section.toggle( showAll || !sectionEmpty );
7458 }
7459
7460 if ( anyVisible && this.items.length && !exactMatch ) {
7461 this.scrollItemIntoView( this.items[ 0 ] );
7462 }
7463
7464 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7465 }
7466
7467 // Reevaluate clipping
7468 this.clip();
7469 };
7470
7471 /**
7472 * @inheritdoc
7473 */
7474 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7475 if ( this.$input ) {
7476 this.$input.on( 'keydown', this.onKeyDownHandler );
7477 } else {
7478 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7479 }
7480 };
7481
7482 /**
7483 * @inheritdoc
7484 */
7485 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7486 if ( this.$input ) {
7487 this.$input.off( 'keydown', this.onKeyDownHandler );
7488 } else {
7489 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7490 }
7491 };
7492
7493 /**
7494 * @inheritdoc
7495 */
7496 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7497 if ( this.$input ) {
7498 if ( this.filterFromInput ) {
7499 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7500 this.updateItemVisibility();
7501 }
7502 } else {
7503 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7504 }
7505 };
7506
7507 /**
7508 * @inheritdoc
7509 */
7510 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7511 if ( this.$input ) {
7512 if ( this.filterFromInput ) {
7513 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7514 this.updateItemVisibility();
7515 }
7516 } else {
7517 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7518 }
7519 };
7520
7521 /**
7522 * Choose an item.
7523 *
7524 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7525 *
7526 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7527 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7528 *
7529 * @param {OO.ui.OptionWidget} item Item to choose
7530 * @chainable
7531 */
7532 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7533 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7534 if ( this.hideOnChoose ) {
7535 this.toggle( false );
7536 }
7537 return this;
7538 };
7539
7540 /**
7541 * @inheritdoc
7542 */
7543 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7544 // Parent method
7545 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7546
7547 this.updateItemVisibility();
7548
7549 return this;
7550 };
7551
7552 /**
7553 * @inheritdoc
7554 */
7555 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7556 // Parent method
7557 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7558
7559 this.updateItemVisibility();
7560
7561 return this;
7562 };
7563
7564 /**
7565 * @inheritdoc
7566 */
7567 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7568 // Parent method
7569 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7570
7571 this.updateItemVisibility();
7572
7573 return this;
7574 };
7575
7576 /**
7577 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7578 * `.toggle( true )` after its #$element is attached to the DOM.
7579 *
7580 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7581 * it in the right place and with the right dimensions only work correctly while it is attached.
7582 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7583 * strictly enforced, so currently it only generates a warning in the browser console.
7584 *
7585 * @fires ready
7586 * @inheritdoc
7587 */
7588 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7589 var change, belowHeight, aboveHeight;
7590
7591 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7592 change = visible !== this.isVisible();
7593
7594 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7595 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7596 this.warnedUnattached = true;
7597 }
7598
7599 if ( change ) {
7600 if ( visible && ( this.width || this.$floatableContainer ) ) {
7601 this.setIdealSize( this.width || this.$floatableContainer.width() );
7602 }
7603 if ( visible ) {
7604 // Reset position before showing the popup again. It's possible we no longer need to flip
7605 // (e.g. if the user scrolled).
7606 this.setVerticalPosition( 'below' );
7607 }
7608 }
7609
7610 // Parent method
7611 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7612
7613 if ( change ) {
7614 if ( visible ) {
7615 this.bindKeyDownListener();
7616 this.bindKeyPressListener();
7617
7618 this.togglePositioning( !!this.$floatableContainer );
7619 this.toggleClipping( true );
7620
7621 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7622 // If opening the menu downwards causes it to be clipped, flip it to open upwards instead
7623 belowHeight = this.$element.height();
7624 this.setVerticalPosition( 'above' );
7625 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7626 // If opening upwards also causes it to be clipped, flip it to open in whichever direction
7627 // we have more space
7628 aboveHeight = this.$element.height();
7629 if ( aboveHeight < belowHeight ) {
7630 this.setVerticalPosition( 'below' );
7631 }
7632 }
7633 }
7634 // Note that we do not flip the menu's opening direction if the clipping changes
7635 // later (e.g. after the user scrolls), that seems like it would be annoying
7636
7637 this.$focusOwner.attr( 'aria-expanded', 'true' );
7638
7639 if ( this.findSelectedItem() ) {
7640 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7641 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7642 }
7643
7644 // Auto-hide
7645 if ( this.autoHide ) {
7646 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7647 }
7648
7649 this.emit( 'ready' );
7650 } else {
7651 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7652 this.unbindKeyDownListener();
7653 this.unbindKeyPressListener();
7654 this.$focusOwner.attr( 'aria-expanded', 'false' );
7655 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7656 this.togglePositioning( false );
7657 this.toggleClipping( false );
7658 }
7659 }
7660
7661 return this;
7662 };
7663
7664 /**
7665 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7666 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7667 * users can interact with it.
7668 *
7669 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7670 * OO.ui.DropdownInputWidget instead.
7671 *
7672 * @example
7673 * // Example: A DropdownWidget with a menu that contains three options
7674 * var dropDown = new OO.ui.DropdownWidget( {
7675 * label: 'Dropdown menu: Select a menu option',
7676 * menu: {
7677 * items: [
7678 * new OO.ui.MenuOptionWidget( {
7679 * data: 'a',
7680 * label: 'First'
7681 * } ),
7682 * new OO.ui.MenuOptionWidget( {
7683 * data: 'b',
7684 * label: 'Second'
7685 * } ),
7686 * new OO.ui.MenuOptionWidget( {
7687 * data: 'c',
7688 * label: 'Third'
7689 * } )
7690 * ]
7691 * }
7692 * } );
7693 *
7694 * $( 'body' ).append( dropDown.$element );
7695 *
7696 * dropDown.getMenu().selectItemByData( 'b' );
7697 *
7698 * dropDown.getMenu().findSelectedItem().getData(); // returns 'b'
7699 *
7700 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7701 *
7702 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7703 *
7704 * @class
7705 * @extends OO.ui.Widget
7706 * @mixins OO.ui.mixin.IconElement
7707 * @mixins OO.ui.mixin.IndicatorElement
7708 * @mixins OO.ui.mixin.LabelElement
7709 * @mixins OO.ui.mixin.TitledElement
7710 * @mixins OO.ui.mixin.TabIndexedElement
7711 *
7712 * @constructor
7713 * @param {Object} [config] Configuration options
7714 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7715 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7716 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7717 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7718 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
7719 */
7720 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7721 // Configuration initialization
7722 config = $.extend( { indicator: 'down' }, config );
7723
7724 // Parent constructor
7725 OO.ui.DropdownWidget.parent.call( this, config );
7726
7727 // Properties (must be set before TabIndexedElement constructor call)
7728 this.$handle = $( '<span>' );
7729 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7730
7731 // Mixin constructors
7732 OO.ui.mixin.IconElement.call( this, config );
7733 OO.ui.mixin.IndicatorElement.call( this, config );
7734 OO.ui.mixin.LabelElement.call( this, config );
7735 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7736 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7737
7738 // Properties
7739 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7740 widget: this,
7741 $floatableContainer: this.$element
7742 }, config.menu ) );
7743
7744 // Events
7745 this.$handle.on( {
7746 click: this.onClick.bind( this ),
7747 keydown: this.onKeyDown.bind( this ),
7748 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7749 keypress: this.menu.onKeyPressHandler,
7750 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7751 } );
7752 this.menu.connect( this, {
7753 select: 'onMenuSelect',
7754 toggle: 'onMenuToggle'
7755 } );
7756
7757 // Initialization
7758 this.$handle
7759 .addClass( 'oo-ui-dropdownWidget-handle' )
7760 .attr( {
7761 role: 'combobox',
7762 'aria-owns': this.menu.getElementId(),
7763 'aria-autocomplete': 'list'
7764 } )
7765 .append( this.$icon, this.$label, this.$indicator );
7766 this.$element
7767 .addClass( 'oo-ui-dropdownWidget' )
7768 .append( this.$handle );
7769 this.$overlay.append( this.menu.$element );
7770 };
7771
7772 /* Setup */
7773
7774 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7775 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7776 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7777 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7778 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7779 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7780
7781 /* Methods */
7782
7783 /**
7784 * Get the menu.
7785 *
7786 * @return {OO.ui.MenuSelectWidget} Menu of widget
7787 */
7788 OO.ui.DropdownWidget.prototype.getMenu = function () {
7789 return this.menu;
7790 };
7791
7792 /**
7793 * Handles menu select events.
7794 *
7795 * @private
7796 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7797 */
7798 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7799 var selectedLabel;
7800
7801 if ( !item ) {
7802 this.setLabel( null );
7803 return;
7804 }
7805
7806 selectedLabel = item.getLabel();
7807
7808 // If the label is a DOM element, clone it, because setLabel will append() it
7809 if ( selectedLabel instanceof jQuery ) {
7810 selectedLabel = selectedLabel.clone();
7811 }
7812
7813 this.setLabel( selectedLabel );
7814 };
7815
7816 /**
7817 * Handle menu toggle events.
7818 *
7819 * @private
7820 * @param {boolean} isVisible Open state of the menu
7821 */
7822 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7823 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7824 this.$handle.attr(
7825 'aria-expanded',
7826 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7827 );
7828 };
7829
7830 /**
7831 * Handle mouse click events.
7832 *
7833 * @private
7834 * @param {jQuery.Event} e Mouse click event
7835 */
7836 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7837 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7838 this.menu.toggle();
7839 }
7840 return false;
7841 };
7842
7843 /**
7844 * Handle key down events.
7845 *
7846 * @private
7847 * @param {jQuery.Event} e Key down event
7848 */
7849 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7850 if (
7851 !this.isDisabled() &&
7852 (
7853 e.which === OO.ui.Keys.ENTER ||
7854 (
7855 e.which === OO.ui.Keys.SPACE &&
7856 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7857 // Space only closes the menu is the user is not typing to search.
7858 this.menu.keyPressBuffer === ''
7859 ) ||
7860 (
7861 !this.menu.isVisible() &&
7862 (
7863 e.which === OO.ui.Keys.UP ||
7864 e.which === OO.ui.Keys.DOWN
7865 )
7866 )
7867 )
7868 ) {
7869 this.menu.toggle();
7870 return false;
7871 }
7872 };
7873
7874 /**
7875 * RadioOptionWidget is an option widget that looks like a radio button.
7876 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7877 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
7878 *
7879 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
7880 *
7881 * @class
7882 * @extends OO.ui.OptionWidget
7883 *
7884 * @constructor
7885 * @param {Object} [config] Configuration options
7886 */
7887 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7888 // Configuration initialization
7889 config = config || {};
7890
7891 // Properties (must be done before parent constructor which calls #setDisabled)
7892 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7893
7894 // Parent constructor
7895 OO.ui.RadioOptionWidget.parent.call( this, config );
7896
7897 // Initialization
7898 // Remove implicit role, we're handling it ourselves
7899 this.radio.$input.attr( 'role', 'presentation' );
7900 this.$element
7901 .addClass( 'oo-ui-radioOptionWidget' )
7902 .attr( 'role', 'radio' )
7903 .attr( 'aria-checked', 'false' )
7904 .removeAttr( 'aria-selected' )
7905 .prepend( this.radio.$element );
7906 };
7907
7908 /* Setup */
7909
7910 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7911
7912 /* Static Properties */
7913
7914 /**
7915 * @static
7916 * @inheritdoc
7917 */
7918 OO.ui.RadioOptionWidget.static.highlightable = false;
7919
7920 /**
7921 * @static
7922 * @inheritdoc
7923 */
7924 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7925
7926 /**
7927 * @static
7928 * @inheritdoc
7929 */
7930 OO.ui.RadioOptionWidget.static.pressable = false;
7931
7932 /**
7933 * @static
7934 * @inheritdoc
7935 */
7936 OO.ui.RadioOptionWidget.static.tagName = 'label';
7937
7938 /* Methods */
7939
7940 /**
7941 * @inheritdoc
7942 */
7943 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7944 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7945
7946 this.radio.setSelected( state );
7947 this.$element
7948 .attr( 'aria-checked', state.toString() )
7949 .removeAttr( 'aria-selected' );
7950
7951 return this;
7952 };
7953
7954 /**
7955 * @inheritdoc
7956 */
7957 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7958 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7959
7960 this.radio.setDisabled( this.isDisabled() );
7961
7962 return this;
7963 };
7964
7965 /**
7966 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7967 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7968 * an interface for adding, removing and selecting options.
7969 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7970 *
7971 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7972 * OO.ui.RadioSelectInputWidget instead.
7973 *
7974 * @example
7975 * // A RadioSelectWidget with RadioOptions.
7976 * var option1 = new OO.ui.RadioOptionWidget( {
7977 * data: 'a',
7978 * label: 'Selected radio option'
7979 * } );
7980 *
7981 * var option2 = new OO.ui.RadioOptionWidget( {
7982 * data: 'b',
7983 * label: 'Unselected radio option'
7984 * } );
7985 *
7986 * var radioSelect=new OO.ui.RadioSelectWidget( {
7987 * items: [ option1, option2 ]
7988 * } );
7989 *
7990 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7991 * radioSelect.selectItem( option1 );
7992 *
7993 * $( 'body' ).append( radioSelect.$element );
7994 *
7995 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7996
7997 *
7998 * @class
7999 * @extends OO.ui.SelectWidget
8000 * @mixins OO.ui.mixin.TabIndexedElement
8001 *
8002 * @constructor
8003 * @param {Object} [config] Configuration options
8004 */
8005 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8006 // Parent constructor
8007 OO.ui.RadioSelectWidget.parent.call( this, config );
8008
8009 // Mixin constructors
8010 OO.ui.mixin.TabIndexedElement.call( this, config );
8011
8012 // Events
8013 this.$element.on( {
8014 focus: this.bindKeyDownListener.bind( this ),
8015 blur: this.unbindKeyDownListener.bind( this )
8016 } );
8017
8018 // Initialization
8019 this.$element
8020 .addClass( 'oo-ui-radioSelectWidget' )
8021 .attr( 'role', 'radiogroup' );
8022 };
8023
8024 /* Setup */
8025
8026 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8027 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8028
8029 /**
8030 * MultioptionWidgets are special elements that can be selected and configured with data. The
8031 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8032 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8033 * and examples, please see the [OOUI documentation on MediaWiki][1].
8034 *
8035 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8036 *
8037 * @class
8038 * @extends OO.ui.Widget
8039 * @mixins OO.ui.mixin.ItemWidget
8040 * @mixins OO.ui.mixin.LabelElement
8041 *
8042 * @constructor
8043 * @param {Object} [config] Configuration options
8044 * @cfg {boolean} [selected=false] Whether the option is initially selected
8045 */
8046 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8047 // Configuration initialization
8048 config = config || {};
8049
8050 // Parent constructor
8051 OO.ui.MultioptionWidget.parent.call( this, config );
8052
8053 // Mixin constructors
8054 OO.ui.mixin.ItemWidget.call( this );
8055 OO.ui.mixin.LabelElement.call( this, config );
8056
8057 // Properties
8058 this.selected = null;
8059
8060 // Initialization
8061 this.$element
8062 .addClass( 'oo-ui-multioptionWidget' )
8063 .append( this.$label );
8064 this.setSelected( config.selected );
8065 };
8066
8067 /* Setup */
8068
8069 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8070 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8071 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8072
8073 /* Events */
8074
8075 /**
8076 * @event change
8077 *
8078 * A change event is emitted when the selected state of the option changes.
8079 *
8080 * @param {boolean} selected Whether the option is now selected
8081 */
8082
8083 /* Methods */
8084
8085 /**
8086 * Check if the option is selected.
8087 *
8088 * @return {boolean} Item is selected
8089 */
8090 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8091 return this.selected;
8092 };
8093
8094 /**
8095 * Set the option’s selected state. In general, all modifications to the selection
8096 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8097 * method instead of this method.
8098 *
8099 * @param {boolean} [state=false] Select option
8100 * @chainable
8101 */
8102 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8103 state = !!state;
8104 if ( this.selected !== state ) {
8105 this.selected = state;
8106 this.emit( 'change', state );
8107 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8108 }
8109 return this;
8110 };
8111
8112 /**
8113 * MultiselectWidget allows selecting multiple options from a list.
8114 *
8115 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8116 *
8117 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8118 *
8119 * @class
8120 * @abstract
8121 * @extends OO.ui.Widget
8122 * @mixins OO.ui.mixin.GroupWidget
8123 *
8124 * @constructor
8125 * @param {Object} [config] Configuration options
8126 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8127 */
8128 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8129 // Parent constructor
8130 OO.ui.MultiselectWidget.parent.call( this, config );
8131
8132 // Configuration initialization
8133 config = config || {};
8134
8135 // Mixin constructors
8136 OO.ui.mixin.GroupWidget.call( this, config );
8137
8138 // Events
8139 this.aggregate( { change: 'select' } );
8140 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
8141 // by GroupElement only when items are added/removed
8142 this.connect( this, { select: [ 'emit', 'change' ] } );
8143
8144 // Initialization
8145 if ( config.items ) {
8146 this.addItems( config.items );
8147 }
8148 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8149 this.$element.addClass( 'oo-ui-multiselectWidget' )
8150 .append( this.$group );
8151 };
8152
8153 /* Setup */
8154
8155 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8156 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8157
8158 /* Events */
8159
8160 /**
8161 * @event change
8162 *
8163 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8164 */
8165
8166 /**
8167 * @event select
8168 *
8169 * A select event is emitted when an item is selected or deselected.
8170 */
8171
8172 /* Methods */
8173
8174 /**
8175 * Find options that are selected.
8176 *
8177 * @return {OO.ui.MultioptionWidget[]} Selected options
8178 */
8179 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8180 return this.items.filter( function ( item ) {
8181 return item.isSelected();
8182 } );
8183 };
8184
8185 /**
8186 * Get options that are selected.
8187 *
8188 * @deprecated Since v0.25.0; use {@link #findSelectedItems} instead.
8189 * @return {OO.ui.MultioptionWidget[]} Selected options
8190 */
8191 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
8192 OO.ui.warnDeprecation( 'MultiselectWidget#getSelectedItems: Deprecated function. Use findSelectedItems instead. See T76630.' );
8193 return this.findSelectedItems();
8194 };
8195
8196 /**
8197 * Find the data of options that are selected.
8198 *
8199 * @return {Object[]|string[]} Values of selected options
8200 */
8201 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8202 return this.findSelectedItems().map( function ( item ) {
8203 return item.data;
8204 } );
8205 };
8206
8207 /**
8208 * Get the data of options that are selected.
8209 *
8210 * @deprecated Since v0.25.0; use {@link #findSelectedItemsData} instead.
8211 * @return {Object[]|string[]} Values of selected options
8212 */
8213 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
8214 OO.ui.warnDeprecation( 'MultiselectWidget#getSelectedItemsData: Deprecated function. Use findSelectedItemsData instead. See T76630.' );
8215 return this.findSelectedItemsData();
8216 };
8217
8218 /**
8219 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8220 *
8221 * @param {OO.ui.MultioptionWidget[]} items Items to select
8222 * @chainable
8223 */
8224 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8225 this.items.forEach( function ( item ) {
8226 var selected = items.indexOf( item ) !== -1;
8227 item.setSelected( selected );
8228 } );
8229 return this;
8230 };
8231
8232 /**
8233 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8234 *
8235 * @param {Object[]|string[]} datas Values of items to select
8236 * @chainable
8237 */
8238 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8239 var items,
8240 widget = this;
8241 items = datas.map( function ( data ) {
8242 return widget.findItemFromData( data );
8243 } );
8244 this.selectItems( items );
8245 return this;
8246 };
8247
8248 /**
8249 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8250 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8251 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8252 *
8253 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8254 *
8255 * @class
8256 * @extends OO.ui.MultioptionWidget
8257 *
8258 * @constructor
8259 * @param {Object} [config] Configuration options
8260 */
8261 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8262 // Configuration initialization
8263 config = config || {};
8264
8265 // Properties (must be done before parent constructor which calls #setDisabled)
8266 this.checkbox = new OO.ui.CheckboxInputWidget();
8267
8268 // Parent constructor
8269 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8270
8271 // Events
8272 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8273 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8274
8275 // Initialization
8276 this.$element
8277 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8278 .prepend( this.checkbox.$element );
8279 };
8280
8281 /* Setup */
8282
8283 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8284
8285 /* Static Properties */
8286
8287 /**
8288 * @static
8289 * @inheritdoc
8290 */
8291 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8292
8293 /* Methods */
8294
8295 /**
8296 * Handle checkbox selected state change.
8297 *
8298 * @private
8299 */
8300 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8301 this.setSelected( this.checkbox.isSelected() );
8302 };
8303
8304 /**
8305 * @inheritdoc
8306 */
8307 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8308 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8309 this.checkbox.setSelected( state );
8310 return this;
8311 };
8312
8313 /**
8314 * @inheritdoc
8315 */
8316 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8317 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8318 this.checkbox.setDisabled( this.isDisabled() );
8319 return this;
8320 };
8321
8322 /**
8323 * Focus the widget.
8324 */
8325 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8326 this.checkbox.focus();
8327 };
8328
8329 /**
8330 * Handle key down events.
8331 *
8332 * @protected
8333 * @param {jQuery.Event} e
8334 */
8335 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8336 var
8337 element = this.getElementGroup(),
8338 nextItem;
8339
8340 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8341 nextItem = element.getRelativeFocusableItem( this, -1 );
8342 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8343 nextItem = element.getRelativeFocusableItem( this, 1 );
8344 }
8345
8346 if ( nextItem ) {
8347 e.preventDefault();
8348 nextItem.focus();
8349 }
8350 };
8351
8352 /**
8353 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8354 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8355 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8356 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8357 *
8358 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8359 * OO.ui.CheckboxMultiselectInputWidget instead.
8360 *
8361 * @example
8362 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8363 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8364 * data: 'a',
8365 * selected: true,
8366 * label: 'Selected checkbox'
8367 * } );
8368 *
8369 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8370 * data: 'b',
8371 * label: 'Unselected checkbox'
8372 * } );
8373 *
8374 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8375 * items: [ option1, option2 ]
8376 * } );
8377 *
8378 * $( 'body' ).append( multiselect.$element );
8379 *
8380 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8381 *
8382 * @class
8383 * @extends OO.ui.MultiselectWidget
8384 *
8385 * @constructor
8386 * @param {Object} [config] Configuration options
8387 */
8388 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8389 // Parent constructor
8390 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8391
8392 // Properties
8393 this.$lastClicked = null;
8394
8395 // Events
8396 this.$group.on( 'click', this.onClick.bind( this ) );
8397
8398 // Initialization
8399 this.$element
8400 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8401 };
8402
8403 /* Setup */
8404
8405 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8406
8407 /* Methods */
8408
8409 /**
8410 * Get an option by its position relative to the specified item (or to the start of the option array,
8411 * if item is `null`). The direction in which to search through the option array is specified with a
8412 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8413 * `null` if there are no options in the array.
8414 *
8415 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8416 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8417 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8418 */
8419 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8420 var currentIndex, nextIndex, i,
8421 increase = direction > 0 ? 1 : -1,
8422 len = this.items.length;
8423
8424 if ( item ) {
8425 currentIndex = this.items.indexOf( item );
8426 nextIndex = ( currentIndex + increase + len ) % len;
8427 } else {
8428 // If no item is selected and moving forward, start at the beginning.
8429 // If moving backward, start at the end.
8430 nextIndex = direction > 0 ? 0 : len - 1;
8431 }
8432
8433 for ( i = 0; i < len; i++ ) {
8434 item = this.items[ nextIndex ];
8435 if ( item && !item.isDisabled() ) {
8436 return item;
8437 }
8438 nextIndex = ( nextIndex + increase + len ) % len;
8439 }
8440 return null;
8441 };
8442
8443 /**
8444 * Handle click events on checkboxes.
8445 *
8446 * @param {jQuery.Event} e
8447 */
8448 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8449 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8450 $lastClicked = this.$lastClicked,
8451 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8452 .not( '.oo-ui-widget-disabled' );
8453
8454 // Allow selecting multiple options at once by Shift-clicking them
8455 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8456 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8457 lastClickedIndex = $options.index( $lastClicked );
8458 nowClickedIndex = $options.index( $nowClicked );
8459 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8460 // browser. In either case we don't need custom handling.
8461 if ( nowClickedIndex !== lastClickedIndex ) {
8462 items = this.items;
8463 wasSelected = items[ nowClickedIndex ].isSelected();
8464 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8465
8466 // This depends on the DOM order of the items and the order of the .items array being the same.
8467 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8468 if ( !items[ i ].isDisabled() ) {
8469 items[ i ].setSelected( !wasSelected );
8470 }
8471 }
8472 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8473 // handling first, then set our value. The order in which events happen is different for
8474 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8475 // non-click actions that change the checkboxes.
8476 e.preventDefault();
8477 setTimeout( function () {
8478 if ( !items[ nowClickedIndex ].isDisabled() ) {
8479 items[ nowClickedIndex ].setSelected( !wasSelected );
8480 }
8481 } );
8482 }
8483 }
8484
8485 if ( $nowClicked.length ) {
8486 this.$lastClicked = $nowClicked;
8487 }
8488 };
8489
8490 /**
8491 * Focus the widget
8492 *
8493 * @chainable
8494 */
8495 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8496 var item;
8497 if ( !this.isDisabled() ) {
8498 item = this.getRelativeFocusableItem( null, 1 );
8499 if ( item ) {
8500 item.focus();
8501 }
8502 }
8503 return this;
8504 };
8505
8506 /**
8507 * @inheritdoc
8508 */
8509 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8510 this.focus();
8511 };
8512
8513 /**
8514 * Progress bars visually display the status of an operation, such as a download,
8515 * and can be either determinate or indeterminate:
8516 *
8517 * - **determinate** process bars show the percent of an operation that is complete.
8518 *
8519 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8520 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8521 * not use percentages.
8522 *
8523 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8524 *
8525 * @example
8526 * // Examples of determinate and indeterminate progress bars.
8527 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8528 * progress: 33
8529 * } );
8530 * var progressBar2 = new OO.ui.ProgressBarWidget();
8531 *
8532 * // Create a FieldsetLayout to layout progress bars
8533 * var fieldset = new OO.ui.FieldsetLayout;
8534 * fieldset.addItems( [
8535 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8536 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8537 * ] );
8538 * $( 'body' ).append( fieldset.$element );
8539 *
8540 * @class
8541 * @extends OO.ui.Widget
8542 *
8543 * @constructor
8544 * @param {Object} [config] Configuration options
8545 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8546 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8547 * By default, the progress bar is indeterminate.
8548 */
8549 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8550 // Configuration initialization
8551 config = config || {};
8552
8553 // Parent constructor
8554 OO.ui.ProgressBarWidget.parent.call( this, config );
8555
8556 // Properties
8557 this.$bar = $( '<div>' );
8558 this.progress = null;
8559
8560 // Initialization
8561 this.setProgress( config.progress !== undefined ? config.progress : false );
8562 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8563 this.$element
8564 .attr( {
8565 role: 'progressbar',
8566 'aria-valuemin': 0,
8567 'aria-valuemax': 100
8568 } )
8569 .addClass( 'oo-ui-progressBarWidget' )
8570 .append( this.$bar );
8571 };
8572
8573 /* Setup */
8574
8575 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8576
8577 /* Static Properties */
8578
8579 /**
8580 * @static
8581 * @inheritdoc
8582 */
8583 OO.ui.ProgressBarWidget.static.tagName = 'div';
8584
8585 /* Methods */
8586
8587 /**
8588 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8589 *
8590 * @return {number|boolean} Progress percent
8591 */
8592 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8593 return this.progress;
8594 };
8595
8596 /**
8597 * Set the percent of the process completed or `false` for an indeterminate process.
8598 *
8599 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8600 */
8601 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8602 this.progress = progress;
8603
8604 if ( progress !== false ) {
8605 this.$bar.css( 'width', this.progress + '%' );
8606 this.$element.attr( 'aria-valuenow', this.progress );
8607 } else {
8608 this.$bar.css( 'width', '' );
8609 this.$element.removeAttr( 'aria-valuenow' );
8610 }
8611 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8612 };
8613
8614 /**
8615 * InputWidget is the base class for all input widgets, which
8616 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8617 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8618 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8619 *
8620 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8621 *
8622 * @abstract
8623 * @class
8624 * @extends OO.ui.Widget
8625 * @mixins OO.ui.mixin.FlaggedElement
8626 * @mixins OO.ui.mixin.TabIndexedElement
8627 * @mixins OO.ui.mixin.TitledElement
8628 * @mixins OO.ui.mixin.AccessKeyedElement
8629 *
8630 * @constructor
8631 * @param {Object} [config] Configuration options
8632 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8633 * @cfg {string} [value=''] The value of the input.
8634 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8635 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8636 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8637 * before it is accepted.
8638 */
8639 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8640 // Configuration initialization
8641 config = config || {};
8642
8643 // Parent constructor
8644 OO.ui.InputWidget.parent.call( this, config );
8645
8646 // Properties
8647 // See #reusePreInfuseDOM about config.$input
8648 this.$input = config.$input || this.getInputElement( config );
8649 this.value = '';
8650 this.inputFilter = config.inputFilter;
8651
8652 // Mixin constructors
8653 OO.ui.mixin.FlaggedElement.call( this, config );
8654 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8655 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8656 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8657
8658 // Events
8659 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8660
8661 // Initialization
8662 this.$input
8663 .addClass( 'oo-ui-inputWidget-input' )
8664 .attr( 'name', config.name )
8665 .prop( 'disabled', this.isDisabled() );
8666 this.$element
8667 .addClass( 'oo-ui-inputWidget' )
8668 .append( this.$input );
8669 this.setValue( config.value );
8670 if ( config.dir ) {
8671 this.setDir( config.dir );
8672 }
8673 if ( config.inputId !== undefined ) {
8674 this.setInputId( config.inputId );
8675 }
8676 };
8677
8678 /* Setup */
8679
8680 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8681 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8682 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8683 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8684 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8685
8686 /* Static Methods */
8687
8688 /**
8689 * @inheritdoc
8690 */
8691 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8692 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8693 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8694 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8695 return config;
8696 };
8697
8698 /**
8699 * @inheritdoc
8700 */
8701 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8702 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8703 if ( config.$input && config.$input.length ) {
8704 state.value = config.$input.val();
8705 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8706 state.focus = config.$input.is( ':focus' );
8707 }
8708 return state;
8709 };
8710
8711 /* Events */
8712
8713 /**
8714 * @event change
8715 *
8716 * A change event is emitted when the value of the input changes.
8717 *
8718 * @param {string} value
8719 */
8720
8721 /* Methods */
8722
8723 /**
8724 * Get input element.
8725 *
8726 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8727 * different circumstances. The element must have a `value` property (like form elements).
8728 *
8729 * @protected
8730 * @param {Object} config Configuration options
8731 * @return {jQuery} Input element
8732 */
8733 OO.ui.InputWidget.prototype.getInputElement = function () {
8734 return $( '<input>' );
8735 };
8736
8737 /**
8738 * Handle potentially value-changing events.
8739 *
8740 * @private
8741 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8742 */
8743 OO.ui.InputWidget.prototype.onEdit = function () {
8744 var widget = this;
8745 if ( !this.isDisabled() ) {
8746 // Allow the stack to clear so the value will be updated
8747 setTimeout( function () {
8748 widget.setValue( widget.$input.val() );
8749 } );
8750 }
8751 };
8752
8753 /**
8754 * Get the value of the input.
8755 *
8756 * @return {string} Input value
8757 */
8758 OO.ui.InputWidget.prototype.getValue = function () {
8759 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8760 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8761 var value = this.$input.val();
8762 if ( this.value !== value ) {
8763 this.setValue( value );
8764 }
8765 return this.value;
8766 };
8767
8768 /**
8769 * Set the directionality of the input.
8770 *
8771 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8772 * @chainable
8773 */
8774 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8775 this.$input.prop( 'dir', dir );
8776 return this;
8777 };
8778
8779 /**
8780 * Set the value of the input.
8781 *
8782 * @param {string} value New value
8783 * @fires change
8784 * @chainable
8785 */
8786 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8787 value = this.cleanUpValue( value );
8788 // Update the DOM if it has changed. Note that with cleanUpValue, it
8789 // is possible for the DOM value to change without this.value changing.
8790 if ( this.$input.val() !== value ) {
8791 this.$input.val( value );
8792 }
8793 if ( this.value !== value ) {
8794 this.value = value;
8795 this.emit( 'change', this.value );
8796 }
8797 // The first time that the value is set (probably while constructing the widget),
8798 // remember it in defaultValue. This property can be later used to check whether
8799 // the value of the input has been changed since it was created.
8800 if ( this.defaultValue === undefined ) {
8801 this.defaultValue = this.value;
8802 this.$input[ 0 ].defaultValue = this.defaultValue;
8803 }
8804 return this;
8805 };
8806
8807 /**
8808 * Clean up incoming value.
8809 *
8810 * Ensures value is a string, and converts undefined and null to empty string.
8811 *
8812 * @private
8813 * @param {string} value Original value
8814 * @return {string} Cleaned up value
8815 */
8816 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8817 if ( value === undefined || value === null ) {
8818 return '';
8819 } else if ( this.inputFilter ) {
8820 return this.inputFilter( String( value ) );
8821 } else {
8822 return String( value );
8823 }
8824 };
8825
8826 /**
8827 * @inheritdoc
8828 */
8829 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8830 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8831 if ( this.$input ) {
8832 this.$input.prop( 'disabled', this.isDisabled() );
8833 }
8834 return this;
8835 };
8836
8837 /**
8838 * Set the 'id' attribute of the `<input>` element.
8839 *
8840 * @param {string} id
8841 * @chainable
8842 */
8843 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8844 this.$input.attr( 'id', id );
8845 return this;
8846 };
8847
8848 /**
8849 * @inheritdoc
8850 */
8851 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8852 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8853 if ( state.value !== undefined && state.value !== this.getValue() ) {
8854 this.setValue( state.value );
8855 }
8856 if ( state.focus ) {
8857 this.focus();
8858 }
8859 };
8860
8861 /**
8862 * Data widget intended for creating 'hidden'-type inputs.
8863 *
8864 * @class
8865 * @extends OO.ui.Widget
8866 *
8867 * @constructor
8868 * @param {Object} [config] Configuration options
8869 * @cfg {string} [value=''] The value of the input.
8870 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8871 */
8872 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8873 // Configuration initialization
8874 config = $.extend( { value: '', name: '' }, config );
8875
8876 // Parent constructor
8877 OO.ui.HiddenInputWidget.parent.call( this, config );
8878
8879 // Initialization
8880 this.$element.attr( {
8881 type: 'hidden',
8882 value: config.value,
8883 name: config.name
8884 } );
8885 this.$element.removeAttr( 'aria-disabled' );
8886 };
8887
8888 /* Setup */
8889
8890 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8891
8892 /* Static Properties */
8893
8894 /**
8895 * @static
8896 * @inheritdoc
8897 */
8898 OO.ui.HiddenInputWidget.static.tagName = 'input';
8899
8900 /**
8901 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8902 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8903 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8904 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8905 * [OOUI documentation on MediaWiki] [1] for more information.
8906 *
8907 * @example
8908 * // A ButtonInputWidget rendered as an HTML button, the default.
8909 * var button = new OO.ui.ButtonInputWidget( {
8910 * label: 'Input button',
8911 * icon: 'check',
8912 * value: 'check'
8913 * } );
8914 * $( 'body' ).append( button.$element );
8915 *
8916 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
8917 *
8918 * @class
8919 * @extends OO.ui.InputWidget
8920 * @mixins OO.ui.mixin.ButtonElement
8921 * @mixins OO.ui.mixin.IconElement
8922 * @mixins OO.ui.mixin.IndicatorElement
8923 * @mixins OO.ui.mixin.LabelElement
8924 * @mixins OO.ui.mixin.TitledElement
8925 *
8926 * @constructor
8927 * @param {Object} [config] Configuration options
8928 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8929 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8930 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8931 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8932 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8933 */
8934 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8935 // Configuration initialization
8936 config = $.extend( { type: 'button', useInputTag: false }, config );
8937
8938 // See InputWidget#reusePreInfuseDOM about config.$input
8939 if ( config.$input ) {
8940 config.$input.empty();
8941 }
8942
8943 // Properties (must be set before parent constructor, which calls #setValue)
8944 this.useInputTag = config.useInputTag;
8945
8946 // Parent constructor
8947 OO.ui.ButtonInputWidget.parent.call( this, config );
8948
8949 // Mixin constructors
8950 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8951 OO.ui.mixin.IconElement.call( this, config );
8952 OO.ui.mixin.IndicatorElement.call( this, config );
8953 OO.ui.mixin.LabelElement.call( this, config );
8954 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8955
8956 // Initialization
8957 if ( !config.useInputTag ) {
8958 this.$input.append( this.$icon, this.$label, this.$indicator );
8959 }
8960 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8961 };
8962
8963 /* Setup */
8964
8965 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8966 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8967 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8968 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8969 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8970 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8971
8972 /* Static Properties */
8973
8974 /**
8975 * @static
8976 * @inheritdoc
8977 */
8978 OO.ui.ButtonInputWidget.static.tagName = 'span';
8979
8980 /* Methods */
8981
8982 /**
8983 * @inheritdoc
8984 * @protected
8985 */
8986 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8987 var type;
8988 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8989 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8990 };
8991
8992 /**
8993 * Set label value.
8994 *
8995 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8996 *
8997 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8998 * text, or `null` for no label
8999 * @chainable
9000 */
9001 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9002 if ( typeof label === 'function' ) {
9003 label = OO.ui.resolveMsg( label );
9004 }
9005
9006 if ( this.useInputTag ) {
9007 // Discard non-plaintext labels
9008 if ( typeof label !== 'string' ) {
9009 label = '';
9010 }
9011
9012 this.$input.val( label );
9013 }
9014
9015 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9016 };
9017
9018 /**
9019 * Set the value of the input.
9020 *
9021 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9022 * they do not support {@link #value values}.
9023 *
9024 * @param {string} value New value
9025 * @chainable
9026 */
9027 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9028 if ( !this.useInputTag ) {
9029 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9030 }
9031 return this;
9032 };
9033
9034 /**
9035 * @inheritdoc
9036 */
9037 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9038 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9039 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9040 return null;
9041 };
9042
9043 /**
9044 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9045 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9046 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9047 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9048 *
9049 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9050 *
9051 * @example
9052 * // An example of selected, unselected, and disabled checkbox inputs
9053 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9054 * value: 'a',
9055 * selected: true
9056 * } );
9057 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9058 * value: 'b'
9059 * } );
9060 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9061 * value:'c',
9062 * disabled: true
9063 * } );
9064 * // Create a fieldset layout with fields for each checkbox.
9065 * var fieldset = new OO.ui.FieldsetLayout( {
9066 * label: 'Checkboxes'
9067 * } );
9068 * fieldset.addItems( [
9069 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9070 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9071 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9072 * ] );
9073 * $( 'body' ).append( fieldset.$element );
9074 *
9075 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9076 *
9077 * @class
9078 * @extends OO.ui.InputWidget
9079 *
9080 * @constructor
9081 * @param {Object} [config] Configuration options
9082 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9083 */
9084 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9085 // Configuration initialization
9086 config = config || {};
9087
9088 // Parent constructor
9089 OO.ui.CheckboxInputWidget.parent.call( this, config );
9090
9091 // Properties
9092 this.checkIcon = new OO.ui.IconWidget( {
9093 icon: 'check',
9094 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9095 } );
9096
9097 // Initialization
9098 this.$element
9099 .addClass( 'oo-ui-checkboxInputWidget' )
9100 // Required for pretty styling in WikimediaUI theme
9101 .append( this.checkIcon.$element );
9102 this.setSelected( config.selected !== undefined ? config.selected : false );
9103 };
9104
9105 /* Setup */
9106
9107 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9108
9109 /* Static Properties */
9110
9111 /**
9112 * @static
9113 * @inheritdoc
9114 */
9115 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9116
9117 /* Static Methods */
9118
9119 /**
9120 * @inheritdoc
9121 */
9122 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9123 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9124 state.checked = config.$input.prop( 'checked' );
9125 return state;
9126 };
9127
9128 /* Methods */
9129
9130 /**
9131 * @inheritdoc
9132 * @protected
9133 */
9134 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9135 return $( '<input>' ).attr( 'type', 'checkbox' );
9136 };
9137
9138 /**
9139 * @inheritdoc
9140 */
9141 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9142 var widget = this;
9143 if ( !this.isDisabled() ) {
9144 // Allow the stack to clear so the value will be updated
9145 setTimeout( function () {
9146 widget.setSelected( widget.$input.prop( 'checked' ) );
9147 } );
9148 }
9149 };
9150
9151 /**
9152 * Set selection state of this checkbox.
9153 *
9154 * @param {boolean} state `true` for selected
9155 * @chainable
9156 */
9157 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9158 state = !!state;
9159 if ( this.selected !== state ) {
9160 this.selected = state;
9161 this.$input.prop( 'checked', this.selected );
9162 this.emit( 'change', this.selected );
9163 }
9164 // The first time that the selection state is set (probably while constructing the widget),
9165 // remember it in defaultSelected. This property can be later used to check whether
9166 // the selection state of the input has been changed since it was created.
9167 if ( this.defaultSelected === undefined ) {
9168 this.defaultSelected = this.selected;
9169 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9170 }
9171 return this;
9172 };
9173
9174 /**
9175 * Check if this checkbox is selected.
9176 *
9177 * @return {boolean} Checkbox is selected
9178 */
9179 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9180 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9181 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9182 var selected = this.$input.prop( 'checked' );
9183 if ( this.selected !== selected ) {
9184 this.setSelected( selected );
9185 }
9186 return this.selected;
9187 };
9188
9189 /**
9190 * @inheritdoc
9191 */
9192 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9193 if ( !this.isDisabled() ) {
9194 this.$input.click();
9195 }
9196 this.focus();
9197 };
9198
9199 /**
9200 * @inheritdoc
9201 */
9202 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9203 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9204 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9205 this.setSelected( state.checked );
9206 }
9207 };
9208
9209 /**
9210 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9211 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9212 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9213 * more information about input widgets.
9214 *
9215 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9216 * are no options. If no `value` configuration option is provided, the first option is selected.
9217 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9218 *
9219 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9220 *
9221 * @example
9222 * // Example: A DropdownInputWidget with three options
9223 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9224 * options: [
9225 * { data: 'a', label: 'First' },
9226 * { data: 'b', label: 'Second'},
9227 * { data: 'c', label: 'Third' }
9228 * ]
9229 * } );
9230 * $( 'body' ).append( dropdownInput.$element );
9231 *
9232 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9233 *
9234 * @class
9235 * @extends OO.ui.InputWidget
9236 *
9237 * @constructor
9238 * @param {Object} [config] Configuration options
9239 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9240 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9241 */
9242 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9243 // Configuration initialization
9244 config = config || {};
9245
9246 // Properties (must be done before parent constructor which calls #setDisabled)
9247 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
9248 // Set up the options before parent constructor, which uses them to validate config.value.
9249 // Use this instead of setOptions() because this.$input is not set up yet.
9250 this.setOptionsData( config.options || [] );
9251
9252 // Parent constructor
9253 OO.ui.DropdownInputWidget.parent.call( this, config );
9254
9255 // Events
9256 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9257
9258 // Initialization
9259 this.$element
9260 .addClass( 'oo-ui-dropdownInputWidget' )
9261 .append( this.dropdownWidget.$element );
9262 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9263 };
9264
9265 /* Setup */
9266
9267 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9268
9269 /* Methods */
9270
9271 /**
9272 * @inheritdoc
9273 * @protected
9274 */
9275 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9276 return $( '<select>' );
9277 };
9278
9279 /**
9280 * Handles menu select events.
9281 *
9282 * @private
9283 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9284 */
9285 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9286 this.setValue( item ? item.getData() : '' );
9287 };
9288
9289 /**
9290 * @inheritdoc
9291 */
9292 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9293 var selected;
9294 value = this.cleanUpValue( value );
9295 // Only allow setting values that are actually present in the dropdown
9296 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9297 this.dropdownWidget.getMenu().findFirstSelectableItem();
9298 this.dropdownWidget.getMenu().selectItem( selected );
9299 value = selected ? selected.getData() : '';
9300 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9301 if ( this.optionsDirty ) {
9302 // We reached this from the constructor or from #setOptions.
9303 // We have to update the <select> element.
9304 this.updateOptionsInterface();
9305 }
9306 return this;
9307 };
9308
9309 /**
9310 * @inheritdoc
9311 */
9312 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9313 this.dropdownWidget.setDisabled( state );
9314 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9315 return this;
9316 };
9317
9318 /**
9319 * Set the options available for this input.
9320 *
9321 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9322 * @chainable
9323 */
9324 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9325 var value = this.getValue();
9326
9327 this.setOptionsData( options );
9328
9329 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9330 // In case the previous value is no longer an available option, select the first valid one.
9331 this.setValue( value );
9332
9333 return this;
9334 };
9335
9336 /**
9337 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9338 *
9339 * This method may be called before the parent constructor, so various properties may not be
9340 * intialized yet.
9341 *
9342 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9343 * @private
9344 */
9345 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9346 var
9347 optionWidgets,
9348 widget = this;
9349
9350 this.optionsDirty = true;
9351
9352 optionWidgets = options.map( function ( opt ) {
9353 var optValue;
9354
9355 if ( opt.optgroup !== undefined ) {
9356 return widget.createMenuSectionOptionWidget( opt.optgroup );
9357 }
9358
9359 optValue = widget.cleanUpValue( opt.data );
9360 return widget.createMenuOptionWidget(
9361 optValue,
9362 opt.label !== undefined ? opt.label : optValue
9363 );
9364
9365 } );
9366
9367 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9368 };
9369
9370 /**
9371 * Create a menu option widget.
9372 *
9373 * @protected
9374 * @param {string} data Item data
9375 * @param {string} label Item label
9376 * @return {OO.ui.MenuOptionWidget} Option widget
9377 */
9378 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9379 return new OO.ui.MenuOptionWidget( {
9380 data: data,
9381 label: label
9382 } );
9383 };
9384
9385 /**
9386 * Create a menu section option widget.
9387 *
9388 * @protected
9389 * @param {string} label Section item label
9390 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9391 */
9392 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9393 return new OO.ui.MenuSectionOptionWidget( {
9394 label: label
9395 } );
9396 };
9397
9398 /**
9399 * Update the user-visible interface to match the internal list of options and value.
9400 *
9401 * This method must only be called after the parent constructor.
9402 *
9403 * @private
9404 */
9405 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9406 var
9407 $optionsContainer = this.$input,
9408 defaultValue = this.defaultValue,
9409 widget = this;
9410
9411 this.$input.empty();
9412
9413 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9414 var $optionNode;
9415
9416 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9417 $optionNode = $( '<option>' )
9418 .attr( 'value', optionWidget.getData() )
9419 .text( optionWidget.getLabel() );
9420
9421 // Remember original selection state. This property can be later used to check whether
9422 // the selection state of the input has been changed since it was created.
9423 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9424
9425 $optionsContainer.append( $optionNode );
9426 } else {
9427 $optionNode = $( '<optgroup>' )
9428 .attr( 'label', optionWidget.getLabel() );
9429 widget.$input.append( $optionNode );
9430 $optionsContainer = $optionNode;
9431 }
9432 } );
9433
9434 this.optionsDirty = false;
9435 };
9436
9437 /**
9438 * @inheritdoc
9439 */
9440 OO.ui.DropdownInputWidget.prototype.focus = function () {
9441 this.dropdownWidget.focus();
9442 return this;
9443 };
9444
9445 /**
9446 * @inheritdoc
9447 */
9448 OO.ui.DropdownInputWidget.prototype.blur = function () {
9449 this.dropdownWidget.blur();
9450 return this;
9451 };
9452
9453 /**
9454 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9455 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9456 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9457 * please see the [OOUI documentation on MediaWiki][1].
9458 *
9459 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9460 *
9461 * @example
9462 * // An example of selected, unselected, and disabled radio inputs
9463 * var radio1 = new OO.ui.RadioInputWidget( {
9464 * value: 'a',
9465 * selected: true
9466 * } );
9467 * var radio2 = new OO.ui.RadioInputWidget( {
9468 * value: 'b'
9469 * } );
9470 * var radio3 = new OO.ui.RadioInputWidget( {
9471 * value: 'c',
9472 * disabled: true
9473 * } );
9474 * // Create a fieldset layout with fields for each radio button.
9475 * var fieldset = new OO.ui.FieldsetLayout( {
9476 * label: 'Radio inputs'
9477 * } );
9478 * fieldset.addItems( [
9479 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9480 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9481 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9482 * ] );
9483 * $( 'body' ).append( fieldset.$element );
9484 *
9485 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9486 *
9487 * @class
9488 * @extends OO.ui.InputWidget
9489 *
9490 * @constructor
9491 * @param {Object} [config] Configuration options
9492 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9493 */
9494 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9495 // Configuration initialization
9496 config = config || {};
9497
9498 // Parent constructor
9499 OO.ui.RadioInputWidget.parent.call( this, config );
9500
9501 // Initialization
9502 this.$element
9503 .addClass( 'oo-ui-radioInputWidget' )
9504 // Required for pretty styling in WikimediaUI theme
9505 .append( $( '<span>' ) );
9506 this.setSelected( config.selected !== undefined ? config.selected : false );
9507 };
9508
9509 /* Setup */
9510
9511 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9512
9513 /* Static Properties */
9514
9515 /**
9516 * @static
9517 * @inheritdoc
9518 */
9519 OO.ui.RadioInputWidget.static.tagName = 'span';
9520
9521 /* Static Methods */
9522
9523 /**
9524 * @inheritdoc
9525 */
9526 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9527 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9528 state.checked = config.$input.prop( 'checked' );
9529 return state;
9530 };
9531
9532 /* Methods */
9533
9534 /**
9535 * @inheritdoc
9536 * @protected
9537 */
9538 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9539 return $( '<input>' ).attr( 'type', 'radio' );
9540 };
9541
9542 /**
9543 * @inheritdoc
9544 */
9545 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9546 // RadioInputWidget doesn't track its state.
9547 };
9548
9549 /**
9550 * Set selection state of this radio button.
9551 *
9552 * @param {boolean} state `true` for selected
9553 * @chainable
9554 */
9555 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9556 // RadioInputWidget doesn't track its state.
9557 this.$input.prop( 'checked', state );
9558 // The first time that the selection state is set (probably while constructing the widget),
9559 // remember it in defaultSelected. This property can be later used to check whether
9560 // the selection state of the input has been changed since it was created.
9561 if ( this.defaultSelected === undefined ) {
9562 this.defaultSelected = state;
9563 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9564 }
9565 return this;
9566 };
9567
9568 /**
9569 * Check if this radio button is selected.
9570 *
9571 * @return {boolean} Radio is selected
9572 */
9573 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9574 return this.$input.prop( 'checked' );
9575 };
9576
9577 /**
9578 * @inheritdoc
9579 */
9580 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9581 if ( !this.isDisabled() ) {
9582 this.$input.click();
9583 }
9584 this.focus();
9585 };
9586
9587 /**
9588 * @inheritdoc
9589 */
9590 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9591 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9592 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9593 this.setSelected( state.checked );
9594 }
9595 };
9596
9597 /**
9598 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9599 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9600 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9601 * more information about input widgets.
9602 *
9603 * This and OO.ui.DropdownInputWidget support the same configuration options.
9604 *
9605 * @example
9606 * // Example: A RadioSelectInputWidget with three options
9607 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9608 * options: [
9609 * { data: 'a', label: 'First' },
9610 * { data: 'b', label: 'Second'},
9611 * { data: 'c', label: 'Third' }
9612 * ]
9613 * } );
9614 * $( 'body' ).append( radioSelectInput.$element );
9615 *
9616 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9617 *
9618 * @class
9619 * @extends OO.ui.InputWidget
9620 *
9621 * @constructor
9622 * @param {Object} [config] Configuration options
9623 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9624 */
9625 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9626 // Configuration initialization
9627 config = config || {};
9628
9629 // Properties (must be done before parent constructor which calls #setDisabled)
9630 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9631 // Set up the options before parent constructor, which uses them to validate config.value.
9632 // Use this instead of setOptions() because this.$input is not set up yet
9633 this.setOptionsData( config.options || [] );
9634
9635 // Parent constructor
9636 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9637
9638 // Events
9639 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9640
9641 // Initialization
9642 this.$element
9643 .addClass( 'oo-ui-radioSelectInputWidget' )
9644 .append( this.radioSelectWidget.$element );
9645 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9646 };
9647
9648 /* Setup */
9649
9650 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9651
9652 /* Static Methods */
9653
9654 /**
9655 * @inheritdoc
9656 */
9657 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9658 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9659 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9660 return state;
9661 };
9662
9663 /**
9664 * @inheritdoc
9665 */
9666 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9667 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9668 // Cannot reuse the `<input type=radio>` set
9669 delete config.$input;
9670 return config;
9671 };
9672
9673 /* Methods */
9674
9675 /**
9676 * @inheritdoc
9677 * @protected
9678 */
9679 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9680 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9681 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9682 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9683 };
9684
9685 /**
9686 * Handles menu select events.
9687 *
9688 * @private
9689 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9690 */
9691 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9692 this.setValue( item.getData() );
9693 };
9694
9695 /**
9696 * @inheritdoc
9697 */
9698 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9699 var selected;
9700 value = this.cleanUpValue( value );
9701 // Only allow setting values that are actually present in the dropdown
9702 selected = this.radioSelectWidget.findItemFromData( value ) ||
9703 this.radioSelectWidget.findFirstSelectableItem();
9704 this.radioSelectWidget.selectItem( selected );
9705 value = selected ? selected.getData() : '';
9706 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9707 return this;
9708 };
9709
9710 /**
9711 * @inheritdoc
9712 */
9713 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9714 this.radioSelectWidget.setDisabled( state );
9715 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9716 return this;
9717 };
9718
9719 /**
9720 * Set the options available for this input.
9721 *
9722 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9723 * @chainable
9724 */
9725 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9726 var value = this.getValue();
9727
9728 this.setOptionsData( options );
9729
9730 // Re-set the value to update the visible interface (RadioSelectWidget).
9731 // In case the previous value is no longer an available option, select the first valid one.
9732 this.setValue( value );
9733
9734 return this;
9735 };
9736
9737 /**
9738 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9739 *
9740 * This method may be called before the parent constructor, so various properties may not be
9741 * intialized yet.
9742 *
9743 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9744 * @private
9745 */
9746 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
9747 var widget = this;
9748
9749 this.radioSelectWidget
9750 .clearItems()
9751 .addItems( options.map( function ( opt ) {
9752 var optValue = widget.cleanUpValue( opt.data );
9753 return new OO.ui.RadioOptionWidget( {
9754 data: optValue,
9755 label: opt.label !== undefined ? opt.label : optValue
9756 } );
9757 } ) );
9758 };
9759
9760 /**
9761 * @inheritdoc
9762 */
9763 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9764 this.radioSelectWidget.focus();
9765 return this;
9766 };
9767
9768 /**
9769 * @inheritdoc
9770 */
9771 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9772 this.radioSelectWidget.blur();
9773 return this;
9774 };
9775
9776 /**
9777 * CheckboxMultiselectInputWidget is a
9778 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9779 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9780 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
9781 * more information about input widgets.
9782 *
9783 * @example
9784 * // Example: A CheckboxMultiselectInputWidget with three options
9785 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9786 * options: [
9787 * { data: 'a', label: 'First' },
9788 * { data: 'b', label: 'Second'},
9789 * { data: 'c', label: 'Third' }
9790 * ]
9791 * } );
9792 * $( 'body' ).append( multiselectInput.$element );
9793 *
9794 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9795 *
9796 * @class
9797 * @extends OO.ui.InputWidget
9798 *
9799 * @constructor
9800 * @param {Object} [config] Configuration options
9801 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9802 */
9803 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9804 // Configuration initialization
9805 config = config || {};
9806
9807 // Properties (must be done before parent constructor which calls #setDisabled)
9808 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9809 // Must be set before the #setOptionsData call below
9810 this.inputName = config.name;
9811 // Set up the options before parent constructor, which uses them to validate config.value.
9812 // Use this instead of setOptions() because this.$input is not set up yet
9813 this.setOptionsData( config.options || [] );
9814
9815 // Parent constructor
9816 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9817
9818 // Events
9819 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
9820
9821 // Initialization
9822 this.$element
9823 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9824 .append( this.checkboxMultiselectWidget.$element );
9825 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9826 this.$input.detach();
9827 };
9828
9829 /* Setup */
9830
9831 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9832
9833 /* Static Methods */
9834
9835 /**
9836 * @inheritdoc
9837 */
9838 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9839 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9840 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9841 .toArray().map( function ( el ) { return el.value; } );
9842 return state;
9843 };
9844
9845 /**
9846 * @inheritdoc
9847 */
9848 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9849 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9850 // Cannot reuse the `<input type=checkbox>` set
9851 delete config.$input;
9852 return config;
9853 };
9854
9855 /* Methods */
9856
9857 /**
9858 * @inheritdoc
9859 * @protected
9860 */
9861 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9862 // Actually unused
9863 return $( '<unused>' );
9864 };
9865
9866 /**
9867 * Handles CheckboxMultiselectWidget select events.
9868 *
9869 * @private
9870 */
9871 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
9872 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
9873 };
9874
9875 /**
9876 * @inheritdoc
9877 */
9878 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9879 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9880 .toArray().map( function ( el ) { return el.value; } );
9881 if ( this.value !== value ) {
9882 this.setValue( value );
9883 }
9884 return this.value;
9885 };
9886
9887 /**
9888 * @inheritdoc
9889 */
9890 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9891 value = this.cleanUpValue( value );
9892 this.checkboxMultiselectWidget.selectItemsByData( value );
9893 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9894 if ( this.optionsDirty ) {
9895 // We reached this from the constructor or from #setOptions.
9896 // We have to update the <select> element.
9897 this.updateOptionsInterface();
9898 }
9899 return this;
9900 };
9901
9902 /**
9903 * Clean up incoming value.
9904 *
9905 * @param {string[]} value Original value
9906 * @return {string[]} Cleaned up value
9907 */
9908 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9909 var i, singleValue,
9910 cleanValue = [];
9911 if ( !Array.isArray( value ) ) {
9912 return cleanValue;
9913 }
9914 for ( i = 0; i < value.length; i++ ) {
9915 singleValue =
9916 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9917 // Remove options that we don't have here
9918 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
9919 continue;
9920 }
9921 cleanValue.push( singleValue );
9922 }
9923 return cleanValue;
9924 };
9925
9926 /**
9927 * @inheritdoc
9928 */
9929 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9930 this.checkboxMultiselectWidget.setDisabled( state );
9931 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9932 return this;
9933 };
9934
9935 /**
9936 * Set the options available for this input.
9937 *
9938 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9939 * @chainable
9940 */
9941 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9942 var value = this.getValue();
9943
9944 this.setOptionsData( options );
9945
9946 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
9947 // This will also get rid of any stale options that we just removed.
9948 this.setValue( value );
9949
9950 return this;
9951 };
9952
9953 /**
9954 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9955 *
9956 * This method may be called before the parent constructor, so various properties may not be
9957 * intialized yet.
9958 *
9959 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9960 * @private
9961 */
9962 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
9963 var widget = this;
9964
9965 this.optionsDirty = true;
9966
9967 this.checkboxMultiselectWidget
9968 .clearItems()
9969 .addItems( options.map( function ( opt ) {
9970 var optValue, item, optDisabled;
9971 optValue =
9972 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9973 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9974 item = new OO.ui.CheckboxMultioptionWidget( {
9975 data: optValue,
9976 label: opt.label !== undefined ? opt.label : optValue,
9977 disabled: optDisabled
9978 } );
9979 // Set the 'name' and 'value' for form submission
9980 item.checkbox.$input.attr( 'name', widget.inputName );
9981 item.checkbox.setValue( optValue );
9982 return item;
9983 } ) );
9984 };
9985
9986 /**
9987 * Update the user-visible interface to match the internal list of options and value.
9988 *
9989 * This method must only be called after the parent constructor.
9990 *
9991 * @private
9992 */
9993 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
9994 var defaultValue = this.defaultValue;
9995
9996 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
9997 // Remember original selection state. This property can be later used to check whether
9998 // the selection state of the input has been changed since it was created.
9999 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10000 item.checkbox.defaultSelected = isDefault;
10001 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10002 } );
10003
10004 this.optionsDirty = false;
10005 };
10006
10007 /**
10008 * @inheritdoc
10009 */
10010 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10011 this.checkboxMultiselectWidget.focus();
10012 return this;
10013 };
10014
10015 /**
10016 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10017 * size of the field as well as its presentation. In addition, these widgets can be configured
10018 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10019 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10020 * which modifies incoming values rather than validating them.
10021 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10022 *
10023 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10024 *
10025 * @example
10026 * // Example of a text input widget
10027 * var textInput = new OO.ui.TextInputWidget( {
10028 * value: 'Text input'
10029 * } )
10030 * $( 'body' ).append( textInput.$element );
10031 *
10032 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10033 *
10034 * @class
10035 * @extends OO.ui.InputWidget
10036 * @mixins OO.ui.mixin.IconElement
10037 * @mixins OO.ui.mixin.IndicatorElement
10038 * @mixins OO.ui.mixin.PendingElement
10039 * @mixins OO.ui.mixin.LabelElement
10040 *
10041 * @constructor
10042 * @param {Object} [config] Configuration options
10043 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10044 * 'email', 'url' or 'number'.
10045 * @cfg {string} [placeholder] Placeholder text
10046 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10047 * instruct the browser to focus this widget.
10048 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10049 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10050 *
10051 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10052 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10053 * many emojis) count as 2 characters each.
10054 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10055 * the value or placeholder text: `'before'` or `'after'`
10056 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10057 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10058 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10059 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10060 * leaving it up to the browser).
10061 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10062 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10063 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10064 * value for it to be considered valid; when Function, a function receiving the value as parameter
10065 * that must return true, or promise resolving to true, for it to be considered valid.
10066 */
10067 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10068 // Configuration initialization
10069 config = $.extend( {
10070 type: 'text',
10071 labelPosition: 'after'
10072 }, config );
10073
10074 if ( config.multiline ) {
10075 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
10076 return new OO.ui.MultilineTextInputWidget( config );
10077 }
10078
10079 // Parent constructor
10080 OO.ui.TextInputWidget.parent.call( this, config );
10081
10082 // Mixin constructors
10083 OO.ui.mixin.IconElement.call( this, config );
10084 OO.ui.mixin.IndicatorElement.call( this, config );
10085 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10086 OO.ui.mixin.LabelElement.call( this, config );
10087
10088 // Properties
10089 this.type = this.getSaneType( config );
10090 this.readOnly = false;
10091 this.required = false;
10092 this.validate = null;
10093 this.styleHeight = null;
10094 this.scrollWidth = null;
10095
10096 this.setValidation( config.validate );
10097 this.setLabelPosition( config.labelPosition );
10098
10099 // Events
10100 this.$input.on( {
10101 keypress: this.onKeyPress.bind( this ),
10102 blur: this.onBlur.bind( this ),
10103 focus: this.onFocus.bind( this )
10104 } );
10105 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10106 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10107 this.on( 'labelChange', this.updatePosition.bind( this ) );
10108 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10109
10110 // Initialization
10111 this.$element
10112 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10113 .append( this.$icon, this.$indicator );
10114 this.setReadOnly( !!config.readOnly );
10115 this.setRequired( !!config.required );
10116 if ( config.placeholder !== undefined ) {
10117 this.$input.attr( 'placeholder', config.placeholder );
10118 }
10119 if ( config.maxLength !== undefined ) {
10120 this.$input.attr( 'maxlength', config.maxLength );
10121 }
10122 if ( config.autofocus ) {
10123 this.$input.attr( 'autofocus', 'autofocus' );
10124 }
10125 if ( config.autocomplete === false ) {
10126 this.$input.attr( 'autocomplete', 'off' );
10127 // Turning off autocompletion also disables "form caching" when the user navigates to a
10128 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10129 $( window ).on( {
10130 beforeunload: function () {
10131 this.$input.removeAttr( 'autocomplete' );
10132 }.bind( this ),
10133 pageshow: function () {
10134 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10135 // whole page... it shouldn't hurt, though.
10136 this.$input.attr( 'autocomplete', 'off' );
10137 }.bind( this )
10138 } );
10139 }
10140 if ( config.spellcheck !== undefined ) {
10141 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10142 }
10143 if ( this.label ) {
10144 this.isWaitingToBeAttached = true;
10145 this.installParentChangeDetector();
10146 }
10147 };
10148
10149 /* Setup */
10150
10151 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10152 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10153 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10154 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10155 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10156
10157 /* Static Properties */
10158
10159 OO.ui.TextInputWidget.static.validationPatterns = {
10160 'non-empty': /.+/,
10161 integer: /^\d+$/
10162 };
10163
10164 /* Events */
10165
10166 /**
10167 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10168 *
10169 * @event enter
10170 */
10171
10172 /* Methods */
10173
10174 /**
10175 * Handle icon mouse down events.
10176 *
10177 * @private
10178 * @param {jQuery.Event} e Mouse down event
10179 */
10180 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10181 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10182 this.focus();
10183 return false;
10184 }
10185 };
10186
10187 /**
10188 * Handle indicator mouse down events.
10189 *
10190 * @private
10191 * @param {jQuery.Event} e Mouse down event
10192 */
10193 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10194 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10195 this.focus();
10196 return false;
10197 }
10198 };
10199
10200 /**
10201 * Handle key press events.
10202 *
10203 * @private
10204 * @param {jQuery.Event} e Key press event
10205 * @fires enter If enter key is pressed
10206 */
10207 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10208 if ( e.which === OO.ui.Keys.ENTER ) {
10209 this.emit( 'enter', e );
10210 }
10211 };
10212
10213 /**
10214 * Handle blur events.
10215 *
10216 * @private
10217 * @param {jQuery.Event} e Blur event
10218 */
10219 OO.ui.TextInputWidget.prototype.onBlur = function () {
10220 this.setValidityFlag();
10221 };
10222
10223 /**
10224 * Handle focus events.
10225 *
10226 * @private
10227 * @param {jQuery.Event} e Focus event
10228 */
10229 OO.ui.TextInputWidget.prototype.onFocus = function () {
10230 if ( this.isWaitingToBeAttached ) {
10231 // If we've received focus, then we must be attached to the document, and if
10232 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10233 this.onElementAttach();
10234 }
10235 this.setValidityFlag( true );
10236 };
10237
10238 /**
10239 * Handle element attach events.
10240 *
10241 * @private
10242 * @param {jQuery.Event} e Element attach event
10243 */
10244 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10245 this.isWaitingToBeAttached = false;
10246 // Any previously calculated size is now probably invalid if we reattached elsewhere
10247 this.valCache = null;
10248 this.positionLabel();
10249 };
10250
10251 /**
10252 * Handle debounced change events.
10253 *
10254 * @param {string} value
10255 * @private
10256 */
10257 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10258 this.setValidityFlag();
10259 };
10260
10261 /**
10262 * Check if the input is {@link #readOnly read-only}.
10263 *
10264 * @return {boolean}
10265 */
10266 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10267 return this.readOnly;
10268 };
10269
10270 /**
10271 * Set the {@link #readOnly read-only} state of the input.
10272 *
10273 * @param {boolean} state Make input read-only
10274 * @chainable
10275 */
10276 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10277 this.readOnly = !!state;
10278 this.$input.prop( 'readOnly', this.readOnly );
10279 return this;
10280 };
10281
10282 /**
10283 * Check if the input is {@link #required required}.
10284 *
10285 * @return {boolean}
10286 */
10287 OO.ui.TextInputWidget.prototype.isRequired = function () {
10288 return this.required;
10289 };
10290
10291 /**
10292 * Set the {@link #required required} state of the input.
10293 *
10294 * @param {boolean} state Make input required
10295 * @chainable
10296 */
10297 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10298 this.required = !!state;
10299 if ( this.required ) {
10300 this.$input
10301 .prop( 'required', true )
10302 .attr( 'aria-required', 'true' );
10303 if ( this.getIndicator() === null ) {
10304 this.setIndicator( 'required' );
10305 }
10306 } else {
10307 this.$input
10308 .prop( 'required', false )
10309 .removeAttr( 'aria-required' );
10310 if ( this.getIndicator() === 'required' ) {
10311 this.setIndicator( null );
10312 }
10313 }
10314 return this;
10315 };
10316
10317 /**
10318 * Support function for making #onElementAttach work across browsers.
10319 *
10320 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10321 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10322 *
10323 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10324 * first time that the element gets attached to the documented.
10325 */
10326 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10327 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10328 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10329 widget = this;
10330
10331 if ( MutationObserver ) {
10332 // The new way. If only it wasn't so ugly.
10333
10334 if ( this.isElementAttached() ) {
10335 // Widget is attached already, do nothing. This breaks the functionality of this function when
10336 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10337 // would require observation of the whole document, which would hurt performance of other,
10338 // more important code.
10339 return;
10340 }
10341
10342 // Find topmost node in the tree
10343 topmostNode = this.$element[ 0 ];
10344 while ( topmostNode.parentNode ) {
10345 topmostNode = topmostNode.parentNode;
10346 }
10347
10348 // We have no way to detect the $element being attached somewhere without observing the entire
10349 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10350 // parent node of $element, and instead detect when $element is removed from it (and thus
10351 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10352 // doesn't get attached, we end up back here and create the parent.
10353
10354 mutationObserver = new MutationObserver( function ( mutations ) {
10355 var i, j, removedNodes;
10356 for ( i = 0; i < mutations.length; i++ ) {
10357 removedNodes = mutations[ i ].removedNodes;
10358 for ( j = 0; j < removedNodes.length; j++ ) {
10359 if ( removedNodes[ j ] === topmostNode ) {
10360 setTimeout( onRemove, 0 );
10361 return;
10362 }
10363 }
10364 }
10365 } );
10366
10367 onRemove = function () {
10368 // If the node was attached somewhere else, report it
10369 if ( widget.isElementAttached() ) {
10370 widget.onElementAttach();
10371 }
10372 mutationObserver.disconnect();
10373 widget.installParentChangeDetector();
10374 };
10375
10376 // Create a fake parent and observe it
10377 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10378 mutationObserver.observe( fakeParentNode, { childList: true } );
10379 } else {
10380 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10381 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10382 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10383 }
10384 };
10385
10386 /**
10387 * @inheritdoc
10388 * @protected
10389 */
10390 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10391 if ( this.getSaneType( config ) === 'number' ) {
10392 return $( '<input>' )
10393 .attr( 'step', 'any' )
10394 .attr( 'type', 'number' );
10395 } else {
10396 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10397 }
10398 };
10399
10400 /**
10401 * Get sanitized value for 'type' for given config.
10402 *
10403 * @param {Object} config Configuration options
10404 * @return {string|null}
10405 * @protected
10406 */
10407 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10408 var allowedTypes = [
10409 'text',
10410 'password',
10411 'email',
10412 'url',
10413 'number'
10414 ];
10415 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10416 };
10417
10418 /**
10419 * Focus the input and select a specified range within the text.
10420 *
10421 * @param {number} from Select from offset
10422 * @param {number} [to] Select to offset, defaults to from
10423 * @chainable
10424 */
10425 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10426 var isBackwards, start, end,
10427 input = this.$input[ 0 ];
10428
10429 to = to || from;
10430
10431 isBackwards = to < from;
10432 start = isBackwards ? to : from;
10433 end = isBackwards ? from : to;
10434
10435 this.focus();
10436
10437 try {
10438 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10439 } catch ( e ) {
10440 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10441 // Rather than expensively check if the input is attached every time, just check
10442 // if it was the cause of an error being thrown. If not, rethrow the error.
10443 if ( this.getElementDocument().body.contains( input ) ) {
10444 throw e;
10445 }
10446 }
10447 return this;
10448 };
10449
10450 /**
10451 * Get an object describing the current selection range in a directional manner
10452 *
10453 * @return {Object} Object containing 'from' and 'to' offsets
10454 */
10455 OO.ui.TextInputWidget.prototype.getRange = function () {
10456 var input = this.$input[ 0 ],
10457 start = input.selectionStart,
10458 end = input.selectionEnd,
10459 isBackwards = input.selectionDirection === 'backward';
10460
10461 return {
10462 from: isBackwards ? end : start,
10463 to: isBackwards ? start : end
10464 };
10465 };
10466
10467 /**
10468 * Get the length of the text input value.
10469 *
10470 * This could differ from the length of #getValue if the
10471 * value gets filtered
10472 *
10473 * @return {number} Input length
10474 */
10475 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10476 return this.$input[ 0 ].value.length;
10477 };
10478
10479 /**
10480 * Focus the input and select the entire text.
10481 *
10482 * @chainable
10483 */
10484 OO.ui.TextInputWidget.prototype.select = function () {
10485 return this.selectRange( 0, this.getInputLength() );
10486 };
10487
10488 /**
10489 * Focus the input and move the cursor to the start.
10490 *
10491 * @chainable
10492 */
10493 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10494 return this.selectRange( 0 );
10495 };
10496
10497 /**
10498 * Focus the input and move the cursor to the end.
10499 *
10500 * @chainable
10501 */
10502 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10503 return this.selectRange( this.getInputLength() );
10504 };
10505
10506 /**
10507 * Insert new content into the input.
10508 *
10509 * @param {string} content Content to be inserted
10510 * @chainable
10511 */
10512 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10513 var start, end,
10514 range = this.getRange(),
10515 value = this.getValue();
10516
10517 start = Math.min( range.from, range.to );
10518 end = Math.max( range.from, range.to );
10519
10520 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10521 this.selectRange( start + content.length );
10522 return this;
10523 };
10524
10525 /**
10526 * Insert new content either side of a selection.
10527 *
10528 * @param {string} pre Content to be inserted before the selection
10529 * @param {string} post Content to be inserted after the selection
10530 * @chainable
10531 */
10532 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10533 var start, end,
10534 range = this.getRange(),
10535 offset = pre.length;
10536
10537 start = Math.min( range.from, range.to );
10538 end = Math.max( range.from, range.to );
10539
10540 this.selectRange( start ).insertContent( pre );
10541 this.selectRange( offset + end ).insertContent( post );
10542
10543 this.selectRange( offset + start, offset + end );
10544 return this;
10545 };
10546
10547 /**
10548 * Set the validation pattern.
10549 *
10550 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10551 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10552 * value must contain only numbers).
10553 *
10554 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10555 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10556 */
10557 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10558 if ( validate instanceof RegExp || validate instanceof Function ) {
10559 this.validate = validate;
10560 } else {
10561 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10562 }
10563 };
10564
10565 /**
10566 * Sets the 'invalid' flag appropriately.
10567 *
10568 * @param {boolean} [isValid] Optionally override validation result
10569 */
10570 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10571 var widget = this,
10572 setFlag = function ( valid ) {
10573 if ( !valid ) {
10574 widget.$input.attr( 'aria-invalid', 'true' );
10575 } else {
10576 widget.$input.removeAttr( 'aria-invalid' );
10577 }
10578 widget.setFlags( { invalid: !valid } );
10579 };
10580
10581 if ( isValid !== undefined ) {
10582 setFlag( isValid );
10583 } else {
10584 this.getValidity().then( function () {
10585 setFlag( true );
10586 }, function () {
10587 setFlag( false );
10588 } );
10589 }
10590 };
10591
10592 /**
10593 * Get the validity of current value.
10594 *
10595 * This method returns a promise that resolves if the value is valid and rejects if
10596 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10597 *
10598 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10599 */
10600 OO.ui.TextInputWidget.prototype.getValidity = function () {
10601 var result;
10602
10603 function rejectOrResolve( valid ) {
10604 if ( valid ) {
10605 return $.Deferred().resolve().promise();
10606 } else {
10607 return $.Deferred().reject().promise();
10608 }
10609 }
10610
10611 // Check browser validity and reject if it is invalid
10612 if (
10613 this.$input[ 0 ].checkValidity !== undefined &&
10614 this.$input[ 0 ].checkValidity() === false
10615 ) {
10616 return rejectOrResolve( false );
10617 }
10618
10619 // Run our checks if the browser thinks the field is valid
10620 if ( this.validate instanceof Function ) {
10621 result = this.validate( this.getValue() );
10622 if ( result && $.isFunction( result.promise ) ) {
10623 return result.promise().then( function ( valid ) {
10624 return rejectOrResolve( valid );
10625 } );
10626 } else {
10627 return rejectOrResolve( result );
10628 }
10629 } else {
10630 return rejectOrResolve( this.getValue().match( this.validate ) );
10631 }
10632 };
10633
10634 /**
10635 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10636 *
10637 * @param {string} labelPosition Label position, 'before' or 'after'
10638 * @chainable
10639 */
10640 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10641 this.labelPosition = labelPosition;
10642 if ( this.label ) {
10643 // If there is no label and we only change the position, #updatePosition is a no-op,
10644 // but it takes really a lot of work to do nothing.
10645 this.updatePosition();
10646 }
10647 return this;
10648 };
10649
10650 /**
10651 * Update the position of the inline label.
10652 *
10653 * This method is called by #setLabelPosition, and can also be called on its own if
10654 * something causes the label to be mispositioned.
10655 *
10656 * @chainable
10657 */
10658 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10659 var after = this.labelPosition === 'after';
10660
10661 this.$element
10662 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10663 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10664
10665 this.valCache = null;
10666 this.scrollWidth = null;
10667 this.positionLabel();
10668
10669 return this;
10670 };
10671
10672 /**
10673 * Position the label by setting the correct padding on the input.
10674 *
10675 * @private
10676 * @chainable
10677 */
10678 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10679 var after, rtl, property, newCss;
10680
10681 if ( this.isWaitingToBeAttached ) {
10682 // #onElementAttach will be called soon, which calls this method
10683 return this;
10684 }
10685
10686 newCss = {
10687 'padding-right': '',
10688 'padding-left': ''
10689 };
10690
10691 if ( this.label ) {
10692 this.$element.append( this.$label );
10693 } else {
10694 this.$label.detach();
10695 // Clear old values if present
10696 this.$input.css( newCss );
10697 return;
10698 }
10699
10700 after = this.labelPosition === 'after';
10701 rtl = this.$element.css( 'direction' ) === 'rtl';
10702 property = after === rtl ? 'padding-left' : 'padding-right';
10703
10704 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10705 // We have to clear the padding on the other side, in case the element direction changed
10706 this.$input.css( newCss );
10707
10708 return this;
10709 };
10710
10711 /**
10712 * @class
10713 * @extends OO.ui.TextInputWidget
10714 *
10715 * @constructor
10716 * @param {Object} [config] Configuration options
10717 */
10718 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10719 config = $.extend( {
10720 icon: 'search'
10721 }, config );
10722
10723 // Parent constructor
10724 OO.ui.SearchInputWidget.parent.call( this, config );
10725
10726 // Events
10727 this.connect( this, {
10728 change: 'onChange'
10729 } );
10730
10731 // Initialization
10732 this.updateSearchIndicator();
10733 this.connect( this, {
10734 disable: 'onDisable'
10735 } );
10736 };
10737
10738 /* Setup */
10739
10740 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10741
10742 /* Methods */
10743
10744 /**
10745 * @inheritdoc
10746 * @protected
10747 */
10748 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10749 return 'search';
10750 };
10751
10752 /**
10753 * @inheritdoc
10754 */
10755 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10756 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10757 // Clear the text field
10758 this.setValue( '' );
10759 this.focus();
10760 return false;
10761 }
10762 };
10763
10764 /**
10765 * Update the 'clear' indicator displayed on type: 'search' text
10766 * fields, hiding it when the field is already empty or when it's not
10767 * editable.
10768 */
10769 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10770 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10771 this.setIndicator( null );
10772 } else {
10773 this.setIndicator( 'clear' );
10774 }
10775 };
10776
10777 /**
10778 * Handle change events.
10779 *
10780 * @private
10781 */
10782 OO.ui.SearchInputWidget.prototype.onChange = function () {
10783 this.updateSearchIndicator();
10784 };
10785
10786 /**
10787 * Handle disable events.
10788 *
10789 * @param {boolean} disabled Element is disabled
10790 * @private
10791 */
10792 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10793 this.updateSearchIndicator();
10794 };
10795
10796 /**
10797 * @inheritdoc
10798 */
10799 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10800 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10801 this.updateSearchIndicator();
10802 return this;
10803 };
10804
10805 /**
10806 * @class
10807 * @extends OO.ui.TextInputWidget
10808 *
10809 * @constructor
10810 * @param {Object} [config] Configuration options
10811 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10812 * specifies minimum number of rows to display.
10813 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10814 * Use the #maxRows config to specify a maximum number of displayed rows.
10815 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
10816 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10817 */
10818 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10819 config = $.extend( {
10820 type: 'text'
10821 }, config );
10822 config.multiline = false;
10823 // Parent constructor
10824 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10825
10826 // Properties
10827 this.multiline = true;
10828 this.autosize = !!config.autosize;
10829 this.minRows = config.rows !== undefined ? config.rows : '';
10830 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10831
10832 // Clone for resizing
10833 if ( this.autosize ) {
10834 this.$clone = this.$input
10835 .clone()
10836 .insertAfter( this.$input )
10837 .attr( 'aria-hidden', 'true' )
10838 .addClass( 'oo-ui-element-hidden' );
10839 }
10840
10841 // Events
10842 this.connect( this, {
10843 change: 'onChange'
10844 } );
10845
10846 // Initialization
10847 if ( this.multiline && config.rows ) {
10848 this.$input.attr( 'rows', config.rows );
10849 }
10850 if ( this.autosize ) {
10851 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
10852 this.isWaitingToBeAttached = true;
10853 this.installParentChangeDetector();
10854 }
10855 };
10856
10857 /* Setup */
10858
10859 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10860
10861 /* Static Methods */
10862
10863 /**
10864 * @inheritdoc
10865 */
10866 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10867 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10868 state.scrollTop = config.$input.scrollTop();
10869 return state;
10870 };
10871
10872 /* Methods */
10873
10874 /**
10875 * @inheritdoc
10876 */
10877 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10878 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10879 this.adjustSize();
10880 };
10881
10882 /**
10883 * Handle change events.
10884 *
10885 * @private
10886 */
10887 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10888 this.adjustSize();
10889 };
10890
10891 /**
10892 * @inheritdoc
10893 */
10894 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10895 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10896 this.adjustSize();
10897 };
10898
10899 /**
10900 * @inheritdoc
10901 *
10902 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
10903 */
10904 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
10905 if (
10906 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
10907 // Some platforms emit keycode 10 for ctrl+enter in a textarea
10908 e.which === 10
10909 ) {
10910 this.emit( 'enter', e );
10911 }
10912 };
10913
10914 /**
10915 * Automatically adjust the size of the text input.
10916 *
10917 * This only affects multiline inputs that are {@link #autosize autosized}.
10918 *
10919 * @chainable
10920 * @fires resize
10921 */
10922 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10923 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10924 idealHeight, newHeight, scrollWidth, property;
10925
10926 if ( this.$input.val() !== this.valCache ) {
10927 if ( this.autosize ) {
10928 this.$clone
10929 .val( this.$input.val() )
10930 .attr( 'rows', this.minRows )
10931 // Set inline height property to 0 to measure scroll height
10932 .css( 'height', 0 );
10933
10934 this.$clone.removeClass( 'oo-ui-element-hidden' );
10935
10936 this.valCache = this.$input.val();
10937
10938 scrollHeight = this.$clone[ 0 ].scrollHeight;
10939
10940 // Remove inline height property to measure natural heights
10941 this.$clone.css( 'height', '' );
10942 innerHeight = this.$clone.innerHeight();
10943 outerHeight = this.$clone.outerHeight();
10944
10945 // Measure max rows height
10946 this.$clone
10947 .attr( 'rows', this.maxRows )
10948 .css( 'height', 'auto' )
10949 .val( '' );
10950 maxInnerHeight = this.$clone.innerHeight();
10951
10952 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10953 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10954 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10955 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10956
10957 this.$clone.addClass( 'oo-ui-element-hidden' );
10958
10959 // Only apply inline height when expansion beyond natural height is needed
10960 // Use the difference between the inner and outer height as a buffer
10961 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10962 if ( newHeight !== this.styleHeight ) {
10963 this.$input.css( 'height', newHeight );
10964 this.styleHeight = newHeight;
10965 this.emit( 'resize' );
10966 }
10967 }
10968 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10969 if ( scrollWidth !== this.scrollWidth ) {
10970 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10971 // Reset
10972 this.$label.css( { right: '', left: '' } );
10973 this.$indicator.css( { right: '', left: '' } );
10974
10975 if ( scrollWidth ) {
10976 this.$indicator.css( property, scrollWidth );
10977 if ( this.labelPosition === 'after' ) {
10978 this.$label.css( property, scrollWidth );
10979 }
10980 }
10981
10982 this.scrollWidth = scrollWidth;
10983 this.positionLabel();
10984 }
10985 }
10986 return this;
10987 };
10988
10989 /**
10990 * @inheritdoc
10991 * @protected
10992 */
10993 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10994 return $( '<textarea>' );
10995 };
10996
10997 /**
10998 * Check if the input supports multiple lines.
10999 *
11000 * @return {boolean}
11001 */
11002 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
11003 return !!this.multiline;
11004 };
11005
11006 /**
11007 * Check if the input automatically adjusts its size.
11008 *
11009 * @return {boolean}
11010 */
11011 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11012 return !!this.autosize;
11013 };
11014
11015 /**
11016 * @inheritdoc
11017 */
11018 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11019 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11020 if ( state.scrollTop !== undefined ) {
11021 this.$input.scrollTop( state.scrollTop );
11022 }
11023 };
11024
11025 /**
11026 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11027 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11028 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11029 *
11030 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11031 * option, that option will appear to be selected.
11032 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11033 * input field.
11034 *
11035 * After the user chooses an option, its `data` will be used as a new value for the widget.
11036 * A `label` also can be specified for each option: if given, it will be shown instead of the
11037 * `data` in the dropdown menu.
11038 *
11039 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11040 *
11041 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11042 *
11043 * @example
11044 * // Example: A ComboBoxInputWidget.
11045 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11046 * value: 'Option 1',
11047 * options: [
11048 * { data: 'Option 1' },
11049 * { data: 'Option 2' },
11050 * { data: 'Option 3' }
11051 * ]
11052 * } );
11053 * $( 'body' ).append( comboBox.$element );
11054 *
11055 * @example
11056 * // Example: A ComboBoxInputWidget with additional option labels.
11057 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11058 * value: 'Option 1',
11059 * options: [
11060 * {
11061 * data: 'Option 1',
11062 * label: 'Option One'
11063 * },
11064 * {
11065 * data: 'Option 2',
11066 * label: 'Option Two'
11067 * },
11068 * {
11069 * data: 'Option 3',
11070 * label: 'Option Three'
11071 * }
11072 * ]
11073 * } );
11074 * $( 'body' ).append( comboBox.$element );
11075 *
11076 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11077 *
11078 * @class
11079 * @extends OO.ui.TextInputWidget
11080 *
11081 * @constructor
11082 * @param {Object} [config] Configuration options
11083 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11084 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11085 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11086 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11087 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11088 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11089 */
11090 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11091 // Configuration initialization
11092 config = $.extend( {
11093 autocomplete: false
11094 }, config );
11095
11096 // ComboBoxInputWidget shouldn't support `multiline`
11097 config.multiline = false;
11098
11099 // See InputWidget#reusePreInfuseDOM about `config.$input`
11100 if ( config.$input ) {
11101 config.$input.removeAttr( 'list' );
11102 }
11103
11104 // Parent constructor
11105 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11106
11107 // Properties
11108 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11109 this.dropdownButton = new OO.ui.ButtonWidget( {
11110 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11111 indicator: 'down',
11112 disabled: this.disabled
11113 } );
11114 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11115 {
11116 widget: this,
11117 input: this,
11118 $floatableContainer: this.$element,
11119 disabled: this.isDisabled()
11120 },
11121 config.menu
11122 ) );
11123
11124 // Events
11125 this.connect( this, {
11126 change: 'onInputChange',
11127 enter: 'onInputEnter'
11128 } );
11129 this.dropdownButton.connect( this, {
11130 click: 'onDropdownButtonClick'
11131 } );
11132 this.menu.connect( this, {
11133 choose: 'onMenuChoose',
11134 add: 'onMenuItemsChange',
11135 remove: 'onMenuItemsChange',
11136 toggle: 'onMenuToggle'
11137 } );
11138
11139 // Initialization
11140 this.$input.attr( {
11141 role: 'combobox',
11142 'aria-owns': this.menu.getElementId(),
11143 'aria-autocomplete': 'list'
11144 } );
11145 // Do not override options set via config.menu.items
11146 if ( config.options !== undefined ) {
11147 this.setOptions( config.options );
11148 }
11149 this.$field = $( '<div>' )
11150 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11151 .append( this.$input, this.dropdownButton.$element );
11152 this.$element
11153 .addClass( 'oo-ui-comboBoxInputWidget' )
11154 .append( this.$field );
11155 this.$overlay.append( this.menu.$element );
11156 this.onMenuItemsChange();
11157 };
11158
11159 /* Setup */
11160
11161 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11162
11163 /* Methods */
11164
11165 /**
11166 * Get the combobox's menu.
11167 *
11168 * @return {OO.ui.MenuSelectWidget} Menu widget
11169 */
11170 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11171 return this.menu;
11172 };
11173
11174 /**
11175 * Get the combobox's text input widget.
11176 *
11177 * @return {OO.ui.TextInputWidget} Text input widget
11178 */
11179 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11180 return this;
11181 };
11182
11183 /**
11184 * Handle input change events.
11185 *
11186 * @private
11187 * @param {string} value New value
11188 */
11189 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11190 var match = this.menu.findItemFromData( value );
11191
11192 this.menu.selectItem( match );
11193 if ( this.menu.findHighlightedItem() ) {
11194 this.menu.highlightItem( match );
11195 }
11196
11197 if ( !this.isDisabled() ) {
11198 this.menu.toggle( true );
11199 }
11200 };
11201
11202 /**
11203 * Handle input enter events.
11204 *
11205 * @private
11206 */
11207 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11208 if ( !this.isDisabled() ) {
11209 this.menu.toggle( false );
11210 }
11211 };
11212
11213 /**
11214 * Handle button click events.
11215 *
11216 * @private
11217 */
11218 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11219 this.menu.toggle();
11220 this.focus();
11221 };
11222
11223 /**
11224 * Handle menu choose events.
11225 *
11226 * @private
11227 * @param {OO.ui.OptionWidget} item Chosen item
11228 */
11229 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11230 this.setValue( item.getData() );
11231 };
11232
11233 /**
11234 * Handle menu item change events.
11235 *
11236 * @private
11237 */
11238 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11239 var match = this.menu.findItemFromData( this.getValue() );
11240 this.menu.selectItem( match );
11241 if ( this.menu.findHighlightedItem() ) {
11242 this.menu.highlightItem( match );
11243 }
11244 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11245 };
11246
11247 /**
11248 * Handle menu toggle events.
11249 *
11250 * @private
11251 * @param {boolean} isVisible Open state of the menu
11252 */
11253 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11254 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11255 };
11256
11257 /**
11258 * @inheritdoc
11259 */
11260 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11261 // Parent method
11262 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11263
11264 if ( this.dropdownButton ) {
11265 this.dropdownButton.setDisabled( this.isDisabled() );
11266 }
11267 if ( this.menu ) {
11268 this.menu.setDisabled( this.isDisabled() );
11269 }
11270
11271 return this;
11272 };
11273
11274 /**
11275 * Set the options available for this input.
11276 *
11277 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11278 * @chainable
11279 */
11280 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11281 this.getMenu()
11282 .clearItems()
11283 .addItems( options.map( function ( opt ) {
11284 return new OO.ui.MenuOptionWidget( {
11285 data: opt.data,
11286 label: opt.label !== undefined ? opt.label : opt.data
11287 } );
11288 } ) );
11289
11290 return this;
11291 };
11292
11293 /**
11294 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11295 * which is a widget that is specified by reference before any optional configuration settings.
11296 *
11297 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11298 *
11299 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11300 * A left-alignment is used for forms with many fields.
11301 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11302 * A right-alignment is used for long but familiar forms which users tab through,
11303 * verifying the current field with a quick glance at the label.
11304 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11305 * that users fill out from top to bottom.
11306 * - **inline**: The label is placed after the field-widget and aligned to the left.
11307 * An inline-alignment is best used with checkboxes or radio buttons.
11308 *
11309 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
11310 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11311 *
11312 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11313 *
11314 * @class
11315 * @extends OO.ui.Layout
11316 * @mixins OO.ui.mixin.LabelElement
11317 * @mixins OO.ui.mixin.TitledElement
11318 *
11319 * @constructor
11320 * @param {OO.ui.Widget} fieldWidget Field widget
11321 * @param {Object} [config] Configuration options
11322 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
11323 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
11324 * The array may contain strings or OO.ui.HtmlSnippet instances.
11325 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
11326 * The array may contain strings or OO.ui.HtmlSnippet instances.
11327 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11328 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11329 * For important messages, you are advised to use `notices`, as they are always shown.
11330 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11331 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11332 *
11333 * @throws {Error} An error is thrown if no widget is specified
11334 */
11335 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11336 // Allow passing positional parameters inside the config object
11337 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11338 config = fieldWidget;
11339 fieldWidget = config.fieldWidget;
11340 }
11341
11342 // Make sure we have required constructor arguments
11343 if ( fieldWidget === undefined ) {
11344 throw new Error( 'Widget not found' );
11345 }
11346
11347 // Configuration initialization
11348 config = $.extend( { align: 'left' }, config );
11349
11350 // Parent constructor
11351 OO.ui.FieldLayout.parent.call( this, config );
11352
11353 // Mixin constructors
11354 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11355 $label: $( '<label>' )
11356 } ) );
11357 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11358
11359 // Properties
11360 this.fieldWidget = fieldWidget;
11361 this.errors = [];
11362 this.notices = [];
11363 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11364 this.$messages = $( '<ul>' );
11365 this.$header = $( '<span>' );
11366 this.$body = $( '<div>' );
11367 this.align = null;
11368 if ( config.help ) {
11369 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11370 $overlay: config.$overlay,
11371 popup: {
11372 padded: true
11373 },
11374 classes: [ 'oo-ui-fieldLayout-help' ],
11375 framed: false,
11376 icon: 'info'
11377 } );
11378 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11379 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11380 } else {
11381 this.popupButtonWidget.getPopup().$body.text( config.help );
11382 }
11383 this.$help = this.popupButtonWidget.$element;
11384 } else {
11385 this.$help = $( [] );
11386 }
11387
11388 // Events
11389 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11390
11391 // Initialization
11392 if ( config.help ) {
11393 // Set the 'aria-describedby' attribute on the fieldWidget
11394 // Preference given to an input or a button
11395 (
11396 this.fieldWidget.$input ||
11397 this.fieldWidget.$button ||
11398 this.fieldWidget.$element
11399 ).attr(
11400 'aria-describedby',
11401 this.popupButtonWidget.getPopup().getBodyId()
11402 );
11403 }
11404 if ( this.fieldWidget.getInputId() ) {
11405 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11406 } else {
11407 this.$label.on( 'click', function () {
11408 this.fieldWidget.simulateLabelClick();
11409 }.bind( this ) );
11410 }
11411 this.$element
11412 .addClass( 'oo-ui-fieldLayout' )
11413 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11414 .append( this.$body );
11415 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11416 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11417 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11418 this.$field
11419 .addClass( 'oo-ui-fieldLayout-field' )
11420 .append( this.fieldWidget.$element );
11421
11422 this.setErrors( config.errors || [] );
11423 this.setNotices( config.notices || [] );
11424 this.setAlignment( config.align );
11425 // Call this again to take into account the widget's accessKey
11426 this.updateTitle();
11427 };
11428
11429 /* Setup */
11430
11431 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11432 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11433 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11434
11435 /* Methods */
11436
11437 /**
11438 * Handle field disable events.
11439 *
11440 * @private
11441 * @param {boolean} value Field is disabled
11442 */
11443 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11444 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11445 };
11446
11447 /**
11448 * Get the widget contained by the field.
11449 *
11450 * @return {OO.ui.Widget} Field widget
11451 */
11452 OO.ui.FieldLayout.prototype.getField = function () {
11453 return this.fieldWidget;
11454 };
11455
11456 /**
11457 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11458 * #setAlignment). Return `false` if it can't or if this can't be determined.
11459 *
11460 * @return {boolean}
11461 */
11462 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11463 // This is very simplistic, but should be good enough.
11464 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11465 };
11466
11467 /**
11468 * @protected
11469 * @param {string} kind 'error' or 'notice'
11470 * @param {string|OO.ui.HtmlSnippet} text
11471 * @return {jQuery}
11472 */
11473 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11474 var $listItem, $icon, message;
11475 $listItem = $( '<li>' );
11476 if ( kind === 'error' ) {
11477 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11478 $listItem.attr( 'role', 'alert' );
11479 } else if ( kind === 'notice' ) {
11480 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11481 } else {
11482 $icon = '';
11483 }
11484 message = new OO.ui.LabelWidget( { label: text } );
11485 $listItem
11486 .append( $icon, message.$element )
11487 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11488 return $listItem;
11489 };
11490
11491 /**
11492 * Set the field alignment mode.
11493 *
11494 * @private
11495 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11496 * @chainable
11497 */
11498 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11499 if ( value !== this.align ) {
11500 // Default to 'left'
11501 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11502 value = 'left';
11503 }
11504 // Validate
11505 if ( value === 'inline' && !this.isFieldInline() ) {
11506 value = 'top';
11507 }
11508 // Reorder elements
11509 if ( value === 'top' ) {
11510 this.$header.append( this.$help, this.$label );
11511 this.$body.append( this.$header, this.$field );
11512 } else if ( value === 'inline' ) {
11513 this.$header.append( this.$help, this.$label );
11514 this.$body.append( this.$field, this.$header );
11515 } else {
11516 this.$header.append( this.$label );
11517 this.$body.append( this.$header, this.$help, this.$field );
11518 }
11519 // Set classes. The following classes can be used here:
11520 // * oo-ui-fieldLayout-align-left
11521 // * oo-ui-fieldLayout-align-right
11522 // * oo-ui-fieldLayout-align-top
11523 // * oo-ui-fieldLayout-align-inline
11524 if ( this.align ) {
11525 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11526 }
11527 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11528 this.align = value;
11529 }
11530
11531 return this;
11532 };
11533
11534 /**
11535 * Set the list of error messages.
11536 *
11537 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11538 * The array may contain strings or OO.ui.HtmlSnippet instances.
11539 * @chainable
11540 */
11541 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11542 this.errors = errors.slice();
11543 this.updateMessages();
11544 return this;
11545 };
11546
11547 /**
11548 * Set the list of notice messages.
11549 *
11550 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11551 * The array may contain strings or OO.ui.HtmlSnippet instances.
11552 * @chainable
11553 */
11554 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11555 this.notices = notices.slice();
11556 this.updateMessages();
11557 return this;
11558 };
11559
11560 /**
11561 * Update the rendering of error and notice messages.
11562 *
11563 * @private
11564 */
11565 OO.ui.FieldLayout.prototype.updateMessages = function () {
11566 var i;
11567 this.$messages.empty();
11568
11569 if ( this.errors.length || this.notices.length ) {
11570 this.$body.after( this.$messages );
11571 } else {
11572 this.$messages.remove();
11573 return;
11574 }
11575
11576 for ( i = 0; i < this.notices.length; i++ ) {
11577 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11578 }
11579 for ( i = 0; i < this.errors.length; i++ ) {
11580 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11581 }
11582 };
11583
11584 /**
11585 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11586 * (This is a bit of a hack.)
11587 *
11588 * @protected
11589 * @param {string} title Tooltip label for 'title' attribute
11590 * @return {string}
11591 */
11592 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11593 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11594 return this.fieldWidget.formatTitleWithAccessKey( title );
11595 }
11596 return title;
11597 };
11598
11599 /**
11600 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11601 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11602 * is required and is specified before any optional configuration settings.
11603 *
11604 * Labels can be aligned in one of four ways:
11605 *
11606 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11607 * A left-alignment is used for forms with many fields.
11608 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11609 * A right-alignment is used for long but familiar forms which users tab through,
11610 * verifying the current field with a quick glance at the label.
11611 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11612 * that users fill out from top to bottom.
11613 * - **inline**: The label is placed after the field-widget and aligned to the left.
11614 * An inline-alignment is best used with checkboxes or radio buttons.
11615 *
11616 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11617 * text is specified.
11618 *
11619 * @example
11620 * // Example of an ActionFieldLayout
11621 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11622 * new OO.ui.TextInputWidget( {
11623 * placeholder: 'Field widget'
11624 * } ),
11625 * new OO.ui.ButtonWidget( {
11626 * label: 'Button'
11627 * } ),
11628 * {
11629 * label: 'An ActionFieldLayout. This label is aligned top',
11630 * align: 'top',
11631 * help: 'This is help text'
11632 * }
11633 * );
11634 *
11635 * $( 'body' ).append( actionFieldLayout.$element );
11636 *
11637 * @class
11638 * @extends OO.ui.FieldLayout
11639 *
11640 * @constructor
11641 * @param {OO.ui.Widget} fieldWidget Field widget
11642 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11643 * @param {Object} config
11644 */
11645 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11646 // Allow passing positional parameters inside the config object
11647 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11648 config = fieldWidget;
11649 fieldWidget = config.fieldWidget;
11650 buttonWidget = config.buttonWidget;
11651 }
11652
11653 // Parent constructor
11654 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11655
11656 // Properties
11657 this.buttonWidget = buttonWidget;
11658 this.$button = $( '<span>' );
11659 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11660
11661 // Initialization
11662 this.$element
11663 .addClass( 'oo-ui-actionFieldLayout' );
11664 this.$button
11665 .addClass( 'oo-ui-actionFieldLayout-button' )
11666 .append( this.buttonWidget.$element );
11667 this.$input
11668 .addClass( 'oo-ui-actionFieldLayout-input' )
11669 .append( this.fieldWidget.$element );
11670 this.$field
11671 .append( this.$input, this.$button );
11672 };
11673
11674 /* Setup */
11675
11676 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11677
11678 /**
11679 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11680 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11681 * configured with a label as well. For more information and examples,
11682 * please see the [OOUI documentation on MediaWiki][1].
11683 *
11684 * @example
11685 * // Example of a fieldset layout
11686 * var input1 = new OO.ui.TextInputWidget( {
11687 * placeholder: 'A text input field'
11688 * } );
11689 *
11690 * var input2 = new OO.ui.TextInputWidget( {
11691 * placeholder: 'A text input field'
11692 * } );
11693 *
11694 * var fieldset = new OO.ui.FieldsetLayout( {
11695 * label: 'Example of a fieldset layout'
11696 * } );
11697 *
11698 * fieldset.addItems( [
11699 * new OO.ui.FieldLayout( input1, {
11700 * label: 'Field One'
11701 * } ),
11702 * new OO.ui.FieldLayout( input2, {
11703 * label: 'Field Two'
11704 * } )
11705 * ] );
11706 * $( 'body' ).append( fieldset.$element );
11707 *
11708 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11709 *
11710 * @class
11711 * @extends OO.ui.Layout
11712 * @mixins OO.ui.mixin.IconElement
11713 * @mixins OO.ui.mixin.LabelElement
11714 * @mixins OO.ui.mixin.GroupElement
11715 *
11716 * @constructor
11717 * @param {Object} [config] Configuration options
11718 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11719 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11720 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11721 * For important messages, you are advised to use `notices`, as they are always shown.
11722 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11723 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11724 */
11725 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11726 // Configuration initialization
11727 config = config || {};
11728
11729 // Parent constructor
11730 OO.ui.FieldsetLayout.parent.call( this, config );
11731
11732 // Mixin constructors
11733 OO.ui.mixin.IconElement.call( this, config );
11734 OO.ui.mixin.LabelElement.call( this, config );
11735 OO.ui.mixin.GroupElement.call( this, config );
11736
11737 // Properties
11738 this.$header = $( '<legend>' );
11739 if ( config.help ) {
11740 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11741 $overlay: config.$overlay,
11742 popup: {
11743 padded: true
11744 },
11745 classes: [ 'oo-ui-fieldsetLayout-help' ],
11746 framed: false,
11747 icon: 'info'
11748 } );
11749 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11750 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11751 } else {
11752 this.popupButtonWidget.getPopup().$body.text( config.help );
11753 }
11754 this.$help = this.popupButtonWidget.$element;
11755 } else {
11756 this.$help = $( [] );
11757 }
11758
11759 // Initialization
11760 this.$header
11761 .addClass( 'oo-ui-fieldsetLayout-header' )
11762 .append( this.$icon, this.$label, this.$help );
11763 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11764 this.$element
11765 .addClass( 'oo-ui-fieldsetLayout' )
11766 .prepend( this.$header, this.$group );
11767 if ( Array.isArray( config.items ) ) {
11768 this.addItems( config.items );
11769 }
11770 };
11771
11772 /* Setup */
11773
11774 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11775 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11776 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11777 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11778
11779 /* Static Properties */
11780
11781 /**
11782 * @static
11783 * @inheritdoc
11784 */
11785 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11786
11787 /**
11788 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11789 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11790 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11791 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
11792 *
11793 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11794 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11795 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11796 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11797 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11798 * often have simplified APIs to match the capabilities of HTML forms.
11799 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
11800 *
11801 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
11802 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11803 *
11804 * @example
11805 * // Example of a form layout that wraps a fieldset layout
11806 * var input1 = new OO.ui.TextInputWidget( {
11807 * placeholder: 'Username'
11808 * } );
11809 * var input2 = new OO.ui.TextInputWidget( {
11810 * placeholder: 'Password',
11811 * type: 'password'
11812 * } );
11813 * var submit = new OO.ui.ButtonInputWidget( {
11814 * label: 'Submit'
11815 * } );
11816 *
11817 * var fieldset = new OO.ui.FieldsetLayout( {
11818 * label: 'A form layout'
11819 * } );
11820 * fieldset.addItems( [
11821 * new OO.ui.FieldLayout( input1, {
11822 * label: 'Username',
11823 * align: 'top'
11824 * } ),
11825 * new OO.ui.FieldLayout( input2, {
11826 * label: 'Password',
11827 * align: 'top'
11828 * } ),
11829 * new OO.ui.FieldLayout( submit )
11830 * ] );
11831 * var form = new OO.ui.FormLayout( {
11832 * items: [ fieldset ],
11833 * action: '/api/formhandler',
11834 * method: 'get'
11835 * } )
11836 * $( 'body' ).append( form.$element );
11837 *
11838 * @class
11839 * @extends OO.ui.Layout
11840 * @mixins OO.ui.mixin.GroupElement
11841 *
11842 * @constructor
11843 * @param {Object} [config] Configuration options
11844 * @cfg {string} [method] HTML form `method` attribute
11845 * @cfg {string} [action] HTML form `action` attribute
11846 * @cfg {string} [enctype] HTML form `enctype` attribute
11847 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11848 */
11849 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11850 var action;
11851
11852 // Configuration initialization
11853 config = config || {};
11854
11855 // Parent constructor
11856 OO.ui.FormLayout.parent.call( this, config );
11857
11858 // Mixin constructors
11859 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11860
11861 // Events
11862 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11863
11864 // Make sure the action is safe
11865 action = config.action;
11866 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11867 action = './' + action;
11868 }
11869
11870 // Initialization
11871 this.$element
11872 .addClass( 'oo-ui-formLayout' )
11873 .attr( {
11874 method: config.method,
11875 action: action,
11876 enctype: config.enctype
11877 } );
11878 if ( Array.isArray( config.items ) ) {
11879 this.addItems( config.items );
11880 }
11881 };
11882
11883 /* Setup */
11884
11885 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11886 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11887
11888 /* Events */
11889
11890 /**
11891 * A 'submit' event is emitted when the form is submitted.
11892 *
11893 * @event submit
11894 */
11895
11896 /* Static Properties */
11897
11898 /**
11899 * @static
11900 * @inheritdoc
11901 */
11902 OO.ui.FormLayout.static.tagName = 'form';
11903
11904 /* Methods */
11905
11906 /**
11907 * Handle form submit events.
11908 *
11909 * @private
11910 * @param {jQuery.Event} e Submit event
11911 * @fires submit
11912 */
11913 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11914 if ( this.emit( 'submit' ) ) {
11915 return false;
11916 }
11917 };
11918
11919 /**
11920 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11921 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11922 *
11923 * @example
11924 * // Example of a panel layout
11925 * var panel = new OO.ui.PanelLayout( {
11926 * expanded: false,
11927 * framed: true,
11928 * padded: true,
11929 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11930 * } );
11931 * $( 'body' ).append( panel.$element );
11932 *
11933 * @class
11934 * @extends OO.ui.Layout
11935 *
11936 * @constructor
11937 * @param {Object} [config] Configuration options
11938 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11939 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11940 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11941 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11942 */
11943 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11944 // Configuration initialization
11945 config = $.extend( {
11946 scrollable: false,
11947 padded: false,
11948 expanded: true,
11949 framed: false
11950 }, config );
11951
11952 // Parent constructor
11953 OO.ui.PanelLayout.parent.call( this, config );
11954
11955 // Initialization
11956 this.$element.addClass( 'oo-ui-panelLayout' );
11957 if ( config.scrollable ) {
11958 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11959 }
11960 if ( config.padded ) {
11961 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11962 }
11963 if ( config.expanded ) {
11964 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11965 }
11966 if ( config.framed ) {
11967 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11968 }
11969 };
11970
11971 /* Setup */
11972
11973 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11974
11975 /* Methods */
11976
11977 /**
11978 * Focus the panel layout
11979 *
11980 * The default implementation just focuses the first focusable element in the panel
11981 */
11982 OO.ui.PanelLayout.prototype.focus = function () {
11983 OO.ui.findFocusable( this.$element ).focus();
11984 };
11985
11986 /**
11987 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11988 * items), with small margins between them. Convenient when you need to put a number of block-level
11989 * widgets on a single line next to each other.
11990 *
11991 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11992 *
11993 * @example
11994 * // HorizontalLayout with a text input and a label
11995 * var layout = new OO.ui.HorizontalLayout( {
11996 * items: [
11997 * new OO.ui.LabelWidget( { label: 'Label' } ),
11998 * new OO.ui.TextInputWidget( { value: 'Text' } )
11999 * ]
12000 * } );
12001 * $( 'body' ).append( layout.$element );
12002 *
12003 * @class
12004 * @extends OO.ui.Layout
12005 * @mixins OO.ui.mixin.GroupElement
12006 *
12007 * @constructor
12008 * @param {Object} [config] Configuration options
12009 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12010 */
12011 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12012 // Configuration initialization
12013 config = config || {};
12014
12015 // Parent constructor
12016 OO.ui.HorizontalLayout.parent.call( this, config );
12017
12018 // Mixin constructors
12019 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12020
12021 // Initialization
12022 this.$element.addClass( 'oo-ui-horizontalLayout' );
12023 if ( Array.isArray( config.items ) ) {
12024 this.addItems( config.items );
12025 }
12026 };
12027
12028 /* Setup */
12029
12030 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12031 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12032
12033 }( OO ) );
12034
12035 //# sourceMappingURL=oojs-ui-core.js.map