Merge "RELEASE-NOTES: Add note for breaking CategoryMultiselectWidget"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOUI v0.27.4
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-06-27T17:25:08Z
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 * @param {Object} [config] Configuration options
337 * @return {OO.ui.Element}
338 * The `OO.ui.Element` corresponding to this (infusable) document node.
339 */
340 OO.ui.infuse = function ( idOrNode, config ) {
341 return OO.ui.Element.static.infuse( idOrNode, config );
342 };
343
344 ( function () {
345 /**
346 * Message store for the default implementation of OO.ui.msg
347 *
348 * Environments that provide a localization system should not use this, but should override
349 * OO.ui.msg altogether.
350 *
351 * @private
352 */
353 var messages = {
354 // Tool tip for a button that moves items in a list down one place
355 'ooui-outline-control-move-down': 'Move item down',
356 // Tool tip for a button that moves items in a list up one place
357 'ooui-outline-control-move-up': 'Move item up',
358 // Tool tip for a button that removes items from a list
359 'ooui-outline-control-remove': 'Remove item',
360 // Label for the toolbar group that contains a list of all other available tools
361 'ooui-toolbar-more': 'More',
362 // Label for the fake tool that expands the full list of tools in a toolbar group
363 'ooui-toolgroup-expand': 'More',
364 // Label for the fake tool that collapses the full list of tools in a toolbar group
365 'ooui-toolgroup-collapse': 'Fewer',
366 // Default label for the tooltip for the button that removes a tag item
367 'ooui-item-remove': 'Remove',
368 // Default label for the accept button of a confirmation dialog
369 'ooui-dialog-message-accept': 'OK',
370 // Default label for the reject button of a confirmation dialog
371 'ooui-dialog-message-reject': 'Cancel',
372 // Title for process dialog error description
373 'ooui-dialog-process-error': 'Something went wrong',
374 // Label for process dialog dismiss error button, visible when describing errors
375 'ooui-dialog-process-dismiss': 'Dismiss',
376 // Label for process dialog retry action button, visible when describing only recoverable errors
377 'ooui-dialog-process-retry': 'Try again',
378 // Label for process dialog retry action button, visible when describing only warnings
379 'ooui-dialog-process-continue': 'Continue',
380 // Label for the file selection widget's select file button
381 'ooui-selectfile-button-select': 'Select a file',
382 // Label for the file selection widget if file selection is not supported
383 'ooui-selectfile-not-supported': 'File selection is not supported',
384 // Label for the file selection widget when no file is currently selected
385 'ooui-selectfile-placeholder': 'No file is selected',
386 // Label for the file selection widget's drop target
387 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
388 };
389
390 /**
391 * Get a localized message.
392 *
393 * After the message key, message parameters may optionally be passed. In the default implementation,
394 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
395 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
396 * they support unnamed, ordered message parameters.
397 *
398 * In environments that provide a localization system, this function should be overridden to
399 * return the message translated in the user's language. The default implementation always returns
400 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
401 * follows.
402 *
403 * @example
404 * var i, iLen, button,
405 * messagePath = 'oojs-ui/dist/i18n/',
406 * languages = [ $.i18n().locale, 'ur', 'en' ],
407 * languageMap = {};
408 *
409 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
410 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
411 * }
412 *
413 * $.i18n().load( languageMap ).done( function() {
414 * // Replace the built-in `msg` only once we've loaded the internationalization.
415 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
416 * // you put off creating any widgets until this promise is complete, no English
417 * // will be displayed.
418 * OO.ui.msg = $.i18n;
419 *
420 * // A button displaying "OK" in the default locale
421 * button = new OO.ui.ButtonWidget( {
422 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
423 * icon: 'check'
424 * } );
425 * $( 'body' ).append( button.$element );
426 *
427 * // A button displaying "OK" in Urdu
428 * $.i18n().locale = 'ur';
429 * button = new OO.ui.ButtonWidget( {
430 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
431 * icon: 'check'
432 * } );
433 * $( 'body' ).append( button.$element );
434 * } );
435 *
436 * @param {string} key Message key
437 * @param {...Mixed} [params] Message parameters
438 * @return {string} Translated message with parameters substituted
439 */
440 OO.ui.msg = function ( key ) {
441 var message = messages[ key ],
442 params = Array.prototype.slice.call( arguments, 1 );
443 if ( typeof message === 'string' ) {
444 // Perform $1 substitution
445 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
446 var i = parseInt( n, 10 );
447 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
448 } );
449 } else {
450 // Return placeholder if message not found
451 message = '[' + key + ']';
452 }
453 return message;
454 };
455 }() );
456
457 /**
458 * Package a message and arguments for deferred resolution.
459 *
460 * Use this when you are statically specifying a message and the message may not yet be present.
461 *
462 * @param {string} key Message key
463 * @param {...Mixed} [params] Message parameters
464 * @return {Function} Function that returns the resolved message when executed
465 */
466 OO.ui.deferMsg = function () {
467 var args = arguments;
468 return function () {
469 return OO.ui.msg.apply( OO.ui, args );
470 };
471 };
472
473 /**
474 * Resolve a message.
475 *
476 * If the message is a function it will be executed, otherwise it will pass through directly.
477 *
478 * @param {Function|string} msg Deferred message, or message text
479 * @return {string} Resolved message
480 */
481 OO.ui.resolveMsg = function ( msg ) {
482 if ( $.isFunction( msg ) ) {
483 return msg();
484 }
485 return msg;
486 };
487
488 /**
489 * @param {string} url
490 * @return {boolean}
491 */
492 OO.ui.isSafeUrl = function ( url ) {
493 // Keep this function in sync with php/Tag.php
494 var i, protocolWhitelist;
495
496 function stringStartsWith( haystack, needle ) {
497 return haystack.substr( 0, needle.length ) === needle;
498 }
499
500 protocolWhitelist = [
501 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
502 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
503 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
504 ];
505
506 if ( url === '' ) {
507 return true;
508 }
509
510 for ( i = 0; i < protocolWhitelist.length; i++ ) {
511 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
512 return true;
513 }
514 }
515
516 // This matches '//' too
517 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
518 return true;
519 }
520 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
521 return true;
522 }
523
524 return false;
525 };
526
527 /**
528 * Check if the user has a 'mobile' device.
529 *
530 * For our purposes this means the user is primarily using an
531 * on-screen keyboard, touch input instead of a mouse and may
532 * have a physically small display.
533 *
534 * It is left up to implementors to decide how to compute this
535 * so the default implementation always returns false.
536 *
537 * @return {boolean} User is on a mobile device
538 */
539 OO.ui.isMobile = function () {
540 return false;
541 };
542
543 /**
544 * Get the additional spacing that should be taken into account when displaying elements that are
545 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
546 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
547 *
548 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
549 * the extra spacing from that edge of viewport (in pixels)
550 */
551 OO.ui.getViewportSpacing = function () {
552 return {
553 top: 0,
554 right: 0,
555 bottom: 0,
556 left: 0
557 };
558 };
559
560 /**
561 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
562 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
563 *
564 * @return {jQuery} Default overlay node
565 */
566 OO.ui.getDefaultOverlay = function () {
567 if ( !OO.ui.$defaultOverlay ) {
568 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
569 $( 'body' ).append( OO.ui.$defaultOverlay );
570 }
571 return OO.ui.$defaultOverlay;
572 };
573
574 /*!
575 * Mixin namespace.
576 */
577
578 /**
579 * Namespace for OOUI mixins.
580 *
581 * Mixins are named according to the type of object they are intended to
582 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
583 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
584 * is intended to be mixed in to an instance of OO.ui.Widget.
585 *
586 * @class
587 * @singleton
588 */
589 OO.ui.mixin = {};
590
591 /**
592 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
593 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
594 * connected to them and can't be interacted with.
595 *
596 * @abstract
597 * @class
598 *
599 * @constructor
600 * @param {Object} [config] Configuration options
601 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
602 * to the top level (e.g., the outermost div) of the element. See the [OOUI documentation on MediaWiki][2]
603 * for an example.
604 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
605 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
606 * @cfg {string} [text] Text to insert
607 * @cfg {Array} [content] An array of content elements to append (after #text).
608 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
609 * Instances of OO.ui.Element will have their $element appended.
610 * @cfg {jQuery} [$content] Content elements to append (after #text).
611 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
612 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
613 * Data can also be specified with the #setData method.
614 */
615 OO.ui.Element = function OoUiElement( config ) {
616 if ( OO.ui.isDemo ) {
617 this.initialConfig = config;
618 }
619 // Configuration initialization
620 config = config || {};
621
622 // Properties
623 this.$ = $;
624 this.elementId = null;
625 this.visible = true;
626 this.data = config.data;
627 this.$element = config.$element ||
628 $( document.createElement( this.getTagName() ) );
629 this.elementGroup = null;
630
631 // Initialization
632 if ( Array.isArray( config.classes ) ) {
633 this.$element.addClass( config.classes.join( ' ' ) );
634 }
635 if ( config.id ) {
636 this.setElementId( config.id );
637 }
638 if ( config.text ) {
639 this.$element.text( config.text );
640 }
641 if ( config.content ) {
642 // The `content` property treats plain strings as text; use an
643 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
644 // appropriate $element appended.
645 this.$element.append( config.content.map( function ( v ) {
646 if ( typeof v === 'string' ) {
647 // Escape string so it is properly represented in HTML.
648 return document.createTextNode( v );
649 } else if ( v instanceof OO.ui.HtmlSnippet ) {
650 // Bypass escaping.
651 return v.toString();
652 } else if ( v instanceof OO.ui.Element ) {
653 return v.$element;
654 }
655 return v;
656 } ) );
657 }
658 if ( config.$content ) {
659 // The `$content` property treats plain strings as HTML.
660 this.$element.append( config.$content );
661 }
662 };
663
664 /* Setup */
665
666 OO.initClass( OO.ui.Element );
667
668 /* Static Properties */
669
670 /**
671 * The name of the HTML tag used by the element.
672 *
673 * The static value may be ignored if the #getTagName method is overridden.
674 *
675 * @static
676 * @inheritable
677 * @property {string}
678 */
679 OO.ui.Element.static.tagName = 'div';
680
681 /* Static Methods */
682
683 /**
684 * Reconstitute a JavaScript object corresponding to a widget created
685 * by the PHP implementation.
686 *
687 * @param {string|HTMLElement|jQuery} idOrNode
688 * A DOM id (if a string) or node for the widget to infuse.
689 * @param {Object} [config] Configuration options
690 * @return {OO.ui.Element}
691 * The `OO.ui.Element` corresponding to this (infusable) document node.
692 * For `Tag` objects emitted on the HTML side (used occasionally for content)
693 * the value returned is a newly-created Element wrapping around the existing
694 * DOM node.
695 */
696 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
697 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
698 // Verify that the type matches up.
699 // FIXME: uncomment after T89721 is fixed, see T90929.
700 /*
701 if ( !( obj instanceof this['class'] ) ) {
702 throw new Error( 'Infusion type mismatch!' );
703 }
704 */
705 return obj;
706 };
707
708 /**
709 * Implementation helper for `infuse`; skips the type check and has an
710 * extra property so that only the top-level invocation touches the DOM.
711 *
712 * @private
713 * @param {string|HTMLElement|jQuery} idOrNode
714 * @param {Object} [config] Configuration options
715 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
716 * when the top-level widget of this infusion is inserted into DOM,
717 * replacing the original node; only used internally.
718 * @return {OO.ui.Element}
719 */
720 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
721 // look for a cached result of a previous infusion.
722 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
723 if ( typeof idOrNode === 'string' ) {
724 id = idOrNode;
725 $elem = $( document.getElementById( id ) );
726 } else {
727 $elem = $( idOrNode );
728 id = $elem.attr( 'id' );
729 }
730 if ( !$elem.length ) {
731 if ( typeof idOrNode === 'string' ) {
732 error = 'Widget not found: ' + idOrNode;
733 } else if ( idOrNode && idOrNode.selector ) {
734 error = 'Widget not found: ' + idOrNode.selector;
735 } else {
736 error = 'Widget not found';
737 }
738 throw new Error( error );
739 }
740 if ( $elem[ 0 ].oouiInfused ) {
741 $elem = $elem[ 0 ].oouiInfused;
742 }
743 data = $elem.data( 'ooui-infused' );
744 if ( data ) {
745 // cached!
746 if ( data === true ) {
747 throw new Error( 'Circular dependency! ' + id );
748 }
749 if ( domPromise ) {
750 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
751 state = data.constructor.static.gatherPreInfuseState( $elem, data );
752 // restore dynamic state after the new element is re-inserted into DOM under infused parent
753 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
754 infusedChildren = $elem.data( 'ooui-infused-children' );
755 if ( infusedChildren && infusedChildren.length ) {
756 infusedChildren.forEach( function ( data ) {
757 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
758 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
759 } );
760 }
761 }
762 return data;
763 }
764 data = $elem.attr( 'data-ooui' );
765 if ( !data ) {
766 throw new Error( 'No infusion data found: ' + id );
767 }
768 try {
769 data = JSON.parse( data );
770 } catch ( _ ) {
771 data = null;
772 }
773 if ( !( data && data._ ) ) {
774 throw new Error( 'No valid infusion data found: ' + id );
775 }
776 if ( data._ === 'Tag' ) {
777 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
778 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
779 }
780 parts = data._.split( '.' );
781 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
782 if ( cls === undefined ) {
783 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
784 }
785
786 // Verify that we're creating an OO.ui.Element instance
787 parent = cls.parent;
788
789 while ( parent !== undefined ) {
790 if ( parent === OO.ui.Element ) {
791 // Safe
792 break;
793 }
794
795 parent = parent.parent;
796 }
797
798 if ( parent !== OO.ui.Element ) {
799 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
800 }
801
802 if ( !domPromise ) {
803 top = $.Deferred();
804 domPromise = top.promise();
805 }
806 $elem.data( 'ooui-infused', true ); // prevent loops
807 data.id = id; // implicit
808 infusedChildren = [];
809 data = OO.copy( data, null, function deserialize( value ) {
810 var infused;
811 if ( OO.isPlainObject( value ) ) {
812 if ( value.tag ) {
813 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
814 infusedChildren.push( infused );
815 // Flatten the structure
816 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
817 infused.$element.removeData( 'ooui-infused-children' );
818 return infused;
819 }
820 if ( value.html !== undefined ) {
821 return new OO.ui.HtmlSnippet( value.html );
822 }
823 }
824 } );
825 // allow widgets to reuse parts of the DOM
826 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
827 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
828 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
829 // rebuild widget
830 // eslint-disable-next-line new-cap
831 obj = new cls( $.extend( {}, config, data ) );
832 // If anyone is holding a reference to the old DOM element,
833 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
834 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
835 $elem[ 0 ].oouiInfused = obj.$element;
836 // now replace old DOM with this new DOM.
837 if ( top ) {
838 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
839 // so only mutate the DOM if we need to.
840 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
841 $elem.replaceWith( obj.$element );
842 }
843 top.resolve();
844 }
845 obj.$element.data( 'ooui-infused', obj );
846 obj.$element.data( 'ooui-infused-children', infusedChildren );
847 // set the 'data-ooui' attribute so we can identify infused widgets
848 obj.$element.attr( 'data-ooui', '' );
849 // restore dynamic state after the new element is inserted into DOM
850 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
851 return obj;
852 };
853
854 /**
855 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
856 *
857 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
858 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
859 * constructor, which will be given the enhanced config.
860 *
861 * @protected
862 * @param {HTMLElement} node
863 * @param {Object} config
864 * @return {Object}
865 */
866 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
867 return config;
868 };
869
870 /**
871 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
872 * (and its children) that represent an Element of the same class and the given configuration,
873 * generated by the PHP implementation.
874 *
875 * This method is called just before `node` is detached from the DOM. The return value of this
876 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
877 * is inserted into DOM to replace `node`.
878 *
879 * @protected
880 * @param {HTMLElement} node
881 * @param {Object} config
882 * @return {Object}
883 */
884 OO.ui.Element.static.gatherPreInfuseState = function () {
885 return {};
886 };
887
888 /**
889 * Get a jQuery function within a specific document.
890 *
891 * @static
892 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
893 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
894 * not in an iframe
895 * @return {Function} Bound jQuery function
896 */
897 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
898 function wrapper( selector ) {
899 return $( selector, wrapper.context );
900 }
901
902 wrapper.context = this.getDocument( context );
903
904 if ( $iframe ) {
905 wrapper.$iframe = $iframe;
906 }
907
908 return wrapper;
909 };
910
911 /**
912 * Get the document of an element.
913 *
914 * @static
915 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
916 * @return {HTMLDocument|null} Document object
917 */
918 OO.ui.Element.static.getDocument = function ( obj ) {
919 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
920 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
921 // Empty jQuery selections might have a context
922 obj.context ||
923 // HTMLElement
924 obj.ownerDocument ||
925 // Window
926 obj.document ||
927 // HTMLDocument
928 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
929 null;
930 };
931
932 /**
933 * Get the window of an element or document.
934 *
935 * @static
936 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
937 * @return {Window} Window object
938 */
939 OO.ui.Element.static.getWindow = function ( obj ) {
940 var doc = this.getDocument( obj );
941 return doc.defaultView;
942 };
943
944 /**
945 * Get the direction of an element or document.
946 *
947 * @static
948 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
949 * @return {string} Text direction, either 'ltr' or 'rtl'
950 */
951 OO.ui.Element.static.getDir = function ( obj ) {
952 var isDoc, isWin;
953
954 if ( obj instanceof jQuery ) {
955 obj = obj[ 0 ];
956 }
957 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
958 isWin = obj.document !== undefined;
959 if ( isDoc || isWin ) {
960 if ( isWin ) {
961 obj = obj.document;
962 }
963 obj = obj.body;
964 }
965 return $( obj ).css( 'direction' );
966 };
967
968 /**
969 * Get the offset between two frames.
970 *
971 * TODO: Make this function not use recursion.
972 *
973 * @static
974 * @param {Window} from Window of the child frame
975 * @param {Window} [to=window] Window of the parent frame
976 * @param {Object} [offset] Offset to start with, used internally
977 * @return {Object} Offset object, containing left and top properties
978 */
979 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
980 var i, len, frames, frame, rect;
981
982 if ( !to ) {
983 to = window;
984 }
985 if ( !offset ) {
986 offset = { top: 0, left: 0 };
987 }
988 if ( from.parent === from ) {
989 return offset;
990 }
991
992 // Get iframe element
993 frames = from.parent.document.getElementsByTagName( 'iframe' );
994 for ( i = 0, len = frames.length; i < len; i++ ) {
995 if ( frames[ i ].contentWindow === from ) {
996 frame = frames[ i ];
997 break;
998 }
999 }
1000
1001 // Recursively accumulate offset values
1002 if ( frame ) {
1003 rect = frame.getBoundingClientRect();
1004 offset.left += rect.left;
1005 offset.top += rect.top;
1006 if ( from !== to ) {
1007 this.getFrameOffset( from.parent, offset );
1008 }
1009 }
1010 return offset;
1011 };
1012
1013 /**
1014 * Get the offset between two elements.
1015 *
1016 * The two elements may be in a different frame, but in that case the frame $element is in must
1017 * be contained in the frame $anchor is in.
1018 *
1019 * @static
1020 * @param {jQuery} $element Element whose position to get
1021 * @param {jQuery} $anchor Element to get $element's position relative to
1022 * @return {Object} Translated position coordinates, containing top and left properties
1023 */
1024 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1025 var iframe, iframePos,
1026 pos = $element.offset(),
1027 anchorPos = $anchor.offset(),
1028 elementDocument = this.getDocument( $element ),
1029 anchorDocument = this.getDocument( $anchor );
1030
1031 // If $element isn't in the same document as $anchor, traverse up
1032 while ( elementDocument !== anchorDocument ) {
1033 iframe = elementDocument.defaultView.frameElement;
1034 if ( !iframe ) {
1035 throw new Error( '$element frame is not contained in $anchor frame' );
1036 }
1037 iframePos = $( iframe ).offset();
1038 pos.left += iframePos.left;
1039 pos.top += iframePos.top;
1040 elementDocument = iframe.ownerDocument;
1041 }
1042 pos.left -= anchorPos.left;
1043 pos.top -= anchorPos.top;
1044 return pos;
1045 };
1046
1047 /**
1048 * Get element border sizes.
1049 *
1050 * @static
1051 * @param {HTMLElement} el Element to measure
1052 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1053 */
1054 OO.ui.Element.static.getBorders = function ( el ) {
1055 var doc = el.ownerDocument,
1056 win = doc.defaultView,
1057 style = win.getComputedStyle( el, null ),
1058 $el = $( el ),
1059 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1060 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1061 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1062 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1063
1064 return {
1065 top: top,
1066 left: left,
1067 bottom: bottom,
1068 right: right
1069 };
1070 };
1071
1072 /**
1073 * Get dimensions of an element or window.
1074 *
1075 * @static
1076 * @param {HTMLElement|Window} el Element to measure
1077 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1078 */
1079 OO.ui.Element.static.getDimensions = function ( el ) {
1080 var $el, $win,
1081 doc = el.ownerDocument || el.document,
1082 win = doc.defaultView;
1083
1084 if ( win === el || el === doc.documentElement ) {
1085 $win = $( win );
1086 return {
1087 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1088 scroll: {
1089 top: $win.scrollTop(),
1090 left: $win.scrollLeft()
1091 },
1092 scrollbar: { right: 0, bottom: 0 },
1093 rect: {
1094 top: 0,
1095 left: 0,
1096 bottom: $win.innerHeight(),
1097 right: $win.innerWidth()
1098 }
1099 };
1100 } else {
1101 $el = $( el );
1102 return {
1103 borders: this.getBorders( el ),
1104 scroll: {
1105 top: $el.scrollTop(),
1106 left: $el.scrollLeft()
1107 },
1108 scrollbar: {
1109 right: $el.innerWidth() - el.clientWidth,
1110 bottom: $el.innerHeight() - el.clientHeight
1111 },
1112 rect: el.getBoundingClientRect()
1113 };
1114 }
1115 };
1116
1117 /**
1118 * Get the number of pixels that an element's content is scrolled to the left.
1119 *
1120 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1121 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1122 *
1123 * This function smooths out browser inconsistencies (nicely described in the README at
1124 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1125 * with Firefox's 'scrollLeft', which seems the sanest.
1126 *
1127 * @static
1128 * @method
1129 * @param {HTMLElement|Window} el Element to measure
1130 * @return {number} Scroll position from the left.
1131 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1132 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1133 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1134 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1135 */
1136 OO.ui.Element.static.getScrollLeft = ( function () {
1137 var rtlScrollType = null;
1138
1139 function test() {
1140 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1141 definer = $definer[ 0 ];
1142
1143 $definer.appendTo( 'body' );
1144 if ( definer.scrollLeft > 0 ) {
1145 // Safari, Chrome
1146 rtlScrollType = 'default';
1147 } else {
1148 definer.scrollLeft = 1;
1149 if ( definer.scrollLeft === 0 ) {
1150 // Firefox, old Opera
1151 rtlScrollType = 'negative';
1152 } else {
1153 // Internet Explorer, Edge
1154 rtlScrollType = 'reverse';
1155 }
1156 }
1157 $definer.remove();
1158 }
1159
1160 return function getScrollLeft( el ) {
1161 var isRoot = el.window === el ||
1162 el === el.ownerDocument.body ||
1163 el === el.ownerDocument.documentElement,
1164 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1165 // All browsers use the correct scroll type ('negative') on the root, so don't
1166 // do any fixups when looking at the root element
1167 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1168
1169 if ( direction === 'rtl' ) {
1170 if ( rtlScrollType === null ) {
1171 test();
1172 }
1173 if ( rtlScrollType === 'reverse' ) {
1174 scrollLeft = -scrollLeft;
1175 } else if ( rtlScrollType === 'default' ) {
1176 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1177 }
1178 }
1179
1180 return scrollLeft;
1181 };
1182 }() );
1183
1184 /**
1185 * Get the root scrollable element of given element's document.
1186 *
1187 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1188 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1189 * lets us use 'body' or 'documentElement' based on what is working.
1190 *
1191 * https://code.google.com/p/chromium/issues/detail?id=303131
1192 *
1193 * @static
1194 * @param {HTMLElement} el Element to find root scrollable parent for
1195 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1196 * depending on browser
1197 */
1198 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1199 var scrollTop, body;
1200
1201 if ( OO.ui.scrollableElement === undefined ) {
1202 body = el.ownerDocument.body;
1203 scrollTop = body.scrollTop;
1204 body.scrollTop = 1;
1205
1206 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1207 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1208 if ( Math.round( body.scrollTop ) === 1 ) {
1209 body.scrollTop = scrollTop;
1210 OO.ui.scrollableElement = 'body';
1211 } else {
1212 OO.ui.scrollableElement = 'documentElement';
1213 }
1214 }
1215
1216 return el.ownerDocument[ OO.ui.scrollableElement ];
1217 };
1218
1219 /**
1220 * Get closest scrollable container.
1221 *
1222 * Traverses up until either a scrollable element or the root is reached, in which case the root
1223 * scrollable element will be returned (see #getRootScrollableElement).
1224 *
1225 * @static
1226 * @param {HTMLElement} el Element to find scrollable container for
1227 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1228 * @return {HTMLElement} Closest scrollable container
1229 */
1230 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1231 var i, val,
1232 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1233 // 'overflow-y' have different values, so we need to check the separate properties.
1234 props = [ 'overflow-x', 'overflow-y' ],
1235 $parent = $( el ).parent();
1236
1237 if ( dimension === 'x' || dimension === 'y' ) {
1238 props = [ 'overflow-' + dimension ];
1239 }
1240
1241 // Special case for the document root (which doesn't really have any scrollable container, since
1242 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1243 if ( $( el ).is( 'html, body' ) ) {
1244 return this.getRootScrollableElement( el );
1245 }
1246
1247 while ( $parent.length ) {
1248 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1249 return $parent[ 0 ];
1250 }
1251 i = props.length;
1252 while ( i-- ) {
1253 val = $parent.css( props[ i ] );
1254 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1255 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1256 // unintentionally perform a scroll in such case even if the application doesn't scroll
1257 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1258 // This could cause funny issues...
1259 if ( val === 'auto' || val === 'scroll' ) {
1260 return $parent[ 0 ];
1261 }
1262 }
1263 $parent = $parent.parent();
1264 }
1265 // The element is unattached... return something mostly sane
1266 return this.getRootScrollableElement( el );
1267 };
1268
1269 /**
1270 * Scroll element into view.
1271 *
1272 * @static
1273 * @param {HTMLElement} el Element to scroll into view
1274 * @param {Object} [config] Configuration options
1275 * @param {string} [config.duration='fast'] jQuery animation duration value
1276 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1277 * to scroll in both directions
1278 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1279 */
1280 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1281 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1282 deferred = $.Deferred();
1283
1284 // Configuration initialization
1285 config = config || {};
1286
1287 animations = {};
1288 container = this.getClosestScrollableContainer( el, config.direction );
1289 $container = $( container );
1290 elementDimensions = this.getDimensions( el );
1291 containerDimensions = this.getDimensions( container );
1292 $window = $( this.getWindow( el ) );
1293
1294 // Compute the element's position relative to the container
1295 if ( $container.is( 'html, body' ) ) {
1296 // If the scrollable container is the root, this is easy
1297 position = {
1298 top: elementDimensions.rect.top,
1299 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1300 left: elementDimensions.rect.left,
1301 right: $window.innerWidth() - elementDimensions.rect.right
1302 };
1303 } else {
1304 // Otherwise, we have to subtract el's coordinates from container's coordinates
1305 position = {
1306 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1307 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1308 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1309 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1310 };
1311 }
1312
1313 if ( !config.direction || config.direction === 'y' ) {
1314 if ( position.top < 0 ) {
1315 animations.scrollTop = containerDimensions.scroll.top + position.top;
1316 } else if ( position.top > 0 && position.bottom < 0 ) {
1317 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1318 }
1319 }
1320 if ( !config.direction || config.direction === 'x' ) {
1321 if ( position.left < 0 ) {
1322 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1323 } else if ( position.left > 0 && position.right < 0 ) {
1324 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1325 }
1326 }
1327 if ( !$.isEmptyObject( animations ) ) {
1328 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1329 $container.queue( function ( next ) {
1330 deferred.resolve();
1331 next();
1332 } );
1333 } else {
1334 deferred.resolve();
1335 }
1336 return deferred.promise();
1337 };
1338
1339 /**
1340 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1341 * and reserve space for them, because it probably doesn't.
1342 *
1343 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1344 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1345 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1346 * and then reattach (or show) them back.
1347 *
1348 * @static
1349 * @param {HTMLElement} el Element to reconsider the scrollbars on
1350 */
1351 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1352 var i, len, scrollLeft, scrollTop, nodes = [];
1353 // Save scroll position
1354 scrollLeft = el.scrollLeft;
1355 scrollTop = el.scrollTop;
1356 // Detach all children
1357 while ( el.firstChild ) {
1358 nodes.push( el.firstChild );
1359 el.removeChild( el.firstChild );
1360 }
1361 // Force reflow
1362 void el.offsetHeight;
1363 // Reattach all children
1364 for ( i = 0, len = nodes.length; i < len; i++ ) {
1365 el.appendChild( nodes[ i ] );
1366 }
1367 // Restore scroll position (no-op if scrollbars disappeared)
1368 el.scrollLeft = scrollLeft;
1369 el.scrollTop = scrollTop;
1370 };
1371
1372 /* Methods */
1373
1374 /**
1375 * Toggle visibility of an element.
1376 *
1377 * @param {boolean} [show] Make element visible, omit to toggle visibility
1378 * @fires visible
1379 * @chainable
1380 */
1381 OO.ui.Element.prototype.toggle = function ( show ) {
1382 show = show === undefined ? !this.visible : !!show;
1383
1384 if ( show !== this.isVisible() ) {
1385 this.visible = show;
1386 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1387 this.emit( 'toggle', show );
1388 }
1389
1390 return this;
1391 };
1392
1393 /**
1394 * Check if element is visible.
1395 *
1396 * @return {boolean} element is visible
1397 */
1398 OO.ui.Element.prototype.isVisible = function () {
1399 return this.visible;
1400 };
1401
1402 /**
1403 * Get element data.
1404 *
1405 * @return {Mixed} Element data
1406 */
1407 OO.ui.Element.prototype.getData = function () {
1408 return this.data;
1409 };
1410
1411 /**
1412 * Set element data.
1413 *
1414 * @param {Mixed} data Element data
1415 * @chainable
1416 */
1417 OO.ui.Element.prototype.setData = function ( data ) {
1418 this.data = data;
1419 return this;
1420 };
1421
1422 /**
1423 * Set the element has an 'id' attribute.
1424 *
1425 * @param {string} id
1426 * @chainable
1427 */
1428 OO.ui.Element.prototype.setElementId = function ( id ) {
1429 this.elementId = id;
1430 this.$element.attr( 'id', id );
1431 return this;
1432 };
1433
1434 /**
1435 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1436 * and return its value.
1437 *
1438 * @return {string}
1439 */
1440 OO.ui.Element.prototype.getElementId = function () {
1441 if ( this.elementId === null ) {
1442 this.setElementId( OO.ui.generateElementId() );
1443 }
1444 return this.elementId;
1445 };
1446
1447 /**
1448 * Check if element supports one or more methods.
1449 *
1450 * @param {string|string[]} methods Method or list of methods to check
1451 * @return {boolean} All methods are supported
1452 */
1453 OO.ui.Element.prototype.supports = function ( methods ) {
1454 var i, len,
1455 support = 0;
1456
1457 methods = Array.isArray( methods ) ? methods : [ methods ];
1458 for ( i = 0, len = methods.length; i < len; i++ ) {
1459 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1460 support++;
1461 }
1462 }
1463
1464 return methods.length === support;
1465 };
1466
1467 /**
1468 * Update the theme-provided classes.
1469 *
1470 * @localdoc This is called in element mixins and widget classes any time state changes.
1471 * Updating is debounced, minimizing overhead of changing multiple attributes and
1472 * guaranteeing that theme updates do not occur within an element's constructor
1473 */
1474 OO.ui.Element.prototype.updateThemeClasses = function () {
1475 OO.ui.theme.queueUpdateElementClasses( this );
1476 };
1477
1478 /**
1479 * Get the HTML tag name.
1480 *
1481 * Override this method to base the result on instance information.
1482 *
1483 * @return {string} HTML tag name
1484 */
1485 OO.ui.Element.prototype.getTagName = function () {
1486 return this.constructor.static.tagName;
1487 };
1488
1489 /**
1490 * Check if the element is attached to the DOM
1491 *
1492 * @return {boolean} The element is attached to the DOM
1493 */
1494 OO.ui.Element.prototype.isElementAttached = function () {
1495 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1496 };
1497
1498 /**
1499 * Get the DOM document.
1500 *
1501 * @return {HTMLDocument} Document object
1502 */
1503 OO.ui.Element.prototype.getElementDocument = function () {
1504 // Don't cache this in other ways either because subclasses could can change this.$element
1505 return OO.ui.Element.static.getDocument( this.$element );
1506 };
1507
1508 /**
1509 * Get the DOM window.
1510 *
1511 * @return {Window} Window object
1512 */
1513 OO.ui.Element.prototype.getElementWindow = function () {
1514 return OO.ui.Element.static.getWindow( this.$element );
1515 };
1516
1517 /**
1518 * Get closest scrollable container.
1519 *
1520 * @return {HTMLElement} Closest scrollable container
1521 */
1522 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1523 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1524 };
1525
1526 /**
1527 * Get group element is in.
1528 *
1529 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1530 */
1531 OO.ui.Element.prototype.getElementGroup = function () {
1532 return this.elementGroup;
1533 };
1534
1535 /**
1536 * Set group element is in.
1537 *
1538 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1539 * @chainable
1540 */
1541 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1542 this.elementGroup = group;
1543 return this;
1544 };
1545
1546 /**
1547 * Scroll element into view.
1548 *
1549 * @param {Object} [config] Configuration options
1550 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1551 */
1552 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1553 if (
1554 !this.isElementAttached() ||
1555 !this.isVisible() ||
1556 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1557 ) {
1558 return $.Deferred().resolve();
1559 }
1560 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1561 };
1562
1563 /**
1564 * Restore the pre-infusion dynamic state for this widget.
1565 *
1566 * This method is called after #$element has been inserted into DOM. The parameter is the return
1567 * value of #gatherPreInfuseState.
1568 *
1569 * @protected
1570 * @param {Object} state
1571 */
1572 OO.ui.Element.prototype.restorePreInfuseState = function () {
1573 };
1574
1575 /**
1576 * Wraps an HTML snippet for use with configuration values which default
1577 * to strings. This bypasses the default html-escaping done to string
1578 * values.
1579 *
1580 * @class
1581 *
1582 * @constructor
1583 * @param {string} [content] HTML content
1584 */
1585 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1586 // Properties
1587 this.content = content;
1588 };
1589
1590 /* Setup */
1591
1592 OO.initClass( OO.ui.HtmlSnippet );
1593
1594 /* Methods */
1595
1596 /**
1597 * Render into HTML.
1598 *
1599 * @return {string} Unchanged HTML snippet.
1600 */
1601 OO.ui.HtmlSnippet.prototype.toString = function () {
1602 return this.content;
1603 };
1604
1605 /**
1606 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1607 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1608 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1609 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1610 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1611 *
1612 * @abstract
1613 * @class
1614 * @extends OO.ui.Element
1615 * @mixins OO.EventEmitter
1616 *
1617 * @constructor
1618 * @param {Object} [config] Configuration options
1619 */
1620 OO.ui.Layout = function OoUiLayout( config ) {
1621 // Configuration initialization
1622 config = config || {};
1623
1624 // Parent constructor
1625 OO.ui.Layout.parent.call( this, config );
1626
1627 // Mixin constructors
1628 OO.EventEmitter.call( this );
1629
1630 // Initialization
1631 this.$element.addClass( 'oo-ui-layout' );
1632 };
1633
1634 /* Setup */
1635
1636 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1637 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1638
1639 /**
1640 * Widgets are compositions of one or more OOUI elements that users can both view
1641 * and interact with. All widgets can be configured and modified via a standard API,
1642 * and their state can change dynamically according to a model.
1643 *
1644 * @abstract
1645 * @class
1646 * @extends OO.ui.Element
1647 * @mixins OO.EventEmitter
1648 *
1649 * @constructor
1650 * @param {Object} [config] Configuration options
1651 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1652 * appearance reflects this state.
1653 */
1654 OO.ui.Widget = function OoUiWidget( config ) {
1655 // Initialize config
1656 config = $.extend( { disabled: false }, config );
1657
1658 // Parent constructor
1659 OO.ui.Widget.parent.call( this, config );
1660
1661 // Mixin constructors
1662 OO.EventEmitter.call( this );
1663
1664 // Properties
1665 this.disabled = null;
1666 this.wasDisabled = null;
1667
1668 // Initialization
1669 this.$element.addClass( 'oo-ui-widget' );
1670 this.setDisabled( !!config.disabled );
1671 };
1672
1673 /* Setup */
1674
1675 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1676 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1677
1678 /* Events */
1679
1680 /**
1681 * @event disable
1682 *
1683 * A 'disable' event is emitted when the disabled state of the widget changes
1684 * (i.e. on disable **and** enable).
1685 *
1686 * @param {boolean} disabled Widget is disabled
1687 */
1688
1689 /**
1690 * @event toggle
1691 *
1692 * A 'toggle' event is emitted when the visibility of the widget changes.
1693 *
1694 * @param {boolean} visible Widget is visible
1695 */
1696
1697 /* Methods */
1698
1699 /**
1700 * Check if the widget is disabled.
1701 *
1702 * @return {boolean} Widget is disabled
1703 */
1704 OO.ui.Widget.prototype.isDisabled = function () {
1705 return this.disabled;
1706 };
1707
1708 /**
1709 * Set the 'disabled' state of the widget.
1710 *
1711 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1712 *
1713 * @param {boolean} disabled Disable widget
1714 * @chainable
1715 */
1716 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1717 var isDisabled;
1718
1719 this.disabled = !!disabled;
1720 isDisabled = this.isDisabled();
1721 if ( isDisabled !== this.wasDisabled ) {
1722 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1723 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1724 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1725 this.emit( 'disable', isDisabled );
1726 this.updateThemeClasses();
1727 }
1728 this.wasDisabled = isDisabled;
1729
1730 return this;
1731 };
1732
1733 /**
1734 * Update the disabled state, in case of changes in parent widget.
1735 *
1736 * @chainable
1737 */
1738 OO.ui.Widget.prototype.updateDisabled = function () {
1739 this.setDisabled( this.disabled );
1740 return this;
1741 };
1742
1743 /**
1744 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1745 * value.
1746 *
1747 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1748 * instead.
1749 *
1750 * @return {string|null} The ID of the labelable element
1751 */
1752 OO.ui.Widget.prototype.getInputId = function () {
1753 return null;
1754 };
1755
1756 /**
1757 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1758 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1759 * override this method to provide intuitive, accessible behavior.
1760 *
1761 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1762 * Individual widgets may override it too.
1763 *
1764 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1765 * directly.
1766 */
1767 OO.ui.Widget.prototype.simulateLabelClick = function () {
1768 };
1769
1770 /**
1771 * Theme logic.
1772 *
1773 * @abstract
1774 * @class
1775 *
1776 * @constructor
1777 */
1778 OO.ui.Theme = function OoUiTheme() {
1779 this.elementClassesQueue = [];
1780 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1781 };
1782
1783 /* Setup */
1784
1785 OO.initClass( OO.ui.Theme );
1786
1787 /* Methods */
1788
1789 /**
1790 * Get a list of classes to be applied to a widget.
1791 *
1792 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1793 * otherwise state transitions will not work properly.
1794 *
1795 * @param {OO.ui.Element} element Element for which to get classes
1796 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1797 */
1798 OO.ui.Theme.prototype.getElementClasses = function () {
1799 return { on: [], off: [] };
1800 };
1801
1802 /**
1803 * Update CSS classes provided by the theme.
1804 *
1805 * For elements with theme logic hooks, this should be called any time there's a state change.
1806 *
1807 * @param {OO.ui.Element} element Element for which to update classes
1808 */
1809 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1810 var $elements = $( [] ),
1811 classes = this.getElementClasses( element );
1812
1813 if ( element.$icon ) {
1814 $elements = $elements.add( element.$icon );
1815 }
1816 if ( element.$indicator ) {
1817 $elements = $elements.add( element.$indicator );
1818 }
1819
1820 $elements
1821 .removeClass( classes.off.join( ' ' ) )
1822 .addClass( classes.on.join( ' ' ) );
1823 };
1824
1825 /**
1826 * @private
1827 */
1828 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1829 var i;
1830 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1831 this.updateElementClasses( this.elementClassesQueue[ i ] );
1832 }
1833 // Clear the queue
1834 this.elementClassesQueue = [];
1835 };
1836
1837 /**
1838 * Queue #updateElementClasses to be called for this element.
1839 *
1840 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1841 * to make them synchronous.
1842 *
1843 * @param {OO.ui.Element} element Element for which to update classes
1844 */
1845 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1846 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1847 // the most common case (this method is often called repeatedly for the same element).
1848 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1849 return;
1850 }
1851 this.elementClassesQueue.push( element );
1852 this.debouncedUpdateQueuedElementClasses();
1853 };
1854
1855 /**
1856 * Get the transition duration in milliseconds for dialogs opening/closing
1857 *
1858 * The dialog should be fully rendered this many milliseconds after the
1859 * ready process has executed.
1860 *
1861 * @return {number} Transition duration in milliseconds
1862 */
1863 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1864 return 0;
1865 };
1866
1867 /**
1868 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1869 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1870 * order in which users will navigate through the focusable elements via the "tab" key.
1871 *
1872 * @example
1873 * // TabIndexedElement is mixed into the ButtonWidget class
1874 * // to provide a tabIndex property.
1875 * var button1 = new OO.ui.ButtonWidget( {
1876 * label: 'fourth',
1877 * tabIndex: 4
1878 * } );
1879 * var button2 = new OO.ui.ButtonWidget( {
1880 * label: 'second',
1881 * tabIndex: 2
1882 * } );
1883 * var button3 = new OO.ui.ButtonWidget( {
1884 * label: 'third',
1885 * tabIndex: 3
1886 * } );
1887 * var button4 = new OO.ui.ButtonWidget( {
1888 * label: 'first',
1889 * tabIndex: 1
1890 * } );
1891 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1892 *
1893 * @abstract
1894 * @class
1895 *
1896 * @constructor
1897 * @param {Object} [config] Configuration options
1898 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1899 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1900 * functionality will be applied to it instead.
1901 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1902 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1903 * to remove the element from the tab-navigation flow.
1904 */
1905 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1906 // Configuration initialization
1907 config = $.extend( { tabIndex: 0 }, config );
1908
1909 // Properties
1910 this.$tabIndexed = null;
1911 this.tabIndex = null;
1912
1913 // Events
1914 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1915
1916 // Initialization
1917 this.setTabIndex( config.tabIndex );
1918 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1919 };
1920
1921 /* Setup */
1922
1923 OO.initClass( OO.ui.mixin.TabIndexedElement );
1924
1925 /* Methods */
1926
1927 /**
1928 * Set the element that should use the tabindex functionality.
1929 *
1930 * This method is used to retarget a tabindex mixin so that its functionality applies
1931 * to the specified element. If an element is currently using the functionality, the mixin’s
1932 * effect on that element is removed before the new element is set up.
1933 *
1934 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1935 * @chainable
1936 */
1937 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1938 var tabIndex = this.tabIndex;
1939 // Remove attributes from old $tabIndexed
1940 this.setTabIndex( null );
1941 // Force update of new $tabIndexed
1942 this.$tabIndexed = $tabIndexed;
1943 this.tabIndex = tabIndex;
1944 return this.updateTabIndex();
1945 };
1946
1947 /**
1948 * Set the value of the tabindex.
1949 *
1950 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1951 * @chainable
1952 */
1953 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1954 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1955
1956 if ( this.tabIndex !== tabIndex ) {
1957 this.tabIndex = tabIndex;
1958 this.updateTabIndex();
1959 }
1960
1961 return this;
1962 };
1963
1964 /**
1965 * Update the `tabindex` attribute, in case of changes to tab index or
1966 * disabled state.
1967 *
1968 * @private
1969 * @chainable
1970 */
1971 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1972 if ( this.$tabIndexed ) {
1973 if ( this.tabIndex !== null ) {
1974 // Do not index over disabled elements
1975 this.$tabIndexed.attr( {
1976 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1977 // Support: ChromeVox and NVDA
1978 // These do not seem to inherit aria-disabled from parent elements
1979 'aria-disabled': this.isDisabled().toString()
1980 } );
1981 } else {
1982 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1983 }
1984 }
1985 return this;
1986 };
1987
1988 /**
1989 * Handle disable events.
1990 *
1991 * @private
1992 * @param {boolean} disabled Element is disabled
1993 */
1994 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1995 this.updateTabIndex();
1996 };
1997
1998 /**
1999 * Get the value of the tabindex.
2000 *
2001 * @return {number|null} Tabindex value
2002 */
2003 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2004 return this.tabIndex;
2005 };
2006
2007 /**
2008 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2009 *
2010 * If the element already has an ID then that is returned, otherwise unique ID is
2011 * generated, set on the element, and returned.
2012 *
2013 * @return {string|null} The ID of the focusable element
2014 */
2015 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2016 var id;
2017
2018 if ( !this.$tabIndexed ) {
2019 return null;
2020 }
2021 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2022 return null;
2023 }
2024
2025 id = this.$tabIndexed.attr( 'id' );
2026 if ( id === undefined ) {
2027 id = OO.ui.generateElementId();
2028 this.$tabIndexed.attr( 'id', id );
2029 }
2030
2031 return id;
2032 };
2033
2034 /**
2035 * Whether the node is 'labelable' according to the HTML spec
2036 * (i.e., whether it can be interacted with through a `<label for="…">`).
2037 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2038 *
2039 * @private
2040 * @param {jQuery} $node
2041 * @return {boolean}
2042 */
2043 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2044 var
2045 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2046 tagName = $node.prop( 'tagName' ).toLowerCase();
2047
2048 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2049 return true;
2050 }
2051 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2052 return true;
2053 }
2054 return false;
2055 };
2056
2057 /**
2058 * Focus this element.
2059 *
2060 * @chainable
2061 */
2062 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2063 if ( !this.isDisabled() ) {
2064 this.$tabIndexed.focus();
2065 }
2066 return this;
2067 };
2068
2069 /**
2070 * Blur this element.
2071 *
2072 * @chainable
2073 */
2074 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2075 this.$tabIndexed.blur();
2076 return this;
2077 };
2078
2079 /**
2080 * @inheritdoc OO.ui.Widget
2081 */
2082 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2083 this.focus();
2084 };
2085
2086 /**
2087 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2088 * interface element that can be configured with access keys for accessibility.
2089 * See the [OOUI documentation on MediaWiki] [1] for examples.
2090 *
2091 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2092 *
2093 * @abstract
2094 * @class
2095 *
2096 * @constructor
2097 * @param {Object} [config] Configuration options
2098 * @cfg {jQuery} [$button] The button element created by the class.
2099 * If this configuration is omitted, the button element will use a generated `<a>`.
2100 * @cfg {boolean} [framed=true] Render the button with a frame
2101 */
2102 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2103 // Configuration initialization
2104 config = config || {};
2105
2106 // Properties
2107 this.$button = null;
2108 this.framed = null;
2109 this.active = config.active !== undefined && config.active;
2110 this.onMouseUpHandler = this.onMouseUp.bind( this );
2111 this.onMouseDownHandler = this.onMouseDown.bind( this );
2112 this.onKeyDownHandler = this.onKeyDown.bind( this );
2113 this.onKeyUpHandler = this.onKeyUp.bind( this );
2114 this.onClickHandler = this.onClick.bind( this );
2115 this.onKeyPressHandler = this.onKeyPress.bind( this );
2116
2117 // Initialization
2118 this.$element.addClass( 'oo-ui-buttonElement' );
2119 this.toggleFramed( config.framed === undefined || config.framed );
2120 this.setButtonElement( config.$button || $( '<a>' ) );
2121 };
2122
2123 /* Setup */
2124
2125 OO.initClass( OO.ui.mixin.ButtonElement );
2126
2127 /* Static Properties */
2128
2129 /**
2130 * Cancel mouse down events.
2131 *
2132 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2133 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2134 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2135 * parent widget.
2136 *
2137 * @static
2138 * @inheritable
2139 * @property {boolean}
2140 */
2141 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2142
2143 /* Events */
2144
2145 /**
2146 * A 'click' event is emitted when the button element is clicked.
2147 *
2148 * @event click
2149 */
2150
2151 /* Methods */
2152
2153 /**
2154 * Set the button element.
2155 *
2156 * This method is used to retarget a button mixin so that its functionality applies to
2157 * the specified button element instead of the one created by the class. If a button element
2158 * is already set, the method will remove the mixin’s effect on that element.
2159 *
2160 * @param {jQuery} $button Element to use as button
2161 */
2162 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2163 if ( this.$button ) {
2164 this.$button
2165 .removeClass( 'oo-ui-buttonElement-button' )
2166 .removeAttr( 'role accesskey' )
2167 .off( {
2168 mousedown: this.onMouseDownHandler,
2169 keydown: this.onKeyDownHandler,
2170 click: this.onClickHandler,
2171 keypress: this.onKeyPressHandler
2172 } );
2173 }
2174
2175 this.$button = $button
2176 .addClass( 'oo-ui-buttonElement-button' )
2177 .on( {
2178 mousedown: this.onMouseDownHandler,
2179 keydown: this.onKeyDownHandler,
2180 click: this.onClickHandler,
2181 keypress: this.onKeyPressHandler
2182 } );
2183
2184 // Add `role="button"` on `<a>` elements, where it's needed
2185 // `toUppercase()` is added for XHTML documents
2186 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2187 this.$button.attr( 'role', 'button' );
2188 }
2189 };
2190
2191 /**
2192 * Handles mouse down events.
2193 *
2194 * @protected
2195 * @param {jQuery.Event} e Mouse down event
2196 */
2197 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2198 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2199 return;
2200 }
2201 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2202 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2203 // reliably remove the pressed class
2204 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2205 // Prevent change of focus unless specifically configured otherwise
2206 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2207 return false;
2208 }
2209 };
2210
2211 /**
2212 * Handles mouse up events.
2213 *
2214 * @protected
2215 * @param {MouseEvent} e Mouse up event
2216 */
2217 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2218 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2219 return;
2220 }
2221 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2222 // Stop listening for mouseup, since we only needed this once
2223 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2224 };
2225
2226 /**
2227 * Handles mouse click events.
2228 *
2229 * @protected
2230 * @param {jQuery.Event} e Mouse click event
2231 * @fires click
2232 */
2233 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2234 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2235 if ( this.emit( 'click' ) ) {
2236 return false;
2237 }
2238 }
2239 };
2240
2241 /**
2242 * Handles key down events.
2243 *
2244 * @protected
2245 * @param {jQuery.Event} e Key down event
2246 */
2247 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2248 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2249 return;
2250 }
2251 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2252 // Run the keyup handler no matter where the key is when the button is let go, so we can
2253 // reliably remove the pressed class
2254 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2255 };
2256
2257 /**
2258 * Handles key up events.
2259 *
2260 * @protected
2261 * @param {KeyboardEvent} e Key up event
2262 */
2263 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2264 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2265 return;
2266 }
2267 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2268 // Stop listening for keyup, since we only needed this once
2269 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2270 };
2271
2272 /**
2273 * Handles key press events.
2274 *
2275 * @protected
2276 * @param {jQuery.Event} e Key press event
2277 * @fires click
2278 */
2279 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2280 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2281 if ( this.emit( 'click' ) ) {
2282 return false;
2283 }
2284 }
2285 };
2286
2287 /**
2288 * Check if button has a frame.
2289 *
2290 * @return {boolean} Button is framed
2291 */
2292 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2293 return this.framed;
2294 };
2295
2296 /**
2297 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2298 *
2299 * @param {boolean} [framed] Make button framed, omit to toggle
2300 * @chainable
2301 */
2302 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2303 framed = framed === undefined ? !this.framed : !!framed;
2304 if ( framed !== this.framed ) {
2305 this.framed = framed;
2306 this.$element
2307 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2308 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2309 this.updateThemeClasses();
2310 }
2311
2312 return this;
2313 };
2314
2315 /**
2316 * Set the button's active state.
2317 *
2318 * The active state can be set on:
2319 *
2320 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2321 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2322 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2323 *
2324 * @protected
2325 * @param {boolean} value Make button active
2326 * @chainable
2327 */
2328 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2329 this.active = !!value;
2330 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2331 this.updateThemeClasses();
2332 return this;
2333 };
2334
2335 /**
2336 * Check if the button is active
2337 *
2338 * @protected
2339 * @return {boolean} The button is active
2340 */
2341 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2342 return this.active;
2343 };
2344
2345 /**
2346 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2347 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2348 * items from the group is done through the interface the class provides.
2349 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2350 *
2351 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2352 *
2353 * @abstract
2354 * @mixins OO.EmitterList
2355 * @class
2356 *
2357 * @constructor
2358 * @param {Object} [config] Configuration options
2359 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2360 * is omitted, the group element will use a generated `<div>`.
2361 */
2362 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2363 // Configuration initialization
2364 config = config || {};
2365
2366 // Mixin constructors
2367 OO.EmitterList.call( this, config );
2368
2369 // Properties
2370 this.$group = null;
2371
2372 // Initialization
2373 this.setGroupElement( config.$group || $( '<div>' ) );
2374 };
2375
2376 /* Setup */
2377
2378 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2379
2380 /* Events */
2381
2382 /**
2383 * @event change
2384 *
2385 * A change event is emitted when the set of selected items changes.
2386 *
2387 * @param {OO.ui.Element[]} items Items currently in the group
2388 */
2389
2390 /* Methods */
2391
2392 /**
2393 * Set the group element.
2394 *
2395 * If an element is already set, items will be moved to the new element.
2396 *
2397 * @param {jQuery} $group Element to use as group
2398 */
2399 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2400 var i, len;
2401
2402 this.$group = $group;
2403 for ( i = 0, len = this.items.length; i < len; i++ ) {
2404 this.$group.append( this.items[ i ].$element );
2405 }
2406 };
2407
2408 /**
2409 * Find an item by its data.
2410 *
2411 * Only the first item with matching data will be returned. To return all matching items,
2412 * use the #findItemsFromData method.
2413 *
2414 * @param {Object} data Item data to search for
2415 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2416 */
2417 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2418 var i, len, item,
2419 hash = OO.getHash( data );
2420
2421 for ( i = 0, len = this.items.length; i < len; i++ ) {
2422 item = this.items[ i ];
2423 if ( hash === OO.getHash( item.getData() ) ) {
2424 return item;
2425 }
2426 }
2427
2428 return null;
2429 };
2430
2431 /**
2432 * Find items by their data.
2433 *
2434 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2435 *
2436 * @param {Object} data Item data to search for
2437 * @return {OO.ui.Element[]} Items with equivalent data
2438 */
2439 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2440 var i, len, item,
2441 hash = OO.getHash( data ),
2442 items = [];
2443
2444 for ( i = 0, len = this.items.length; i < len; i++ ) {
2445 item = this.items[ i ];
2446 if ( hash === OO.getHash( item.getData() ) ) {
2447 items.push( item );
2448 }
2449 }
2450
2451 return items;
2452 };
2453
2454 /**
2455 * Add items to the group.
2456 *
2457 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2458 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2459 *
2460 * @param {OO.ui.Element[]} items An array of items to add to the group
2461 * @param {number} [index] Index of the insertion point
2462 * @chainable
2463 */
2464 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2465 // Mixin method
2466 OO.EmitterList.prototype.addItems.call( this, items, index );
2467
2468 this.emit( 'change', this.getItems() );
2469 return this;
2470 };
2471
2472 /**
2473 * @inheritdoc
2474 */
2475 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2476 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2477 this.insertItemElements( items, newIndex );
2478
2479 // Mixin method
2480 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2481
2482 return newIndex;
2483 };
2484
2485 /**
2486 * @inheritdoc
2487 */
2488 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2489 item.setElementGroup( this );
2490 this.insertItemElements( item, index );
2491
2492 // Mixin method
2493 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2494
2495 return index;
2496 };
2497
2498 /**
2499 * Insert elements into the group
2500 *
2501 * @private
2502 * @param {OO.ui.Element} itemWidget Item to insert
2503 * @param {number} index Insertion index
2504 */
2505 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2506 if ( index === undefined || index < 0 || index >= this.items.length ) {
2507 this.$group.append( itemWidget.$element );
2508 } else if ( index === 0 ) {
2509 this.$group.prepend( itemWidget.$element );
2510 } else {
2511 this.items[ index ].$element.before( itemWidget.$element );
2512 }
2513 };
2514
2515 /**
2516 * Remove the specified items from a group.
2517 *
2518 * Removed items are detached (not removed) from the DOM so that they may be reused.
2519 * To remove all items from a group, you may wish to use the #clearItems method instead.
2520 *
2521 * @param {OO.ui.Element[]} items An array of items to remove
2522 * @chainable
2523 */
2524 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2525 var i, len, item, index;
2526
2527 // Remove specific items elements
2528 for ( i = 0, len = items.length; i < len; i++ ) {
2529 item = items[ i ];
2530 index = this.items.indexOf( item );
2531 if ( index !== -1 ) {
2532 item.setElementGroup( null );
2533 item.$element.detach();
2534 }
2535 }
2536
2537 // Mixin method
2538 OO.EmitterList.prototype.removeItems.call( this, items );
2539
2540 this.emit( 'change', this.getItems() );
2541 return this;
2542 };
2543
2544 /**
2545 * Clear all items from the group.
2546 *
2547 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2548 * To remove only a subset of items from a group, use the #removeItems method.
2549 *
2550 * @chainable
2551 */
2552 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2553 var i, len;
2554
2555 // Remove all item elements
2556 for ( i = 0, len = this.items.length; i < len; i++ ) {
2557 this.items[ i ].setElementGroup( null );
2558 this.items[ i ].$element.detach();
2559 }
2560
2561 // Mixin method
2562 OO.EmitterList.prototype.clearItems.call( this );
2563
2564 this.emit( 'change', this.getItems() );
2565 return this;
2566 };
2567
2568 /**
2569 * IconElement is often mixed into other classes to generate an icon.
2570 * Icons are graphics, about the size of normal text. They are used to aid the user
2571 * in locating a control or to convey information in a space-efficient way. See the
2572 * [OOUI documentation on MediaWiki] [1] for a list of icons
2573 * included in the library.
2574 *
2575 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2576 *
2577 * @abstract
2578 * @class
2579 *
2580 * @constructor
2581 * @param {Object} [config] Configuration options
2582 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2583 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2584 * the icon element be set to an existing icon instead of the one generated by this class, set a
2585 * value using a jQuery selection. For example:
2586 *
2587 * // Use a <div> tag instead of a <span>
2588 * $icon: $("<div>")
2589 * // Use an existing icon element instead of the one generated by the class
2590 * $icon: this.$element
2591 * // Use an icon element from a child widget
2592 * $icon: this.childwidget.$element
2593 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2594 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2595 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2596 * by the user's language.
2597 *
2598 * Example of an i18n map:
2599 *
2600 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2601 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2602 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2603 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2604 * text. The icon title is displayed when users move the mouse over the icon.
2605 */
2606 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2607 // Configuration initialization
2608 config = config || {};
2609
2610 // Properties
2611 this.$icon = null;
2612 this.icon = null;
2613 this.iconTitle = null;
2614
2615 // Initialization
2616 this.setIcon( config.icon || this.constructor.static.icon );
2617 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2618 this.setIconElement( config.$icon || $( '<span>' ) );
2619 };
2620
2621 /* Setup */
2622
2623 OO.initClass( OO.ui.mixin.IconElement );
2624
2625 /* Static Properties */
2626
2627 /**
2628 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2629 * for i18n purposes and contains a `default` icon name and additional names keyed by
2630 * language code. The `default` name is used when no icon is keyed by the user's language.
2631 *
2632 * Example of an i18n map:
2633 *
2634 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2635 *
2636 * Note: the static property will be overridden if the #icon configuration is used.
2637 *
2638 * @static
2639 * @inheritable
2640 * @property {Object|string}
2641 */
2642 OO.ui.mixin.IconElement.static.icon = null;
2643
2644 /**
2645 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2646 * function that returns title text, or `null` for no title.
2647 *
2648 * The static property will be overridden if the #iconTitle configuration is used.
2649 *
2650 * @static
2651 * @inheritable
2652 * @property {string|Function|null}
2653 */
2654 OO.ui.mixin.IconElement.static.iconTitle = null;
2655
2656 /* Methods */
2657
2658 /**
2659 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2660 * applies to the specified icon element instead of the one created by the class. If an icon
2661 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2662 * and mixin methods will no longer affect the element.
2663 *
2664 * @param {jQuery} $icon Element to use as icon
2665 */
2666 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2667 if ( this.$icon ) {
2668 this.$icon
2669 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2670 .removeAttr( 'title' );
2671 }
2672
2673 this.$icon = $icon
2674 .addClass( 'oo-ui-iconElement-icon' )
2675 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2676 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2677 if ( this.iconTitle !== null ) {
2678 this.$icon.attr( 'title', this.iconTitle );
2679 }
2680
2681 this.updateThemeClasses();
2682 };
2683
2684 /**
2685 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2686 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2687 * for an example.
2688 *
2689 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2690 * by language code, or `null` to remove the icon.
2691 * @chainable
2692 */
2693 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2694 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2695 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2696
2697 if ( this.icon !== icon ) {
2698 if ( this.$icon ) {
2699 if ( this.icon !== null ) {
2700 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2701 }
2702 if ( icon !== null ) {
2703 this.$icon.addClass( 'oo-ui-icon-' + icon );
2704 }
2705 }
2706 this.icon = icon;
2707 }
2708
2709 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2710 if ( this.$icon ) {
2711 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2712 }
2713 this.updateThemeClasses();
2714
2715 return this;
2716 };
2717
2718 /**
2719 * Set the icon title. Use `null` to remove the title.
2720 *
2721 * @param {string|Function|null} iconTitle A text string used as the icon title,
2722 * a function that returns title text, or `null` for no title.
2723 * @chainable
2724 */
2725 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2726 iconTitle =
2727 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2728 OO.ui.resolveMsg( iconTitle ) : null;
2729
2730 if ( this.iconTitle !== iconTitle ) {
2731 this.iconTitle = iconTitle;
2732 if ( this.$icon ) {
2733 if ( this.iconTitle !== null ) {
2734 this.$icon.attr( 'title', iconTitle );
2735 } else {
2736 this.$icon.removeAttr( 'title' );
2737 }
2738 }
2739 }
2740
2741 return this;
2742 };
2743
2744 /**
2745 * Get the symbolic name of the icon.
2746 *
2747 * @return {string} Icon name
2748 */
2749 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2750 return this.icon;
2751 };
2752
2753 /**
2754 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2755 *
2756 * @return {string} Icon title text
2757 */
2758 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2759 return this.iconTitle;
2760 };
2761
2762 /**
2763 * IndicatorElement is often mixed into other classes to generate an indicator.
2764 * Indicators are small graphics that are generally used in two ways:
2765 *
2766 * - To draw attention to the status of an item. For example, an indicator might be
2767 * used to show that an item in a list has errors that need to be resolved.
2768 * - To clarify the function of a control that acts in an exceptional way (a button
2769 * that opens a menu instead of performing an action directly, for example).
2770 *
2771 * For a list of indicators included in the library, please see the
2772 * [OOUI documentation on MediaWiki] [1].
2773 *
2774 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2775 *
2776 * @abstract
2777 * @class
2778 *
2779 * @constructor
2780 * @param {Object} [config] Configuration options
2781 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2782 * configuration is omitted, the indicator element will use a generated `<span>`.
2783 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2784 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
2785 * in the library.
2786 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2787 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2788 * or a function that returns title text. The indicator title is displayed when users move
2789 * the mouse over the indicator.
2790 */
2791 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2792 // Configuration initialization
2793 config = config || {};
2794
2795 // Properties
2796 this.$indicator = null;
2797 this.indicator = null;
2798 this.indicatorTitle = null;
2799
2800 // Initialization
2801 this.setIndicator( config.indicator || this.constructor.static.indicator );
2802 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2803 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2804 };
2805
2806 /* Setup */
2807
2808 OO.initClass( OO.ui.mixin.IndicatorElement );
2809
2810 /* Static Properties */
2811
2812 /**
2813 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2814 * The static property will be overridden if the #indicator configuration is used.
2815 *
2816 * @static
2817 * @inheritable
2818 * @property {string|null}
2819 */
2820 OO.ui.mixin.IndicatorElement.static.indicator = null;
2821
2822 /**
2823 * A text string used as the indicator title, a function that returns title text, or `null`
2824 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2825 *
2826 * @static
2827 * @inheritable
2828 * @property {string|Function|null}
2829 */
2830 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2831
2832 /* Methods */
2833
2834 /**
2835 * Set the indicator element.
2836 *
2837 * If an element is already set, it will be cleaned up before setting up the new element.
2838 *
2839 * @param {jQuery} $indicator Element to use as indicator
2840 */
2841 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2842 if ( this.$indicator ) {
2843 this.$indicator
2844 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2845 .removeAttr( 'title' );
2846 }
2847
2848 this.$indicator = $indicator
2849 .addClass( 'oo-ui-indicatorElement-indicator' )
2850 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
2851 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2852 if ( this.indicatorTitle !== null ) {
2853 this.$indicator.attr( 'title', this.indicatorTitle );
2854 }
2855
2856 this.updateThemeClasses();
2857 };
2858
2859 /**
2860 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
2861 *
2862 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2863 * @chainable
2864 */
2865 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2866 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2867
2868 if ( this.indicator !== indicator ) {
2869 if ( this.$indicator ) {
2870 if ( this.indicator !== null ) {
2871 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2872 }
2873 if ( indicator !== null ) {
2874 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2875 }
2876 }
2877 this.indicator = indicator;
2878 }
2879
2880 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2881 if ( this.$indicator ) {
2882 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
2883 }
2884 this.updateThemeClasses();
2885
2886 return this;
2887 };
2888
2889 /**
2890 * Set the indicator title.
2891 *
2892 * The title is displayed when a user moves the mouse over the indicator.
2893 *
2894 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2895 * `null` for no indicator title
2896 * @chainable
2897 */
2898 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2899 indicatorTitle =
2900 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2901 OO.ui.resolveMsg( indicatorTitle ) : null;
2902
2903 if ( this.indicatorTitle !== indicatorTitle ) {
2904 this.indicatorTitle = indicatorTitle;
2905 if ( this.$indicator ) {
2906 if ( this.indicatorTitle !== null ) {
2907 this.$indicator.attr( 'title', indicatorTitle );
2908 } else {
2909 this.$indicator.removeAttr( 'title' );
2910 }
2911 }
2912 }
2913
2914 return this;
2915 };
2916
2917 /**
2918 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2919 *
2920 * @return {string} Symbolic name of indicator
2921 */
2922 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2923 return this.indicator;
2924 };
2925
2926 /**
2927 * Get the indicator title.
2928 *
2929 * The title is displayed when a user moves the mouse over the indicator.
2930 *
2931 * @return {string} Indicator title text
2932 */
2933 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2934 return this.indicatorTitle;
2935 };
2936
2937 /**
2938 * LabelElement is often mixed into other classes to generate a label, which
2939 * helps identify the function of an interface element.
2940 * See the [OOUI documentation on MediaWiki] [1] for more information.
2941 *
2942 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2943 *
2944 * @abstract
2945 * @class
2946 *
2947 * @constructor
2948 * @param {Object} [config] Configuration options
2949 * @cfg {jQuery} [$label] The label element created by the class. If this
2950 * configuration is omitted, the label element will use a generated `<span>`.
2951 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2952 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2953 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2954 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2955 */
2956 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2957 // Configuration initialization
2958 config = config || {};
2959
2960 // Properties
2961 this.$label = null;
2962 this.label = null;
2963
2964 // Initialization
2965 this.setLabel( config.label || this.constructor.static.label );
2966 this.setLabelElement( config.$label || $( '<span>' ) );
2967 };
2968
2969 /* Setup */
2970
2971 OO.initClass( OO.ui.mixin.LabelElement );
2972
2973 /* Events */
2974
2975 /**
2976 * @event labelChange
2977 * @param {string} value
2978 */
2979
2980 /* Static Properties */
2981
2982 /**
2983 * The label text. The label can be specified as a plaintext string, a function that will
2984 * produce a string in the future, or `null` for no label. The static value will
2985 * be overridden if a label is specified with the #label config option.
2986 *
2987 * @static
2988 * @inheritable
2989 * @property {string|Function|null}
2990 */
2991 OO.ui.mixin.LabelElement.static.label = null;
2992
2993 /* Static methods */
2994
2995 /**
2996 * Highlight the first occurrence of the query in the given text
2997 *
2998 * @param {string} text Text
2999 * @param {string} query Query to find
3000 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3001 * @return {jQuery} Text with the first match of the query
3002 * sub-string wrapped in highlighted span
3003 */
3004 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
3005 var i, tLen, qLen,
3006 offset = -1,
3007 $result = $( '<span>' );
3008
3009 if ( compare ) {
3010 tLen = text.length;
3011 qLen = query.length;
3012 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
3013 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
3014 offset = i;
3015 }
3016 }
3017 } else {
3018 offset = text.toLowerCase().indexOf( query.toLowerCase() );
3019 }
3020
3021 if ( !query.length || offset === -1 ) {
3022 $result.text( text );
3023 } else {
3024 $result.append(
3025 document.createTextNode( text.slice( 0, offset ) ),
3026 $( '<span>' )
3027 .addClass( 'oo-ui-labelElement-label-highlight' )
3028 .text( text.slice( offset, offset + query.length ) ),
3029 document.createTextNode( text.slice( offset + query.length ) )
3030 );
3031 }
3032 return $result.contents();
3033 };
3034
3035 /* Methods */
3036
3037 /**
3038 * Set the label element.
3039 *
3040 * If an element is already set, it will be cleaned up before setting up the new element.
3041 *
3042 * @param {jQuery} $label Element to use as label
3043 */
3044 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3045 if ( this.$label ) {
3046 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3047 }
3048
3049 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3050 this.setLabelContent( this.label );
3051 };
3052
3053 /**
3054 * Set the label.
3055 *
3056 * An empty string will result in the label being hidden. A string containing only whitespace will
3057 * be converted to a single `&nbsp;`.
3058 *
3059 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3060 * text; or null for no label
3061 * @chainable
3062 */
3063 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3064 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3065 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3066
3067 if ( this.label !== label ) {
3068 if ( this.$label ) {
3069 this.setLabelContent( label );
3070 }
3071 this.label = label;
3072 this.emit( 'labelChange' );
3073 }
3074
3075 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3076
3077 return this;
3078 };
3079
3080 /**
3081 * Set the label as plain text with a highlighted query
3082 *
3083 * @param {string} text Text label to set
3084 * @param {string} query Substring of text to highlight
3085 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3086 * @chainable
3087 */
3088 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3089 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3090 };
3091
3092 /**
3093 * Get the label.
3094 *
3095 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3096 * text; or null for no label
3097 */
3098 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3099 return this.label;
3100 };
3101
3102 /**
3103 * Set the content of the label.
3104 *
3105 * Do not call this method until after the label element has been set by #setLabelElement.
3106 *
3107 * @private
3108 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3109 * text; or null for no label
3110 */
3111 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3112 if ( typeof label === 'string' ) {
3113 if ( label.match( /^\s*$/ ) ) {
3114 // Convert whitespace only string to a single non-breaking space
3115 this.$label.html( '&nbsp;' );
3116 } else {
3117 this.$label.text( label );
3118 }
3119 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3120 this.$label.html( label.toString() );
3121 } else if ( label instanceof jQuery ) {
3122 this.$label.empty().append( label );
3123 } else {
3124 this.$label.empty();
3125 }
3126 };
3127
3128 /**
3129 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3130 * additional functionality to an element created by another class. The class provides
3131 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3132 * which are used to customize the look and feel of a widget to better describe its
3133 * importance and functionality.
3134 *
3135 * The library currently contains the following styling flags for general use:
3136 *
3137 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3138 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3139 *
3140 * The flags affect the appearance of the buttons:
3141 *
3142 * @example
3143 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3144 * var button1 = new OO.ui.ButtonWidget( {
3145 * label: 'Progressive',
3146 * flags: 'progressive'
3147 * } );
3148 * var button2 = new OO.ui.ButtonWidget( {
3149 * label: 'Destructive',
3150 * flags: 'destructive'
3151 * } );
3152 * $( 'body' ).append( button1.$element, button2.$element );
3153 *
3154 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3155 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3156 *
3157 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3158 *
3159 * @abstract
3160 * @class
3161 *
3162 * @constructor
3163 * @param {Object} [config] Configuration options
3164 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3165 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3166 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3167 * @cfg {jQuery} [$flagged] The flagged element. By default,
3168 * the flagged functionality is applied to the element created by the class ($element).
3169 * If a different element is specified, the flagged functionality will be applied to it instead.
3170 */
3171 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3172 // Configuration initialization
3173 config = config || {};
3174
3175 // Properties
3176 this.flags = {};
3177 this.$flagged = null;
3178
3179 // Initialization
3180 this.setFlags( config.flags );
3181 this.setFlaggedElement( config.$flagged || this.$element );
3182 };
3183
3184 /* Events */
3185
3186 /**
3187 * @event flag
3188 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3189 * parameter contains the name of each modified flag and indicates whether it was
3190 * added or removed.
3191 *
3192 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3193 * that the flag was added, `false` that the flag was removed.
3194 */
3195
3196 /* Methods */
3197
3198 /**
3199 * Set the flagged element.
3200 *
3201 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3202 * If an element is already set, the method will remove the mixin’s effect on that element.
3203 *
3204 * @param {jQuery} $flagged Element that should be flagged
3205 */
3206 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3207 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3208 return 'oo-ui-flaggedElement-' + flag;
3209 } ).join( ' ' );
3210
3211 if ( this.$flagged ) {
3212 this.$flagged.removeClass( classNames );
3213 }
3214
3215 this.$flagged = $flagged.addClass( classNames );
3216 };
3217
3218 /**
3219 * Check if the specified flag is set.
3220 *
3221 * @param {string} flag Name of flag
3222 * @return {boolean} The flag is set
3223 */
3224 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3225 // This may be called before the constructor, thus before this.flags is set
3226 return this.flags && ( flag in this.flags );
3227 };
3228
3229 /**
3230 * Get the names of all flags set.
3231 *
3232 * @return {string[]} Flag names
3233 */
3234 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3235 // This may be called before the constructor, thus before this.flags is set
3236 return Object.keys( this.flags || {} );
3237 };
3238
3239 /**
3240 * Clear all flags.
3241 *
3242 * @chainable
3243 * @fires flag
3244 */
3245 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3246 var flag, className,
3247 changes = {},
3248 remove = [],
3249 classPrefix = 'oo-ui-flaggedElement-';
3250
3251 for ( flag in this.flags ) {
3252 className = classPrefix + flag;
3253 changes[ flag ] = false;
3254 delete this.flags[ flag ];
3255 remove.push( className );
3256 }
3257
3258 if ( this.$flagged ) {
3259 this.$flagged.removeClass( remove.join( ' ' ) );
3260 }
3261
3262 this.updateThemeClasses();
3263 this.emit( 'flag', changes );
3264
3265 return this;
3266 };
3267
3268 /**
3269 * Add one or more flags.
3270 *
3271 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3272 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3273 * be added (`true`) or removed (`false`).
3274 * @chainable
3275 * @fires flag
3276 */
3277 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3278 var i, len, flag, className,
3279 changes = {},
3280 add = [],
3281 remove = [],
3282 classPrefix = 'oo-ui-flaggedElement-';
3283
3284 if ( typeof flags === 'string' ) {
3285 className = classPrefix + flags;
3286 // Set
3287 if ( !this.flags[ flags ] ) {
3288 this.flags[ flags ] = true;
3289 add.push( className );
3290 }
3291 } else if ( Array.isArray( flags ) ) {
3292 for ( i = 0, len = flags.length; i < len; i++ ) {
3293 flag = flags[ i ];
3294 className = classPrefix + flag;
3295 // Set
3296 if ( !this.flags[ flag ] ) {
3297 changes[ flag ] = true;
3298 this.flags[ flag ] = true;
3299 add.push( className );
3300 }
3301 }
3302 } else if ( OO.isPlainObject( flags ) ) {
3303 for ( flag in flags ) {
3304 className = classPrefix + flag;
3305 if ( flags[ flag ] ) {
3306 // Set
3307 if ( !this.flags[ flag ] ) {
3308 changes[ flag ] = true;
3309 this.flags[ flag ] = true;
3310 add.push( className );
3311 }
3312 } else {
3313 // Remove
3314 if ( this.flags[ flag ] ) {
3315 changes[ flag ] = false;
3316 delete this.flags[ flag ];
3317 remove.push( className );
3318 }
3319 }
3320 }
3321 }
3322
3323 if ( this.$flagged ) {
3324 this.$flagged
3325 .addClass( add.join( ' ' ) )
3326 .removeClass( remove.join( ' ' ) );
3327 }
3328
3329 this.updateThemeClasses();
3330 this.emit( 'flag', changes );
3331
3332 return this;
3333 };
3334
3335 /**
3336 * TitledElement is mixed into other classes to provide a `title` attribute.
3337 * Titles are rendered by the browser and are made visible when the user moves
3338 * the mouse over the element. Titles are not visible on touch devices.
3339 *
3340 * @example
3341 * // TitledElement provides a 'title' attribute to the
3342 * // ButtonWidget class
3343 * var button = new OO.ui.ButtonWidget( {
3344 * label: 'Button with Title',
3345 * title: 'I am a button'
3346 * } );
3347 * $( 'body' ).append( button.$element );
3348 *
3349 * @abstract
3350 * @class
3351 *
3352 * @constructor
3353 * @param {Object} [config] Configuration options
3354 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3355 * If this config is omitted, the title functionality is applied to $element, the
3356 * element created by the class.
3357 * @cfg {string|Function} [title] The title text or a function that returns text. If
3358 * this config is omitted, the value of the {@link #static-title static title} property is used.
3359 */
3360 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3361 // Configuration initialization
3362 config = config || {};
3363
3364 // Properties
3365 this.$titled = null;
3366 this.title = null;
3367
3368 // Initialization
3369 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3370 this.setTitledElement( config.$titled || this.$element );
3371 };
3372
3373 /* Setup */
3374
3375 OO.initClass( OO.ui.mixin.TitledElement );
3376
3377 /* Static Properties */
3378
3379 /**
3380 * The title text, a function that returns text, or `null` for no title. The value of the static property
3381 * is overridden if the #title config option is used.
3382 *
3383 * @static
3384 * @inheritable
3385 * @property {string|Function|null}
3386 */
3387 OO.ui.mixin.TitledElement.static.title = null;
3388
3389 /* Methods */
3390
3391 /**
3392 * Set the titled element.
3393 *
3394 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3395 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3396 *
3397 * @param {jQuery} $titled Element that should use the 'titled' functionality
3398 */
3399 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3400 if ( this.$titled ) {
3401 this.$titled.removeAttr( 'title' );
3402 }
3403
3404 this.$titled = $titled;
3405 if ( this.title ) {
3406 this.updateTitle();
3407 }
3408 };
3409
3410 /**
3411 * Set title.
3412 *
3413 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3414 * @chainable
3415 */
3416 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3417 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3418 title = ( typeof title === 'string' && title.length ) ? title : null;
3419
3420 if ( this.title !== title ) {
3421 this.title = title;
3422 this.updateTitle();
3423 }
3424
3425 return this;
3426 };
3427
3428 /**
3429 * Update the title attribute, in case of changes to title or accessKey.
3430 *
3431 * @protected
3432 * @chainable
3433 */
3434 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3435 var title = this.getTitle();
3436 if ( this.$titled ) {
3437 if ( title !== null ) {
3438 // Only if this is an AccessKeyedElement
3439 if ( this.formatTitleWithAccessKey ) {
3440 title = this.formatTitleWithAccessKey( title );
3441 }
3442 this.$titled.attr( 'title', title );
3443 } else {
3444 this.$titled.removeAttr( 'title' );
3445 }
3446 }
3447 return this;
3448 };
3449
3450 /**
3451 * Get title.
3452 *
3453 * @return {string} Title string
3454 */
3455 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3456 return this.title;
3457 };
3458
3459 /**
3460 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3461 * Accesskeys allow an user to go to a specific element by using
3462 * a shortcut combination of a browser specific keys + the key
3463 * set to the field.
3464 *
3465 * @example
3466 * // AccessKeyedElement provides an 'accesskey' attribute to the
3467 * // ButtonWidget class
3468 * var button = new OO.ui.ButtonWidget( {
3469 * label: 'Button with Accesskey',
3470 * accessKey: 'k'
3471 * } );
3472 * $( 'body' ).append( button.$element );
3473 *
3474 * @abstract
3475 * @class
3476 *
3477 * @constructor
3478 * @param {Object} [config] Configuration options
3479 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3480 * If this config is omitted, the accesskey functionality is applied to $element, the
3481 * element created by the class.
3482 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3483 * this config is omitted, no accesskey will be added.
3484 */
3485 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3486 // Configuration initialization
3487 config = config || {};
3488
3489 // Properties
3490 this.$accessKeyed = null;
3491 this.accessKey = null;
3492
3493 // Initialization
3494 this.setAccessKey( config.accessKey || null );
3495 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3496
3497 // If this is also a TitledElement and it initialized before we did, we may have
3498 // to update the title with the access key
3499 if ( this.updateTitle ) {
3500 this.updateTitle();
3501 }
3502 };
3503
3504 /* Setup */
3505
3506 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3507
3508 /* Static Properties */
3509
3510 /**
3511 * The access key, a function that returns a key, or `null` for no accesskey.
3512 *
3513 * @static
3514 * @inheritable
3515 * @property {string|Function|null}
3516 */
3517 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3518
3519 /* Methods */
3520
3521 /**
3522 * Set the accesskeyed element.
3523 *
3524 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3525 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3526 *
3527 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3528 */
3529 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3530 if ( this.$accessKeyed ) {
3531 this.$accessKeyed.removeAttr( 'accesskey' );
3532 }
3533
3534 this.$accessKeyed = $accessKeyed;
3535 if ( this.accessKey ) {
3536 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3537 }
3538 };
3539
3540 /**
3541 * Set accesskey.
3542 *
3543 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3544 * @chainable
3545 */
3546 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3547 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3548
3549 if ( this.accessKey !== accessKey ) {
3550 if ( this.$accessKeyed ) {
3551 if ( accessKey !== null ) {
3552 this.$accessKeyed.attr( 'accesskey', accessKey );
3553 } else {
3554 this.$accessKeyed.removeAttr( 'accesskey' );
3555 }
3556 }
3557 this.accessKey = accessKey;
3558
3559 // Only if this is a TitledElement
3560 if ( this.updateTitle ) {
3561 this.updateTitle();
3562 }
3563 }
3564
3565 return this;
3566 };
3567
3568 /**
3569 * Get accesskey.
3570 *
3571 * @return {string} accessKey string
3572 */
3573 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3574 return this.accessKey;
3575 };
3576
3577 /**
3578 * Add information about the access key to the element's tooltip label.
3579 * (This is only public for hacky usage in FieldLayout.)
3580 *
3581 * @param {string} title Tooltip label for `title` attribute
3582 * @return {string}
3583 */
3584 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3585 var accessKey;
3586
3587 if ( !this.$accessKeyed ) {
3588 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3589 return title;
3590 }
3591 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3592 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3593 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3594 } else {
3595 accessKey = this.getAccessKey();
3596 }
3597 if ( accessKey ) {
3598 title += ' [' + accessKey + ']';
3599 }
3600 return title;
3601 };
3602
3603 /**
3604 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3605 * feels, and functionality can be customized via the class’s configuration options
3606 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3607 * and examples.
3608 *
3609 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3610 *
3611 * @example
3612 * // A button widget
3613 * var button = new OO.ui.ButtonWidget( {
3614 * label: 'Button with Icon',
3615 * icon: 'trash',
3616 * title: 'Remove'
3617 * } );
3618 * $( 'body' ).append( button.$element );
3619 *
3620 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3621 *
3622 * @class
3623 * @extends OO.ui.Widget
3624 * @mixins OO.ui.mixin.ButtonElement
3625 * @mixins OO.ui.mixin.IconElement
3626 * @mixins OO.ui.mixin.IndicatorElement
3627 * @mixins OO.ui.mixin.LabelElement
3628 * @mixins OO.ui.mixin.TitledElement
3629 * @mixins OO.ui.mixin.FlaggedElement
3630 * @mixins OO.ui.mixin.TabIndexedElement
3631 * @mixins OO.ui.mixin.AccessKeyedElement
3632 *
3633 * @constructor
3634 * @param {Object} [config] Configuration options
3635 * @cfg {boolean} [active=false] Whether button should be shown as active
3636 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3637 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3638 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3639 */
3640 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3641 // Configuration initialization
3642 config = config || {};
3643
3644 // Parent constructor
3645 OO.ui.ButtonWidget.parent.call( this, config );
3646
3647 // Mixin constructors
3648 OO.ui.mixin.ButtonElement.call( this, config );
3649 OO.ui.mixin.IconElement.call( this, config );
3650 OO.ui.mixin.IndicatorElement.call( this, config );
3651 OO.ui.mixin.LabelElement.call( this, config );
3652 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3653 OO.ui.mixin.FlaggedElement.call( this, config );
3654 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3655 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3656
3657 // Properties
3658 this.href = null;
3659 this.target = null;
3660 this.noFollow = false;
3661
3662 // Events
3663 this.connect( this, { disable: 'onDisable' } );
3664
3665 // Initialization
3666 this.$button.append( this.$icon, this.$label, this.$indicator );
3667 this.$element
3668 .addClass( 'oo-ui-buttonWidget' )
3669 .append( this.$button );
3670 this.setActive( config.active );
3671 this.setHref( config.href );
3672 this.setTarget( config.target );
3673 this.setNoFollow( config.noFollow );
3674 };
3675
3676 /* Setup */
3677
3678 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3679 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3680 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3681 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3682 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3683 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3684 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3685 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3686 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3687
3688 /* Static Properties */
3689
3690 /**
3691 * @static
3692 * @inheritdoc
3693 */
3694 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3695
3696 /**
3697 * @static
3698 * @inheritdoc
3699 */
3700 OO.ui.ButtonWidget.static.tagName = 'span';
3701
3702 /* Methods */
3703
3704 /**
3705 * Get hyperlink location.
3706 *
3707 * @return {string} Hyperlink location
3708 */
3709 OO.ui.ButtonWidget.prototype.getHref = function () {
3710 return this.href;
3711 };
3712
3713 /**
3714 * Get hyperlink target.
3715 *
3716 * @return {string} Hyperlink target
3717 */
3718 OO.ui.ButtonWidget.prototype.getTarget = function () {
3719 return this.target;
3720 };
3721
3722 /**
3723 * Get search engine traversal hint.
3724 *
3725 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3726 */
3727 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3728 return this.noFollow;
3729 };
3730
3731 /**
3732 * Set hyperlink location.
3733 *
3734 * @param {string|null} href Hyperlink location, null to remove
3735 */
3736 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3737 href = typeof href === 'string' ? href : null;
3738 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3739 href = './' + href;
3740 }
3741
3742 if ( href !== this.href ) {
3743 this.href = href;
3744 this.updateHref();
3745 }
3746
3747 return this;
3748 };
3749
3750 /**
3751 * Update the `href` attribute, in case of changes to href or
3752 * disabled state.
3753 *
3754 * @private
3755 * @chainable
3756 */
3757 OO.ui.ButtonWidget.prototype.updateHref = function () {
3758 if ( this.href !== null && !this.isDisabled() ) {
3759 this.$button.attr( 'href', this.href );
3760 } else {
3761 this.$button.removeAttr( 'href' );
3762 }
3763
3764 return this;
3765 };
3766
3767 /**
3768 * Handle disable events.
3769 *
3770 * @private
3771 * @param {boolean} disabled Element is disabled
3772 */
3773 OO.ui.ButtonWidget.prototype.onDisable = function () {
3774 this.updateHref();
3775 };
3776
3777 /**
3778 * Set hyperlink target.
3779 *
3780 * @param {string|null} target Hyperlink target, null to remove
3781 */
3782 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3783 target = typeof target === 'string' ? target : null;
3784
3785 if ( target !== this.target ) {
3786 this.target = target;
3787 if ( target !== null ) {
3788 this.$button.attr( 'target', target );
3789 } else {
3790 this.$button.removeAttr( 'target' );
3791 }
3792 }
3793
3794 return this;
3795 };
3796
3797 /**
3798 * Set search engine traversal hint.
3799 *
3800 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3801 */
3802 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3803 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3804
3805 if ( noFollow !== this.noFollow ) {
3806 this.noFollow = noFollow;
3807 if ( noFollow ) {
3808 this.$button.attr( 'rel', 'nofollow' );
3809 } else {
3810 this.$button.removeAttr( 'rel' );
3811 }
3812 }
3813
3814 return this;
3815 };
3816
3817 // Override method visibility hints from ButtonElement
3818 /**
3819 * @method setActive
3820 * @inheritdoc
3821 */
3822 /**
3823 * @method isActive
3824 * @inheritdoc
3825 */
3826
3827 /**
3828 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3829 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3830 * removed, and cleared from the group.
3831 *
3832 * @example
3833 * // Example: A ButtonGroupWidget with two buttons
3834 * var button1 = new OO.ui.PopupButtonWidget( {
3835 * label: 'Select a category',
3836 * icon: 'menu',
3837 * popup: {
3838 * $content: $( '<p>List of categories...</p>' ),
3839 * padded: true,
3840 * align: 'left'
3841 * }
3842 * } );
3843 * var button2 = new OO.ui.ButtonWidget( {
3844 * label: 'Add item'
3845 * });
3846 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3847 * items: [button1, button2]
3848 * } );
3849 * $( 'body' ).append( buttonGroup.$element );
3850 *
3851 * @class
3852 * @extends OO.ui.Widget
3853 * @mixins OO.ui.mixin.GroupElement
3854 *
3855 * @constructor
3856 * @param {Object} [config] Configuration options
3857 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3858 */
3859 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3860 // Configuration initialization
3861 config = config || {};
3862
3863 // Parent constructor
3864 OO.ui.ButtonGroupWidget.parent.call( this, config );
3865
3866 // Mixin constructors
3867 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3868
3869 // Initialization
3870 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3871 if ( Array.isArray( config.items ) ) {
3872 this.addItems( config.items );
3873 }
3874 };
3875
3876 /* Setup */
3877
3878 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3879 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3880
3881 /* Static Properties */
3882
3883 /**
3884 * @static
3885 * @inheritdoc
3886 */
3887 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3888
3889 /* Methods */
3890
3891 /**
3892 * Focus the widget
3893 *
3894 * @chainable
3895 */
3896 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3897 if ( !this.isDisabled() ) {
3898 if ( this.items[ 0 ] ) {
3899 this.items[ 0 ].focus();
3900 }
3901 }
3902 return this;
3903 };
3904
3905 /**
3906 * @inheritdoc
3907 */
3908 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3909 this.focus();
3910 };
3911
3912 /**
3913 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3914 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
3915 * for a list of icons included in the library.
3916 *
3917 * @example
3918 * // An icon widget with a label
3919 * var myIcon = new OO.ui.IconWidget( {
3920 * icon: 'help',
3921 * title: 'Help'
3922 * } );
3923 * // Create a label.
3924 * var iconLabel = new OO.ui.LabelWidget( {
3925 * label: 'Help'
3926 * } );
3927 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3928 *
3929 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3930 *
3931 * @class
3932 * @extends OO.ui.Widget
3933 * @mixins OO.ui.mixin.IconElement
3934 * @mixins OO.ui.mixin.TitledElement
3935 * @mixins OO.ui.mixin.FlaggedElement
3936 *
3937 * @constructor
3938 * @param {Object} [config] Configuration options
3939 */
3940 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3941 // Configuration initialization
3942 config = config || {};
3943
3944 // Parent constructor
3945 OO.ui.IconWidget.parent.call( this, config );
3946
3947 // Mixin constructors
3948 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3949 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3950 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3951
3952 // Initialization
3953 this.$element.addClass( 'oo-ui-iconWidget' );
3954 };
3955
3956 /* Setup */
3957
3958 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3959 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3960 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3961 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3962
3963 /* Static Properties */
3964
3965 /**
3966 * @static
3967 * @inheritdoc
3968 */
3969 OO.ui.IconWidget.static.tagName = 'span';
3970
3971 /**
3972 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3973 * attention to the status of an item or to clarify the function within a control. For a list of
3974 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
3975 *
3976 * @example
3977 * // Example of an indicator widget
3978 * var indicator1 = new OO.ui.IndicatorWidget( {
3979 * indicator: 'required'
3980 * } );
3981 *
3982 * // Create a fieldset layout to add a label
3983 * var fieldset = new OO.ui.FieldsetLayout();
3984 * fieldset.addItems( [
3985 * new OO.ui.FieldLayout( indicator1, { label: 'A required indicator:' } )
3986 * ] );
3987 * $( 'body' ).append( fieldset.$element );
3988 *
3989 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3990 *
3991 * @class
3992 * @extends OO.ui.Widget
3993 * @mixins OO.ui.mixin.IndicatorElement
3994 * @mixins OO.ui.mixin.TitledElement
3995 *
3996 * @constructor
3997 * @param {Object} [config] Configuration options
3998 */
3999 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4000 // Configuration initialization
4001 config = config || {};
4002
4003 // Parent constructor
4004 OO.ui.IndicatorWidget.parent.call( this, config );
4005
4006 // Mixin constructors
4007 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4008 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4009
4010 // Initialization
4011 this.$element.addClass( 'oo-ui-indicatorWidget' );
4012 };
4013
4014 /* Setup */
4015
4016 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4017 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4018 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4019
4020 /* Static Properties */
4021
4022 /**
4023 * @static
4024 * @inheritdoc
4025 */
4026 OO.ui.IndicatorWidget.static.tagName = 'span';
4027
4028 /**
4029 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4030 * be configured with a `label` option that is set to a string, a label node, or a function:
4031 *
4032 * - String: a plaintext string
4033 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4034 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4035 * - Function: a function that will produce a string in the future. Functions are used
4036 * in cases where the value of the label is not currently defined.
4037 *
4038 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4039 * will come into focus when the label is clicked.
4040 *
4041 * @example
4042 * // Examples of LabelWidgets
4043 * var label1 = new OO.ui.LabelWidget( {
4044 * label: 'plaintext label'
4045 * } );
4046 * var label2 = new OO.ui.LabelWidget( {
4047 * label: $( '<a href="default.html">jQuery label</a>' )
4048 * } );
4049 * // Create a fieldset layout with fields for each example
4050 * var fieldset = new OO.ui.FieldsetLayout();
4051 * fieldset.addItems( [
4052 * new OO.ui.FieldLayout( label1 ),
4053 * new OO.ui.FieldLayout( label2 )
4054 * ] );
4055 * $( 'body' ).append( fieldset.$element );
4056 *
4057 * @class
4058 * @extends OO.ui.Widget
4059 * @mixins OO.ui.mixin.LabelElement
4060 * @mixins OO.ui.mixin.TitledElement
4061 *
4062 * @constructor
4063 * @param {Object} [config] Configuration options
4064 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4065 * Clicking the label will focus the specified input field.
4066 */
4067 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4068 // Configuration initialization
4069 config = config || {};
4070
4071 // Parent constructor
4072 OO.ui.LabelWidget.parent.call( this, config );
4073
4074 // Mixin constructors
4075 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4076 OO.ui.mixin.TitledElement.call( this, config );
4077
4078 // Properties
4079 this.input = config.input;
4080
4081 // Initialization
4082 if ( this.input ) {
4083 if ( this.input.getInputId() ) {
4084 this.$element.attr( 'for', this.input.getInputId() );
4085 } else {
4086 this.$label.on( 'click', function () {
4087 this.input.simulateLabelClick();
4088 }.bind( this ) );
4089 }
4090 }
4091 this.$element.addClass( 'oo-ui-labelWidget' );
4092 };
4093
4094 /* Setup */
4095
4096 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4097 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4098 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4099
4100 /* Static Properties */
4101
4102 /**
4103 * @static
4104 * @inheritdoc
4105 */
4106 OO.ui.LabelWidget.static.tagName = 'label';
4107
4108 /**
4109 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4110 * and that they should wait before proceeding. The pending state is visually represented with a pending
4111 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4112 * field of a {@link OO.ui.TextInputWidget text input widget}.
4113 *
4114 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4115 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4116 * in process dialogs.
4117 *
4118 * @example
4119 * function MessageDialog( config ) {
4120 * MessageDialog.parent.call( this, config );
4121 * }
4122 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4123 *
4124 * MessageDialog.static.name = 'myMessageDialog';
4125 * MessageDialog.static.actions = [
4126 * { action: 'save', label: 'Done', flags: 'primary' },
4127 * { label: 'Cancel', flags: 'safe' }
4128 * ];
4129 *
4130 * MessageDialog.prototype.initialize = function () {
4131 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4132 * this.content = new OO.ui.PanelLayout( { padded: true } );
4133 * 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>' );
4134 * this.$body.append( this.content.$element );
4135 * };
4136 * MessageDialog.prototype.getBodyHeight = function () {
4137 * return 100;
4138 * }
4139 * MessageDialog.prototype.getActionProcess = function ( action ) {
4140 * var dialog = this;
4141 * if ( action === 'save' ) {
4142 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4143 * return new OO.ui.Process()
4144 * .next( 1000 )
4145 * .next( function () {
4146 * dialog.getActions().get({actions: 'save'})[0].popPending();
4147 * } );
4148 * }
4149 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4150 * };
4151 *
4152 * var windowManager = new OO.ui.WindowManager();
4153 * $( 'body' ).append( windowManager.$element );
4154 *
4155 * var dialog = new MessageDialog();
4156 * windowManager.addWindows( [ dialog ] );
4157 * windowManager.openWindow( dialog );
4158 *
4159 * @abstract
4160 * @class
4161 *
4162 * @constructor
4163 * @param {Object} [config] Configuration options
4164 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4165 */
4166 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4167 // Configuration initialization
4168 config = config || {};
4169
4170 // Properties
4171 this.pending = 0;
4172 this.$pending = null;
4173
4174 // Initialisation
4175 this.setPendingElement( config.$pending || this.$element );
4176 };
4177
4178 /* Setup */
4179
4180 OO.initClass( OO.ui.mixin.PendingElement );
4181
4182 /* Methods */
4183
4184 /**
4185 * Set the pending element (and clean up any existing one).
4186 *
4187 * @param {jQuery} $pending The element to set to pending.
4188 */
4189 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4190 if ( this.$pending ) {
4191 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4192 }
4193
4194 this.$pending = $pending;
4195 if ( this.pending > 0 ) {
4196 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4197 }
4198 };
4199
4200 /**
4201 * Check if an element is pending.
4202 *
4203 * @return {boolean} Element is pending
4204 */
4205 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4206 return !!this.pending;
4207 };
4208
4209 /**
4210 * Increase the pending counter. The pending state will remain active until the counter is zero
4211 * (i.e., the number of calls to #pushPending and #popPending is the same).
4212 *
4213 * @chainable
4214 */
4215 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4216 if ( this.pending === 0 ) {
4217 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4218 this.updateThemeClasses();
4219 }
4220 this.pending++;
4221
4222 return this;
4223 };
4224
4225 /**
4226 * Decrease the pending counter. The pending state will remain active until the counter is zero
4227 * (i.e., the number of calls to #pushPending and #popPending is the same).
4228 *
4229 * @chainable
4230 */
4231 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4232 if ( this.pending === 1 ) {
4233 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4234 this.updateThemeClasses();
4235 }
4236 this.pending = Math.max( 0, this.pending - 1 );
4237
4238 return this;
4239 };
4240
4241 /**
4242 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4243 * in the document (for example, in an OO.ui.Window's $overlay).
4244 *
4245 * The elements's position is automatically calculated and maintained when window is resized or the
4246 * page is scrolled. If you reposition the container manually, you have to call #position to make
4247 * sure the element is still placed correctly.
4248 *
4249 * As positioning is only possible when both the element and the container are attached to the DOM
4250 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4251 * the #toggle method to display a floating popup, for example.
4252 *
4253 * @abstract
4254 * @class
4255 *
4256 * @constructor
4257 * @param {Object} [config] Configuration options
4258 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4259 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4260 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4261 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4262 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4263 * 'top': Align the top edge with $floatableContainer's top edge
4264 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4265 * 'center': Vertically align the center with $floatableContainer's center
4266 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4267 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4268 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4269 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4270 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4271 * 'center': Horizontally align the center with $floatableContainer's center
4272 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4273 * is out of view
4274 */
4275 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4276 // Configuration initialization
4277 config = config || {};
4278
4279 // Properties
4280 this.$floatable = null;
4281 this.$floatableContainer = null;
4282 this.$floatableWindow = null;
4283 this.$floatableClosestScrollable = null;
4284 this.floatableOutOfView = false;
4285 this.onFloatableScrollHandler = this.position.bind( this );
4286 this.onFloatableWindowResizeHandler = this.position.bind( this );
4287
4288 // Initialization
4289 this.setFloatableContainer( config.$floatableContainer );
4290 this.setFloatableElement( config.$floatable || this.$element );
4291 this.setVerticalPosition( config.verticalPosition || 'below' );
4292 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4293 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4294 };
4295
4296 /* Methods */
4297
4298 /**
4299 * Set floatable element.
4300 *
4301 * If an element is already set, it will be cleaned up before setting up the new element.
4302 *
4303 * @param {jQuery} $floatable Element to make floatable
4304 */
4305 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4306 if ( this.$floatable ) {
4307 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4308 this.$floatable.css( { left: '', top: '' } );
4309 }
4310
4311 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4312 this.position();
4313 };
4314
4315 /**
4316 * Set floatable container.
4317 *
4318 * The element will be positioned relative to the specified container.
4319 *
4320 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4321 */
4322 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4323 this.$floatableContainer = $floatableContainer;
4324 if ( this.$floatable ) {
4325 this.position();
4326 }
4327 };
4328
4329 /**
4330 * Change how the element is positioned vertically.
4331 *
4332 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4333 */
4334 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4335 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4336 throw new Error( 'Invalid value for vertical position: ' + position );
4337 }
4338 if ( this.verticalPosition !== position ) {
4339 this.verticalPosition = position;
4340 if ( this.$floatable ) {
4341 this.position();
4342 }
4343 }
4344 };
4345
4346 /**
4347 * Change how the element is positioned horizontally.
4348 *
4349 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4350 */
4351 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4352 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4353 throw new Error( 'Invalid value for horizontal position: ' + position );
4354 }
4355 if ( this.horizontalPosition !== position ) {
4356 this.horizontalPosition = position;
4357 if ( this.$floatable ) {
4358 this.position();
4359 }
4360 }
4361 };
4362
4363 /**
4364 * Toggle positioning.
4365 *
4366 * Do not turn positioning on until after the element is attached to the DOM and visible.
4367 *
4368 * @param {boolean} [positioning] Enable positioning, omit to toggle
4369 * @chainable
4370 */
4371 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4372 var closestScrollableOfContainer;
4373
4374 if ( !this.$floatable || !this.$floatableContainer ) {
4375 return this;
4376 }
4377
4378 positioning = positioning === undefined ? !this.positioning : !!positioning;
4379
4380 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4381 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4382 this.warnedUnattached = true;
4383 }
4384
4385 if ( this.positioning !== positioning ) {
4386 this.positioning = positioning;
4387
4388 this.needsCustomPosition =
4389 this.verticalPostion !== 'below' ||
4390 this.horizontalPosition !== 'start' ||
4391 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4392
4393 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4394 // If the scrollable is the root, we have to listen to scroll events
4395 // on the window because of browser inconsistencies.
4396 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4397 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4398 }
4399
4400 if ( positioning ) {
4401 this.$floatableWindow = $( this.getElementWindow() );
4402 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4403
4404 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4405 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4406
4407 // Initial position after visible
4408 this.position();
4409 } else {
4410 if ( this.$floatableWindow ) {
4411 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4412 this.$floatableWindow = null;
4413 }
4414
4415 if ( this.$floatableClosestScrollable ) {
4416 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4417 this.$floatableClosestScrollable = null;
4418 }
4419
4420 this.$floatable.css( { left: '', right: '', top: '' } );
4421 }
4422 }
4423
4424 return this;
4425 };
4426
4427 /**
4428 * Check whether the bottom edge of the given element is within the viewport of the given container.
4429 *
4430 * @private
4431 * @param {jQuery} $element
4432 * @param {jQuery} $container
4433 * @return {boolean}
4434 */
4435 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4436 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4437 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4438 direction = $element.css( 'direction' );
4439
4440 elemRect = $element[ 0 ].getBoundingClientRect();
4441 if ( $container[ 0 ] === window ) {
4442 viewportSpacing = OO.ui.getViewportSpacing();
4443 contRect = {
4444 top: 0,
4445 left: 0,
4446 right: document.documentElement.clientWidth,
4447 bottom: document.documentElement.clientHeight
4448 };
4449 contRect.top += viewportSpacing.top;
4450 contRect.left += viewportSpacing.left;
4451 contRect.right -= viewportSpacing.right;
4452 contRect.bottom -= viewportSpacing.bottom;
4453 } else {
4454 contRect = $container[ 0 ].getBoundingClientRect();
4455 }
4456
4457 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4458 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4459 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4460 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4461 if ( direction === 'rtl' ) {
4462 startEdgeInBounds = rightEdgeInBounds;
4463 endEdgeInBounds = leftEdgeInBounds;
4464 } else {
4465 startEdgeInBounds = leftEdgeInBounds;
4466 endEdgeInBounds = rightEdgeInBounds;
4467 }
4468
4469 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4470 return false;
4471 }
4472 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4473 return false;
4474 }
4475 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4476 return false;
4477 }
4478 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4479 return false;
4480 }
4481
4482 // The other positioning values are all about being inside the container,
4483 // so in those cases all we care about is that any part of the container is visible.
4484 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4485 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4486 };
4487
4488 /**
4489 * Check if the floatable is hidden to the user because it was offscreen.
4490 *
4491 * @return {boolean} Floatable is out of view
4492 */
4493 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4494 return this.floatableOutOfView;
4495 };
4496
4497 /**
4498 * Position the floatable below its container.
4499 *
4500 * This should only be done when both of them are attached to the DOM and visible.
4501 *
4502 * @chainable
4503 */
4504 OO.ui.mixin.FloatableElement.prototype.position = function () {
4505 if ( !this.positioning ) {
4506 return this;
4507 }
4508
4509 if ( !(
4510 // To continue, some things need to be true:
4511 // The element must actually be in the DOM
4512 this.isElementAttached() && (
4513 // The closest scrollable is the current window
4514 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4515 // OR is an element in the element's DOM
4516 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4517 )
4518 ) ) {
4519 // Abort early if important parts of the widget are no longer attached to the DOM
4520 return this;
4521 }
4522
4523 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4524 if ( this.floatableOutOfView ) {
4525 this.$floatable.addClass( 'oo-ui-element-hidden' );
4526 return this;
4527 } else {
4528 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4529 }
4530
4531 if ( !this.needsCustomPosition ) {
4532 return this;
4533 }
4534
4535 this.$floatable.css( this.computePosition() );
4536
4537 // We updated the position, so re-evaluate the clipping state.
4538 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4539 // will not notice the need to update itself.)
4540 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4541 // it not listen to the right events in the right places?
4542 if ( this.clip ) {
4543 this.clip();
4544 }
4545
4546 return this;
4547 };
4548
4549 /**
4550 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4551 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4552 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4553 *
4554 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4555 */
4556 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4557 var isBody, scrollableX, scrollableY, containerPos,
4558 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4559 newPos = { top: '', left: '', bottom: '', right: '' },
4560 direction = this.$floatableContainer.css( 'direction' ),
4561 $offsetParent = this.$floatable.offsetParent();
4562
4563 if ( $offsetParent.is( 'html' ) ) {
4564 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4565 // <html> element, but they do work on the <body>
4566 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4567 }
4568 isBody = $offsetParent.is( 'body' );
4569 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4570 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4571
4572 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4573 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4574 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4575 // or if it isn't scrollable
4576 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4577 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4578
4579 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4580 // if the <body> has a margin
4581 containerPos = isBody ?
4582 this.$floatableContainer.offset() :
4583 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4584 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4585 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4586 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4587 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4588
4589 if ( this.verticalPosition === 'below' ) {
4590 newPos.top = containerPos.bottom;
4591 } else if ( this.verticalPosition === 'above' ) {
4592 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4593 } else if ( this.verticalPosition === 'top' ) {
4594 newPos.top = containerPos.top;
4595 } else if ( this.verticalPosition === 'bottom' ) {
4596 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4597 } else if ( this.verticalPosition === 'center' ) {
4598 newPos.top = containerPos.top +
4599 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4600 }
4601
4602 if ( this.horizontalPosition === 'before' ) {
4603 newPos.end = containerPos.start;
4604 } else if ( this.horizontalPosition === 'after' ) {
4605 newPos.start = containerPos.end;
4606 } else if ( this.horizontalPosition === 'start' ) {
4607 newPos.start = containerPos.start;
4608 } else if ( this.horizontalPosition === 'end' ) {
4609 newPos.end = containerPos.end;
4610 } else if ( this.horizontalPosition === 'center' ) {
4611 newPos.left = containerPos.left +
4612 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4613 }
4614
4615 if ( newPos.start !== undefined ) {
4616 if ( direction === 'rtl' ) {
4617 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4618 } else {
4619 newPos.left = newPos.start;
4620 }
4621 delete newPos.start;
4622 }
4623 if ( newPos.end !== undefined ) {
4624 if ( direction === 'rtl' ) {
4625 newPos.left = newPos.end;
4626 } else {
4627 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4628 }
4629 delete newPos.end;
4630 }
4631
4632 // Account for scroll position
4633 if ( newPos.top !== '' ) {
4634 newPos.top += scrollTop;
4635 }
4636 if ( newPos.bottom !== '' ) {
4637 newPos.bottom -= scrollTop;
4638 }
4639 if ( newPos.left !== '' ) {
4640 newPos.left += scrollLeft;
4641 }
4642 if ( newPos.right !== '' ) {
4643 newPos.right -= scrollLeft;
4644 }
4645
4646 // Account for scrollbar gutter
4647 if ( newPos.bottom !== '' ) {
4648 newPos.bottom -= horizScrollbarHeight;
4649 }
4650 if ( direction === 'rtl' ) {
4651 if ( newPos.left !== '' ) {
4652 newPos.left -= vertScrollbarWidth;
4653 }
4654 } else {
4655 if ( newPos.right !== '' ) {
4656 newPos.right -= vertScrollbarWidth;
4657 }
4658 }
4659
4660 return newPos;
4661 };
4662
4663 /**
4664 * Element that can be automatically clipped to visible boundaries.
4665 *
4666 * Whenever the element's natural height changes, you have to call
4667 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4668 * clipping correctly.
4669 *
4670 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4671 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4672 * then #$clippable will be given a fixed reduced height and/or width and will be made
4673 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4674 * but you can build a static footer by setting #$clippableContainer to an element that contains
4675 * #$clippable and the footer.
4676 *
4677 * @abstract
4678 * @class
4679 *
4680 * @constructor
4681 * @param {Object} [config] Configuration options
4682 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4683 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4684 * omit to use #$clippable
4685 */
4686 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4687 // Configuration initialization
4688 config = config || {};
4689
4690 // Properties
4691 this.$clippable = null;
4692 this.$clippableContainer = null;
4693 this.clipping = false;
4694 this.clippedHorizontally = false;
4695 this.clippedVertically = false;
4696 this.$clippableScrollableContainer = null;
4697 this.$clippableScroller = null;
4698 this.$clippableWindow = null;
4699 this.idealWidth = null;
4700 this.idealHeight = null;
4701 this.onClippableScrollHandler = this.clip.bind( this );
4702 this.onClippableWindowResizeHandler = this.clip.bind( this );
4703
4704 // Initialization
4705 if ( config.$clippableContainer ) {
4706 this.setClippableContainer( config.$clippableContainer );
4707 }
4708 this.setClippableElement( config.$clippable || this.$element );
4709 };
4710
4711 /* Methods */
4712
4713 /**
4714 * Set clippable element.
4715 *
4716 * If an element is already set, it will be cleaned up before setting up the new element.
4717 *
4718 * @param {jQuery} $clippable Element to make clippable
4719 */
4720 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4721 if ( this.$clippable ) {
4722 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4723 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4724 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4725 }
4726
4727 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4728 this.clip();
4729 };
4730
4731 /**
4732 * Set clippable container.
4733 *
4734 * This is the container that will be measured when deciding whether to clip. When clipping,
4735 * #$clippable will be resized in order to keep the clippable container fully visible.
4736 *
4737 * If the clippable container is unset, #$clippable will be used.
4738 *
4739 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4740 */
4741 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4742 this.$clippableContainer = $clippableContainer;
4743 if ( this.$clippable ) {
4744 this.clip();
4745 }
4746 };
4747
4748 /**
4749 * Toggle clipping.
4750 *
4751 * Do not turn clipping on until after the element is attached to the DOM and visible.
4752 *
4753 * @param {boolean} [clipping] Enable clipping, omit to toggle
4754 * @chainable
4755 */
4756 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4757 clipping = clipping === undefined ? !this.clipping : !!clipping;
4758
4759 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4760 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4761 this.warnedUnattached = true;
4762 }
4763
4764 if ( this.clipping !== clipping ) {
4765 this.clipping = clipping;
4766 if ( clipping ) {
4767 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4768 // If the clippable container is the root, we have to listen to scroll events and check
4769 // jQuery.scrollTop on the window because of browser inconsistencies
4770 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4771 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4772 this.$clippableScrollableContainer;
4773 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4774 this.$clippableWindow = $( this.getElementWindow() )
4775 .on( 'resize', this.onClippableWindowResizeHandler );
4776 // Initial clip after visible
4777 this.clip();
4778 } else {
4779 this.$clippable.css( {
4780 width: '',
4781 height: '',
4782 maxWidth: '',
4783 maxHeight: '',
4784 overflowX: '',
4785 overflowY: ''
4786 } );
4787 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4788
4789 this.$clippableScrollableContainer = null;
4790 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4791 this.$clippableScroller = null;
4792 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4793 this.$clippableWindow = null;
4794 }
4795 }
4796
4797 return this;
4798 };
4799
4800 /**
4801 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4802 *
4803 * @return {boolean} Element will be clipped to the visible area
4804 */
4805 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4806 return this.clipping;
4807 };
4808
4809 /**
4810 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4811 *
4812 * @return {boolean} Part of the element is being clipped
4813 */
4814 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4815 return this.clippedHorizontally || this.clippedVertically;
4816 };
4817
4818 /**
4819 * Check if the right of the element is being clipped by the nearest scrollable container.
4820 *
4821 * @return {boolean} Part of the element is being clipped
4822 */
4823 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4824 return this.clippedHorizontally;
4825 };
4826
4827 /**
4828 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4829 *
4830 * @return {boolean} Part of the element is being clipped
4831 */
4832 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4833 return this.clippedVertically;
4834 };
4835
4836 /**
4837 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4838 *
4839 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4840 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4841 */
4842 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4843 this.idealWidth = width;
4844 this.idealHeight = height;
4845
4846 if ( !this.clipping ) {
4847 // Update dimensions
4848 this.$clippable.css( { width: width, height: height } );
4849 }
4850 // While clipping, idealWidth and idealHeight are not considered
4851 };
4852
4853 /**
4854 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4855 * ClippableElement will clip the opposite side when reducing element's width.
4856 *
4857 * Classes that mix in ClippableElement should override this to return 'right' if their
4858 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4859 * If your class also mixes in FloatableElement, this is handled automatically.
4860 *
4861 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4862 * always in pixels, even if they were unset or set to 'auto'.)
4863 *
4864 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4865 *
4866 * @return {string} 'left' or 'right'
4867 */
4868 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4869 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4870 return 'right';
4871 }
4872 return 'left';
4873 };
4874
4875 /**
4876 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4877 * ClippableElement will clip the opposite side when reducing element's width.
4878 *
4879 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4880 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4881 * If your class also mixes in FloatableElement, this is handled automatically.
4882 *
4883 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4884 * always in pixels, even if they were unset or set to 'auto'.)
4885 *
4886 * When in doubt, 'top' is a sane fallback.
4887 *
4888 * @return {string} 'top' or 'bottom'
4889 */
4890 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
4891 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
4892 return 'bottom';
4893 }
4894 return 'top';
4895 };
4896
4897 /**
4898 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4899 * when the element's natural height changes.
4900 *
4901 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4902 * overlapped by, the visible area of the nearest scrollable container.
4903 *
4904 * Because calling clip() when the natural height changes isn't always possible, we also set
4905 * max-height when the element isn't being clipped. This means that if the element tries to grow
4906 * beyond the edge, something reasonable will happen before clip() is called.
4907 *
4908 * @chainable
4909 */
4910 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4911 var extraHeight, extraWidth, viewportSpacing,
4912 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4913 naturalWidth, naturalHeight, clipWidth, clipHeight,
4914 $item, itemRect, $viewport, viewportRect, availableRect,
4915 direction, vertScrollbarWidth, horizScrollbarHeight,
4916 // Extra tolerance so that the sloppy code below doesn't result in results that are off
4917 // by one or two pixels. (And also so that we have space to display drop shadows.)
4918 // Chosen by fair dice roll.
4919 buffer = 7;
4920
4921 if ( !this.clipping ) {
4922 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4923 return this;
4924 }
4925
4926 function rectIntersection( a, b ) {
4927 var out = {};
4928 out.top = Math.max( a.top, b.top );
4929 out.left = Math.max( a.left, b.left );
4930 out.bottom = Math.min( a.bottom, b.bottom );
4931 out.right = Math.min( a.right, b.right );
4932 return out;
4933 }
4934
4935 viewportSpacing = OO.ui.getViewportSpacing();
4936
4937 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4938 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
4939 // Dimensions of the browser window, rather than the element!
4940 viewportRect = {
4941 top: 0,
4942 left: 0,
4943 right: document.documentElement.clientWidth,
4944 bottom: document.documentElement.clientHeight
4945 };
4946 viewportRect.top += viewportSpacing.top;
4947 viewportRect.left += viewportSpacing.left;
4948 viewportRect.right -= viewportSpacing.right;
4949 viewportRect.bottom -= viewportSpacing.bottom;
4950 } else {
4951 $viewport = this.$clippableScrollableContainer;
4952 viewportRect = $viewport[ 0 ].getBoundingClientRect();
4953 // Convert into a plain object
4954 viewportRect = $.extend( {}, viewportRect );
4955 }
4956
4957 // Account for scrollbar gutter
4958 direction = $viewport.css( 'direction' );
4959 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
4960 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
4961 viewportRect.bottom -= horizScrollbarHeight;
4962 if ( direction === 'rtl' ) {
4963 viewportRect.left += vertScrollbarWidth;
4964 } else {
4965 viewportRect.right -= vertScrollbarWidth;
4966 }
4967
4968 // Add arbitrary tolerance
4969 viewportRect.top += buffer;
4970 viewportRect.left += buffer;
4971 viewportRect.right -= buffer;
4972 viewportRect.bottom -= buffer;
4973
4974 $item = this.$clippableContainer || this.$clippable;
4975
4976 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
4977 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
4978
4979 itemRect = $item[ 0 ].getBoundingClientRect();
4980 // Convert into a plain object
4981 itemRect = $.extend( {}, itemRect );
4982
4983 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
4984 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
4985 if ( this.getHorizontalAnchorEdge() === 'right' ) {
4986 itemRect.left = viewportRect.left;
4987 } else {
4988 itemRect.right = viewportRect.right;
4989 }
4990 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
4991 itemRect.top = viewportRect.top;
4992 } else {
4993 itemRect.bottom = viewportRect.bottom;
4994 }
4995
4996 availableRect = rectIntersection( viewportRect, itemRect );
4997
4998 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
4999 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5000 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5001 desiredWidth = Math.min( desiredWidth,
5002 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5003 desiredHeight = Math.min( desiredHeight,
5004 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5005 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5006 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5007 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5008 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5009 clipWidth = allotedWidth < naturalWidth;
5010 clipHeight = allotedHeight < naturalHeight;
5011
5012 if ( clipWidth ) {
5013 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5014 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5015 this.$clippable.css( 'overflowX', 'scroll' );
5016 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5017 this.$clippable.css( {
5018 width: Math.max( 0, allotedWidth ),
5019 maxWidth: ''
5020 } );
5021 } else {
5022 this.$clippable.css( {
5023 overflowX: '',
5024 width: this.idealWidth || '',
5025 maxWidth: Math.max( 0, allotedWidth )
5026 } );
5027 }
5028 if ( clipHeight ) {
5029 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5030 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5031 this.$clippable.css( 'overflowY', 'scroll' );
5032 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5033 this.$clippable.css( {
5034 height: Math.max( 0, allotedHeight ),
5035 maxHeight: ''
5036 } );
5037 } else {
5038 this.$clippable.css( {
5039 overflowY: '',
5040 height: this.idealHeight || '',
5041 maxHeight: Math.max( 0, allotedHeight )
5042 } );
5043 }
5044
5045 // If we stopped clipping in at least one of the dimensions
5046 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5047 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5048 }
5049
5050 this.clippedHorizontally = clipWidth;
5051 this.clippedVertically = clipHeight;
5052
5053 return this;
5054 };
5055
5056 /**
5057 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5058 * By default, each popup has an anchor that points toward its origin.
5059 * Please see the [OOUI documentation on Mediawiki] [1] for more information and examples.
5060 *
5061 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5062 *
5063 * @example
5064 * // A popup widget.
5065 * var popup = new OO.ui.PopupWidget( {
5066 * $content: $( '<p>Hi there!</p>' ),
5067 * padded: true,
5068 * width: 300
5069 * } );
5070 *
5071 * $( 'body' ).append( popup.$element );
5072 * // To display the popup, toggle the visibility to 'true'.
5073 * popup.toggle( true );
5074 *
5075 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5076 *
5077 * @class
5078 * @extends OO.ui.Widget
5079 * @mixins OO.ui.mixin.LabelElement
5080 * @mixins OO.ui.mixin.ClippableElement
5081 * @mixins OO.ui.mixin.FloatableElement
5082 *
5083 * @constructor
5084 * @param {Object} [config] Configuration options
5085 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5086 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5087 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5088 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5089 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5090 * of $floatableContainer
5091 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5092 * of $floatableContainer
5093 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5094 * endwards (right/left) to the vertical center of $floatableContainer
5095 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5096 * startwards (left/right) to the vertical center of $floatableContainer
5097 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5098 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5099 * as possible while still keeping the anchor within the popup;
5100 * if position is before/after, move the popup as far downwards as possible.
5101 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5102 * as possible while still keeping the anchor within the popup;
5103 * if position in before/after, move the popup as far upwards as possible.
5104 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5105 * of the popup with the center of $floatableContainer.
5106 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5107 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5108 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5109 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5110 * desired direction to display the popup without clipping
5111 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5112 * See the [OOUI docs on MediaWiki][3] for an example.
5113 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5114 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5115 * @cfg {jQuery} [$content] Content to append to the popup's body
5116 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5117 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5118 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5119 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5120 * for an example.
5121 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5122 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5123 * button.
5124 * @cfg {boolean} [padded=false] Add padding to the popup's body
5125 */
5126 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5127 // Configuration initialization
5128 config = config || {};
5129
5130 // Parent constructor
5131 OO.ui.PopupWidget.parent.call( this, config );
5132
5133 // Properties (must be set before ClippableElement constructor call)
5134 this.$body = $( '<div>' );
5135 this.$popup = $( '<div>' );
5136
5137 // Mixin constructors
5138 OO.ui.mixin.LabelElement.call( this, config );
5139 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5140 $clippable: this.$body,
5141 $clippableContainer: this.$popup
5142 } ) );
5143 OO.ui.mixin.FloatableElement.call( this, config );
5144
5145 // Properties
5146 this.$anchor = $( '<div>' );
5147 // If undefined, will be computed lazily in computePosition()
5148 this.$container = config.$container;
5149 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5150 this.autoClose = !!config.autoClose;
5151 this.transitionTimeout = null;
5152 this.anchored = false;
5153 this.onMouseDownHandler = this.onMouseDown.bind( this );
5154 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5155
5156 // Initialization
5157 this.setSize( config.width, config.height );
5158 this.toggleAnchor( config.anchor === undefined || config.anchor );
5159 this.setAlignment( config.align || 'center' );
5160 this.setPosition( config.position || 'below' );
5161 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5162 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5163 this.$body.addClass( 'oo-ui-popupWidget-body' );
5164 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5165 this.$popup
5166 .addClass( 'oo-ui-popupWidget-popup' )
5167 .append( this.$body );
5168 this.$element
5169 .addClass( 'oo-ui-popupWidget' )
5170 .append( this.$popup, this.$anchor );
5171 // Move content, which was added to #$element by OO.ui.Widget, to the body
5172 // FIXME This is gross, we should use '$body' or something for the config
5173 if ( config.$content instanceof jQuery ) {
5174 this.$body.append( config.$content );
5175 }
5176
5177 if ( config.padded ) {
5178 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5179 }
5180
5181 if ( config.head ) {
5182 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5183 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5184 this.$head = $( '<div>' )
5185 .addClass( 'oo-ui-popupWidget-head' )
5186 .append( this.$label, this.closeButton.$element );
5187 this.$popup.prepend( this.$head );
5188 }
5189
5190 if ( config.$footer ) {
5191 this.$footer = $( '<div>' )
5192 .addClass( 'oo-ui-popupWidget-footer' )
5193 .append( config.$footer );
5194 this.$popup.append( this.$footer );
5195 }
5196
5197 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5198 // that reference properties not initialized at that time of parent class construction
5199 // TODO: Find a better way to handle post-constructor setup
5200 this.visible = false;
5201 this.$element.addClass( 'oo-ui-element-hidden' );
5202 };
5203
5204 /* Setup */
5205
5206 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5207 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5208 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5209 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5210
5211 /* Events */
5212
5213 /**
5214 * @event ready
5215 *
5216 * The popup is ready: it is visible and has been positioned and clipped.
5217 */
5218
5219 /* Methods */
5220
5221 /**
5222 * Handles mouse down events.
5223 *
5224 * @private
5225 * @param {MouseEvent} e Mouse down event
5226 */
5227 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5228 if (
5229 this.isVisible() &&
5230 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5231 ) {
5232 this.toggle( false );
5233 }
5234 };
5235
5236 /**
5237 * Bind mouse down listener.
5238 *
5239 * @private
5240 */
5241 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5242 // Capture clicks outside popup
5243 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5244 // We add 'click' event because iOS safari needs to respond to this event.
5245 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5246 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5247 // of occasionally not emitting 'click' properly, that event seems to be the standard
5248 // that it should be emitting, so we add it to this and will operate the event handler
5249 // on whichever of these events was triggered first
5250 this.getElementDocument().addEventListener( 'click', this.onMouseDownHandler, true );
5251 };
5252
5253 /**
5254 * Handles close button click events.
5255 *
5256 * @private
5257 */
5258 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5259 if ( this.isVisible() ) {
5260 this.toggle( false );
5261 }
5262 };
5263
5264 /**
5265 * Unbind mouse down listener.
5266 *
5267 * @private
5268 */
5269 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5270 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5271 this.getElementDocument().removeEventListener( 'click', this.onMouseDownHandler, true );
5272 };
5273
5274 /**
5275 * Handles key down events.
5276 *
5277 * @private
5278 * @param {KeyboardEvent} e Key down event
5279 */
5280 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5281 if (
5282 e.which === OO.ui.Keys.ESCAPE &&
5283 this.isVisible()
5284 ) {
5285 this.toggle( false );
5286 e.preventDefault();
5287 e.stopPropagation();
5288 }
5289 };
5290
5291 /**
5292 * Bind key down listener.
5293 *
5294 * @private
5295 */
5296 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5297 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5298 };
5299
5300 /**
5301 * Unbind key down listener.
5302 *
5303 * @private
5304 */
5305 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5306 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5307 };
5308
5309 /**
5310 * Show, hide, or toggle the visibility of the anchor.
5311 *
5312 * @param {boolean} [show] Show anchor, omit to toggle
5313 */
5314 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5315 show = show === undefined ? !this.anchored : !!show;
5316
5317 if ( this.anchored !== show ) {
5318 if ( show ) {
5319 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5320 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5321 } else {
5322 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5323 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5324 }
5325 this.anchored = show;
5326 }
5327 };
5328
5329 /**
5330 * Change which edge the anchor appears on.
5331 *
5332 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5333 */
5334 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5335 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5336 throw new Error( 'Invalid value for edge: ' + edge );
5337 }
5338 if ( this.anchorEdge !== null ) {
5339 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5340 }
5341 this.anchorEdge = edge;
5342 if ( this.anchored ) {
5343 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5344 }
5345 };
5346
5347 /**
5348 * Check if the anchor is visible.
5349 *
5350 * @return {boolean} Anchor is visible
5351 */
5352 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5353 return this.anchored;
5354 };
5355
5356 /**
5357 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5358 * `.toggle( true )` after its #$element is attached to the DOM.
5359 *
5360 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5361 * it in the right place and with the right dimensions only work correctly while it is attached.
5362 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5363 * strictly enforced, so currently it only generates a warning in the browser console.
5364 *
5365 * @fires ready
5366 * @inheritdoc
5367 */
5368 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5369 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5370 show = show === undefined ? !this.isVisible() : !!show;
5371
5372 change = show !== this.isVisible();
5373
5374 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5375 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5376 this.warnedUnattached = true;
5377 }
5378 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5379 // Fall back to the parent node if the floatableContainer is not set
5380 this.setFloatableContainer( this.$element.parent() );
5381 }
5382
5383 if ( change && show && this.autoFlip ) {
5384 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5385 // (e.g. if the user scrolled).
5386 this.isAutoFlipped = false;
5387 }
5388
5389 // Parent method
5390 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5391
5392 if ( change ) {
5393 this.togglePositioning( show && !!this.$floatableContainer );
5394
5395 if ( show ) {
5396 if ( this.autoClose ) {
5397 this.bindMouseDownListener();
5398 this.bindKeyDownListener();
5399 }
5400 this.updateDimensions();
5401 this.toggleClipping( true );
5402
5403 if ( this.autoFlip ) {
5404 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5405 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5406 // If opening the popup in the normal direction causes it to be clipped, open
5407 // in the opposite one instead
5408 normalHeight = this.$element.height();
5409 this.isAutoFlipped = !this.isAutoFlipped;
5410 this.position();
5411 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5412 // If that also causes it to be clipped, open in whichever direction
5413 // we have more space
5414 oppositeHeight = this.$element.height();
5415 if ( oppositeHeight < normalHeight ) {
5416 this.isAutoFlipped = !this.isAutoFlipped;
5417 this.position();
5418 }
5419 }
5420 }
5421 }
5422 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5423 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5424 // If opening the popup in the normal direction causes it to be clipped, open
5425 // in the opposite one instead
5426 normalWidth = this.$element.width();
5427 this.isAutoFlipped = !this.isAutoFlipped;
5428 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5429 // which causes positioning to be off. Toggle clipping back and fort to work around.
5430 this.toggleClipping( false );
5431 this.position();
5432 this.toggleClipping( true );
5433 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5434 // If that also causes it to be clipped, open in whichever direction
5435 // we have more space
5436 oppositeWidth = this.$element.width();
5437 if ( oppositeWidth < normalWidth ) {
5438 this.isAutoFlipped = !this.isAutoFlipped;
5439 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5440 // which causes positioning to be off. Toggle clipping back and fort to work around.
5441 this.toggleClipping( false );
5442 this.position();
5443 this.toggleClipping( true );
5444 }
5445 }
5446 }
5447 }
5448 }
5449
5450 this.emit( 'ready' );
5451 } else {
5452 this.toggleClipping( false );
5453 if ( this.autoClose ) {
5454 this.unbindMouseDownListener();
5455 this.unbindKeyDownListener();
5456 }
5457 }
5458 }
5459
5460 return this;
5461 };
5462
5463 /**
5464 * Set the size of the popup.
5465 *
5466 * Changing the size may also change the popup's position depending on the alignment.
5467 *
5468 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5469 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5470 * @param {boolean} [transition=false] Use a smooth transition
5471 * @chainable
5472 */
5473 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5474 this.width = width !== undefined ? width : 320;
5475 this.height = height !== undefined ? height : null;
5476 if ( this.isVisible() ) {
5477 this.updateDimensions( transition );
5478 }
5479 };
5480
5481 /**
5482 * Update the size and position.
5483 *
5484 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5485 * be called automatically.
5486 *
5487 * @param {boolean} [transition=false] Use a smooth transition
5488 * @chainable
5489 */
5490 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5491 var widget = this;
5492
5493 // Prevent transition from being interrupted
5494 clearTimeout( this.transitionTimeout );
5495 if ( transition ) {
5496 // Enable transition
5497 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5498 }
5499
5500 this.position();
5501
5502 if ( transition ) {
5503 // Prevent transitioning after transition is complete
5504 this.transitionTimeout = setTimeout( function () {
5505 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5506 }, 200 );
5507 } else {
5508 // Prevent transitioning immediately
5509 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5510 }
5511 };
5512
5513 /**
5514 * @inheritdoc
5515 */
5516 OO.ui.PopupWidget.prototype.computePosition = function () {
5517 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5518 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5519 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5520 popupPos = {},
5521 anchorCss = { left: '', right: '', top: '', bottom: '' },
5522 popupPositionOppositeMap = {
5523 above: 'below',
5524 below: 'above',
5525 before: 'after',
5526 after: 'before'
5527 },
5528 alignMap = {
5529 ltr: {
5530 'force-left': 'backwards',
5531 'force-right': 'forwards'
5532 },
5533 rtl: {
5534 'force-left': 'forwards',
5535 'force-right': 'backwards'
5536 }
5537 },
5538 anchorEdgeMap = {
5539 above: 'bottom',
5540 below: 'top',
5541 before: 'end',
5542 after: 'start'
5543 },
5544 hPosMap = {
5545 forwards: 'start',
5546 center: 'center',
5547 backwards: this.anchored ? 'before' : 'end'
5548 },
5549 vPosMap = {
5550 forwards: 'top',
5551 center: 'center',
5552 backwards: 'bottom'
5553 };
5554
5555 if ( !this.$container ) {
5556 // Lazy-initialize $container if not specified in constructor
5557 this.$container = $( this.getClosestScrollableElementContainer() );
5558 }
5559 direction = this.$container.css( 'direction' );
5560
5561 // Set height and width before we do anything else, since it might cause our measurements
5562 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5563 this.$popup.css( {
5564 width: this.width !== null ? this.width : 'auto',
5565 height: this.height !== null ? this.height : 'auto'
5566 } );
5567
5568 align = alignMap[ direction ][ this.align ] || this.align;
5569 popupPosition = this.popupPosition;
5570 if ( this.isAutoFlipped ) {
5571 popupPosition = popupPositionOppositeMap[ popupPosition ];
5572 }
5573
5574 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5575 vertical = popupPosition === 'before' || popupPosition === 'after';
5576 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5577 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5578 near = vertical ? 'top' : 'left';
5579 far = vertical ? 'bottom' : 'right';
5580 sizeProp = vertical ? 'Height' : 'Width';
5581 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5582
5583 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5584 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5585 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5586
5587 // Parent method
5588 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5589 // Find out which property FloatableElement used for positioning, and adjust that value
5590 positionProp = vertical ?
5591 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5592 ( parentPosition.left !== '' ? 'left' : 'right' );
5593
5594 // Figure out where the near and far edges of the popup and $floatableContainer are
5595 floatablePos = this.$floatableContainer.offset();
5596 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5597 // Measure where the offsetParent is and compute our position based on that and parentPosition
5598 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5599 { top: 0, left: 0 } :
5600 this.$element.offsetParent().offset();
5601
5602 if ( positionProp === near ) {
5603 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5604 popupPos[ far ] = popupPos[ near ] + popupSize;
5605 } else {
5606 popupPos[ far ] = offsetParentPos[ near ] +
5607 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5608 popupPos[ near ] = popupPos[ far ] - popupSize;
5609 }
5610
5611 if ( this.anchored ) {
5612 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5613 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5614 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5615
5616 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5617 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5618 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5619 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5620 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5621 // Not enough space for the anchor on the start side; pull the popup startwards
5622 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5623 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5624 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5625 // Not enough space for the anchor on the end side; pull the popup endwards
5626 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5627 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5628 } else {
5629 positionAdjustment = 0;
5630 }
5631 } else {
5632 positionAdjustment = 0;
5633 }
5634
5635 // Check if the popup will go beyond the edge of this.$container
5636 containerPos = this.$container[ 0 ] === document.documentElement ?
5637 { top: 0, left: 0 } :
5638 this.$container.offset();
5639 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5640 if ( this.$container[ 0 ] === document.documentElement ) {
5641 viewportSpacing = OO.ui.getViewportSpacing();
5642 containerPos[ near ] += viewportSpacing[ near ];
5643 containerPos[ far ] -= viewportSpacing[ far ];
5644 }
5645 // Take into account how much the popup will move because of the adjustments we're going to make
5646 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5647 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5648 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5649 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5650 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5651 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5652 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5653 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5654 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5655 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5656 }
5657
5658 if ( this.anchored ) {
5659 // Adjust anchorOffset for positionAdjustment
5660 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5661
5662 // Position the anchor
5663 anchorCss[ start ] = anchorOffset;
5664 this.$anchor.css( anchorCss );
5665 }
5666
5667 // Move the popup if needed
5668 parentPosition[ positionProp ] += positionAdjustment;
5669
5670 return parentPosition;
5671 };
5672
5673 /**
5674 * Set popup alignment
5675 *
5676 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5677 * `backwards` or `forwards`.
5678 */
5679 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5680 // Validate alignment
5681 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5682 this.align = align;
5683 } else {
5684 this.align = 'center';
5685 }
5686 this.position();
5687 };
5688
5689 /**
5690 * Get popup alignment
5691 *
5692 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5693 * `backwards` or `forwards`.
5694 */
5695 OO.ui.PopupWidget.prototype.getAlignment = function () {
5696 return this.align;
5697 };
5698
5699 /**
5700 * Change the positioning of the popup.
5701 *
5702 * @param {string} position 'above', 'below', 'before' or 'after'
5703 */
5704 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5705 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5706 position = 'below';
5707 }
5708 this.popupPosition = position;
5709 this.position();
5710 };
5711
5712 /**
5713 * Get popup positioning.
5714 *
5715 * @return {string} 'above', 'below', 'before' or 'after'
5716 */
5717 OO.ui.PopupWidget.prototype.getPosition = function () {
5718 return this.popupPosition;
5719 };
5720
5721 /**
5722 * Set popup auto-flipping.
5723 *
5724 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5725 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5726 * desired direction to display the popup without clipping
5727 */
5728 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5729 autoFlip = !!autoFlip;
5730
5731 if ( this.autoFlip !== autoFlip ) {
5732 this.autoFlip = autoFlip;
5733 }
5734 };
5735
5736 /**
5737 * Set which elements will not close the popup when clicked.
5738 *
5739 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5740 *
5741 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5742 */
5743 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5744 this.$autoCloseIgnore = $autoCloseIgnore;
5745 };
5746
5747 /**
5748 * Get an ID of the body element, this can be used as the
5749 * `aria-describedby` attribute for an input field.
5750 *
5751 * @return {string} The ID of the body element
5752 */
5753 OO.ui.PopupWidget.prototype.getBodyId = function () {
5754 var id = this.$body.attr( 'id' );
5755 if ( id === undefined ) {
5756 id = OO.ui.generateElementId();
5757 this.$body.attr( 'id', id );
5758 }
5759 return id;
5760 };
5761
5762 /**
5763 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5764 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5765 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5766 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5767 *
5768 * @abstract
5769 * @class
5770 *
5771 * @constructor
5772 * @param {Object} [config] Configuration options
5773 * @cfg {Object} [popup] Configuration to pass to popup
5774 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5775 */
5776 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5777 // Configuration initialization
5778 config = config || {};
5779
5780 // Properties
5781 this.popup = new OO.ui.PopupWidget( $.extend(
5782 {
5783 autoClose: true,
5784 $floatableContainer: this.$element
5785 },
5786 config.popup,
5787 {
5788 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5789 }
5790 ) );
5791 };
5792
5793 /* Methods */
5794
5795 /**
5796 * Get popup.
5797 *
5798 * @return {OO.ui.PopupWidget} Popup widget
5799 */
5800 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5801 return this.popup;
5802 };
5803
5804 /**
5805 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5806 * which is used to display additional information or options.
5807 *
5808 * @example
5809 * // Example of a popup button.
5810 * var popupButton = new OO.ui.PopupButtonWidget( {
5811 * label: 'Popup button with options',
5812 * icon: 'menu',
5813 * popup: {
5814 * $content: $( '<p>Additional options here.</p>' ),
5815 * padded: true,
5816 * align: 'force-left'
5817 * }
5818 * } );
5819 * // Append the button to the DOM.
5820 * $( 'body' ).append( popupButton.$element );
5821 *
5822 * @class
5823 * @extends OO.ui.ButtonWidget
5824 * @mixins OO.ui.mixin.PopupElement
5825 *
5826 * @constructor
5827 * @param {Object} [config] Configuration options
5828 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5829 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5830 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5831 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5832 */
5833 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5834 // Configuration initialization
5835 config = config || {};
5836
5837 // Parent constructor
5838 OO.ui.PopupButtonWidget.parent.call( this, config );
5839
5840 // Mixin constructors
5841 OO.ui.mixin.PopupElement.call( this, config );
5842
5843 // Properties
5844 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5845
5846 // Events
5847 this.connect( this, { click: 'onAction' } );
5848
5849 // Initialization
5850 this.$element
5851 .addClass( 'oo-ui-popupButtonWidget' );
5852 this.popup.$element
5853 .addClass( 'oo-ui-popupButtonWidget-popup' )
5854 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5855 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5856 this.$overlay.append( this.popup.$element );
5857 };
5858
5859 /* Setup */
5860
5861 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5862 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5863
5864 /* Methods */
5865
5866 /**
5867 * Handle the button action being triggered.
5868 *
5869 * @private
5870 */
5871 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5872 this.popup.toggle();
5873 };
5874
5875 /**
5876 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5877 *
5878 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5879 *
5880 * @private
5881 * @abstract
5882 * @class
5883 * @mixins OO.ui.mixin.GroupElement
5884 *
5885 * @constructor
5886 * @param {Object} [config] Configuration options
5887 */
5888 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5889 // Mixin constructors
5890 OO.ui.mixin.GroupElement.call( this, config );
5891 };
5892
5893 /* Setup */
5894
5895 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5896
5897 /* Methods */
5898
5899 /**
5900 * Set the disabled state of the widget.
5901 *
5902 * This will also update the disabled state of child widgets.
5903 *
5904 * @param {boolean} disabled Disable widget
5905 * @chainable
5906 */
5907 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5908 var i, len;
5909
5910 // Parent method
5911 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5912 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5913
5914 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5915 if ( this.items ) {
5916 for ( i = 0, len = this.items.length; i < len; i++ ) {
5917 this.items[ i ].updateDisabled();
5918 }
5919 }
5920
5921 return this;
5922 };
5923
5924 /**
5925 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5926 *
5927 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5928 * allows bidirectional communication.
5929 *
5930 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5931 *
5932 * @private
5933 * @abstract
5934 * @class
5935 *
5936 * @constructor
5937 */
5938 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5939 //
5940 };
5941
5942 /* Methods */
5943
5944 /**
5945 * Check if widget is disabled.
5946 *
5947 * Checks parent if present, making disabled state inheritable.
5948 *
5949 * @return {boolean} Widget is disabled
5950 */
5951 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5952 return this.disabled ||
5953 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5954 };
5955
5956 /**
5957 * Set group element is in.
5958 *
5959 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5960 * @chainable
5961 */
5962 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5963 // Parent method
5964 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5965 OO.ui.Element.prototype.setElementGroup.call( this, group );
5966
5967 // Initialize item disabled states
5968 this.updateDisabled();
5969
5970 return this;
5971 };
5972
5973 /**
5974 * OptionWidgets are special elements that can be selected and configured with data. The
5975 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5976 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5977 * and examples, please see the [OOUI documentation on MediaWiki][1].
5978 *
5979 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
5980 *
5981 * @class
5982 * @extends OO.ui.Widget
5983 * @mixins OO.ui.mixin.ItemWidget
5984 * @mixins OO.ui.mixin.LabelElement
5985 * @mixins OO.ui.mixin.FlaggedElement
5986 * @mixins OO.ui.mixin.AccessKeyedElement
5987 *
5988 * @constructor
5989 * @param {Object} [config] Configuration options
5990 */
5991 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5992 // Configuration initialization
5993 config = config || {};
5994
5995 // Parent constructor
5996 OO.ui.OptionWidget.parent.call( this, config );
5997
5998 // Mixin constructors
5999 OO.ui.mixin.ItemWidget.call( this );
6000 OO.ui.mixin.LabelElement.call( this, config );
6001 OO.ui.mixin.FlaggedElement.call( this, config );
6002 OO.ui.mixin.AccessKeyedElement.call( this, config );
6003
6004 // Properties
6005 this.selected = false;
6006 this.highlighted = false;
6007 this.pressed = false;
6008
6009 // Initialization
6010 this.$element
6011 .data( 'oo-ui-optionWidget', this )
6012 // Allow programmatic focussing (and by accesskey), but not tabbing
6013 .attr( 'tabindex', '-1' )
6014 .attr( 'role', 'option' )
6015 .attr( 'aria-selected', 'false' )
6016 .addClass( 'oo-ui-optionWidget' )
6017 .append( this.$label );
6018 };
6019
6020 /* Setup */
6021
6022 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6023 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6024 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6025 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6026 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6027
6028 /* Static Properties */
6029
6030 /**
6031 * Whether this option can be selected. See #setSelected.
6032 *
6033 * @static
6034 * @inheritable
6035 * @property {boolean}
6036 */
6037 OO.ui.OptionWidget.static.selectable = true;
6038
6039 /**
6040 * Whether this option can be highlighted. See #setHighlighted.
6041 *
6042 * @static
6043 * @inheritable
6044 * @property {boolean}
6045 */
6046 OO.ui.OptionWidget.static.highlightable = true;
6047
6048 /**
6049 * Whether this option can be pressed. See #setPressed.
6050 *
6051 * @static
6052 * @inheritable
6053 * @property {boolean}
6054 */
6055 OO.ui.OptionWidget.static.pressable = true;
6056
6057 /**
6058 * Whether this option will be scrolled into view when it is selected.
6059 *
6060 * @static
6061 * @inheritable
6062 * @property {boolean}
6063 */
6064 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6065
6066 /* Methods */
6067
6068 /**
6069 * Check if the option can be selected.
6070 *
6071 * @return {boolean} Item is selectable
6072 */
6073 OO.ui.OptionWidget.prototype.isSelectable = function () {
6074 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6075 };
6076
6077 /**
6078 * Check if the option can be highlighted. A highlight indicates that the option
6079 * may be selected when a user presses enter or clicks. Disabled items cannot
6080 * be highlighted.
6081 *
6082 * @return {boolean} Item is highlightable
6083 */
6084 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6085 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6086 };
6087
6088 /**
6089 * Check if the option can be pressed. The pressed state occurs when a user mouses
6090 * down on an item, but has not yet let go of the mouse.
6091 *
6092 * @return {boolean} Item is pressable
6093 */
6094 OO.ui.OptionWidget.prototype.isPressable = function () {
6095 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6096 };
6097
6098 /**
6099 * Check if the option is selected.
6100 *
6101 * @return {boolean} Item is selected
6102 */
6103 OO.ui.OptionWidget.prototype.isSelected = function () {
6104 return this.selected;
6105 };
6106
6107 /**
6108 * Check if the option is highlighted. A highlight indicates that the
6109 * item may be selected when a user presses enter or clicks.
6110 *
6111 * @return {boolean} Item is highlighted
6112 */
6113 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6114 return this.highlighted;
6115 };
6116
6117 /**
6118 * Check if the option is pressed. The pressed state occurs when a user mouses
6119 * down on an item, but has not yet let go of the mouse. The item may appear
6120 * selected, but it will not be selected until the user releases the mouse.
6121 *
6122 * @return {boolean} Item is pressed
6123 */
6124 OO.ui.OptionWidget.prototype.isPressed = function () {
6125 return this.pressed;
6126 };
6127
6128 /**
6129 * Set the option’s selected state. In general, all modifications to the selection
6130 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6131 * method instead of this method.
6132 *
6133 * @param {boolean} [state=false] Select option
6134 * @chainable
6135 */
6136 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6137 if ( this.constructor.static.selectable ) {
6138 this.selected = !!state;
6139 this.$element
6140 .toggleClass( 'oo-ui-optionWidget-selected', state )
6141 .attr( 'aria-selected', state.toString() );
6142 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6143 this.scrollElementIntoView();
6144 }
6145 this.updateThemeClasses();
6146 }
6147 return this;
6148 };
6149
6150 /**
6151 * Set the option’s highlighted state. In general, all programmatic
6152 * modifications to the highlight should be handled by the
6153 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6154 * method instead of this method.
6155 *
6156 * @param {boolean} [state=false] Highlight option
6157 * @chainable
6158 */
6159 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6160 if ( this.constructor.static.highlightable ) {
6161 this.highlighted = !!state;
6162 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6163 this.updateThemeClasses();
6164 }
6165 return this;
6166 };
6167
6168 /**
6169 * Set the option’s pressed state. In general, all
6170 * programmatic modifications to the pressed state should be handled by the
6171 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6172 * method instead of this method.
6173 *
6174 * @param {boolean} [state=false] Press option
6175 * @chainable
6176 */
6177 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6178 if ( this.constructor.static.pressable ) {
6179 this.pressed = !!state;
6180 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6181 this.updateThemeClasses();
6182 }
6183 return this;
6184 };
6185
6186 /**
6187 * Get text to match search strings against.
6188 *
6189 * The default implementation returns the label text, but subclasses
6190 * can override this to provide more complex behavior.
6191 *
6192 * @return {string|boolean} String to match search string against
6193 */
6194 OO.ui.OptionWidget.prototype.getMatchText = function () {
6195 var label = this.getLabel();
6196 return typeof label === 'string' ? label : this.$label.text();
6197 };
6198
6199 /**
6200 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6201 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6202 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6203 * menu selects}.
6204 *
6205 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6206 * information, please see the [OOUI documentation on MediaWiki][1].
6207 *
6208 * @example
6209 * // Example of a select widget with three options
6210 * var select = new OO.ui.SelectWidget( {
6211 * items: [
6212 * new OO.ui.OptionWidget( {
6213 * data: 'a',
6214 * label: 'Option One',
6215 * } ),
6216 * new OO.ui.OptionWidget( {
6217 * data: 'b',
6218 * label: 'Option Two',
6219 * } ),
6220 * new OO.ui.OptionWidget( {
6221 * data: 'c',
6222 * label: 'Option Three',
6223 * } )
6224 * ]
6225 * } );
6226 * $( 'body' ).append( select.$element );
6227 *
6228 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6229 *
6230 * @abstract
6231 * @class
6232 * @extends OO.ui.Widget
6233 * @mixins OO.ui.mixin.GroupWidget
6234 *
6235 * @constructor
6236 * @param {Object} [config] Configuration options
6237 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6238 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6239 * the [OOUI documentation on MediaWiki] [2] for examples.
6240 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6241 */
6242 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6243 // Configuration initialization
6244 config = config || {};
6245
6246 // Parent constructor
6247 OO.ui.SelectWidget.parent.call( this, config );
6248
6249 // Mixin constructors
6250 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6251
6252 // Properties
6253 this.pressed = false;
6254 this.selecting = null;
6255 this.onMouseUpHandler = this.onMouseUp.bind( this );
6256 this.onMouseMoveHandler = this.onMouseMove.bind( this );
6257 this.onKeyDownHandler = this.onKeyDown.bind( this );
6258 this.onKeyPressHandler = this.onKeyPress.bind( this );
6259 this.keyPressBuffer = '';
6260 this.keyPressBufferTimer = null;
6261 this.blockMouseOverEvents = 0;
6262
6263 // Events
6264 this.connect( this, {
6265 toggle: 'onToggle'
6266 } );
6267 this.$element.on( {
6268 focusin: this.onFocus.bind( this ),
6269 mousedown: this.onMouseDown.bind( this ),
6270 mouseover: this.onMouseOver.bind( this ),
6271 mouseleave: this.onMouseLeave.bind( this )
6272 } );
6273
6274 // Initialization
6275 this.$element
6276 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6277 .attr( 'role', 'listbox' );
6278 this.setFocusOwner( this.$element );
6279 if ( Array.isArray( config.items ) ) {
6280 this.addItems( config.items );
6281 }
6282 };
6283
6284 /* Setup */
6285
6286 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6287 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6288
6289 /* Events */
6290
6291 /**
6292 * @event highlight
6293 *
6294 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6295 *
6296 * @param {OO.ui.OptionWidget|null} item Highlighted item
6297 */
6298
6299 /**
6300 * @event press
6301 *
6302 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6303 * pressed state of an option.
6304 *
6305 * @param {OO.ui.OptionWidget|null} item Pressed item
6306 */
6307
6308 /**
6309 * @event select
6310 *
6311 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6312 *
6313 * @param {OO.ui.OptionWidget|null} item Selected item
6314 */
6315
6316 /**
6317 * @event choose
6318 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6319 * @param {OO.ui.OptionWidget} item Chosen item
6320 */
6321
6322 /**
6323 * @event add
6324 *
6325 * An `add` event is emitted when options are added to the select with the #addItems method.
6326 *
6327 * @param {OO.ui.OptionWidget[]} items Added items
6328 * @param {number} index Index of insertion point
6329 */
6330
6331 /**
6332 * @event remove
6333 *
6334 * A `remove` event is emitted when options are removed from the select with the #clearItems
6335 * or #removeItems methods.
6336 *
6337 * @param {OO.ui.OptionWidget[]} items Removed items
6338 */
6339
6340 /* Methods */
6341
6342 /**
6343 * Handle focus events
6344 *
6345 * @private
6346 * @param {jQuery.Event} event
6347 */
6348 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6349 var item;
6350 if ( event.target === this.$element[ 0 ] ) {
6351 // This widget was focussed, e.g. by the user tabbing to it.
6352 // The styles for focus state depend on one of the items being selected.
6353 if ( !this.findSelectedItem() ) {
6354 item = this.findFirstSelectableItem();
6355 }
6356 } else {
6357 if ( event.target.tabIndex === -1 ) {
6358 // One of the options got focussed (and the event bubbled up here).
6359 // They can't be tabbed to, but they can be activated using accesskeys.
6360 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6361 item = this.findTargetItem( event );
6362 } else {
6363 // There is something actually user-focusable in one of the labels of the options, and the
6364 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6365 return;
6366 }
6367 }
6368
6369 if ( item ) {
6370 if ( item.constructor.static.highlightable ) {
6371 this.highlightItem( item );
6372 } else {
6373 this.selectItem( item );
6374 }
6375 }
6376
6377 if ( event.target !== this.$element[ 0 ] ) {
6378 this.$focusOwner.focus();
6379 }
6380 };
6381
6382 /**
6383 * Handle mouse down events.
6384 *
6385 * @private
6386 * @param {jQuery.Event} e Mouse down event
6387 */
6388 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6389 var item;
6390
6391 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6392 this.togglePressed( true );
6393 item = this.findTargetItem( e );
6394 if ( item && item.isSelectable() ) {
6395 this.pressItem( item );
6396 this.selecting = item;
6397 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6398 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6399 }
6400 }
6401 return false;
6402 };
6403
6404 /**
6405 * Handle mouse up events.
6406 *
6407 * @private
6408 * @param {MouseEvent} e Mouse up event
6409 */
6410 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6411 var item;
6412
6413 this.togglePressed( false );
6414 if ( !this.selecting ) {
6415 item = this.findTargetItem( e );
6416 if ( item && item.isSelectable() ) {
6417 this.selecting = item;
6418 }
6419 }
6420 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6421 this.pressItem( null );
6422 this.chooseItem( this.selecting );
6423 this.selecting = null;
6424 }
6425
6426 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6427 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6428
6429 return false;
6430 };
6431
6432 /**
6433 * Handle mouse move events.
6434 *
6435 * @private
6436 * @param {MouseEvent} e Mouse move event
6437 */
6438 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6439 var item;
6440
6441 if ( !this.isDisabled() && this.pressed ) {
6442 item = this.findTargetItem( e );
6443 if ( item && item !== this.selecting && item.isSelectable() ) {
6444 this.pressItem( item );
6445 this.selecting = item;
6446 }
6447 }
6448 };
6449
6450 /**
6451 * Handle mouse over events.
6452 *
6453 * @private
6454 * @param {jQuery.Event} e Mouse over event
6455 */
6456 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6457 var item;
6458 if ( this.blockMouseOverEvents ) {
6459 return;
6460 }
6461 if ( !this.isDisabled() ) {
6462 item = this.findTargetItem( e );
6463 this.highlightItem( item && item.isHighlightable() ? item : null );
6464 }
6465 return false;
6466 };
6467
6468 /**
6469 * Handle mouse leave events.
6470 *
6471 * @private
6472 * @param {jQuery.Event} e Mouse over event
6473 */
6474 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6475 if ( !this.isDisabled() ) {
6476 this.highlightItem( null );
6477 }
6478 return false;
6479 };
6480
6481 /**
6482 * Handle key down events.
6483 *
6484 * @protected
6485 * @param {KeyboardEvent} e Key down event
6486 */
6487 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6488 var nextItem,
6489 handled = false,
6490 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6491
6492 if ( !this.isDisabled() && this.isVisible() ) {
6493 switch ( e.keyCode ) {
6494 case OO.ui.Keys.ENTER:
6495 if ( currentItem && currentItem.constructor.static.highlightable ) {
6496 // Was only highlighted, now let's select it. No-op if already selected.
6497 this.chooseItem( currentItem );
6498 handled = true;
6499 }
6500 break;
6501 case OO.ui.Keys.UP:
6502 case OO.ui.Keys.LEFT:
6503 this.clearKeyPressBuffer();
6504 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6505 handled = true;
6506 break;
6507 case OO.ui.Keys.DOWN:
6508 case OO.ui.Keys.RIGHT:
6509 this.clearKeyPressBuffer();
6510 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6511 handled = true;
6512 break;
6513 case OO.ui.Keys.ESCAPE:
6514 case OO.ui.Keys.TAB:
6515 if ( currentItem && currentItem.constructor.static.highlightable ) {
6516 currentItem.setHighlighted( false );
6517 }
6518 this.unbindKeyDownListener();
6519 this.unbindKeyPressListener();
6520 // Don't prevent tabbing away / defocusing
6521 handled = false;
6522 break;
6523 }
6524
6525 if ( nextItem ) {
6526 if ( nextItem.constructor.static.highlightable ) {
6527 this.highlightItem( nextItem );
6528 } else {
6529 this.chooseItem( nextItem );
6530 }
6531 this.scrollItemIntoView( nextItem );
6532 }
6533
6534 if ( handled ) {
6535 e.preventDefault();
6536 e.stopPropagation();
6537 }
6538 }
6539 };
6540
6541 /**
6542 * Bind key down listener.
6543 *
6544 * @protected
6545 */
6546 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6547 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6548 };
6549
6550 /**
6551 * Unbind key down listener.
6552 *
6553 * @protected
6554 */
6555 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6556 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6557 };
6558
6559 /**
6560 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6561 *
6562 * @param {OO.ui.OptionWidget} item Item to scroll into view
6563 */
6564 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6565 var widget = this;
6566 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6567 // and around 100-150 ms after it is finished.
6568 this.blockMouseOverEvents++;
6569 item.scrollElementIntoView().done( function () {
6570 setTimeout( function () {
6571 widget.blockMouseOverEvents--;
6572 }, 200 );
6573 } );
6574 };
6575
6576 /**
6577 * Clear the key-press buffer
6578 *
6579 * @protected
6580 */
6581 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6582 if ( this.keyPressBufferTimer ) {
6583 clearTimeout( this.keyPressBufferTimer );
6584 this.keyPressBufferTimer = null;
6585 }
6586 this.keyPressBuffer = '';
6587 };
6588
6589 /**
6590 * Handle key press events.
6591 *
6592 * @protected
6593 * @param {KeyboardEvent} e Key press event
6594 */
6595 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6596 var c, filter, item;
6597
6598 if ( !e.charCode ) {
6599 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6600 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6601 return false;
6602 }
6603 return;
6604 }
6605 if ( String.fromCodePoint ) {
6606 c = String.fromCodePoint( e.charCode );
6607 } else {
6608 c = String.fromCharCode( e.charCode );
6609 }
6610
6611 if ( this.keyPressBufferTimer ) {
6612 clearTimeout( this.keyPressBufferTimer );
6613 }
6614 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6615
6616 item = this.findHighlightedItem() || this.findSelectedItem();
6617
6618 if ( this.keyPressBuffer === c ) {
6619 // Common (if weird) special case: typing "xxxx" will cycle through all
6620 // the items beginning with "x".
6621 if ( item ) {
6622 item = this.findRelativeSelectableItem( item, 1 );
6623 }
6624 } else {
6625 this.keyPressBuffer += c;
6626 }
6627
6628 filter = this.getItemMatcher( this.keyPressBuffer, false );
6629 if ( !item || !filter( item ) ) {
6630 item = this.findRelativeSelectableItem( item, 1, filter );
6631 }
6632 if ( item ) {
6633 if ( this.isVisible() && item.constructor.static.highlightable ) {
6634 this.highlightItem( item );
6635 } else {
6636 this.chooseItem( item );
6637 }
6638 this.scrollItemIntoView( item );
6639 }
6640
6641 e.preventDefault();
6642 e.stopPropagation();
6643 };
6644
6645 /**
6646 * Get a matcher for the specific string
6647 *
6648 * @protected
6649 * @param {string} s String to match against items
6650 * @param {boolean} [exact=false] Only accept exact matches
6651 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6652 */
6653 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6654 var re;
6655
6656 if ( s.normalize ) {
6657 s = s.normalize();
6658 }
6659 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6660 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6661 if ( exact ) {
6662 re += '\\s*$';
6663 }
6664 re = new RegExp( re, 'i' );
6665 return function ( item ) {
6666 var matchText = item.getMatchText();
6667 if ( matchText.normalize ) {
6668 matchText = matchText.normalize();
6669 }
6670 return re.test( matchText );
6671 };
6672 };
6673
6674 /**
6675 * Bind key press listener.
6676 *
6677 * @protected
6678 */
6679 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6680 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6681 };
6682
6683 /**
6684 * Unbind key down listener.
6685 *
6686 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6687 * implementation.
6688 *
6689 * @protected
6690 */
6691 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6692 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6693 this.clearKeyPressBuffer();
6694 };
6695
6696 /**
6697 * Visibility change handler
6698 *
6699 * @protected
6700 * @param {boolean} visible
6701 */
6702 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6703 if ( !visible ) {
6704 this.clearKeyPressBuffer();
6705 }
6706 };
6707
6708 /**
6709 * Get the closest item to a jQuery.Event.
6710 *
6711 * @private
6712 * @param {jQuery.Event} e
6713 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6714 */
6715 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6716 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6717 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6718 return null;
6719 }
6720 return $option.data( 'oo-ui-optionWidget' ) || null;
6721 };
6722
6723 /**
6724 * Find selected item.
6725 *
6726 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6727 */
6728 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6729 var i, len;
6730
6731 for ( i = 0, len = this.items.length; i < len; i++ ) {
6732 if ( this.items[ i ].isSelected() ) {
6733 return this.items[ i ];
6734 }
6735 }
6736 return null;
6737 };
6738
6739 /**
6740 * Find highlighted item.
6741 *
6742 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6743 */
6744 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6745 var i, len;
6746
6747 for ( i = 0, len = this.items.length; i < len; i++ ) {
6748 if ( this.items[ i ].isHighlighted() ) {
6749 return this.items[ i ];
6750 }
6751 }
6752 return null;
6753 };
6754
6755 /**
6756 * Toggle pressed state.
6757 *
6758 * Press is a state that occurs when a user mouses down on an item, but
6759 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6760 * until the user releases the mouse.
6761 *
6762 * @param {boolean} pressed An option is being pressed
6763 */
6764 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6765 if ( pressed === undefined ) {
6766 pressed = !this.pressed;
6767 }
6768 if ( pressed !== this.pressed ) {
6769 this.$element
6770 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6771 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6772 this.pressed = pressed;
6773 }
6774 };
6775
6776 /**
6777 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6778 * and any existing highlight will be removed. The highlight is mutually exclusive.
6779 *
6780 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6781 * @fires highlight
6782 * @chainable
6783 */
6784 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6785 var i, len, highlighted,
6786 changed = false;
6787
6788 for ( i = 0, len = this.items.length; i < len; i++ ) {
6789 highlighted = this.items[ i ] === item;
6790 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6791 this.items[ i ].setHighlighted( highlighted );
6792 changed = true;
6793 }
6794 }
6795 if ( changed ) {
6796 if ( item ) {
6797 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6798 } else {
6799 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6800 }
6801 this.emit( 'highlight', item );
6802 }
6803
6804 return this;
6805 };
6806
6807 /**
6808 * Fetch an item by its label.
6809 *
6810 * @param {string} label Label of the item to select.
6811 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6812 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6813 */
6814 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6815 var i, item, found,
6816 len = this.items.length,
6817 filter = this.getItemMatcher( label, true );
6818
6819 for ( i = 0; i < len; i++ ) {
6820 item = this.items[ i ];
6821 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6822 return item;
6823 }
6824 }
6825
6826 if ( prefix ) {
6827 found = null;
6828 filter = this.getItemMatcher( label, false );
6829 for ( i = 0; i < len; i++ ) {
6830 item = this.items[ i ];
6831 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6832 if ( found ) {
6833 return null;
6834 }
6835 found = item;
6836 }
6837 }
6838 if ( found ) {
6839 return found;
6840 }
6841 }
6842
6843 return null;
6844 };
6845
6846 /**
6847 * Programmatically select an option by its label. If the item does not exist,
6848 * all options will be deselected.
6849 *
6850 * @param {string} [label] Label of the item to select.
6851 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6852 * @fires select
6853 * @chainable
6854 */
6855 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6856 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6857 if ( label === undefined || !itemFromLabel ) {
6858 return this.selectItem();
6859 }
6860 return this.selectItem( itemFromLabel );
6861 };
6862
6863 /**
6864 * Programmatically select an option by its data. If the `data` parameter is omitted,
6865 * or if the item does not exist, all options will be deselected.
6866 *
6867 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6868 * @fires select
6869 * @chainable
6870 */
6871 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6872 var itemFromData = this.findItemFromData( data );
6873 if ( data === undefined || !itemFromData ) {
6874 return this.selectItem();
6875 }
6876 return this.selectItem( itemFromData );
6877 };
6878
6879 /**
6880 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6881 * all options will be deselected.
6882 *
6883 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6884 * @fires select
6885 * @chainable
6886 */
6887 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6888 var i, len, selected,
6889 changed = false;
6890
6891 for ( i = 0, len = this.items.length; i < len; i++ ) {
6892 selected = this.items[ i ] === item;
6893 if ( this.items[ i ].isSelected() !== selected ) {
6894 this.items[ i ].setSelected( selected );
6895 changed = true;
6896 }
6897 }
6898 if ( changed ) {
6899 if ( item && !item.constructor.static.highlightable ) {
6900 if ( item ) {
6901 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6902 } else {
6903 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6904 }
6905 }
6906 this.emit( 'select', item );
6907 }
6908
6909 return this;
6910 };
6911
6912 /**
6913 * Press an item.
6914 *
6915 * Press is a state that occurs when a user mouses down on an item, but has not
6916 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6917 * releases the mouse.
6918 *
6919 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6920 * @fires press
6921 * @chainable
6922 */
6923 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6924 var i, len, pressed,
6925 changed = false;
6926
6927 for ( i = 0, len = this.items.length; i < len; i++ ) {
6928 pressed = this.items[ i ] === item;
6929 if ( this.items[ i ].isPressed() !== pressed ) {
6930 this.items[ i ].setPressed( pressed );
6931 changed = true;
6932 }
6933 }
6934 if ( changed ) {
6935 this.emit( 'press', item );
6936 }
6937
6938 return this;
6939 };
6940
6941 /**
6942 * Choose an item.
6943 *
6944 * Note that ‘choose’ should never be modified programmatically. A user can choose
6945 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6946 * use the #selectItem method.
6947 *
6948 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6949 * when users choose an item with the keyboard or mouse.
6950 *
6951 * @param {OO.ui.OptionWidget} item Item to choose
6952 * @fires choose
6953 * @chainable
6954 */
6955 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6956 if ( item ) {
6957 this.selectItem( item );
6958 this.emit( 'choose', item );
6959 }
6960
6961 return this;
6962 };
6963
6964 /**
6965 * Find an option by its position relative to the specified item (or to the start of the option array,
6966 * if item is `null`). The direction in which to search through the option array is specified with a
6967 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6968 * `null` if there are no options in the array.
6969 *
6970 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6971 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6972 * @param {Function} [filter] Only consider items for which this function returns
6973 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6974 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6975 */
6976 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6977 var currentIndex, nextIndex, i,
6978 increase = direction > 0 ? 1 : -1,
6979 len = this.items.length;
6980
6981 if ( item instanceof OO.ui.OptionWidget ) {
6982 currentIndex = this.items.indexOf( item );
6983 nextIndex = ( currentIndex + increase + len ) % len;
6984 } else {
6985 // If no item is selected and moving forward, start at the beginning.
6986 // If moving backward, start at the end.
6987 nextIndex = direction > 0 ? 0 : len - 1;
6988 }
6989
6990 for ( i = 0; i < len; i++ ) {
6991 item = this.items[ nextIndex ];
6992 if (
6993 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6994 ( !filter || filter( item ) )
6995 ) {
6996 return item;
6997 }
6998 nextIndex = ( nextIndex + increase + len ) % len;
6999 }
7000 return null;
7001 };
7002
7003 /**
7004 * Find the next selectable item or `null` if there are no selectable items.
7005 * Disabled options and menu-section markers and breaks are not selectable.
7006 *
7007 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7008 */
7009 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7010 return this.findRelativeSelectableItem( null, 1 );
7011 };
7012
7013 /**
7014 * Add an array of options to the select. Optionally, an index number can be used to
7015 * specify an insertion point.
7016 *
7017 * @param {OO.ui.OptionWidget[]} items Items to add
7018 * @param {number} [index] Index to insert items after
7019 * @fires add
7020 * @chainable
7021 */
7022 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7023 // Mixin method
7024 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7025
7026 // Always provide an index, even if it was omitted
7027 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7028
7029 return this;
7030 };
7031
7032 /**
7033 * Remove the specified array of options from the select. Options will be detached
7034 * from the DOM, not removed, so they can be reused later. To remove all options from
7035 * the select, you may wish to use the #clearItems method instead.
7036 *
7037 * @param {OO.ui.OptionWidget[]} items Items to remove
7038 * @fires remove
7039 * @chainable
7040 */
7041 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7042 var i, len, item;
7043
7044 // Deselect items being removed
7045 for ( i = 0, len = items.length; i < len; i++ ) {
7046 item = items[ i ];
7047 if ( item.isSelected() ) {
7048 this.selectItem( null );
7049 }
7050 }
7051
7052 // Mixin method
7053 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7054
7055 this.emit( 'remove', items );
7056
7057 return this;
7058 };
7059
7060 /**
7061 * Clear all options from the select. Options will be detached from the DOM, not removed,
7062 * so that they can be reused later. To remove a subset of options from the select, use
7063 * the #removeItems method.
7064 *
7065 * @fires remove
7066 * @chainable
7067 */
7068 OO.ui.SelectWidget.prototype.clearItems = function () {
7069 var items = this.items.slice();
7070
7071 // Mixin method
7072 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7073
7074 // Clear selection
7075 this.selectItem( null );
7076
7077 this.emit( 'remove', items );
7078
7079 return this;
7080 };
7081
7082 /**
7083 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7084 *
7085 * Currently this is just used to set `aria-activedescendant` on it.
7086 *
7087 * @protected
7088 * @param {jQuery} $focusOwner
7089 */
7090 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7091 this.$focusOwner = $focusOwner;
7092 };
7093
7094 /**
7095 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7096 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7097 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7098 * options. For more information about options and selects, please see the
7099 * [OOUI documentation on MediaWiki][1].
7100 *
7101 * @example
7102 * // Decorated options in a select widget
7103 * var select = new OO.ui.SelectWidget( {
7104 * items: [
7105 * new OO.ui.DecoratedOptionWidget( {
7106 * data: 'a',
7107 * label: 'Option with icon',
7108 * icon: 'help'
7109 * } ),
7110 * new OO.ui.DecoratedOptionWidget( {
7111 * data: 'b',
7112 * label: 'Option with indicator',
7113 * indicator: 'next'
7114 * } )
7115 * ]
7116 * } );
7117 * $( 'body' ).append( select.$element );
7118 *
7119 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7120 *
7121 * @class
7122 * @extends OO.ui.OptionWidget
7123 * @mixins OO.ui.mixin.IconElement
7124 * @mixins OO.ui.mixin.IndicatorElement
7125 *
7126 * @constructor
7127 * @param {Object} [config] Configuration options
7128 */
7129 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7130 // Parent constructor
7131 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7132
7133 // Mixin constructors
7134 OO.ui.mixin.IconElement.call( this, config );
7135 OO.ui.mixin.IndicatorElement.call( this, config );
7136
7137 // Initialization
7138 this.$element
7139 .addClass( 'oo-ui-decoratedOptionWidget' )
7140 .prepend( this.$icon )
7141 .append( this.$indicator );
7142 };
7143
7144 /* Setup */
7145
7146 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7147 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7148 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7149
7150 /**
7151 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7152 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7153 * the [OOUI documentation on MediaWiki] [1] for more information.
7154 *
7155 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7156 *
7157 * @class
7158 * @extends OO.ui.DecoratedOptionWidget
7159 *
7160 * @constructor
7161 * @param {Object} [config] Configuration options
7162 */
7163 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7164 // Parent constructor
7165 OO.ui.MenuOptionWidget.parent.call( this, config );
7166
7167 // Properties
7168 this.checkIcon = new OO.ui.IconWidget( {
7169 icon: 'check',
7170 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7171 } );
7172
7173 // Initialization
7174 this.$element
7175 .prepend( this.checkIcon.$element )
7176 .addClass( 'oo-ui-menuOptionWidget' );
7177 };
7178
7179 /* Setup */
7180
7181 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7182
7183 /* Static Properties */
7184
7185 /**
7186 * @static
7187 * @inheritdoc
7188 */
7189 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7190
7191 /**
7192 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7193 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7194 *
7195 * @example
7196 * var myDropdown = new OO.ui.DropdownWidget( {
7197 * menu: {
7198 * items: [
7199 * new OO.ui.MenuSectionOptionWidget( {
7200 * label: 'Dogs'
7201 * } ),
7202 * new OO.ui.MenuOptionWidget( {
7203 * data: 'corgi',
7204 * label: 'Welsh Corgi'
7205 * } ),
7206 * new OO.ui.MenuOptionWidget( {
7207 * data: 'poodle',
7208 * label: 'Standard Poodle'
7209 * } ),
7210 * new OO.ui.MenuSectionOptionWidget( {
7211 * label: 'Cats'
7212 * } ),
7213 * new OO.ui.MenuOptionWidget( {
7214 * data: 'lion',
7215 * label: 'Lion'
7216 * } )
7217 * ]
7218 * }
7219 * } );
7220 * $( 'body' ).append( myDropdown.$element );
7221 *
7222 * @class
7223 * @extends OO.ui.DecoratedOptionWidget
7224 *
7225 * @constructor
7226 * @param {Object} [config] Configuration options
7227 */
7228 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7229 // Parent constructor
7230 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7231
7232 // Initialization
7233 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7234 .removeAttr( 'role aria-selected' );
7235 };
7236
7237 /* Setup */
7238
7239 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7240
7241 /* Static Properties */
7242
7243 /**
7244 * @static
7245 * @inheritdoc
7246 */
7247 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7248
7249 /**
7250 * @static
7251 * @inheritdoc
7252 */
7253 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7254
7255 /**
7256 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7257 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7258 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7259 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7260 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7261 * and customized to be opened, closed, and displayed as needed.
7262 *
7263 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7264 * mouse outside the menu.
7265 *
7266 * Menus also have support for keyboard interaction:
7267 *
7268 * - Enter/Return key: choose and select a menu option
7269 * - Up-arrow key: highlight the previous menu option
7270 * - Down-arrow key: highlight the next menu option
7271 * - Esc key: hide the menu
7272 *
7273 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7274 *
7275 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7276 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7277 *
7278 * @class
7279 * @extends OO.ui.SelectWidget
7280 * @mixins OO.ui.mixin.ClippableElement
7281 * @mixins OO.ui.mixin.FloatableElement
7282 *
7283 * @constructor
7284 * @param {Object} [config] Configuration options
7285 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7286 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7287 * and {@link OO.ui.mixin.LookupElement LookupElement}
7288 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7289 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7290 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7291 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7292 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7293 * that button, unless the button (or its parent widget) is passed in here.
7294 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7295 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7296 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7297 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7298 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7299 * @cfg {number} [width] Width of the menu
7300 */
7301 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7302 // Configuration initialization
7303 config = config || {};
7304
7305 // Parent constructor
7306 OO.ui.MenuSelectWidget.parent.call( this, config );
7307
7308 // Mixin constructors
7309 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7310 OO.ui.mixin.FloatableElement.call( this, config );
7311
7312 // Initial vertical positions other than 'center' will result in
7313 // the menu being flipped if there is not enough space in the container.
7314 // Store the original position so we know what to reset to.
7315 this.originalVerticalPosition = this.verticalPosition;
7316
7317 // Properties
7318 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7319 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7320 this.filterFromInput = !!config.filterFromInput;
7321 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7322 this.$widget = config.widget ? config.widget.$element : null;
7323 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7324 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7325 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7326 this.highlightOnFilter = !!config.highlightOnFilter;
7327 this.width = config.width;
7328 this.filterQuery = '';
7329
7330 // Initialization
7331 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7332 if ( config.widget ) {
7333 this.setFocusOwner( config.widget.$tabIndexed );
7334 }
7335
7336 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7337 // that reference properties not initialized at that time of parent class construction
7338 // TODO: Find a better way to handle post-constructor setup
7339 this.visible = false;
7340 this.$element.addClass( 'oo-ui-element-hidden' );
7341 };
7342
7343 /* Setup */
7344
7345 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7346 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7347 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7348
7349 /* Events */
7350
7351 /**
7352 * @event ready
7353 *
7354 * The menu is ready: it is visible and has been positioned and clipped.
7355 */
7356
7357 /* Static properties */
7358
7359 /**
7360 * Positions to flip to if there isn't room in the container for the
7361 * menu in a specific direction.
7362 *
7363 * @property {Object.<string,string>}
7364 */
7365 OO.ui.MenuSelectWidget.static.flippedPositions = {
7366 below: 'above',
7367 above: 'below',
7368 top: 'bottom',
7369 bottom: 'top'
7370 };
7371
7372 /* Methods */
7373
7374 /**
7375 * Handles document mouse down events.
7376 *
7377 * @protected
7378 * @param {MouseEvent} e Mouse down event
7379 */
7380 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7381 if (
7382 this.isVisible() &&
7383 !OO.ui.contains(
7384 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7385 e.target,
7386 true
7387 )
7388 ) {
7389 this.toggle( false );
7390 }
7391 };
7392
7393 /**
7394 * @inheritdoc
7395 */
7396 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7397 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7398
7399 if ( !this.isDisabled() && this.isVisible() ) {
7400 switch ( e.keyCode ) {
7401 case OO.ui.Keys.LEFT:
7402 case OO.ui.Keys.RIGHT:
7403 // Do nothing if a text field is associated, arrow keys will be handled natively
7404 if ( !this.$input ) {
7405 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7406 }
7407 break;
7408 case OO.ui.Keys.ESCAPE:
7409 case OO.ui.Keys.TAB:
7410 if ( currentItem ) {
7411 currentItem.setHighlighted( false );
7412 }
7413 this.toggle( false );
7414 // Don't prevent tabbing away, prevent defocusing
7415 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7416 e.preventDefault();
7417 e.stopPropagation();
7418 }
7419 break;
7420 default:
7421 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7422 return;
7423 }
7424 }
7425 };
7426
7427 /**
7428 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7429 * or after items were added/removed (always).
7430 *
7431 * @protected
7432 */
7433 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7434 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7435 anyVisible = false,
7436 len = this.items.length,
7437 showAll = !this.isVisible(),
7438 exactMatch = false;
7439
7440 if ( this.$input && this.filterFromInput && this.filterQuery !== this.$input.val() ) {
7441 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7442 exactFilter = this.getItemMatcher( this.$input.val(), true );
7443 this.filterQuery = this.$input.val();
7444 // Hide non-matching options, and also hide section headers if all options
7445 // in their section are hidden.
7446 for ( i = 0; i < len; i++ ) {
7447 item = this.items[ i ];
7448 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7449 if ( section ) {
7450 // If the previous section was empty, hide its header
7451 section.toggle( showAll || !sectionEmpty );
7452 }
7453 section = item;
7454 sectionEmpty = true;
7455 } else if ( item instanceof OO.ui.OptionWidget ) {
7456 visible = showAll || filter( item );
7457 exactMatch = exactMatch || exactFilter( item );
7458 anyVisible = anyVisible || visible;
7459 sectionEmpty = sectionEmpty && !visible;
7460 item.toggle( visible );
7461 }
7462 }
7463 // Process the final section
7464 if ( section ) {
7465 section.toggle( showAll || !sectionEmpty );
7466 }
7467
7468 if ( anyVisible && this.items.length && !exactMatch ) {
7469 this.scrollItemIntoView( this.items[ 0 ] );
7470 }
7471
7472 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7473
7474 if ( this.highlightOnFilter ) {
7475 // Highlight the first item on the list
7476 item = null;
7477 items = this.getItems();
7478 for ( i = 0; i < items.length; i++ ) {
7479 if ( items[ i ].isVisible() ) {
7480 item = items[ i ];
7481 break;
7482 }
7483 }
7484 this.highlightItem( item );
7485 }
7486
7487 }
7488
7489 // Reevaluate clipping
7490 this.clip();
7491 };
7492
7493 /**
7494 * @inheritdoc
7495 */
7496 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7497 if ( this.$input ) {
7498 this.$input.on( 'keydown', this.onKeyDownHandler );
7499 } else {
7500 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7501 }
7502 };
7503
7504 /**
7505 * @inheritdoc
7506 */
7507 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7508 if ( this.$input ) {
7509 this.$input.off( 'keydown', this.onKeyDownHandler );
7510 } else {
7511 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7512 }
7513 };
7514
7515 /**
7516 * @inheritdoc
7517 */
7518 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7519 if ( this.$input ) {
7520 if ( this.filterFromInput ) {
7521 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7522 this.updateItemVisibility();
7523 }
7524 } else {
7525 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7526 }
7527 };
7528
7529 /**
7530 * @inheritdoc
7531 */
7532 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7533 if ( this.$input ) {
7534 if ( this.filterFromInput ) {
7535 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7536 this.updateItemVisibility();
7537 }
7538 } else {
7539 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7540 }
7541 };
7542
7543 /**
7544 * Choose an item.
7545 *
7546 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7547 *
7548 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7549 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7550 *
7551 * @param {OO.ui.OptionWidget} item Item to choose
7552 * @chainable
7553 */
7554 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7555 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7556 if ( this.hideOnChoose ) {
7557 this.toggle( false );
7558 }
7559 return this;
7560 };
7561
7562 /**
7563 * @inheritdoc
7564 */
7565 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7566 // Parent method
7567 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7568
7569 this.updateItemVisibility();
7570
7571 return this;
7572 };
7573
7574 /**
7575 * @inheritdoc
7576 */
7577 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7578 // Parent method
7579 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7580
7581 this.updateItemVisibility();
7582
7583 return this;
7584 };
7585
7586 /**
7587 * @inheritdoc
7588 */
7589 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7590 // Parent method
7591 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7592
7593 this.updateItemVisibility();
7594
7595 return this;
7596 };
7597
7598 /**
7599 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7600 * `.toggle( true )` after its #$element is attached to the DOM.
7601 *
7602 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7603 * it in the right place and with the right dimensions only work correctly while it is attached.
7604 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7605 * strictly enforced, so currently it only generates a warning in the browser console.
7606 *
7607 * @fires ready
7608 * @inheritdoc
7609 */
7610 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7611 var change, originalHeight, flippedHeight;
7612
7613 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7614 change = visible !== this.isVisible();
7615
7616 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7617 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7618 this.warnedUnattached = true;
7619 }
7620
7621 if ( change && visible ) {
7622 // Reset position before showing the popup again. It's possible we no longer need to flip
7623 // (e.g. if the user scrolled).
7624 this.setVerticalPosition( this.originalVerticalPosition );
7625 }
7626
7627 // Parent method
7628 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7629
7630 if ( change ) {
7631 if ( visible ) {
7632
7633 if ( this.width ) {
7634 this.setIdealSize( this.width );
7635 } else if ( this.$floatableContainer ) {
7636 this.$clippable.css( 'width', 'auto' );
7637 this.setIdealSize(
7638 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7639 // Dropdown is smaller than handle so expand to width
7640 this.$floatableContainer[ 0 ].offsetWidth :
7641 // Dropdown is larger than handle so auto size
7642 'auto'
7643 );
7644 this.$clippable.css( 'width', '' );
7645 }
7646
7647 this.togglePositioning( !!this.$floatableContainer );
7648 this.toggleClipping( true );
7649
7650 this.bindKeyDownListener();
7651 this.bindKeyPressListener();
7652
7653 if (
7654 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7655 this.originalVerticalPosition !== 'center'
7656 ) {
7657 // If opening the menu in one direction causes it to be clipped, flip it
7658 originalHeight = this.$element.height();
7659 this.setVerticalPosition(
7660 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7661 );
7662 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7663 // If flipping also causes it to be clipped, open in whichever direction
7664 // we have more space
7665 flippedHeight = this.$element.height();
7666 if ( originalHeight > flippedHeight ) {
7667 this.setVerticalPosition( this.originalVerticalPosition );
7668 }
7669 }
7670 }
7671 // Note that we do not flip the menu's opening direction if the clipping changes
7672 // later (e.g. after the user scrolls), that seems like it would be annoying
7673
7674 this.$focusOwner.attr( 'aria-expanded', 'true' );
7675
7676 if ( this.findSelectedItem() ) {
7677 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7678 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7679 }
7680
7681 // Auto-hide
7682 if ( this.autoHide ) {
7683 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7684 }
7685
7686 this.emit( 'ready' );
7687 } else {
7688 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7689 this.unbindKeyDownListener();
7690 this.unbindKeyPressListener();
7691 this.$focusOwner.attr( 'aria-expanded', 'false' );
7692 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7693 this.togglePositioning( false );
7694 this.toggleClipping( false );
7695 }
7696 }
7697
7698 return this;
7699 };
7700
7701 /**
7702 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7703 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7704 * users can interact with it.
7705 *
7706 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7707 * OO.ui.DropdownInputWidget instead.
7708 *
7709 * @example
7710 * // Example: A DropdownWidget with a menu that contains three options
7711 * var dropDown = new OO.ui.DropdownWidget( {
7712 * label: 'Dropdown menu: Select a menu option',
7713 * menu: {
7714 * items: [
7715 * new OO.ui.MenuOptionWidget( {
7716 * data: 'a',
7717 * label: 'First'
7718 * } ),
7719 * new OO.ui.MenuOptionWidget( {
7720 * data: 'b',
7721 * label: 'Second'
7722 * } ),
7723 * new OO.ui.MenuOptionWidget( {
7724 * data: 'c',
7725 * label: 'Third'
7726 * } )
7727 * ]
7728 * }
7729 * } );
7730 *
7731 * $( 'body' ).append( dropDown.$element );
7732 *
7733 * dropDown.getMenu().selectItemByData( 'b' );
7734 *
7735 * dropDown.getMenu().findSelectedItem().getData(); // returns 'b'
7736 *
7737 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7738 *
7739 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7740 *
7741 * @class
7742 * @extends OO.ui.Widget
7743 * @mixins OO.ui.mixin.IconElement
7744 * @mixins OO.ui.mixin.IndicatorElement
7745 * @mixins OO.ui.mixin.LabelElement
7746 * @mixins OO.ui.mixin.TitledElement
7747 * @mixins OO.ui.mixin.TabIndexedElement
7748 *
7749 * @constructor
7750 * @param {Object} [config] Configuration options
7751 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7752 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7753 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7754 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7755 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
7756 */
7757 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7758 // Configuration initialization
7759 config = $.extend( { indicator: 'down' }, config );
7760
7761 // Parent constructor
7762 OO.ui.DropdownWidget.parent.call( this, config );
7763
7764 // Properties (must be set before TabIndexedElement constructor call)
7765 this.$handle = $( '<span>' );
7766 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7767
7768 // Mixin constructors
7769 OO.ui.mixin.IconElement.call( this, config );
7770 OO.ui.mixin.IndicatorElement.call( this, config );
7771 OO.ui.mixin.LabelElement.call( this, config );
7772 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7773 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7774
7775 // Properties
7776 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7777 widget: this,
7778 $floatableContainer: this.$element
7779 }, config.menu ) );
7780
7781 // Events
7782 this.$handle.on( {
7783 click: this.onClick.bind( this ),
7784 keydown: this.onKeyDown.bind( this ),
7785 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7786 keypress: this.menu.onKeyPressHandler,
7787 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7788 } );
7789 this.menu.connect( this, {
7790 select: 'onMenuSelect',
7791 toggle: 'onMenuToggle'
7792 } );
7793
7794 // Initialization
7795 this.$handle
7796 .addClass( 'oo-ui-dropdownWidget-handle' )
7797 .attr( {
7798 role: 'combobox',
7799 'aria-owns': this.menu.getElementId(),
7800 'aria-autocomplete': 'list'
7801 } )
7802 .append( this.$icon, this.$label, this.$indicator );
7803 this.$element
7804 .addClass( 'oo-ui-dropdownWidget' )
7805 .append( this.$handle );
7806 this.$overlay.append( this.menu.$element );
7807 };
7808
7809 /* Setup */
7810
7811 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7812 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7813 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7814 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7815 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7816 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7817
7818 /* Methods */
7819
7820 /**
7821 * Get the menu.
7822 *
7823 * @return {OO.ui.MenuSelectWidget} Menu of widget
7824 */
7825 OO.ui.DropdownWidget.prototype.getMenu = function () {
7826 return this.menu;
7827 };
7828
7829 /**
7830 * Handles menu select events.
7831 *
7832 * @private
7833 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7834 */
7835 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7836 var selectedLabel;
7837
7838 if ( !item ) {
7839 this.setLabel( null );
7840 return;
7841 }
7842
7843 selectedLabel = item.getLabel();
7844
7845 // If the label is a DOM element, clone it, because setLabel will append() it
7846 if ( selectedLabel instanceof jQuery ) {
7847 selectedLabel = selectedLabel.clone();
7848 }
7849
7850 this.setLabel( selectedLabel );
7851 };
7852
7853 /**
7854 * Handle menu toggle events.
7855 *
7856 * @private
7857 * @param {boolean} isVisible Open state of the menu
7858 */
7859 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7860 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7861 this.$handle.attr(
7862 'aria-expanded',
7863 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7864 );
7865 };
7866
7867 /**
7868 * Handle mouse click events.
7869 *
7870 * @private
7871 * @param {jQuery.Event} e Mouse click event
7872 */
7873 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7874 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7875 this.menu.toggle();
7876 }
7877 return false;
7878 };
7879
7880 /**
7881 * Handle key down events.
7882 *
7883 * @private
7884 * @param {jQuery.Event} e Key down event
7885 */
7886 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7887 if (
7888 !this.isDisabled() &&
7889 (
7890 e.which === OO.ui.Keys.ENTER ||
7891 (
7892 e.which === OO.ui.Keys.SPACE &&
7893 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7894 // Space only closes the menu is the user is not typing to search.
7895 this.menu.keyPressBuffer === ''
7896 ) ||
7897 (
7898 !this.menu.isVisible() &&
7899 (
7900 e.which === OO.ui.Keys.UP ||
7901 e.which === OO.ui.Keys.DOWN
7902 )
7903 )
7904 )
7905 ) {
7906 this.menu.toggle();
7907 return false;
7908 }
7909 };
7910
7911 /**
7912 * RadioOptionWidget is an option widget that looks like a radio button.
7913 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7914 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
7915 *
7916 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
7917 *
7918 * @class
7919 * @extends OO.ui.OptionWidget
7920 *
7921 * @constructor
7922 * @param {Object} [config] Configuration options
7923 */
7924 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7925 // Configuration initialization
7926 config = config || {};
7927
7928 // Properties (must be done before parent constructor which calls #setDisabled)
7929 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7930
7931 // Parent constructor
7932 OO.ui.RadioOptionWidget.parent.call( this, config );
7933
7934 // Initialization
7935 // Remove implicit role, we're handling it ourselves
7936 this.radio.$input.attr( 'role', 'presentation' );
7937 this.$element
7938 .addClass( 'oo-ui-radioOptionWidget' )
7939 .attr( 'role', 'radio' )
7940 .attr( 'aria-checked', 'false' )
7941 .removeAttr( 'aria-selected' )
7942 .prepend( this.radio.$element );
7943 };
7944
7945 /* Setup */
7946
7947 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7948
7949 /* Static Properties */
7950
7951 /**
7952 * @static
7953 * @inheritdoc
7954 */
7955 OO.ui.RadioOptionWidget.static.highlightable = false;
7956
7957 /**
7958 * @static
7959 * @inheritdoc
7960 */
7961 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7962
7963 /**
7964 * @static
7965 * @inheritdoc
7966 */
7967 OO.ui.RadioOptionWidget.static.pressable = false;
7968
7969 /**
7970 * @static
7971 * @inheritdoc
7972 */
7973 OO.ui.RadioOptionWidget.static.tagName = 'label';
7974
7975 /* Methods */
7976
7977 /**
7978 * @inheritdoc
7979 */
7980 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7981 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7982
7983 this.radio.setSelected( state );
7984 this.$element
7985 .attr( 'aria-checked', state.toString() )
7986 .removeAttr( 'aria-selected' );
7987
7988 return this;
7989 };
7990
7991 /**
7992 * @inheritdoc
7993 */
7994 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7995 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7996
7997 this.radio.setDisabled( this.isDisabled() );
7998
7999 return this;
8000 };
8001
8002 /**
8003 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8004 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8005 * an interface for adding, removing and selecting options.
8006 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8007 *
8008 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8009 * OO.ui.RadioSelectInputWidget instead.
8010 *
8011 * @example
8012 * // A RadioSelectWidget with RadioOptions.
8013 * var option1 = new OO.ui.RadioOptionWidget( {
8014 * data: 'a',
8015 * label: 'Selected radio option'
8016 * } );
8017 *
8018 * var option2 = new OO.ui.RadioOptionWidget( {
8019 * data: 'b',
8020 * label: 'Unselected radio option'
8021 * } );
8022 *
8023 * var radioSelect=new OO.ui.RadioSelectWidget( {
8024 * items: [ option1, option2 ]
8025 * } );
8026 *
8027 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8028 * radioSelect.selectItem( option1 );
8029 *
8030 * $( 'body' ).append( radioSelect.$element );
8031 *
8032 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8033
8034 *
8035 * @class
8036 * @extends OO.ui.SelectWidget
8037 * @mixins OO.ui.mixin.TabIndexedElement
8038 *
8039 * @constructor
8040 * @param {Object} [config] Configuration options
8041 */
8042 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8043 // Parent constructor
8044 OO.ui.RadioSelectWidget.parent.call( this, config );
8045
8046 // Mixin constructors
8047 OO.ui.mixin.TabIndexedElement.call( this, config );
8048
8049 // Events
8050 this.$element.on( {
8051 focus: this.bindKeyDownListener.bind( this ),
8052 blur: this.unbindKeyDownListener.bind( this )
8053 } );
8054
8055 // Initialization
8056 this.$element
8057 .addClass( 'oo-ui-radioSelectWidget' )
8058 .attr( 'role', 'radiogroup' );
8059 };
8060
8061 /* Setup */
8062
8063 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8064 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8065
8066 /**
8067 * MultioptionWidgets are special elements that can be selected and configured with data. The
8068 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8069 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8070 * and examples, please see the [OOUI documentation on MediaWiki][1].
8071 *
8072 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8073 *
8074 * @class
8075 * @extends OO.ui.Widget
8076 * @mixins OO.ui.mixin.ItemWidget
8077 * @mixins OO.ui.mixin.LabelElement
8078 *
8079 * @constructor
8080 * @param {Object} [config] Configuration options
8081 * @cfg {boolean} [selected=false] Whether the option is initially selected
8082 */
8083 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8084 // Configuration initialization
8085 config = config || {};
8086
8087 // Parent constructor
8088 OO.ui.MultioptionWidget.parent.call( this, config );
8089
8090 // Mixin constructors
8091 OO.ui.mixin.ItemWidget.call( this );
8092 OO.ui.mixin.LabelElement.call( this, config );
8093
8094 // Properties
8095 this.selected = null;
8096
8097 // Initialization
8098 this.$element
8099 .addClass( 'oo-ui-multioptionWidget' )
8100 .append( this.$label );
8101 this.setSelected( config.selected );
8102 };
8103
8104 /* Setup */
8105
8106 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8107 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8108 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8109
8110 /* Events */
8111
8112 /**
8113 * @event change
8114 *
8115 * A change event is emitted when the selected state of the option changes.
8116 *
8117 * @param {boolean} selected Whether the option is now selected
8118 */
8119
8120 /* Methods */
8121
8122 /**
8123 * Check if the option is selected.
8124 *
8125 * @return {boolean} Item is selected
8126 */
8127 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8128 return this.selected;
8129 };
8130
8131 /**
8132 * Set the option’s selected state. In general, all modifications to the selection
8133 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8134 * method instead of this method.
8135 *
8136 * @param {boolean} [state=false] Select option
8137 * @chainable
8138 */
8139 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8140 state = !!state;
8141 if ( this.selected !== state ) {
8142 this.selected = state;
8143 this.emit( 'change', state );
8144 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8145 }
8146 return this;
8147 };
8148
8149 /**
8150 * MultiselectWidget allows selecting multiple options from a list.
8151 *
8152 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8153 *
8154 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8155 *
8156 * @class
8157 * @abstract
8158 * @extends OO.ui.Widget
8159 * @mixins OO.ui.mixin.GroupWidget
8160 *
8161 * @constructor
8162 * @param {Object} [config] Configuration options
8163 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8164 */
8165 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8166 // Parent constructor
8167 OO.ui.MultiselectWidget.parent.call( this, config );
8168
8169 // Configuration initialization
8170 config = config || {};
8171
8172 // Mixin constructors
8173 OO.ui.mixin.GroupWidget.call( this, config );
8174
8175 // Events
8176 this.aggregate( { change: 'select' } );
8177 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
8178 // by GroupElement only when items are added/removed
8179 this.connect( this, { select: [ 'emit', 'change' ] } );
8180
8181 // Initialization
8182 if ( config.items ) {
8183 this.addItems( config.items );
8184 }
8185 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8186 this.$element.addClass( 'oo-ui-multiselectWidget' )
8187 .append( this.$group );
8188 };
8189
8190 /* Setup */
8191
8192 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8193 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8194
8195 /* Events */
8196
8197 /**
8198 * @event change
8199 *
8200 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8201 */
8202
8203 /**
8204 * @event select
8205 *
8206 * A select event is emitted when an item is selected or deselected.
8207 */
8208
8209 /* Methods */
8210
8211 /**
8212 * Find options that are selected.
8213 *
8214 * @return {OO.ui.MultioptionWidget[]} Selected options
8215 */
8216 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8217 return this.items.filter( function ( item ) {
8218 return item.isSelected();
8219 } );
8220 };
8221
8222 /**
8223 * Find the data of options that are selected.
8224 *
8225 * @return {Object[]|string[]} Values of selected options
8226 */
8227 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8228 return this.findSelectedItems().map( function ( item ) {
8229 return item.data;
8230 } );
8231 };
8232
8233 /**
8234 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8235 *
8236 * @param {OO.ui.MultioptionWidget[]} items Items to select
8237 * @chainable
8238 */
8239 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8240 this.items.forEach( function ( item ) {
8241 var selected = items.indexOf( item ) !== -1;
8242 item.setSelected( selected );
8243 } );
8244 return this;
8245 };
8246
8247 /**
8248 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8249 *
8250 * @param {Object[]|string[]} datas Values of items to select
8251 * @chainable
8252 */
8253 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8254 var items,
8255 widget = this;
8256 items = datas.map( function ( data ) {
8257 return widget.findItemFromData( data );
8258 } );
8259 this.selectItems( items );
8260 return this;
8261 };
8262
8263 /**
8264 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8265 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8266 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8267 *
8268 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8269 *
8270 * @class
8271 * @extends OO.ui.MultioptionWidget
8272 *
8273 * @constructor
8274 * @param {Object} [config] Configuration options
8275 */
8276 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8277 // Configuration initialization
8278 config = config || {};
8279
8280 // Properties (must be done before parent constructor which calls #setDisabled)
8281 this.checkbox = new OO.ui.CheckboxInputWidget();
8282
8283 // Parent constructor
8284 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8285
8286 // Events
8287 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8288 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8289
8290 // Initialization
8291 this.$element
8292 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8293 .prepend( this.checkbox.$element );
8294 };
8295
8296 /* Setup */
8297
8298 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8299
8300 /* Static Properties */
8301
8302 /**
8303 * @static
8304 * @inheritdoc
8305 */
8306 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8307
8308 /* Methods */
8309
8310 /**
8311 * Handle checkbox selected state change.
8312 *
8313 * @private
8314 */
8315 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8316 this.setSelected( this.checkbox.isSelected() );
8317 };
8318
8319 /**
8320 * @inheritdoc
8321 */
8322 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8323 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8324 this.checkbox.setSelected( state );
8325 return this;
8326 };
8327
8328 /**
8329 * @inheritdoc
8330 */
8331 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8332 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8333 this.checkbox.setDisabled( this.isDisabled() );
8334 return this;
8335 };
8336
8337 /**
8338 * Focus the widget.
8339 */
8340 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8341 this.checkbox.focus();
8342 };
8343
8344 /**
8345 * Handle key down events.
8346 *
8347 * @protected
8348 * @param {jQuery.Event} e
8349 */
8350 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8351 var
8352 element = this.getElementGroup(),
8353 nextItem;
8354
8355 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8356 nextItem = element.getRelativeFocusableItem( this, -1 );
8357 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8358 nextItem = element.getRelativeFocusableItem( this, 1 );
8359 }
8360
8361 if ( nextItem ) {
8362 e.preventDefault();
8363 nextItem.focus();
8364 }
8365 };
8366
8367 /**
8368 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8369 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8370 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8371 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8372 *
8373 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8374 * OO.ui.CheckboxMultiselectInputWidget instead.
8375 *
8376 * @example
8377 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8378 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8379 * data: 'a',
8380 * selected: true,
8381 * label: 'Selected checkbox'
8382 * } );
8383 *
8384 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8385 * data: 'b',
8386 * label: 'Unselected checkbox'
8387 * } );
8388 *
8389 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8390 * items: [ option1, option2 ]
8391 * } );
8392 *
8393 * $( 'body' ).append( multiselect.$element );
8394 *
8395 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8396 *
8397 * @class
8398 * @extends OO.ui.MultiselectWidget
8399 *
8400 * @constructor
8401 * @param {Object} [config] Configuration options
8402 */
8403 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8404 // Parent constructor
8405 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8406
8407 // Properties
8408 this.$lastClicked = null;
8409
8410 // Events
8411 this.$group.on( 'click', this.onClick.bind( this ) );
8412
8413 // Initialization
8414 this.$element
8415 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8416 };
8417
8418 /* Setup */
8419
8420 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8421
8422 /* Methods */
8423
8424 /**
8425 * Get an option by its position relative to the specified item (or to the start of the option array,
8426 * if item is `null`). The direction in which to search through the option array is specified with a
8427 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8428 * `null` if there are no options in the array.
8429 *
8430 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8431 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8432 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8433 */
8434 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8435 var currentIndex, nextIndex, i,
8436 increase = direction > 0 ? 1 : -1,
8437 len = this.items.length;
8438
8439 if ( item ) {
8440 currentIndex = this.items.indexOf( item );
8441 nextIndex = ( currentIndex + increase + len ) % len;
8442 } else {
8443 // If no item is selected and moving forward, start at the beginning.
8444 // If moving backward, start at the end.
8445 nextIndex = direction > 0 ? 0 : len - 1;
8446 }
8447
8448 for ( i = 0; i < len; i++ ) {
8449 item = this.items[ nextIndex ];
8450 if ( item && !item.isDisabled() ) {
8451 return item;
8452 }
8453 nextIndex = ( nextIndex + increase + len ) % len;
8454 }
8455 return null;
8456 };
8457
8458 /**
8459 * Handle click events on checkboxes.
8460 *
8461 * @param {jQuery.Event} e
8462 */
8463 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8464 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8465 $lastClicked = this.$lastClicked,
8466 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8467 .not( '.oo-ui-widget-disabled' );
8468
8469 // Allow selecting multiple options at once by Shift-clicking them
8470 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8471 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8472 lastClickedIndex = $options.index( $lastClicked );
8473 nowClickedIndex = $options.index( $nowClicked );
8474 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8475 // browser. In either case we don't need custom handling.
8476 if ( nowClickedIndex !== lastClickedIndex ) {
8477 items = this.items;
8478 wasSelected = items[ nowClickedIndex ].isSelected();
8479 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8480
8481 // This depends on the DOM order of the items and the order of the .items array being the same.
8482 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8483 if ( !items[ i ].isDisabled() ) {
8484 items[ i ].setSelected( !wasSelected );
8485 }
8486 }
8487 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8488 // handling first, then set our value. The order in which events happen is different for
8489 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8490 // non-click actions that change the checkboxes.
8491 e.preventDefault();
8492 setTimeout( function () {
8493 if ( !items[ nowClickedIndex ].isDisabled() ) {
8494 items[ nowClickedIndex ].setSelected( !wasSelected );
8495 }
8496 } );
8497 }
8498 }
8499
8500 if ( $nowClicked.length ) {
8501 this.$lastClicked = $nowClicked;
8502 }
8503 };
8504
8505 /**
8506 * Focus the widget
8507 *
8508 * @chainable
8509 */
8510 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8511 var item;
8512 if ( !this.isDisabled() ) {
8513 item = this.getRelativeFocusableItem( null, 1 );
8514 if ( item ) {
8515 item.focus();
8516 }
8517 }
8518 return this;
8519 };
8520
8521 /**
8522 * @inheritdoc
8523 */
8524 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8525 this.focus();
8526 };
8527
8528 /**
8529 * Progress bars visually display the status of an operation, such as a download,
8530 * and can be either determinate or indeterminate:
8531 *
8532 * - **determinate** process bars show the percent of an operation that is complete.
8533 *
8534 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8535 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8536 * not use percentages.
8537 *
8538 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8539 *
8540 * @example
8541 * // Examples of determinate and indeterminate progress bars.
8542 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8543 * progress: 33
8544 * } );
8545 * var progressBar2 = new OO.ui.ProgressBarWidget();
8546 *
8547 * // Create a FieldsetLayout to layout progress bars
8548 * var fieldset = new OO.ui.FieldsetLayout;
8549 * fieldset.addItems( [
8550 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8551 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8552 * ] );
8553 * $( 'body' ).append( fieldset.$element );
8554 *
8555 * @class
8556 * @extends OO.ui.Widget
8557 *
8558 * @constructor
8559 * @param {Object} [config] Configuration options
8560 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8561 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8562 * By default, the progress bar is indeterminate.
8563 */
8564 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8565 // Configuration initialization
8566 config = config || {};
8567
8568 // Parent constructor
8569 OO.ui.ProgressBarWidget.parent.call( this, config );
8570
8571 // Properties
8572 this.$bar = $( '<div>' );
8573 this.progress = null;
8574
8575 // Initialization
8576 this.setProgress( config.progress !== undefined ? config.progress : false );
8577 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8578 this.$element
8579 .attr( {
8580 role: 'progressbar',
8581 'aria-valuemin': 0,
8582 'aria-valuemax': 100
8583 } )
8584 .addClass( 'oo-ui-progressBarWidget' )
8585 .append( this.$bar );
8586 };
8587
8588 /* Setup */
8589
8590 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8591
8592 /* Static Properties */
8593
8594 /**
8595 * @static
8596 * @inheritdoc
8597 */
8598 OO.ui.ProgressBarWidget.static.tagName = 'div';
8599
8600 /* Methods */
8601
8602 /**
8603 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8604 *
8605 * @return {number|boolean} Progress percent
8606 */
8607 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8608 return this.progress;
8609 };
8610
8611 /**
8612 * Set the percent of the process completed or `false` for an indeterminate process.
8613 *
8614 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8615 */
8616 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8617 this.progress = progress;
8618
8619 if ( progress !== false ) {
8620 this.$bar.css( 'width', this.progress + '%' );
8621 this.$element.attr( 'aria-valuenow', this.progress );
8622 } else {
8623 this.$bar.css( 'width', '' );
8624 this.$element.removeAttr( 'aria-valuenow' );
8625 }
8626 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8627 };
8628
8629 /**
8630 * InputWidget is the base class for all input widgets, which
8631 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8632 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8633 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8634 *
8635 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8636 *
8637 * @abstract
8638 * @class
8639 * @extends OO.ui.Widget
8640 * @mixins OO.ui.mixin.FlaggedElement
8641 * @mixins OO.ui.mixin.TabIndexedElement
8642 * @mixins OO.ui.mixin.TitledElement
8643 * @mixins OO.ui.mixin.AccessKeyedElement
8644 *
8645 * @constructor
8646 * @param {Object} [config] Configuration options
8647 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8648 * @cfg {string} [value=''] The value of the input.
8649 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8650 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8651 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8652 * before it is accepted.
8653 */
8654 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8655 // Configuration initialization
8656 config = config || {};
8657
8658 // Parent constructor
8659 OO.ui.InputWidget.parent.call( this, config );
8660
8661 // Properties
8662 // See #reusePreInfuseDOM about config.$input
8663 this.$input = config.$input || this.getInputElement( config );
8664 this.value = '';
8665 this.inputFilter = config.inputFilter;
8666
8667 // Mixin constructors
8668 OO.ui.mixin.FlaggedElement.call( this, config );
8669 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8670 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8671 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8672
8673 // Events
8674 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8675
8676 // Initialization
8677 this.$input
8678 .addClass( 'oo-ui-inputWidget-input' )
8679 .attr( 'name', config.name )
8680 .prop( 'disabled', this.isDisabled() );
8681 this.$element
8682 .addClass( 'oo-ui-inputWidget' )
8683 .append( this.$input );
8684 this.setValue( config.value );
8685 if ( config.dir ) {
8686 this.setDir( config.dir );
8687 }
8688 if ( config.inputId !== undefined ) {
8689 this.setInputId( config.inputId );
8690 }
8691 };
8692
8693 /* Setup */
8694
8695 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8696 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8697 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8698 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8699 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8700
8701 /* Static Methods */
8702
8703 /**
8704 * @inheritdoc
8705 */
8706 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8707 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8708 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8709 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8710 return config;
8711 };
8712
8713 /**
8714 * @inheritdoc
8715 */
8716 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8717 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8718 if ( config.$input && config.$input.length ) {
8719 state.value = config.$input.val();
8720 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8721 state.focus = config.$input.is( ':focus' );
8722 }
8723 return state;
8724 };
8725
8726 /* Events */
8727
8728 /**
8729 * @event change
8730 *
8731 * A change event is emitted when the value of the input changes.
8732 *
8733 * @param {string} value
8734 */
8735
8736 /* Methods */
8737
8738 /**
8739 * Get input element.
8740 *
8741 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8742 * different circumstances. The element must have a `value` property (like form elements).
8743 *
8744 * @protected
8745 * @param {Object} config Configuration options
8746 * @return {jQuery} Input element
8747 */
8748 OO.ui.InputWidget.prototype.getInputElement = function () {
8749 return $( '<input>' );
8750 };
8751
8752 /**
8753 * Handle potentially value-changing events.
8754 *
8755 * @private
8756 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8757 */
8758 OO.ui.InputWidget.prototype.onEdit = function () {
8759 var widget = this;
8760 if ( !this.isDisabled() ) {
8761 // Allow the stack to clear so the value will be updated
8762 setTimeout( function () {
8763 widget.setValue( widget.$input.val() );
8764 } );
8765 }
8766 };
8767
8768 /**
8769 * Get the value of the input.
8770 *
8771 * @return {string} Input value
8772 */
8773 OO.ui.InputWidget.prototype.getValue = function () {
8774 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8775 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8776 var value = this.$input.val();
8777 if ( this.value !== value ) {
8778 this.setValue( value );
8779 }
8780 return this.value;
8781 };
8782
8783 /**
8784 * Set the directionality of the input.
8785 *
8786 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8787 * @chainable
8788 */
8789 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8790 this.$input.prop( 'dir', dir );
8791 return this;
8792 };
8793
8794 /**
8795 * Set the value of the input.
8796 *
8797 * @param {string} value New value
8798 * @fires change
8799 * @chainable
8800 */
8801 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8802 value = this.cleanUpValue( value );
8803 // Update the DOM if it has changed. Note that with cleanUpValue, it
8804 // is possible for the DOM value to change without this.value changing.
8805 if ( this.$input.val() !== value ) {
8806 this.$input.val( value );
8807 }
8808 if ( this.value !== value ) {
8809 this.value = value;
8810 this.emit( 'change', this.value );
8811 }
8812 // The first time that the value is set (probably while constructing the widget),
8813 // remember it in defaultValue. This property can be later used to check whether
8814 // the value of the input has been changed since it was created.
8815 if ( this.defaultValue === undefined ) {
8816 this.defaultValue = this.value;
8817 this.$input[ 0 ].defaultValue = this.defaultValue;
8818 }
8819 return this;
8820 };
8821
8822 /**
8823 * Clean up incoming value.
8824 *
8825 * Ensures value is a string, and converts undefined and null to empty string.
8826 *
8827 * @private
8828 * @param {string} value Original value
8829 * @return {string} Cleaned up value
8830 */
8831 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8832 if ( value === undefined || value === null ) {
8833 return '';
8834 } else if ( this.inputFilter ) {
8835 return this.inputFilter( String( value ) );
8836 } else {
8837 return String( value );
8838 }
8839 };
8840
8841 /**
8842 * @inheritdoc
8843 */
8844 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8845 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8846 if ( this.$input ) {
8847 this.$input.prop( 'disabled', this.isDisabled() );
8848 }
8849 return this;
8850 };
8851
8852 /**
8853 * Set the 'id' attribute of the `<input>` element.
8854 *
8855 * @param {string} id
8856 * @chainable
8857 */
8858 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8859 this.$input.attr( 'id', id );
8860 return this;
8861 };
8862
8863 /**
8864 * @inheritdoc
8865 */
8866 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8867 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8868 if ( state.value !== undefined && state.value !== this.getValue() ) {
8869 this.setValue( state.value );
8870 }
8871 if ( state.focus ) {
8872 this.focus();
8873 }
8874 };
8875
8876 /**
8877 * Data widget intended for creating 'hidden'-type inputs.
8878 *
8879 * @class
8880 * @extends OO.ui.Widget
8881 *
8882 * @constructor
8883 * @param {Object} [config] Configuration options
8884 * @cfg {string} [value=''] The value of the input.
8885 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8886 */
8887 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8888 // Configuration initialization
8889 config = $.extend( { value: '', name: '' }, config );
8890
8891 // Parent constructor
8892 OO.ui.HiddenInputWidget.parent.call( this, config );
8893
8894 // Initialization
8895 this.$element.attr( {
8896 type: 'hidden',
8897 value: config.value,
8898 name: config.name
8899 } );
8900 this.$element.removeAttr( 'aria-disabled' );
8901 };
8902
8903 /* Setup */
8904
8905 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8906
8907 /* Static Properties */
8908
8909 /**
8910 * @static
8911 * @inheritdoc
8912 */
8913 OO.ui.HiddenInputWidget.static.tagName = 'input';
8914
8915 /**
8916 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8917 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8918 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8919 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8920 * [OOUI documentation on MediaWiki] [1] for more information.
8921 *
8922 * @example
8923 * // A ButtonInputWidget rendered as an HTML button, the default.
8924 * var button = new OO.ui.ButtonInputWidget( {
8925 * label: 'Input button',
8926 * icon: 'check',
8927 * value: 'check'
8928 * } );
8929 * $( 'body' ).append( button.$element );
8930 *
8931 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
8932 *
8933 * @class
8934 * @extends OO.ui.InputWidget
8935 * @mixins OO.ui.mixin.ButtonElement
8936 * @mixins OO.ui.mixin.IconElement
8937 * @mixins OO.ui.mixin.IndicatorElement
8938 * @mixins OO.ui.mixin.LabelElement
8939 * @mixins OO.ui.mixin.TitledElement
8940 *
8941 * @constructor
8942 * @param {Object} [config] Configuration options
8943 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8944 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8945 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8946 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8947 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8948 */
8949 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8950 // Configuration initialization
8951 config = $.extend( { type: 'button', useInputTag: false }, config );
8952
8953 // See InputWidget#reusePreInfuseDOM about config.$input
8954 if ( config.$input ) {
8955 config.$input.empty();
8956 }
8957
8958 // Properties (must be set before parent constructor, which calls #setValue)
8959 this.useInputTag = config.useInputTag;
8960
8961 // Parent constructor
8962 OO.ui.ButtonInputWidget.parent.call( this, config );
8963
8964 // Mixin constructors
8965 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8966 OO.ui.mixin.IconElement.call( this, config );
8967 OO.ui.mixin.IndicatorElement.call( this, config );
8968 OO.ui.mixin.LabelElement.call( this, config );
8969 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8970
8971 // Initialization
8972 if ( !config.useInputTag ) {
8973 this.$input.append( this.$icon, this.$label, this.$indicator );
8974 }
8975 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8976 };
8977
8978 /* Setup */
8979
8980 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8981 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8982 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8983 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8984 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8985 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8986
8987 /* Static Properties */
8988
8989 /**
8990 * @static
8991 * @inheritdoc
8992 */
8993 OO.ui.ButtonInputWidget.static.tagName = 'span';
8994
8995 /* Methods */
8996
8997 /**
8998 * @inheritdoc
8999 * @protected
9000 */
9001 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9002 var type;
9003 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9004 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9005 };
9006
9007 /**
9008 * Set label value.
9009 *
9010 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9011 *
9012 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9013 * text, or `null` for no label
9014 * @chainable
9015 */
9016 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9017 if ( typeof label === 'function' ) {
9018 label = OO.ui.resolveMsg( label );
9019 }
9020
9021 if ( this.useInputTag ) {
9022 // Discard non-plaintext labels
9023 if ( typeof label !== 'string' ) {
9024 label = '';
9025 }
9026
9027 this.$input.val( label );
9028 }
9029
9030 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9031 };
9032
9033 /**
9034 * Set the value of the input.
9035 *
9036 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9037 * they do not support {@link #value values}.
9038 *
9039 * @param {string} value New value
9040 * @chainable
9041 */
9042 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9043 if ( !this.useInputTag ) {
9044 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9045 }
9046 return this;
9047 };
9048
9049 /**
9050 * @inheritdoc
9051 */
9052 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9053 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9054 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9055 return null;
9056 };
9057
9058 /**
9059 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9060 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9061 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9062 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9063 *
9064 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9065 *
9066 * @example
9067 * // An example of selected, unselected, and disabled checkbox inputs
9068 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9069 * value: 'a',
9070 * selected: true
9071 * } );
9072 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9073 * value: 'b'
9074 * } );
9075 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9076 * value:'c',
9077 * disabled: true
9078 * } );
9079 * // Create a fieldset layout with fields for each checkbox.
9080 * var fieldset = new OO.ui.FieldsetLayout( {
9081 * label: 'Checkboxes'
9082 * } );
9083 * fieldset.addItems( [
9084 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9085 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9086 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9087 * ] );
9088 * $( 'body' ).append( fieldset.$element );
9089 *
9090 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9091 *
9092 * @class
9093 * @extends OO.ui.InputWidget
9094 *
9095 * @constructor
9096 * @param {Object} [config] Configuration options
9097 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9098 */
9099 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9100 // Configuration initialization
9101 config = config || {};
9102
9103 // Parent constructor
9104 OO.ui.CheckboxInputWidget.parent.call( this, config );
9105
9106 // Properties
9107 this.checkIcon = new OO.ui.IconWidget( {
9108 icon: 'check',
9109 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9110 } );
9111
9112 // Initialization
9113 this.$element
9114 .addClass( 'oo-ui-checkboxInputWidget' )
9115 // Required for pretty styling in WikimediaUI theme
9116 .append( this.checkIcon.$element );
9117 this.setSelected( config.selected !== undefined ? config.selected : false );
9118 };
9119
9120 /* Setup */
9121
9122 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9123
9124 /* Static Properties */
9125
9126 /**
9127 * @static
9128 * @inheritdoc
9129 */
9130 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9131
9132 /* Static Methods */
9133
9134 /**
9135 * @inheritdoc
9136 */
9137 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9138 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9139 state.checked = config.$input.prop( 'checked' );
9140 return state;
9141 };
9142
9143 /* Methods */
9144
9145 /**
9146 * @inheritdoc
9147 * @protected
9148 */
9149 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9150 return $( '<input>' ).attr( 'type', 'checkbox' );
9151 };
9152
9153 /**
9154 * @inheritdoc
9155 */
9156 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9157 var widget = this;
9158 if ( !this.isDisabled() ) {
9159 // Allow the stack to clear so the value will be updated
9160 setTimeout( function () {
9161 widget.setSelected( widget.$input.prop( 'checked' ) );
9162 } );
9163 }
9164 };
9165
9166 /**
9167 * Set selection state of this checkbox.
9168 *
9169 * @param {boolean} state `true` for selected
9170 * @chainable
9171 */
9172 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9173 state = !!state;
9174 if ( this.selected !== state ) {
9175 this.selected = state;
9176 this.$input.prop( 'checked', this.selected );
9177 this.emit( 'change', this.selected );
9178 }
9179 // The first time that the selection state is set (probably while constructing the widget),
9180 // remember it in defaultSelected. This property can be later used to check whether
9181 // the selection state of the input has been changed since it was created.
9182 if ( this.defaultSelected === undefined ) {
9183 this.defaultSelected = this.selected;
9184 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9185 }
9186 return this;
9187 };
9188
9189 /**
9190 * Check if this checkbox is selected.
9191 *
9192 * @return {boolean} Checkbox is selected
9193 */
9194 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9195 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9196 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9197 var selected = this.$input.prop( 'checked' );
9198 if ( this.selected !== selected ) {
9199 this.setSelected( selected );
9200 }
9201 return this.selected;
9202 };
9203
9204 /**
9205 * @inheritdoc
9206 */
9207 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9208 if ( !this.isDisabled() ) {
9209 this.$input.click();
9210 }
9211 this.focus();
9212 };
9213
9214 /**
9215 * @inheritdoc
9216 */
9217 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9218 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9219 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9220 this.setSelected( state.checked );
9221 }
9222 };
9223
9224 /**
9225 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9226 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9227 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9228 * more information about input widgets.
9229 *
9230 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9231 * are no options. If no `value` configuration option is provided, the first option is selected.
9232 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9233 *
9234 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9235 *
9236 * @example
9237 * // Example: A DropdownInputWidget with three options
9238 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9239 * options: [
9240 * { data: 'a', label: 'First' },
9241 * { data: 'b', label: 'Second'},
9242 * { data: 'c', label: 'Third' }
9243 * ]
9244 * } );
9245 * $( 'body' ).append( dropdownInput.$element );
9246 *
9247 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9248 *
9249 * @class
9250 * @extends OO.ui.InputWidget
9251 *
9252 * @constructor
9253 * @param {Object} [config] Configuration options
9254 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9255 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9256 */
9257 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9258 // Configuration initialization
9259 config = config || {};
9260
9261 // Properties (must be done before parent constructor which calls #setDisabled)
9262 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
9263 // Set up the options before parent constructor, which uses them to validate config.value.
9264 // Use this instead of setOptions() because this.$input is not set up yet.
9265 this.setOptionsData( config.options || [] );
9266
9267 // Parent constructor
9268 OO.ui.DropdownInputWidget.parent.call( this, config );
9269
9270 // Events
9271 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9272
9273 // Initialization
9274 this.$element
9275 .addClass( 'oo-ui-dropdownInputWidget' )
9276 .append( this.dropdownWidget.$element );
9277 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9278 };
9279
9280 /* Setup */
9281
9282 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9283
9284 /* Methods */
9285
9286 /**
9287 * @inheritdoc
9288 * @protected
9289 */
9290 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9291 return $( '<select>' );
9292 };
9293
9294 /**
9295 * Handles menu select events.
9296 *
9297 * @private
9298 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9299 */
9300 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9301 this.setValue( item ? item.getData() : '' );
9302 };
9303
9304 /**
9305 * @inheritdoc
9306 */
9307 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9308 var selected;
9309 value = this.cleanUpValue( value );
9310 // Only allow setting values that are actually present in the dropdown
9311 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9312 this.dropdownWidget.getMenu().findFirstSelectableItem();
9313 this.dropdownWidget.getMenu().selectItem( selected );
9314 value = selected ? selected.getData() : '';
9315 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9316 if ( this.optionsDirty ) {
9317 // We reached this from the constructor or from #setOptions.
9318 // We have to update the <select> element.
9319 this.updateOptionsInterface();
9320 }
9321 return this;
9322 };
9323
9324 /**
9325 * @inheritdoc
9326 */
9327 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9328 this.dropdownWidget.setDisabled( state );
9329 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9330 return this;
9331 };
9332
9333 /**
9334 * Set the options available for this input.
9335 *
9336 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9337 * @chainable
9338 */
9339 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9340 var value = this.getValue();
9341
9342 this.setOptionsData( options );
9343
9344 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9345 // In case the previous value is no longer an available option, select the first valid one.
9346 this.setValue( value );
9347
9348 return this;
9349 };
9350
9351 /**
9352 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9353 *
9354 * This method may be called before the parent constructor, so various properties may not be
9355 * intialized yet.
9356 *
9357 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9358 * @private
9359 */
9360 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9361 var
9362 optionWidgets,
9363 widget = this;
9364
9365 this.optionsDirty = true;
9366
9367 optionWidgets = options.map( function ( opt ) {
9368 var optValue;
9369
9370 if ( opt.optgroup !== undefined ) {
9371 return widget.createMenuSectionOptionWidget( opt.optgroup );
9372 }
9373
9374 optValue = widget.cleanUpValue( opt.data );
9375 return widget.createMenuOptionWidget(
9376 optValue,
9377 opt.label !== undefined ? opt.label : optValue
9378 );
9379
9380 } );
9381
9382 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9383 };
9384
9385 /**
9386 * Create a menu option widget.
9387 *
9388 * @protected
9389 * @param {string} data Item data
9390 * @param {string} label Item label
9391 * @return {OO.ui.MenuOptionWidget} Option widget
9392 */
9393 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9394 return new OO.ui.MenuOptionWidget( {
9395 data: data,
9396 label: label
9397 } );
9398 };
9399
9400 /**
9401 * Create a menu section option widget.
9402 *
9403 * @protected
9404 * @param {string} label Section item label
9405 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9406 */
9407 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9408 return new OO.ui.MenuSectionOptionWidget( {
9409 label: label
9410 } );
9411 };
9412
9413 /**
9414 * Update the user-visible interface to match the internal list of options and value.
9415 *
9416 * This method must only be called after the parent constructor.
9417 *
9418 * @private
9419 */
9420 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9421 var
9422 $optionsContainer = this.$input,
9423 defaultValue = this.defaultValue,
9424 widget = this;
9425
9426 this.$input.empty();
9427
9428 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9429 var $optionNode;
9430
9431 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9432 $optionNode = $( '<option>' )
9433 .attr( 'value', optionWidget.getData() )
9434 .text( optionWidget.getLabel() );
9435
9436 // Remember original selection state. This property can be later used to check whether
9437 // the selection state of the input has been changed since it was created.
9438 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9439
9440 $optionsContainer.append( $optionNode );
9441 } else {
9442 $optionNode = $( '<optgroup>' )
9443 .attr( 'label', optionWidget.getLabel() );
9444 widget.$input.append( $optionNode );
9445 $optionsContainer = $optionNode;
9446 }
9447 } );
9448
9449 this.optionsDirty = false;
9450 };
9451
9452 /**
9453 * @inheritdoc
9454 */
9455 OO.ui.DropdownInputWidget.prototype.focus = function () {
9456 this.dropdownWidget.focus();
9457 return this;
9458 };
9459
9460 /**
9461 * @inheritdoc
9462 */
9463 OO.ui.DropdownInputWidget.prototype.blur = function () {
9464 this.dropdownWidget.blur();
9465 return this;
9466 };
9467
9468 /**
9469 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9470 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9471 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9472 * please see the [OOUI documentation on MediaWiki][1].
9473 *
9474 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9475 *
9476 * @example
9477 * // An example of selected, unselected, and disabled radio inputs
9478 * var radio1 = new OO.ui.RadioInputWidget( {
9479 * value: 'a',
9480 * selected: true
9481 * } );
9482 * var radio2 = new OO.ui.RadioInputWidget( {
9483 * value: 'b'
9484 * } );
9485 * var radio3 = new OO.ui.RadioInputWidget( {
9486 * value: 'c',
9487 * disabled: true
9488 * } );
9489 * // Create a fieldset layout with fields for each radio button.
9490 * var fieldset = new OO.ui.FieldsetLayout( {
9491 * label: 'Radio inputs'
9492 * } );
9493 * fieldset.addItems( [
9494 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9495 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9496 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9497 * ] );
9498 * $( 'body' ).append( fieldset.$element );
9499 *
9500 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9501 *
9502 * @class
9503 * @extends OO.ui.InputWidget
9504 *
9505 * @constructor
9506 * @param {Object} [config] Configuration options
9507 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9508 */
9509 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9510 // Configuration initialization
9511 config = config || {};
9512
9513 // Parent constructor
9514 OO.ui.RadioInputWidget.parent.call( this, config );
9515
9516 // Initialization
9517 this.$element
9518 .addClass( 'oo-ui-radioInputWidget' )
9519 // Required for pretty styling in WikimediaUI theme
9520 .append( $( '<span>' ) );
9521 this.setSelected( config.selected !== undefined ? config.selected : false );
9522 };
9523
9524 /* Setup */
9525
9526 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9527
9528 /* Static Properties */
9529
9530 /**
9531 * @static
9532 * @inheritdoc
9533 */
9534 OO.ui.RadioInputWidget.static.tagName = 'span';
9535
9536 /* Static Methods */
9537
9538 /**
9539 * @inheritdoc
9540 */
9541 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9542 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9543 state.checked = config.$input.prop( 'checked' );
9544 return state;
9545 };
9546
9547 /* Methods */
9548
9549 /**
9550 * @inheritdoc
9551 * @protected
9552 */
9553 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9554 return $( '<input>' ).attr( 'type', 'radio' );
9555 };
9556
9557 /**
9558 * @inheritdoc
9559 */
9560 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9561 // RadioInputWidget doesn't track its state.
9562 };
9563
9564 /**
9565 * Set selection state of this radio button.
9566 *
9567 * @param {boolean} state `true` for selected
9568 * @chainable
9569 */
9570 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9571 // RadioInputWidget doesn't track its state.
9572 this.$input.prop( 'checked', state );
9573 // The first time that the selection state is set (probably while constructing the widget),
9574 // remember it in defaultSelected. This property can be later used to check whether
9575 // the selection state of the input has been changed since it was created.
9576 if ( this.defaultSelected === undefined ) {
9577 this.defaultSelected = state;
9578 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9579 }
9580 return this;
9581 };
9582
9583 /**
9584 * Check if this radio button is selected.
9585 *
9586 * @return {boolean} Radio is selected
9587 */
9588 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9589 return this.$input.prop( 'checked' );
9590 };
9591
9592 /**
9593 * @inheritdoc
9594 */
9595 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9596 if ( !this.isDisabled() ) {
9597 this.$input.click();
9598 }
9599 this.focus();
9600 };
9601
9602 /**
9603 * @inheritdoc
9604 */
9605 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9606 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9607 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9608 this.setSelected( state.checked );
9609 }
9610 };
9611
9612 /**
9613 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9614 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9615 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9616 * more information about input widgets.
9617 *
9618 * This and OO.ui.DropdownInputWidget support the same configuration options.
9619 *
9620 * @example
9621 * // Example: A RadioSelectInputWidget with three options
9622 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9623 * options: [
9624 * { data: 'a', label: 'First' },
9625 * { data: 'b', label: 'Second'},
9626 * { data: 'c', label: 'Third' }
9627 * ]
9628 * } );
9629 * $( 'body' ).append( radioSelectInput.$element );
9630 *
9631 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9632 *
9633 * @class
9634 * @extends OO.ui.InputWidget
9635 *
9636 * @constructor
9637 * @param {Object} [config] Configuration options
9638 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9639 */
9640 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9641 // Configuration initialization
9642 config = config || {};
9643
9644 // Properties (must be done before parent constructor which calls #setDisabled)
9645 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9646 // Set up the options before parent constructor, which uses them to validate config.value.
9647 // Use this instead of setOptions() because this.$input is not set up yet
9648 this.setOptionsData( config.options || [] );
9649
9650 // Parent constructor
9651 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9652
9653 // Events
9654 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9655
9656 // Initialization
9657 this.$element
9658 .addClass( 'oo-ui-radioSelectInputWidget' )
9659 .append( this.radioSelectWidget.$element );
9660 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9661 };
9662
9663 /* Setup */
9664
9665 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9666
9667 /* Static Methods */
9668
9669 /**
9670 * @inheritdoc
9671 */
9672 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9673 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9674 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9675 return state;
9676 };
9677
9678 /**
9679 * @inheritdoc
9680 */
9681 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9682 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9683 // Cannot reuse the `<input type=radio>` set
9684 delete config.$input;
9685 return config;
9686 };
9687
9688 /* Methods */
9689
9690 /**
9691 * @inheritdoc
9692 * @protected
9693 */
9694 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9695 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9696 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9697 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9698 };
9699
9700 /**
9701 * Handles menu select events.
9702 *
9703 * @private
9704 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9705 */
9706 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9707 this.setValue( item.getData() );
9708 };
9709
9710 /**
9711 * @inheritdoc
9712 */
9713 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9714 var selected;
9715 value = this.cleanUpValue( value );
9716 // Only allow setting values that are actually present in the dropdown
9717 selected = this.radioSelectWidget.findItemFromData( value ) ||
9718 this.radioSelectWidget.findFirstSelectableItem();
9719 this.radioSelectWidget.selectItem( selected );
9720 value = selected ? selected.getData() : '';
9721 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9722 return this;
9723 };
9724
9725 /**
9726 * @inheritdoc
9727 */
9728 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9729 this.radioSelectWidget.setDisabled( state );
9730 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9731 return this;
9732 };
9733
9734 /**
9735 * Set the options available for this input.
9736 *
9737 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9738 * @chainable
9739 */
9740 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9741 var value = this.getValue();
9742
9743 this.setOptionsData( options );
9744
9745 // Re-set the value to update the visible interface (RadioSelectWidget).
9746 // In case the previous value is no longer an available option, select the first valid one.
9747 this.setValue( value );
9748
9749 return this;
9750 };
9751
9752 /**
9753 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9754 *
9755 * This method may be called before the parent constructor, so various properties may not be
9756 * intialized yet.
9757 *
9758 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9759 * @private
9760 */
9761 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
9762 var widget = this;
9763
9764 this.radioSelectWidget
9765 .clearItems()
9766 .addItems( options.map( function ( opt ) {
9767 var optValue = widget.cleanUpValue( opt.data );
9768 return new OO.ui.RadioOptionWidget( {
9769 data: optValue,
9770 label: opt.label !== undefined ? opt.label : optValue
9771 } );
9772 } ) );
9773 };
9774
9775 /**
9776 * @inheritdoc
9777 */
9778 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9779 this.radioSelectWidget.focus();
9780 return this;
9781 };
9782
9783 /**
9784 * @inheritdoc
9785 */
9786 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9787 this.radioSelectWidget.blur();
9788 return this;
9789 };
9790
9791 /**
9792 * CheckboxMultiselectInputWidget is a
9793 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9794 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9795 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
9796 * more information about input widgets.
9797 *
9798 * @example
9799 * // Example: A CheckboxMultiselectInputWidget with three options
9800 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9801 * options: [
9802 * { data: 'a', label: 'First' },
9803 * { data: 'b', label: 'Second'},
9804 * { data: 'c', label: 'Third' }
9805 * ]
9806 * } );
9807 * $( 'body' ).append( multiselectInput.$element );
9808 *
9809 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9810 *
9811 * @class
9812 * @extends OO.ui.InputWidget
9813 *
9814 * @constructor
9815 * @param {Object} [config] Configuration options
9816 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9817 */
9818 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9819 // Configuration initialization
9820 config = config || {};
9821
9822 // Properties (must be done before parent constructor which calls #setDisabled)
9823 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9824 // Must be set before the #setOptionsData call below
9825 this.inputName = config.name;
9826 // Set up the options before parent constructor, which uses them to validate config.value.
9827 // Use this instead of setOptions() because this.$input is not set up yet
9828 this.setOptionsData( config.options || [] );
9829
9830 // Parent constructor
9831 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9832
9833 // Events
9834 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
9835
9836 // Initialization
9837 this.$element
9838 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9839 .append( this.checkboxMultiselectWidget.$element );
9840 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9841 this.$input.detach();
9842 };
9843
9844 /* Setup */
9845
9846 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9847
9848 /* Static Methods */
9849
9850 /**
9851 * @inheritdoc
9852 */
9853 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9854 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9855 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9856 .toArray().map( function ( el ) { return el.value; } );
9857 return state;
9858 };
9859
9860 /**
9861 * @inheritdoc
9862 */
9863 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9864 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9865 // Cannot reuse the `<input type=checkbox>` set
9866 delete config.$input;
9867 return config;
9868 };
9869
9870 /* Methods */
9871
9872 /**
9873 * @inheritdoc
9874 * @protected
9875 */
9876 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9877 // Actually unused
9878 return $( '<unused>' );
9879 };
9880
9881 /**
9882 * Handles CheckboxMultiselectWidget select events.
9883 *
9884 * @private
9885 */
9886 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
9887 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
9888 };
9889
9890 /**
9891 * @inheritdoc
9892 */
9893 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9894 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9895 .toArray().map( function ( el ) { return el.value; } );
9896 if ( this.value !== value ) {
9897 this.setValue( value );
9898 }
9899 return this.value;
9900 };
9901
9902 /**
9903 * @inheritdoc
9904 */
9905 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9906 value = this.cleanUpValue( value );
9907 this.checkboxMultiselectWidget.selectItemsByData( value );
9908 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9909 if ( this.optionsDirty ) {
9910 // We reached this from the constructor or from #setOptions.
9911 // We have to update the <select> element.
9912 this.updateOptionsInterface();
9913 }
9914 return this;
9915 };
9916
9917 /**
9918 * Clean up incoming value.
9919 *
9920 * @param {string[]} value Original value
9921 * @return {string[]} Cleaned up value
9922 */
9923 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9924 var i, singleValue,
9925 cleanValue = [];
9926 if ( !Array.isArray( value ) ) {
9927 return cleanValue;
9928 }
9929 for ( i = 0; i < value.length; i++ ) {
9930 singleValue =
9931 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9932 // Remove options that we don't have here
9933 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
9934 continue;
9935 }
9936 cleanValue.push( singleValue );
9937 }
9938 return cleanValue;
9939 };
9940
9941 /**
9942 * @inheritdoc
9943 */
9944 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9945 this.checkboxMultiselectWidget.setDisabled( state );
9946 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9947 return this;
9948 };
9949
9950 /**
9951 * Set the options available for this input.
9952 *
9953 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9954 * @chainable
9955 */
9956 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9957 var value = this.getValue();
9958
9959 this.setOptionsData( options );
9960
9961 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
9962 // This will also get rid of any stale options that we just removed.
9963 this.setValue( value );
9964
9965 return this;
9966 };
9967
9968 /**
9969 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9970 *
9971 * This method may be called before the parent constructor, so various properties may not be
9972 * intialized yet.
9973 *
9974 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9975 * @private
9976 */
9977 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
9978 var widget = this;
9979
9980 this.optionsDirty = true;
9981
9982 this.checkboxMultiselectWidget
9983 .clearItems()
9984 .addItems( options.map( function ( opt ) {
9985 var optValue, item, optDisabled;
9986 optValue =
9987 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9988 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9989 item = new OO.ui.CheckboxMultioptionWidget( {
9990 data: optValue,
9991 label: opt.label !== undefined ? opt.label : optValue,
9992 disabled: optDisabled
9993 } );
9994 // Set the 'name' and 'value' for form submission
9995 item.checkbox.$input.attr( 'name', widget.inputName );
9996 item.checkbox.setValue( optValue );
9997 return item;
9998 } ) );
9999 };
10000
10001 /**
10002 * Update the user-visible interface to match the internal list of options and value.
10003 *
10004 * This method must only be called after the parent constructor.
10005 *
10006 * @private
10007 */
10008 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10009 var defaultValue = this.defaultValue;
10010
10011 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10012 // Remember original selection state. This property can be later used to check whether
10013 // the selection state of the input has been changed since it was created.
10014 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10015 item.checkbox.defaultSelected = isDefault;
10016 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10017 } );
10018
10019 this.optionsDirty = false;
10020 };
10021
10022 /**
10023 * @inheritdoc
10024 */
10025 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10026 this.checkboxMultiselectWidget.focus();
10027 return this;
10028 };
10029
10030 /**
10031 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10032 * size of the field as well as its presentation. In addition, these widgets can be configured
10033 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10034 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10035 * which modifies incoming values rather than validating them.
10036 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10037 *
10038 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10039 *
10040 * @example
10041 * // Example of a text input widget
10042 * var textInput = new OO.ui.TextInputWidget( {
10043 * value: 'Text input'
10044 * } )
10045 * $( 'body' ).append( textInput.$element );
10046 *
10047 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10048 *
10049 * @class
10050 * @extends OO.ui.InputWidget
10051 * @mixins OO.ui.mixin.IconElement
10052 * @mixins OO.ui.mixin.IndicatorElement
10053 * @mixins OO.ui.mixin.PendingElement
10054 * @mixins OO.ui.mixin.LabelElement
10055 *
10056 * @constructor
10057 * @param {Object} [config] Configuration options
10058 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10059 * 'email', 'url' or 'number'.
10060 * @cfg {string} [placeholder] Placeholder text
10061 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10062 * instruct the browser to focus this widget.
10063 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10064 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10065 *
10066 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10067 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10068 * many emojis) count as 2 characters each.
10069 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10070 * the value or placeholder text: `'before'` or `'after'`
10071 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10072 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10073 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10074 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10075 * leaving it up to the browser).
10076 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10077 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10078 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10079 * value for it to be considered valid; when Function, a function receiving the value as parameter
10080 * that must return true, or promise resolving to true, for it to be considered valid.
10081 */
10082 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10083 // Configuration initialization
10084 config = $.extend( {
10085 type: 'text',
10086 labelPosition: 'after'
10087 }, config );
10088
10089 if ( config.multiline ) {
10090 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
10091 return new OO.ui.MultilineTextInputWidget( config );
10092 }
10093
10094 // Parent constructor
10095 OO.ui.TextInputWidget.parent.call( this, config );
10096
10097 // Mixin constructors
10098 OO.ui.mixin.IconElement.call( this, config );
10099 OO.ui.mixin.IndicatorElement.call( this, config );
10100 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10101 OO.ui.mixin.LabelElement.call( this, config );
10102
10103 // Properties
10104 this.type = this.getSaneType( config );
10105 this.readOnly = false;
10106 this.required = false;
10107 this.validate = null;
10108 this.styleHeight = null;
10109 this.scrollWidth = null;
10110
10111 this.setValidation( config.validate );
10112 this.setLabelPosition( config.labelPosition );
10113
10114 // Events
10115 this.$input.on( {
10116 keypress: this.onKeyPress.bind( this ),
10117 blur: this.onBlur.bind( this ),
10118 focus: this.onFocus.bind( this )
10119 } );
10120 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10121 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10122 this.on( 'labelChange', this.updatePosition.bind( this ) );
10123 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10124
10125 // Initialization
10126 this.$element
10127 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10128 .append( this.$icon, this.$indicator );
10129 this.setReadOnly( !!config.readOnly );
10130 this.setRequired( !!config.required );
10131 if ( config.placeholder !== undefined ) {
10132 this.$input.attr( 'placeholder', config.placeholder );
10133 }
10134 if ( config.maxLength !== undefined ) {
10135 this.$input.attr( 'maxlength', config.maxLength );
10136 }
10137 if ( config.autofocus ) {
10138 this.$input.attr( 'autofocus', 'autofocus' );
10139 }
10140 if ( config.autocomplete === false ) {
10141 this.$input.attr( 'autocomplete', 'off' );
10142 // Turning off autocompletion also disables "form caching" when the user navigates to a
10143 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10144 $( window ).on( {
10145 beforeunload: function () {
10146 this.$input.removeAttr( 'autocomplete' );
10147 }.bind( this ),
10148 pageshow: function () {
10149 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10150 // whole page... it shouldn't hurt, though.
10151 this.$input.attr( 'autocomplete', 'off' );
10152 }.bind( this )
10153 } );
10154 }
10155 if ( config.spellcheck !== undefined ) {
10156 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10157 }
10158 if ( this.label ) {
10159 this.isWaitingToBeAttached = true;
10160 this.installParentChangeDetector();
10161 }
10162 };
10163
10164 /* Setup */
10165
10166 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10167 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10168 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10169 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10170 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10171
10172 /* Static Properties */
10173
10174 OO.ui.TextInputWidget.static.validationPatterns = {
10175 'non-empty': /.+/,
10176 integer: /^\d+$/
10177 };
10178
10179 /* Events */
10180
10181 /**
10182 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10183 *
10184 * @event enter
10185 */
10186
10187 /* Methods */
10188
10189 /**
10190 * Handle icon mouse down events.
10191 *
10192 * @private
10193 * @param {jQuery.Event} e Mouse down event
10194 */
10195 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10196 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10197 this.focus();
10198 return false;
10199 }
10200 };
10201
10202 /**
10203 * Handle indicator mouse down events.
10204 *
10205 * @private
10206 * @param {jQuery.Event} e Mouse down event
10207 */
10208 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10209 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10210 this.focus();
10211 return false;
10212 }
10213 };
10214
10215 /**
10216 * Handle key press events.
10217 *
10218 * @private
10219 * @param {jQuery.Event} e Key press event
10220 * @fires enter If enter key is pressed
10221 */
10222 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10223 if ( e.which === OO.ui.Keys.ENTER ) {
10224 this.emit( 'enter', e );
10225 }
10226 };
10227
10228 /**
10229 * Handle blur events.
10230 *
10231 * @private
10232 * @param {jQuery.Event} e Blur event
10233 */
10234 OO.ui.TextInputWidget.prototype.onBlur = function () {
10235 this.setValidityFlag();
10236 };
10237
10238 /**
10239 * Handle focus events.
10240 *
10241 * @private
10242 * @param {jQuery.Event} e Focus event
10243 */
10244 OO.ui.TextInputWidget.prototype.onFocus = function () {
10245 if ( this.isWaitingToBeAttached ) {
10246 // If we've received focus, then we must be attached to the document, and if
10247 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10248 this.onElementAttach();
10249 }
10250 this.setValidityFlag( true );
10251 };
10252
10253 /**
10254 * Handle element attach events.
10255 *
10256 * @private
10257 * @param {jQuery.Event} e Element attach event
10258 */
10259 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10260 this.isWaitingToBeAttached = false;
10261 // Any previously calculated size is now probably invalid if we reattached elsewhere
10262 this.valCache = null;
10263 this.positionLabel();
10264 };
10265
10266 /**
10267 * Handle debounced change events.
10268 *
10269 * @param {string} value
10270 * @private
10271 */
10272 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10273 this.setValidityFlag();
10274 };
10275
10276 /**
10277 * Check if the input is {@link #readOnly read-only}.
10278 *
10279 * @return {boolean}
10280 */
10281 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10282 return this.readOnly;
10283 };
10284
10285 /**
10286 * Set the {@link #readOnly read-only} state of the input.
10287 *
10288 * @param {boolean} state Make input read-only
10289 * @chainable
10290 */
10291 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10292 this.readOnly = !!state;
10293 this.$input.prop( 'readOnly', this.readOnly );
10294 return this;
10295 };
10296
10297 /**
10298 * Check if the input is {@link #required required}.
10299 *
10300 * @return {boolean}
10301 */
10302 OO.ui.TextInputWidget.prototype.isRequired = function () {
10303 return this.required;
10304 };
10305
10306 /**
10307 * Set the {@link #required required} state of the input.
10308 *
10309 * @param {boolean} state Make input required
10310 * @chainable
10311 */
10312 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10313 this.required = !!state;
10314 if ( this.required ) {
10315 this.$input
10316 .prop( 'required', true )
10317 .attr( 'aria-required', 'true' );
10318 if ( this.getIndicator() === null ) {
10319 this.setIndicator( 'required' );
10320 }
10321 } else {
10322 this.$input
10323 .prop( 'required', false )
10324 .removeAttr( 'aria-required' );
10325 if ( this.getIndicator() === 'required' ) {
10326 this.setIndicator( null );
10327 }
10328 }
10329 return this;
10330 };
10331
10332 /**
10333 * Support function for making #onElementAttach work across browsers.
10334 *
10335 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10336 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10337 *
10338 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10339 * first time that the element gets attached to the documented.
10340 */
10341 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10342 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10343 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10344 widget = this;
10345
10346 if ( MutationObserver ) {
10347 // The new way. If only it wasn't so ugly.
10348
10349 if ( this.isElementAttached() ) {
10350 // Widget is attached already, do nothing. This breaks the functionality of this function when
10351 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10352 // would require observation of the whole document, which would hurt performance of other,
10353 // more important code.
10354 return;
10355 }
10356
10357 // Find topmost node in the tree
10358 topmostNode = this.$element[ 0 ];
10359 while ( topmostNode.parentNode ) {
10360 topmostNode = topmostNode.parentNode;
10361 }
10362
10363 // We have no way to detect the $element being attached somewhere without observing the entire
10364 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10365 // parent node of $element, and instead detect when $element is removed from it (and thus
10366 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10367 // doesn't get attached, we end up back here and create the parent.
10368
10369 mutationObserver = new MutationObserver( function ( mutations ) {
10370 var i, j, removedNodes;
10371 for ( i = 0; i < mutations.length; i++ ) {
10372 removedNodes = mutations[ i ].removedNodes;
10373 for ( j = 0; j < removedNodes.length; j++ ) {
10374 if ( removedNodes[ j ] === topmostNode ) {
10375 setTimeout( onRemove, 0 );
10376 return;
10377 }
10378 }
10379 }
10380 } );
10381
10382 onRemove = function () {
10383 // If the node was attached somewhere else, report it
10384 if ( widget.isElementAttached() ) {
10385 widget.onElementAttach();
10386 }
10387 mutationObserver.disconnect();
10388 widget.installParentChangeDetector();
10389 };
10390
10391 // Create a fake parent and observe it
10392 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10393 mutationObserver.observe( fakeParentNode, { childList: true } );
10394 } else {
10395 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10396 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10397 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10398 }
10399 };
10400
10401 /**
10402 * @inheritdoc
10403 * @protected
10404 */
10405 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10406 if ( this.getSaneType( config ) === 'number' ) {
10407 return $( '<input>' )
10408 .attr( 'step', 'any' )
10409 .attr( 'type', 'number' );
10410 } else {
10411 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10412 }
10413 };
10414
10415 /**
10416 * Get sanitized value for 'type' for given config.
10417 *
10418 * @param {Object} config Configuration options
10419 * @return {string|null}
10420 * @protected
10421 */
10422 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10423 var allowedTypes = [
10424 'text',
10425 'password',
10426 'email',
10427 'url',
10428 'number'
10429 ];
10430 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10431 };
10432
10433 /**
10434 * Focus the input and select a specified range within the text.
10435 *
10436 * @param {number} from Select from offset
10437 * @param {number} [to] Select to offset, defaults to from
10438 * @chainable
10439 */
10440 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10441 var isBackwards, start, end,
10442 input = this.$input[ 0 ];
10443
10444 to = to || from;
10445
10446 isBackwards = to < from;
10447 start = isBackwards ? to : from;
10448 end = isBackwards ? from : to;
10449
10450 this.focus();
10451
10452 try {
10453 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10454 } catch ( e ) {
10455 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10456 // Rather than expensively check if the input is attached every time, just check
10457 // if it was the cause of an error being thrown. If not, rethrow the error.
10458 if ( this.getElementDocument().body.contains( input ) ) {
10459 throw e;
10460 }
10461 }
10462 return this;
10463 };
10464
10465 /**
10466 * Get an object describing the current selection range in a directional manner
10467 *
10468 * @return {Object} Object containing 'from' and 'to' offsets
10469 */
10470 OO.ui.TextInputWidget.prototype.getRange = function () {
10471 var input = this.$input[ 0 ],
10472 start = input.selectionStart,
10473 end = input.selectionEnd,
10474 isBackwards = input.selectionDirection === 'backward';
10475
10476 return {
10477 from: isBackwards ? end : start,
10478 to: isBackwards ? start : end
10479 };
10480 };
10481
10482 /**
10483 * Get the length of the text input value.
10484 *
10485 * This could differ from the length of #getValue if the
10486 * value gets filtered
10487 *
10488 * @return {number} Input length
10489 */
10490 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10491 return this.$input[ 0 ].value.length;
10492 };
10493
10494 /**
10495 * Focus the input and select the entire text.
10496 *
10497 * @chainable
10498 */
10499 OO.ui.TextInputWidget.prototype.select = function () {
10500 return this.selectRange( 0, this.getInputLength() );
10501 };
10502
10503 /**
10504 * Focus the input and move the cursor to the start.
10505 *
10506 * @chainable
10507 */
10508 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10509 return this.selectRange( 0 );
10510 };
10511
10512 /**
10513 * Focus the input and move the cursor to the end.
10514 *
10515 * @chainable
10516 */
10517 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10518 return this.selectRange( this.getInputLength() );
10519 };
10520
10521 /**
10522 * Insert new content into the input.
10523 *
10524 * @param {string} content Content to be inserted
10525 * @chainable
10526 */
10527 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10528 var start, end,
10529 range = this.getRange(),
10530 value = this.getValue();
10531
10532 start = Math.min( range.from, range.to );
10533 end = Math.max( range.from, range.to );
10534
10535 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10536 this.selectRange( start + content.length );
10537 return this;
10538 };
10539
10540 /**
10541 * Insert new content either side of a selection.
10542 *
10543 * @param {string} pre Content to be inserted before the selection
10544 * @param {string} post Content to be inserted after the selection
10545 * @chainable
10546 */
10547 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10548 var start, end,
10549 range = this.getRange(),
10550 offset = pre.length;
10551
10552 start = Math.min( range.from, range.to );
10553 end = Math.max( range.from, range.to );
10554
10555 this.selectRange( start ).insertContent( pre );
10556 this.selectRange( offset + end ).insertContent( post );
10557
10558 this.selectRange( offset + start, offset + end );
10559 return this;
10560 };
10561
10562 /**
10563 * Set the validation pattern.
10564 *
10565 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10566 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10567 * value must contain only numbers).
10568 *
10569 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10570 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10571 */
10572 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10573 if ( validate instanceof RegExp || validate instanceof Function ) {
10574 this.validate = validate;
10575 } else {
10576 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10577 }
10578 };
10579
10580 /**
10581 * Sets the 'invalid' flag appropriately.
10582 *
10583 * @param {boolean} [isValid] Optionally override validation result
10584 */
10585 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10586 var widget = this,
10587 setFlag = function ( valid ) {
10588 if ( !valid ) {
10589 widget.$input.attr( 'aria-invalid', 'true' );
10590 } else {
10591 widget.$input.removeAttr( 'aria-invalid' );
10592 }
10593 widget.setFlags( { invalid: !valid } );
10594 };
10595
10596 if ( isValid !== undefined ) {
10597 setFlag( isValid );
10598 } else {
10599 this.getValidity().then( function () {
10600 setFlag( true );
10601 }, function () {
10602 setFlag( false );
10603 } );
10604 }
10605 };
10606
10607 /**
10608 * Get the validity of current value.
10609 *
10610 * This method returns a promise that resolves if the value is valid and rejects if
10611 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10612 *
10613 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10614 */
10615 OO.ui.TextInputWidget.prototype.getValidity = function () {
10616 var result;
10617
10618 function rejectOrResolve( valid ) {
10619 if ( valid ) {
10620 return $.Deferred().resolve().promise();
10621 } else {
10622 return $.Deferred().reject().promise();
10623 }
10624 }
10625
10626 // Check browser validity and reject if it is invalid
10627 if (
10628 this.$input[ 0 ].checkValidity !== undefined &&
10629 this.$input[ 0 ].checkValidity() === false
10630 ) {
10631 return rejectOrResolve( false );
10632 }
10633
10634 // Run our checks if the browser thinks the field is valid
10635 if ( this.validate instanceof Function ) {
10636 result = this.validate( this.getValue() );
10637 if ( result && $.isFunction( result.promise ) ) {
10638 return result.promise().then( function ( valid ) {
10639 return rejectOrResolve( valid );
10640 } );
10641 } else {
10642 return rejectOrResolve( result );
10643 }
10644 } else {
10645 return rejectOrResolve( this.getValue().match( this.validate ) );
10646 }
10647 };
10648
10649 /**
10650 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10651 *
10652 * @param {string} labelPosition Label position, 'before' or 'after'
10653 * @chainable
10654 */
10655 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10656 this.labelPosition = labelPosition;
10657 if ( this.label ) {
10658 // If there is no label and we only change the position, #updatePosition is a no-op,
10659 // but it takes really a lot of work to do nothing.
10660 this.updatePosition();
10661 }
10662 return this;
10663 };
10664
10665 /**
10666 * Update the position of the inline label.
10667 *
10668 * This method is called by #setLabelPosition, and can also be called on its own if
10669 * something causes the label to be mispositioned.
10670 *
10671 * @chainable
10672 */
10673 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10674 var after = this.labelPosition === 'after';
10675
10676 this.$element
10677 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10678 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10679
10680 this.valCache = null;
10681 this.scrollWidth = null;
10682 this.positionLabel();
10683
10684 return this;
10685 };
10686
10687 /**
10688 * Position the label by setting the correct padding on the input.
10689 *
10690 * @private
10691 * @chainable
10692 */
10693 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10694 var after, rtl, property, newCss;
10695
10696 if ( this.isWaitingToBeAttached ) {
10697 // #onElementAttach will be called soon, which calls this method
10698 return this;
10699 }
10700
10701 newCss = {
10702 'padding-right': '',
10703 'padding-left': ''
10704 };
10705
10706 if ( this.label ) {
10707 this.$element.append( this.$label );
10708 } else {
10709 this.$label.detach();
10710 // Clear old values if present
10711 this.$input.css( newCss );
10712 return;
10713 }
10714
10715 after = this.labelPosition === 'after';
10716 rtl = this.$element.css( 'direction' ) === 'rtl';
10717 property = after === rtl ? 'padding-left' : 'padding-right';
10718
10719 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10720 // We have to clear the padding on the other side, in case the element direction changed
10721 this.$input.css( newCss );
10722
10723 return this;
10724 };
10725
10726 /**
10727 * @class
10728 * @extends OO.ui.TextInputWidget
10729 *
10730 * @constructor
10731 * @param {Object} [config] Configuration options
10732 */
10733 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10734 config = $.extend( {
10735 icon: 'search'
10736 }, config );
10737
10738 // Parent constructor
10739 OO.ui.SearchInputWidget.parent.call( this, config );
10740
10741 // Events
10742 this.connect( this, {
10743 change: 'onChange'
10744 } );
10745
10746 // Initialization
10747 this.updateSearchIndicator();
10748 this.connect( this, {
10749 disable: 'onDisable'
10750 } );
10751 };
10752
10753 /* Setup */
10754
10755 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10756
10757 /* Methods */
10758
10759 /**
10760 * @inheritdoc
10761 * @protected
10762 */
10763 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10764 return 'search';
10765 };
10766
10767 /**
10768 * @inheritdoc
10769 */
10770 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10771 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10772 // Clear the text field
10773 this.setValue( '' );
10774 this.focus();
10775 return false;
10776 }
10777 };
10778
10779 /**
10780 * Update the 'clear' indicator displayed on type: 'search' text
10781 * fields, hiding it when the field is already empty or when it's not
10782 * editable.
10783 */
10784 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10785 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10786 this.setIndicator( null );
10787 } else {
10788 this.setIndicator( 'clear' );
10789 }
10790 };
10791
10792 /**
10793 * Handle change events.
10794 *
10795 * @private
10796 */
10797 OO.ui.SearchInputWidget.prototype.onChange = function () {
10798 this.updateSearchIndicator();
10799 };
10800
10801 /**
10802 * Handle disable events.
10803 *
10804 * @param {boolean} disabled Element is disabled
10805 * @private
10806 */
10807 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10808 this.updateSearchIndicator();
10809 };
10810
10811 /**
10812 * @inheritdoc
10813 */
10814 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10815 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10816 this.updateSearchIndicator();
10817 return this;
10818 };
10819
10820 /**
10821 * @class
10822 * @extends OO.ui.TextInputWidget
10823 *
10824 * @constructor
10825 * @param {Object} [config] Configuration options
10826 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10827 * specifies minimum number of rows to display.
10828 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10829 * Use the #maxRows config to specify a maximum number of displayed rows.
10830 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
10831 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10832 */
10833 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10834 config = $.extend( {
10835 type: 'text'
10836 }, config );
10837 config.multiline = false;
10838 // Parent constructor
10839 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10840
10841 // Properties
10842 this.multiline = true;
10843 this.autosize = !!config.autosize;
10844 this.minRows = config.rows !== undefined ? config.rows : '';
10845 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10846
10847 // Clone for resizing
10848 if ( this.autosize ) {
10849 this.$clone = this.$input
10850 .clone()
10851 .insertAfter( this.$input )
10852 .attr( 'aria-hidden', 'true' )
10853 .addClass( 'oo-ui-element-hidden' );
10854 }
10855
10856 // Events
10857 this.connect( this, {
10858 change: 'onChange'
10859 } );
10860
10861 // Initialization
10862 if ( this.multiline && config.rows ) {
10863 this.$input.attr( 'rows', config.rows );
10864 }
10865 if ( this.autosize ) {
10866 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
10867 this.isWaitingToBeAttached = true;
10868 this.installParentChangeDetector();
10869 }
10870 };
10871
10872 /* Setup */
10873
10874 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10875
10876 /* Static Methods */
10877
10878 /**
10879 * @inheritdoc
10880 */
10881 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10882 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10883 state.scrollTop = config.$input.scrollTop();
10884 return state;
10885 };
10886
10887 /* Methods */
10888
10889 /**
10890 * @inheritdoc
10891 */
10892 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10893 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10894 this.adjustSize();
10895 };
10896
10897 /**
10898 * Handle change events.
10899 *
10900 * @private
10901 */
10902 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10903 this.adjustSize();
10904 };
10905
10906 /**
10907 * @inheritdoc
10908 */
10909 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10910 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10911 this.adjustSize();
10912 };
10913
10914 /**
10915 * @inheritdoc
10916 *
10917 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
10918 */
10919 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
10920 if (
10921 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
10922 // Some platforms emit keycode 10 for ctrl+enter in a textarea
10923 e.which === 10
10924 ) {
10925 this.emit( 'enter', e );
10926 }
10927 };
10928
10929 /**
10930 * Automatically adjust the size of the text input.
10931 *
10932 * This only affects multiline inputs that are {@link #autosize autosized}.
10933 *
10934 * @chainable
10935 * @fires resize
10936 */
10937 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10938 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10939 idealHeight, newHeight, scrollWidth, property;
10940
10941 if ( this.$input.val() !== this.valCache ) {
10942 if ( this.autosize ) {
10943 this.$clone
10944 .val( this.$input.val() )
10945 .attr( 'rows', this.minRows )
10946 // Set inline height property to 0 to measure scroll height
10947 .css( 'height', 0 );
10948
10949 this.$clone.removeClass( 'oo-ui-element-hidden' );
10950
10951 this.valCache = this.$input.val();
10952
10953 scrollHeight = this.$clone[ 0 ].scrollHeight;
10954
10955 // Remove inline height property to measure natural heights
10956 this.$clone.css( 'height', '' );
10957 innerHeight = this.$clone.innerHeight();
10958 outerHeight = this.$clone.outerHeight();
10959
10960 // Measure max rows height
10961 this.$clone
10962 .attr( 'rows', this.maxRows )
10963 .css( 'height', 'auto' )
10964 .val( '' );
10965 maxInnerHeight = this.$clone.innerHeight();
10966
10967 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10968 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10969 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10970 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10971
10972 this.$clone.addClass( 'oo-ui-element-hidden' );
10973
10974 // Only apply inline height when expansion beyond natural height is needed
10975 // Use the difference between the inner and outer height as a buffer
10976 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10977 if ( newHeight !== this.styleHeight ) {
10978 this.$input.css( 'height', newHeight );
10979 this.styleHeight = newHeight;
10980 this.emit( 'resize' );
10981 }
10982 }
10983 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10984 if ( scrollWidth !== this.scrollWidth ) {
10985 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10986 // Reset
10987 this.$label.css( { right: '', left: '' } );
10988 this.$indicator.css( { right: '', left: '' } );
10989
10990 if ( scrollWidth ) {
10991 this.$indicator.css( property, scrollWidth );
10992 if ( this.labelPosition === 'after' ) {
10993 this.$label.css( property, scrollWidth );
10994 }
10995 }
10996
10997 this.scrollWidth = scrollWidth;
10998 this.positionLabel();
10999 }
11000 }
11001 return this;
11002 };
11003
11004 /**
11005 * @inheritdoc
11006 * @protected
11007 */
11008 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11009 return $( '<textarea>' );
11010 };
11011
11012 /**
11013 * Check if the input supports multiple lines.
11014 *
11015 * @return {boolean}
11016 */
11017 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
11018 return !!this.multiline;
11019 };
11020
11021 /**
11022 * Check if the input automatically adjusts its size.
11023 *
11024 * @return {boolean}
11025 */
11026 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11027 return !!this.autosize;
11028 };
11029
11030 /**
11031 * @inheritdoc
11032 */
11033 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11034 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11035 if ( state.scrollTop !== undefined ) {
11036 this.$input.scrollTop( state.scrollTop );
11037 }
11038 };
11039
11040 /**
11041 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11042 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11043 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11044 *
11045 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11046 * option, that option will appear to be selected.
11047 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11048 * input field.
11049 *
11050 * After the user chooses an option, its `data` will be used as a new value for the widget.
11051 * A `label` also can be specified for each option: if given, it will be shown instead of the
11052 * `data` in the dropdown menu.
11053 *
11054 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11055 *
11056 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11057 *
11058 * @example
11059 * // Example: A ComboBoxInputWidget.
11060 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11061 * value: 'Option 1',
11062 * options: [
11063 * { data: 'Option 1' },
11064 * { data: 'Option 2' },
11065 * { data: 'Option 3' }
11066 * ]
11067 * } );
11068 * $( 'body' ).append( comboBox.$element );
11069 *
11070 * @example
11071 * // Example: A ComboBoxInputWidget with additional option labels.
11072 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11073 * value: 'Option 1',
11074 * options: [
11075 * {
11076 * data: 'Option 1',
11077 * label: 'Option One'
11078 * },
11079 * {
11080 * data: 'Option 2',
11081 * label: 'Option Two'
11082 * },
11083 * {
11084 * data: 'Option 3',
11085 * label: 'Option Three'
11086 * }
11087 * ]
11088 * } );
11089 * $( 'body' ).append( comboBox.$element );
11090 *
11091 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11092 *
11093 * @class
11094 * @extends OO.ui.TextInputWidget
11095 *
11096 * @constructor
11097 * @param {Object} [config] Configuration options
11098 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11099 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11100 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11101 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11102 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11103 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11104 */
11105 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11106 // Configuration initialization
11107 config = $.extend( {
11108 autocomplete: false
11109 }, config );
11110
11111 // ComboBoxInputWidget shouldn't support `multiline`
11112 config.multiline = false;
11113
11114 // See InputWidget#reusePreInfuseDOM about `config.$input`
11115 if ( config.$input ) {
11116 config.$input.removeAttr( 'list' );
11117 }
11118
11119 // Parent constructor
11120 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11121
11122 // Properties
11123 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11124 this.dropdownButton = new OO.ui.ButtonWidget( {
11125 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11126 indicator: 'down',
11127 disabled: this.disabled
11128 } );
11129 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11130 {
11131 widget: this,
11132 input: this,
11133 $floatableContainer: this.$element,
11134 disabled: this.isDisabled()
11135 },
11136 config.menu
11137 ) );
11138
11139 // Events
11140 this.connect( this, {
11141 change: 'onInputChange',
11142 enter: 'onInputEnter'
11143 } );
11144 this.dropdownButton.connect( this, {
11145 click: 'onDropdownButtonClick'
11146 } );
11147 this.menu.connect( this, {
11148 choose: 'onMenuChoose',
11149 add: 'onMenuItemsChange',
11150 remove: 'onMenuItemsChange',
11151 toggle: 'onMenuToggle'
11152 } );
11153
11154 // Initialization
11155 this.$input.attr( {
11156 role: 'combobox',
11157 'aria-owns': this.menu.getElementId(),
11158 'aria-autocomplete': 'list'
11159 } );
11160 // Do not override options set via config.menu.items
11161 if ( config.options !== undefined ) {
11162 this.setOptions( config.options );
11163 }
11164 this.$field = $( '<div>' )
11165 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11166 .append( this.$input, this.dropdownButton.$element );
11167 this.$element
11168 .addClass( 'oo-ui-comboBoxInputWidget' )
11169 .append( this.$field );
11170 this.$overlay.append( this.menu.$element );
11171 this.onMenuItemsChange();
11172 };
11173
11174 /* Setup */
11175
11176 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11177
11178 /* Methods */
11179
11180 /**
11181 * Get the combobox's menu.
11182 *
11183 * @return {OO.ui.MenuSelectWidget} Menu widget
11184 */
11185 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11186 return this.menu;
11187 };
11188
11189 /**
11190 * Get the combobox's text input widget.
11191 *
11192 * @return {OO.ui.TextInputWidget} Text input widget
11193 */
11194 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11195 return this;
11196 };
11197
11198 /**
11199 * Handle input change events.
11200 *
11201 * @private
11202 * @param {string} value New value
11203 */
11204 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11205 var match = this.menu.findItemFromData( value );
11206
11207 this.menu.selectItem( match );
11208 if ( this.menu.findHighlightedItem() ) {
11209 this.menu.highlightItem( match );
11210 }
11211
11212 if ( !this.isDisabled() ) {
11213 this.menu.toggle( true );
11214 }
11215 };
11216
11217 /**
11218 * Handle input enter events.
11219 *
11220 * @private
11221 */
11222 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11223 if ( !this.isDisabled() ) {
11224 this.menu.toggle( false );
11225 }
11226 };
11227
11228 /**
11229 * Handle button click events.
11230 *
11231 * @private
11232 */
11233 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11234 this.menu.toggle();
11235 this.focus();
11236 };
11237
11238 /**
11239 * Handle menu choose events.
11240 *
11241 * @private
11242 * @param {OO.ui.OptionWidget} item Chosen item
11243 */
11244 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11245 this.setValue( item.getData() );
11246 };
11247
11248 /**
11249 * Handle menu item change events.
11250 *
11251 * @private
11252 */
11253 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11254 var match = this.menu.findItemFromData( this.getValue() );
11255 this.menu.selectItem( match );
11256 if ( this.menu.findHighlightedItem() ) {
11257 this.menu.highlightItem( match );
11258 }
11259 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11260 };
11261
11262 /**
11263 * Handle menu toggle events.
11264 *
11265 * @private
11266 * @param {boolean} isVisible Open state of the menu
11267 */
11268 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11269 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11270 };
11271
11272 /**
11273 * @inheritdoc
11274 */
11275 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11276 // Parent method
11277 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11278
11279 if ( this.dropdownButton ) {
11280 this.dropdownButton.setDisabled( this.isDisabled() );
11281 }
11282 if ( this.menu ) {
11283 this.menu.setDisabled( this.isDisabled() );
11284 }
11285
11286 return this;
11287 };
11288
11289 /**
11290 * Set the options available for this input.
11291 *
11292 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11293 * @chainable
11294 */
11295 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11296 this.getMenu()
11297 .clearItems()
11298 .addItems( options.map( function ( opt ) {
11299 return new OO.ui.MenuOptionWidget( {
11300 data: opt.data,
11301 label: opt.label !== undefined ? opt.label : opt.data
11302 } );
11303 } ) );
11304
11305 return this;
11306 };
11307
11308 /**
11309 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11310 * which is a widget that is specified by reference before any optional configuration settings.
11311 *
11312 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11313 *
11314 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11315 * A left-alignment is used for forms with many fields.
11316 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11317 * A right-alignment is used for long but familiar forms which users tab through,
11318 * verifying the current field with a quick glance at the label.
11319 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11320 * that users fill out from top to bottom.
11321 * - **inline**: The label is placed after the field-widget and aligned to the left.
11322 * An inline-alignment is best used with checkboxes or radio buttons.
11323 *
11324 * Help text can either be:
11325 *
11326 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11327 * - shown as a subtle explanation below the label.
11328 *
11329 * If the help text is brief, or is essential to always espose it, set `helpInline` to `true`. If it
11330 * is long or not essential, leave `helpInline` to its default, `false`.
11331 *
11332 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11333 *
11334 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11335 *
11336 * @class
11337 * @extends OO.ui.Layout
11338 * @mixins OO.ui.mixin.LabelElement
11339 * @mixins OO.ui.mixin.TitledElement
11340 *
11341 * @constructor
11342 * @param {OO.ui.Widget} fieldWidget Field widget
11343 * @param {Object} [config] Configuration options
11344 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11345 * or 'inline'
11346 * @cfg {Array} [errors] Error messages about the widget, which will be
11347 * displayed below the widget.
11348 * The array may contain strings or OO.ui.HtmlSnippet instances.
11349 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11350 * below the widget.
11351 * The array may contain strings or OO.ui.HtmlSnippet instances.
11352 * These are more visible than `help` messages when `helpInline` is set, and so
11353 * might be good for transient messages.
11354 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11355 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11356 * corner of the rendered field; clicking it will display the text in a popup.
11357 * If `helpInline` is `true`, then a subtle description will be shown after the
11358 * label.
11359 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11360 * or shown when the "help" icon is clicked.
11361 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11362 * `help` is given.
11363 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11364 *
11365 * @throws {Error} An error is thrown if no widget is specified
11366 */
11367 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11368 // Allow passing positional parameters inside the config object
11369 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11370 config = fieldWidget;
11371 fieldWidget = config.fieldWidget;
11372 }
11373
11374 // Make sure we have required constructor arguments
11375 if ( fieldWidget === undefined ) {
11376 throw new Error( 'Widget not found' );
11377 }
11378
11379 // Configuration initialization
11380 config = $.extend( { align: 'left', helpInline: false }, config );
11381
11382 // Parent constructor
11383 OO.ui.FieldLayout.parent.call( this, config );
11384
11385 // Mixin constructors
11386 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11387 $label: $( '<label>' )
11388 } ) );
11389 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11390
11391 // Properties
11392 this.fieldWidget = fieldWidget;
11393 this.errors = [];
11394 this.notices = [];
11395 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11396 this.$messages = $( '<ul>' );
11397 this.$header = $( '<span>' );
11398 this.$body = $( '<div>' );
11399 this.align = null;
11400 this.helpInline = config.helpInline;
11401
11402 // Events
11403 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11404
11405 // Initialization
11406 this.$help = config.help ?
11407 this.createHelpElement( config.help, config.$overlay ) :
11408 $( [] );
11409 if ( this.fieldWidget.getInputId() ) {
11410 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11411 if ( this.helpInline ) {
11412 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11413 }
11414 } else {
11415 this.$label.on( 'click', function () {
11416 this.fieldWidget.simulateLabelClick();
11417 }.bind( this ) );
11418 if ( this.helpInline ) {
11419 this.$help.on( 'click', function () {
11420 this.fieldWidget.simulateLabelClick();
11421 }.bind( this ) );
11422 }
11423 }
11424 this.$element
11425 .addClass( 'oo-ui-fieldLayout' )
11426 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11427 .append( this.$body );
11428 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11429 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11430 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11431 this.$field
11432 .addClass( 'oo-ui-fieldLayout-field' )
11433 .append( this.fieldWidget.$element );
11434
11435 this.setErrors( config.errors || [] );
11436 this.setNotices( config.notices || [] );
11437 this.setAlignment( config.align );
11438 // Call this again to take into account the widget's accessKey
11439 this.updateTitle();
11440 };
11441
11442 /* Setup */
11443
11444 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11445 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11446 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11447
11448 /* Methods */
11449
11450 /**
11451 * Handle field disable events.
11452 *
11453 * @private
11454 * @param {boolean} value Field is disabled
11455 */
11456 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11457 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11458 };
11459
11460 /**
11461 * Get the widget contained by the field.
11462 *
11463 * @return {OO.ui.Widget} Field widget
11464 */
11465 OO.ui.FieldLayout.prototype.getField = function () {
11466 return this.fieldWidget;
11467 };
11468
11469 /**
11470 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11471 * #setAlignment). Return `false` if it can't or if this can't be determined.
11472 *
11473 * @return {boolean}
11474 */
11475 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11476 // This is very simplistic, but should be good enough.
11477 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11478 };
11479
11480 /**
11481 * @protected
11482 * @param {string} kind 'error' or 'notice'
11483 * @param {string|OO.ui.HtmlSnippet} text
11484 * @return {jQuery}
11485 */
11486 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11487 var $listItem, $icon, message;
11488 $listItem = $( '<li>' );
11489 if ( kind === 'error' ) {
11490 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11491 $listItem.attr( 'role', 'alert' );
11492 } else if ( kind === 'notice' ) {
11493 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11494 } else {
11495 $icon = '';
11496 }
11497 message = new OO.ui.LabelWidget( { label: text } );
11498 $listItem
11499 .append( $icon, message.$element )
11500 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11501 return $listItem;
11502 };
11503
11504 /**
11505 * Set the field alignment mode.
11506 *
11507 * @private
11508 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11509 * @chainable
11510 */
11511 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11512 if ( value !== this.align ) {
11513 // Default to 'left'
11514 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11515 value = 'left';
11516 }
11517 // Validate
11518 if ( value === 'inline' && !this.isFieldInline() ) {
11519 value = 'top';
11520 }
11521 // Reorder elements
11522
11523 if ( this.helpInline ) {
11524 if ( value === 'inline' ) {
11525 this.$header.append( this.$label, this.$help );
11526 this.$body.append( this.$field, this.$header );
11527 } else {
11528 this.$header.append( this.$label, this.$help );
11529 this.$body.append( this.$header, this.$field );
11530 }
11531 } else {
11532 if ( value === 'top' ) {
11533 this.$header.append( this.$help, this.$label );
11534 this.$body.append( this.$header, this.$field );
11535 } else if ( value === 'inline' ) {
11536 this.$header.append( this.$help, this.$label );
11537 this.$body.append( this.$field, this.$header );
11538 } else {
11539 this.$header.append( this.$label );
11540 this.$body.append( this.$header, this.$help, this.$field );
11541 }
11542 }
11543 // Set classes. The following classes can be used here:
11544 // * oo-ui-fieldLayout-align-left
11545 // * oo-ui-fieldLayout-align-right
11546 // * oo-ui-fieldLayout-align-top
11547 // * oo-ui-fieldLayout-align-inline
11548 if ( this.align ) {
11549 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11550 }
11551 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11552 this.align = value;
11553 }
11554
11555 return this;
11556 };
11557
11558 /**
11559 * Set the list of error messages.
11560 *
11561 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11562 * The array may contain strings or OO.ui.HtmlSnippet instances.
11563 * @chainable
11564 */
11565 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11566 this.errors = errors.slice();
11567 this.updateMessages();
11568 return this;
11569 };
11570
11571 /**
11572 * Set the list of notice messages.
11573 *
11574 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11575 * The array may contain strings or OO.ui.HtmlSnippet instances.
11576 * @chainable
11577 */
11578 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11579 this.notices = notices.slice();
11580 this.updateMessages();
11581 return this;
11582 };
11583
11584 /**
11585 * Update the rendering of error and notice messages.
11586 *
11587 * @private
11588 */
11589 OO.ui.FieldLayout.prototype.updateMessages = function () {
11590 var i;
11591 this.$messages.empty();
11592
11593 if ( this.errors.length || this.notices.length ) {
11594 this.$body.after( this.$messages );
11595 } else {
11596 this.$messages.remove();
11597 return;
11598 }
11599
11600 for ( i = 0; i < this.notices.length; i++ ) {
11601 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11602 }
11603 for ( i = 0; i < this.errors.length; i++ ) {
11604 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11605 }
11606 };
11607
11608 /**
11609 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11610 * (This is a bit of a hack.)
11611 *
11612 * @protected
11613 * @param {string} title Tooltip label for 'title' attribute
11614 * @return {string}
11615 */
11616 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11617 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11618 return this.fieldWidget.formatTitleWithAccessKey( title );
11619 }
11620 return title;
11621 };
11622
11623 /**
11624 * Creates and returns the help element. Also sets the `aria-describedby`
11625 * attribute on the main element of the `fieldWidget`.
11626 *
11627 * @private
11628 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11629 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11630 * @return {jQuery} The element that should become `this.$help`.
11631 */
11632 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11633 var helpId, helpWidget;
11634
11635 if ( this.helpInline ) {
11636 helpWidget = new OO.ui.LabelWidget( {
11637 label: help,
11638 classes: [ 'oo-ui-inline-help' ]
11639 } );
11640
11641 helpId = helpWidget.getElementId();
11642 } else {
11643 helpWidget = new OO.ui.PopupButtonWidget( {
11644 $overlay: $overlay,
11645 popup: {
11646 padded: true
11647 },
11648 classes: [ 'oo-ui-fieldLayout-help' ],
11649 framed: false,
11650 icon: 'info',
11651 label: OO.ui.msg( 'ooui-field-help' )
11652 } );
11653 if ( help instanceof OO.ui.HtmlSnippet ) {
11654 helpWidget.getPopup().$body.html( help.toString() );
11655 } else {
11656 helpWidget.getPopup().$body.text( help );
11657 }
11658
11659 helpId = helpWidget.getPopup().getBodyId();
11660 }
11661
11662 // Set the 'aria-describedby' attribute on the fieldWidget
11663 // Preference given to an input or a button
11664 (
11665 this.fieldWidget.$input ||
11666 this.fieldWidget.$button ||
11667 this.fieldWidget.$element
11668 ).attr( 'aria-describedby', helpId );
11669
11670 return helpWidget.$element;
11671 };
11672
11673 /**
11674 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11675 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11676 * is required and is specified before any optional configuration settings.
11677 *
11678 * Labels can be aligned in one of four ways:
11679 *
11680 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11681 * A left-alignment is used for forms with many fields.
11682 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11683 * A right-alignment is used for long but familiar forms which users tab through,
11684 * verifying the current field with a quick glance at the label.
11685 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11686 * that users fill out from top to bottom.
11687 * - **inline**: The label is placed after the field-widget and aligned to the left.
11688 * An inline-alignment is best used with checkboxes or radio buttons.
11689 *
11690 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11691 * text is specified.
11692 *
11693 * @example
11694 * // Example of an ActionFieldLayout
11695 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11696 * new OO.ui.TextInputWidget( {
11697 * placeholder: 'Field widget'
11698 * } ),
11699 * new OO.ui.ButtonWidget( {
11700 * label: 'Button'
11701 * } ),
11702 * {
11703 * label: 'An ActionFieldLayout. This label is aligned top',
11704 * align: 'top',
11705 * help: 'This is help text'
11706 * }
11707 * );
11708 *
11709 * $( 'body' ).append( actionFieldLayout.$element );
11710 *
11711 * @class
11712 * @extends OO.ui.FieldLayout
11713 *
11714 * @constructor
11715 * @param {OO.ui.Widget} fieldWidget Field widget
11716 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11717 * @param {Object} config
11718 */
11719 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11720 // Allow passing positional parameters inside the config object
11721 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11722 config = fieldWidget;
11723 fieldWidget = config.fieldWidget;
11724 buttonWidget = config.buttonWidget;
11725 }
11726
11727 // Parent constructor
11728 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11729
11730 // Properties
11731 this.buttonWidget = buttonWidget;
11732 this.$button = $( '<span>' );
11733 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11734
11735 // Initialization
11736 this.$element
11737 .addClass( 'oo-ui-actionFieldLayout' );
11738 this.$button
11739 .addClass( 'oo-ui-actionFieldLayout-button' )
11740 .append( this.buttonWidget.$element );
11741 this.$input
11742 .addClass( 'oo-ui-actionFieldLayout-input' )
11743 .append( this.fieldWidget.$element );
11744 this.$field
11745 .append( this.$input, this.$button );
11746 };
11747
11748 /* Setup */
11749
11750 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11751
11752 /**
11753 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11754 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11755 * configured with a label as well. For more information and examples,
11756 * please see the [OOUI documentation on MediaWiki][1].
11757 *
11758 * @example
11759 * // Example of a fieldset layout
11760 * var input1 = new OO.ui.TextInputWidget( {
11761 * placeholder: 'A text input field'
11762 * } );
11763 *
11764 * var input2 = new OO.ui.TextInputWidget( {
11765 * placeholder: 'A text input field'
11766 * } );
11767 *
11768 * var fieldset = new OO.ui.FieldsetLayout( {
11769 * label: 'Example of a fieldset layout'
11770 * } );
11771 *
11772 * fieldset.addItems( [
11773 * new OO.ui.FieldLayout( input1, {
11774 * label: 'Field One'
11775 * } ),
11776 * new OO.ui.FieldLayout( input2, {
11777 * label: 'Field Two'
11778 * } )
11779 * ] );
11780 * $( 'body' ).append( fieldset.$element );
11781 *
11782 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11783 *
11784 * @class
11785 * @extends OO.ui.Layout
11786 * @mixins OO.ui.mixin.IconElement
11787 * @mixins OO.ui.mixin.LabelElement
11788 * @mixins OO.ui.mixin.GroupElement
11789 *
11790 * @constructor
11791 * @param {Object} [config] Configuration options
11792 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11793 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11794 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11795 * For important messages, you are advised to use `notices`, as they are always shown.
11796 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11797 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11798 */
11799 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11800 // Configuration initialization
11801 config = config || {};
11802
11803 // Parent constructor
11804 OO.ui.FieldsetLayout.parent.call( this, config );
11805
11806 // Mixin constructors
11807 OO.ui.mixin.IconElement.call( this, config );
11808 OO.ui.mixin.LabelElement.call( this, config );
11809 OO.ui.mixin.GroupElement.call( this, config );
11810
11811 // Properties
11812 this.$header = $( '<legend>' );
11813 if ( config.help ) {
11814 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11815 $overlay: config.$overlay,
11816 popup: {
11817 padded: true
11818 },
11819 classes: [ 'oo-ui-fieldsetLayout-help' ],
11820 framed: false,
11821 icon: 'info',
11822 label: OO.ui.msg( 'ooui-field-help' )
11823 } );
11824 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11825 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11826 } else {
11827 this.popupButtonWidget.getPopup().$body.text( config.help );
11828 }
11829 this.$help = this.popupButtonWidget.$element;
11830 } else {
11831 this.$help = $( [] );
11832 }
11833
11834 // Initialization
11835 this.$header
11836 .addClass( 'oo-ui-fieldsetLayout-header' )
11837 .append( this.$icon, this.$label, this.$help );
11838 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11839 this.$element
11840 .addClass( 'oo-ui-fieldsetLayout' )
11841 .prepend( this.$header, this.$group );
11842 if ( Array.isArray( config.items ) ) {
11843 this.addItems( config.items );
11844 }
11845 };
11846
11847 /* Setup */
11848
11849 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11850 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11851 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11852 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11853
11854 /* Static Properties */
11855
11856 /**
11857 * @static
11858 * @inheritdoc
11859 */
11860 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11861
11862 /**
11863 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11864 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11865 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11866 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
11867 *
11868 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11869 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11870 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11871 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11872 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11873 * often have simplified APIs to match the capabilities of HTML forms.
11874 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
11875 *
11876 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
11877 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11878 *
11879 * @example
11880 * // Example of a form layout that wraps a fieldset layout
11881 * var input1 = new OO.ui.TextInputWidget( {
11882 * placeholder: 'Username'
11883 * } );
11884 * var input2 = new OO.ui.TextInputWidget( {
11885 * placeholder: 'Password',
11886 * type: 'password'
11887 * } );
11888 * var submit = new OO.ui.ButtonInputWidget( {
11889 * label: 'Submit'
11890 * } );
11891 *
11892 * var fieldset = new OO.ui.FieldsetLayout( {
11893 * label: 'A form layout'
11894 * } );
11895 * fieldset.addItems( [
11896 * new OO.ui.FieldLayout( input1, {
11897 * label: 'Username',
11898 * align: 'top'
11899 * } ),
11900 * new OO.ui.FieldLayout( input2, {
11901 * label: 'Password',
11902 * align: 'top'
11903 * } ),
11904 * new OO.ui.FieldLayout( submit )
11905 * ] );
11906 * var form = new OO.ui.FormLayout( {
11907 * items: [ fieldset ],
11908 * action: '/api/formhandler',
11909 * method: 'get'
11910 * } )
11911 * $( 'body' ).append( form.$element );
11912 *
11913 * @class
11914 * @extends OO.ui.Layout
11915 * @mixins OO.ui.mixin.GroupElement
11916 *
11917 * @constructor
11918 * @param {Object} [config] Configuration options
11919 * @cfg {string} [method] HTML form `method` attribute
11920 * @cfg {string} [action] HTML form `action` attribute
11921 * @cfg {string} [enctype] HTML form `enctype` attribute
11922 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11923 */
11924 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11925 var action;
11926
11927 // Configuration initialization
11928 config = config || {};
11929
11930 // Parent constructor
11931 OO.ui.FormLayout.parent.call( this, config );
11932
11933 // Mixin constructors
11934 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11935
11936 // Events
11937 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11938
11939 // Make sure the action is safe
11940 action = config.action;
11941 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11942 action = './' + action;
11943 }
11944
11945 // Initialization
11946 this.$element
11947 .addClass( 'oo-ui-formLayout' )
11948 .attr( {
11949 method: config.method,
11950 action: action,
11951 enctype: config.enctype
11952 } );
11953 if ( Array.isArray( config.items ) ) {
11954 this.addItems( config.items );
11955 }
11956 };
11957
11958 /* Setup */
11959
11960 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11961 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11962
11963 /* Events */
11964
11965 /**
11966 * A 'submit' event is emitted when the form is submitted.
11967 *
11968 * @event submit
11969 */
11970
11971 /* Static Properties */
11972
11973 /**
11974 * @static
11975 * @inheritdoc
11976 */
11977 OO.ui.FormLayout.static.tagName = 'form';
11978
11979 /* Methods */
11980
11981 /**
11982 * Handle form submit events.
11983 *
11984 * @private
11985 * @param {jQuery.Event} e Submit event
11986 * @fires submit
11987 */
11988 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11989 if ( this.emit( 'submit' ) ) {
11990 return false;
11991 }
11992 };
11993
11994 /**
11995 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11996 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11997 *
11998 * @example
11999 * // Example of a panel layout
12000 * var panel = new OO.ui.PanelLayout( {
12001 * expanded: false,
12002 * framed: true,
12003 * padded: true,
12004 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12005 * } );
12006 * $( 'body' ).append( panel.$element );
12007 *
12008 * @class
12009 * @extends OO.ui.Layout
12010 *
12011 * @constructor
12012 * @param {Object} [config] Configuration options
12013 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12014 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12015 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12016 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12017 */
12018 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12019 // Configuration initialization
12020 config = $.extend( {
12021 scrollable: false,
12022 padded: false,
12023 expanded: true,
12024 framed: false
12025 }, config );
12026
12027 // Parent constructor
12028 OO.ui.PanelLayout.parent.call( this, config );
12029
12030 // Initialization
12031 this.$element.addClass( 'oo-ui-panelLayout' );
12032 if ( config.scrollable ) {
12033 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12034 }
12035 if ( config.padded ) {
12036 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12037 }
12038 if ( config.expanded ) {
12039 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12040 }
12041 if ( config.framed ) {
12042 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12043 }
12044 };
12045
12046 /* Setup */
12047
12048 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12049
12050 /* Methods */
12051
12052 /**
12053 * Focus the panel layout
12054 *
12055 * The default implementation just focuses the first focusable element in the panel
12056 */
12057 OO.ui.PanelLayout.prototype.focus = function () {
12058 OO.ui.findFocusable( this.$element ).focus();
12059 };
12060
12061 /**
12062 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12063 * items), with small margins between them. Convenient when you need to put a number of block-level
12064 * widgets on a single line next to each other.
12065 *
12066 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12067 *
12068 * @example
12069 * // HorizontalLayout with a text input and a label
12070 * var layout = new OO.ui.HorizontalLayout( {
12071 * items: [
12072 * new OO.ui.LabelWidget( { label: 'Label' } ),
12073 * new OO.ui.TextInputWidget( { value: 'Text' } )
12074 * ]
12075 * } );
12076 * $( 'body' ).append( layout.$element );
12077 *
12078 * @class
12079 * @extends OO.ui.Layout
12080 * @mixins OO.ui.mixin.GroupElement
12081 *
12082 * @constructor
12083 * @param {Object} [config] Configuration options
12084 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12085 */
12086 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12087 // Configuration initialization
12088 config = config || {};
12089
12090 // Parent constructor
12091 OO.ui.HorizontalLayout.parent.call( this, config );
12092
12093 // Mixin constructors
12094 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12095
12096 // Initialization
12097 this.$element.addClass( 'oo-ui-horizontalLayout' );
12098 if ( Array.isArray( config.items ) ) {
12099 this.addItems( config.items );
12100 }
12101 };
12102
12103 /* Setup */
12104
12105 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12106 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12107
12108 /**
12109 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12110 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12111 * (to adjust the value in increments) to allow the user to enter a number.
12112 *
12113 * @example
12114 * // Example: A NumberInputWidget.
12115 * var numberInput = new OO.ui.NumberInputWidget( {
12116 * label: 'NumberInputWidget',
12117 * input: { value: 5 },
12118 * min: 1,
12119 * max: 10
12120 * } );
12121 * $( 'body' ).append( numberInput.$element );
12122 *
12123 * @class
12124 * @extends OO.ui.TextInputWidget
12125 *
12126 * @constructor
12127 * @param {Object} [config] Configuration options
12128 * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
12129 * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
12130 * @cfg {boolean} [allowInteger=false] Whether the field accepts only integer values.
12131 * @cfg {number} [min=-Infinity] Minimum allowed value
12132 * @cfg {number} [max=Infinity] Maximum allowed value
12133 * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
12134 * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
12135 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12136 */
12137 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12138 var $field = $( '<div>' )
12139 .addClass( 'oo-ui-numberInputWidget-field' );
12140
12141 // Configuration initialization
12142 config = $.extend( {
12143 allowInteger: false,
12144 min: -Infinity,
12145 max: Infinity,
12146 step: 1,
12147 pageStep: null,
12148 showButtons: true
12149 }, config );
12150
12151 // For backward compatibility
12152 $.extend( config, config.input );
12153 this.input = this;
12154
12155 // Parent constructor
12156 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12157 type: 'number'
12158 } ) );
12159
12160 if ( config.showButtons ) {
12161 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12162 {
12163 disabled: this.isDisabled(),
12164 tabIndex: -1,
12165 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12166 icon: 'subtract'
12167 },
12168 config.minusButton
12169 ) );
12170 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12171 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12172 {
12173 disabled: this.isDisabled(),
12174 tabIndex: -1,
12175 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12176 icon: 'add'
12177 },
12178 config.plusButton
12179 ) );
12180 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12181 }
12182
12183 // Events
12184 this.$input.on( {
12185 keydown: this.onKeyDown.bind( this ),
12186 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12187 } );
12188 if ( config.showButtons ) {
12189 this.plusButton.connect( this, {
12190 click: [ 'onButtonClick', +1 ]
12191 } );
12192 this.minusButton.connect( this, {
12193 click: [ 'onButtonClick', -1 ]
12194 } );
12195 }
12196
12197 // Build the field
12198 $field.append( this.$input );
12199 if ( config.showButtons ) {
12200 $field
12201 .prepend( this.minusButton.$element )
12202 .append( this.plusButton.$element );
12203 }
12204
12205 // Initialization
12206 this.setAllowInteger( config.allowInteger || config.isInteger );
12207 this.setRange( config.min, config.max );
12208 this.setStep( config.step, config.pageStep );
12209 // Set the validation method after we set allowInteger and range
12210 // so that it doesn't immediately call setValidityFlag
12211 this.setValidation( this.validateNumber.bind( this ) );
12212
12213 this.$element
12214 .addClass( 'oo-ui-numberInputWidget' )
12215 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12216 .append( $field );
12217 };
12218
12219 /* Setup */
12220
12221 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12222
12223 /* Methods */
12224
12225 /**
12226 * Set whether only integers are allowed
12227 *
12228 * @param {boolean} flag
12229 */
12230 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12231 this.allowInteger = !!flag;
12232 this.setValidityFlag();
12233 };
12234 // Backward compatibility
12235 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12236
12237 /**
12238 * Get whether only integers are allowed
12239 *
12240 * @return {boolean} Flag value
12241 */
12242 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12243 return this.allowInteger;
12244 };
12245 // Backward compatibility
12246 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12247
12248 /**
12249 * Set the range of allowed values
12250 *
12251 * @param {number} min Minimum allowed value
12252 * @param {number} max Maximum allowed value
12253 */
12254 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12255 if ( min > max ) {
12256 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12257 }
12258 this.min = min;
12259 this.max = max;
12260 this.$input.attr( 'min', this.min );
12261 this.$input.attr( 'max', this.max );
12262 this.setValidityFlag();
12263 };
12264
12265 /**
12266 * Get the current range
12267 *
12268 * @return {number[]} Minimum and maximum values
12269 */
12270 OO.ui.NumberInputWidget.prototype.getRange = function () {
12271 return [ this.min, this.max ];
12272 };
12273
12274 /**
12275 * Set the stepping deltas
12276 *
12277 * @param {number} step Normal step
12278 * @param {number|null} pageStep Page step. If null, 10 * step will be used.
12279 */
12280 OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
12281 if ( step <= 0 ) {
12282 throw new Error( 'Step value must be positive' );
12283 }
12284 if ( pageStep === null ) {
12285 pageStep = step * 10;
12286 } else if ( pageStep <= 0 ) {
12287 throw new Error( 'Page step value must be positive' );
12288 }
12289 this.step = step;
12290 this.pageStep = pageStep;
12291 this.$input.attr( 'step', this.step );
12292 };
12293
12294 /**
12295 * @inheritdoc
12296 */
12297 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12298 if ( value === '' ) {
12299 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12300 // so here we make sure an 'empty' value is actually displayed as such.
12301 this.$input.val( '' );
12302 }
12303 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12304 };
12305
12306 /**
12307 * Get the current stepping values
12308 *
12309 * @return {number[]} Step and page step
12310 */
12311 OO.ui.NumberInputWidget.prototype.getStep = function () {
12312 return [ this.step, this.pageStep ];
12313 };
12314
12315 /**
12316 * Get the current value of the widget as a number
12317 *
12318 * @return {number} May be NaN, or an invalid number
12319 */
12320 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12321 return +this.getValue();
12322 };
12323
12324 /**
12325 * Adjust the value of the widget
12326 *
12327 * @param {number} delta Adjustment amount
12328 */
12329 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12330 var n, v = this.getNumericValue();
12331
12332 delta = +delta;
12333 if ( isNaN( delta ) || !isFinite( delta ) ) {
12334 throw new Error( 'Delta must be a finite number' );
12335 }
12336
12337 if ( isNaN( v ) ) {
12338 n = 0;
12339 } else {
12340 n = v + delta;
12341 n = Math.max( Math.min( n, this.max ), this.min );
12342 if ( this.allowInteger ) {
12343 n = Math.round( n );
12344 }
12345 }
12346
12347 if ( n !== v ) {
12348 this.setValue( n );
12349 }
12350 };
12351 /**
12352 * Validate input
12353 *
12354 * @private
12355 * @param {string} value Field value
12356 * @return {boolean}
12357 */
12358 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12359 var n = +value;
12360 if ( value === '' ) {
12361 return !this.isRequired();
12362 }
12363
12364 if ( isNaN( n ) || !isFinite( n ) ) {
12365 return false;
12366 }
12367
12368 if ( this.allowInteger && Math.floor( n ) !== n ) {
12369 return false;
12370 }
12371
12372 if ( n < this.min || n > this.max ) {
12373 return false;
12374 }
12375
12376 return true;
12377 };
12378
12379 /**
12380 * Handle mouse click events.
12381 *
12382 * @private
12383 * @param {number} dir +1 or -1
12384 */
12385 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12386 this.adjustValue( dir * this.step );
12387 };
12388
12389 /**
12390 * Handle mouse wheel events.
12391 *
12392 * @private
12393 * @param {jQuery.Event} event
12394 */
12395 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12396 var delta = 0;
12397
12398 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12399 // Standard 'wheel' event
12400 if ( event.originalEvent.deltaMode !== undefined ) {
12401 this.sawWheelEvent = true;
12402 }
12403 if ( event.originalEvent.deltaY ) {
12404 delta = -event.originalEvent.deltaY;
12405 } else if ( event.originalEvent.deltaX ) {
12406 delta = event.originalEvent.deltaX;
12407 }
12408
12409 // Non-standard events
12410 if ( !this.sawWheelEvent ) {
12411 if ( event.originalEvent.wheelDeltaX ) {
12412 delta = -event.originalEvent.wheelDeltaX;
12413 } else if ( event.originalEvent.wheelDeltaY ) {
12414 delta = event.originalEvent.wheelDeltaY;
12415 } else if ( event.originalEvent.wheelDelta ) {
12416 delta = event.originalEvent.wheelDelta;
12417 } else if ( event.originalEvent.detail ) {
12418 delta = -event.originalEvent.detail;
12419 }
12420 }
12421
12422 if ( delta ) {
12423 delta = delta < 0 ? -1 : 1;
12424 this.adjustValue( delta * this.step );
12425 }
12426
12427 return false;
12428 }
12429 };
12430
12431 /**
12432 * Handle key down events.
12433 *
12434 * @private
12435 * @param {jQuery.Event} e Key down event
12436 */
12437 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12438 if ( !this.isDisabled() ) {
12439 switch ( e.which ) {
12440 case OO.ui.Keys.UP:
12441 this.adjustValue( this.step );
12442 return false;
12443 case OO.ui.Keys.DOWN:
12444 this.adjustValue( -this.step );
12445 return false;
12446 case OO.ui.Keys.PAGEUP:
12447 this.adjustValue( this.pageStep );
12448 return false;
12449 case OO.ui.Keys.PAGEDOWN:
12450 this.adjustValue( -this.pageStep );
12451 return false;
12452 }
12453 }
12454 };
12455
12456 /**
12457 * @inheritdoc
12458 */
12459 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12460 // Parent method
12461 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12462
12463 if ( this.minusButton ) {
12464 this.minusButton.setDisabled( this.isDisabled() );
12465 }
12466 if ( this.plusButton ) {
12467 this.plusButton.setDisabled( this.isDisabled() );
12468 }
12469
12470 return this;
12471 };
12472
12473 }( OO ) );
12474
12475 //# sourceMappingURL=oojs-ui-core.js.map.json