Merge "Fix index name in comment to "PHP_INFO""
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.29.2
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-10-08T22:42:55Z
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 );
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 )
1822 .addClass( classes.on );
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.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2111 this.onMouseDownHandler = this.onMouseDown.bind( this );
2112 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2113 this.onKeyDownHandler = this.onKeyDown.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.onDocumentMouseUpHandler, 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 document mouse up events.
2213 *
2214 * @protected
2215 * @param {MouseEvent} e Mouse up event
2216 */
2217 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = 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.onDocumentMouseUpHandler, true );
2224 };
2225
2226 // Deprecated alias since 0.28.3
2227 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function () {
2228 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
2229 this.onDocumentMouseUp.apply( this, arguments );
2230 };
2231
2232 /**
2233 * Handles mouse click events.
2234 *
2235 * @protected
2236 * @param {jQuery.Event} e Mouse click event
2237 * @fires click
2238 */
2239 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2240 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2241 if ( this.emit( 'click' ) ) {
2242 return false;
2243 }
2244 }
2245 };
2246
2247 /**
2248 * Handles key down events.
2249 *
2250 * @protected
2251 * @param {jQuery.Event} e Key down event
2252 */
2253 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2254 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2255 return;
2256 }
2257 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2258 // Run the keyup handler no matter where the key is when the button is let go, so we can
2259 // reliably remove the pressed class
2260 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2261 };
2262
2263 /**
2264 * Handles document key up events.
2265 *
2266 * @protected
2267 * @param {KeyboardEvent} e Key up event
2268 */
2269 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2270 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2271 return;
2272 }
2273 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2274 // Stop listening for keyup, since we only needed this once
2275 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2276 };
2277
2278 // Deprecated alias since 0.28.3
2279 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function () {
2280 OO.ui.warnDeprecation( 'onKeyUp is deprecated, use onDocumentKeyUp instead' );
2281 this.onDocumentKeyUp.apply( this, arguments );
2282 };
2283
2284 /**
2285 * Handles key press events.
2286 *
2287 * @protected
2288 * @param {jQuery.Event} e Key press event
2289 * @fires click
2290 */
2291 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2292 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2293 if ( this.emit( 'click' ) ) {
2294 return false;
2295 }
2296 }
2297 };
2298
2299 /**
2300 * Check if button has a frame.
2301 *
2302 * @return {boolean} Button is framed
2303 */
2304 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2305 return this.framed;
2306 };
2307
2308 /**
2309 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2310 *
2311 * @param {boolean} [framed] Make button framed, omit to toggle
2312 * @chainable
2313 */
2314 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2315 framed = framed === undefined ? !this.framed : !!framed;
2316 if ( framed !== this.framed ) {
2317 this.framed = framed;
2318 this.$element
2319 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2320 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2321 this.updateThemeClasses();
2322 }
2323
2324 return this;
2325 };
2326
2327 /**
2328 * Set the button's active state.
2329 *
2330 * The active state can be set on:
2331 *
2332 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2333 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2334 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2335 *
2336 * @protected
2337 * @param {boolean} value Make button active
2338 * @chainable
2339 */
2340 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2341 this.active = !!value;
2342 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2343 this.updateThemeClasses();
2344 return this;
2345 };
2346
2347 /**
2348 * Check if the button is active
2349 *
2350 * @protected
2351 * @return {boolean} The button is active
2352 */
2353 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2354 return this.active;
2355 };
2356
2357 /**
2358 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2359 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2360 * items from the group is done through the interface the class provides.
2361 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2362 *
2363 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2364 *
2365 * @abstract
2366 * @mixins OO.EmitterList
2367 * @class
2368 *
2369 * @constructor
2370 * @param {Object} [config] Configuration options
2371 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2372 * is omitted, the group element will use a generated `<div>`.
2373 */
2374 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2375 // Configuration initialization
2376 config = config || {};
2377
2378 // Mixin constructors
2379 OO.EmitterList.call( this, config );
2380
2381 // Properties
2382 this.$group = null;
2383
2384 // Initialization
2385 this.setGroupElement( config.$group || $( '<div>' ) );
2386 };
2387
2388 /* Setup */
2389
2390 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2391
2392 /* Events */
2393
2394 /**
2395 * @event change
2396 *
2397 * A change event is emitted when the set of selected items changes.
2398 *
2399 * @param {OO.ui.Element[]} items Items currently in the group
2400 */
2401
2402 /* Methods */
2403
2404 /**
2405 * Set the group element.
2406 *
2407 * If an element is already set, items will be moved to the new element.
2408 *
2409 * @param {jQuery} $group Element to use as group
2410 */
2411 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2412 var i, len;
2413
2414 this.$group = $group;
2415 for ( i = 0, len = this.items.length; i < len; i++ ) {
2416 this.$group.append( this.items[ i ].$element );
2417 }
2418 };
2419
2420 /**
2421 * Find an item by its data.
2422 *
2423 * Only the first item with matching data will be returned. To return all matching items,
2424 * use the #findItemsFromData method.
2425 *
2426 * @param {Object} data Item data to search for
2427 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2428 */
2429 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2430 var i, len, item,
2431 hash = OO.getHash( data );
2432
2433 for ( i = 0, len = this.items.length; i < len; i++ ) {
2434 item = this.items[ i ];
2435 if ( hash === OO.getHash( item.getData() ) ) {
2436 return item;
2437 }
2438 }
2439
2440 return null;
2441 };
2442
2443 /**
2444 * Find items by their data.
2445 *
2446 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2447 *
2448 * @param {Object} data Item data to search for
2449 * @return {OO.ui.Element[]} Items with equivalent data
2450 */
2451 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2452 var i, len, item,
2453 hash = OO.getHash( data ),
2454 items = [];
2455
2456 for ( i = 0, len = this.items.length; i < len; i++ ) {
2457 item = this.items[ i ];
2458 if ( hash === OO.getHash( item.getData() ) ) {
2459 items.push( item );
2460 }
2461 }
2462
2463 return items;
2464 };
2465
2466 /**
2467 * Add items to the group.
2468 *
2469 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2470 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2471 *
2472 * @param {OO.ui.Element[]} items An array of items to add to the group
2473 * @param {number} [index] Index of the insertion point
2474 * @chainable
2475 */
2476 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2477 // Mixin method
2478 OO.EmitterList.prototype.addItems.call( this, items, index );
2479
2480 this.emit( 'change', this.getItems() );
2481 return this;
2482 };
2483
2484 /**
2485 * @inheritdoc
2486 */
2487 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2488 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2489 this.insertItemElements( items, newIndex );
2490
2491 // Mixin method
2492 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2493
2494 return newIndex;
2495 };
2496
2497 /**
2498 * @inheritdoc
2499 */
2500 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2501 item.setElementGroup( this );
2502 this.insertItemElements( item, index );
2503
2504 // Mixin method
2505 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2506
2507 return index;
2508 };
2509
2510 /**
2511 * Insert elements into the group
2512 *
2513 * @private
2514 * @param {OO.ui.Element} itemWidget Item to insert
2515 * @param {number} index Insertion index
2516 */
2517 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2518 if ( index === undefined || index < 0 || index >= this.items.length ) {
2519 this.$group.append( itemWidget.$element );
2520 } else if ( index === 0 ) {
2521 this.$group.prepend( itemWidget.$element );
2522 } else {
2523 this.items[ index ].$element.before( itemWidget.$element );
2524 }
2525 };
2526
2527 /**
2528 * Remove the specified items from a group.
2529 *
2530 * Removed items are detached (not removed) from the DOM so that they may be reused.
2531 * To remove all items from a group, you may wish to use the #clearItems method instead.
2532 *
2533 * @param {OO.ui.Element[]} items An array of items to remove
2534 * @chainable
2535 */
2536 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2537 var i, len, item, index;
2538
2539 // Remove specific items elements
2540 for ( i = 0, len = items.length; i < len; i++ ) {
2541 item = items[ i ];
2542 index = this.items.indexOf( item );
2543 if ( index !== -1 ) {
2544 item.setElementGroup( null );
2545 item.$element.detach();
2546 }
2547 }
2548
2549 // Mixin method
2550 OO.EmitterList.prototype.removeItems.call( this, items );
2551
2552 this.emit( 'change', this.getItems() );
2553 return this;
2554 };
2555
2556 /**
2557 * Clear all items from the group.
2558 *
2559 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2560 * To remove only a subset of items from a group, use the #removeItems method.
2561 *
2562 * @chainable
2563 */
2564 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2565 var i, len;
2566
2567 // Remove all item elements
2568 for ( i = 0, len = this.items.length; i < len; i++ ) {
2569 this.items[ i ].setElementGroup( null );
2570 this.items[ i ].$element.detach();
2571 }
2572
2573 // Mixin method
2574 OO.EmitterList.prototype.clearItems.call( this );
2575
2576 this.emit( 'change', this.getItems() );
2577 return this;
2578 };
2579
2580 /**
2581 * IconElement is often mixed into other classes to generate an icon.
2582 * Icons are graphics, about the size of normal text. They are used to aid the user
2583 * in locating a control or to convey information in a space-efficient way. See the
2584 * [OOUI documentation on MediaWiki] [1] for a list of icons
2585 * included in the library.
2586 *
2587 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2588 *
2589 * @abstract
2590 * @class
2591 *
2592 * @constructor
2593 * @param {Object} [config] Configuration options
2594 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2595 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2596 * the icon element be set to an existing icon instead of the one generated by this class, set a
2597 * value using a jQuery selection. For example:
2598 *
2599 * // Use a <div> tag instead of a <span>
2600 * $icon: $("<div>")
2601 * // Use an existing icon element instead of the one generated by the class
2602 * $icon: this.$element
2603 * // Use an icon element from a child widget
2604 * $icon: this.childwidget.$element
2605 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2606 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2607 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2608 * by the user's language.
2609 *
2610 * Example of an i18n map:
2611 *
2612 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2613 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2614 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2615 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2616 * text. The icon title is displayed when users move the mouse over the icon.
2617 */
2618 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2619 // Configuration initialization
2620 config = config || {};
2621
2622 // Properties
2623 this.$icon = null;
2624 this.icon = null;
2625 this.iconTitle = null;
2626
2627 // Initialization
2628 this.setIcon( config.icon || this.constructor.static.icon );
2629 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2630 this.setIconElement( config.$icon || $( '<span>' ) );
2631 };
2632
2633 /* Setup */
2634
2635 OO.initClass( OO.ui.mixin.IconElement );
2636
2637 /* Static Properties */
2638
2639 /**
2640 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2641 * for i18n purposes and contains a `default` icon name and additional names keyed by
2642 * language code. The `default` name is used when no icon is keyed by the user's language.
2643 *
2644 * Example of an i18n map:
2645 *
2646 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2647 *
2648 * Note: the static property will be overridden if the #icon configuration is used.
2649 *
2650 * @static
2651 * @inheritable
2652 * @property {Object|string}
2653 */
2654 OO.ui.mixin.IconElement.static.icon = null;
2655
2656 /**
2657 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2658 * function that returns title text, or `null` for no title.
2659 *
2660 * The static property will be overridden if the #iconTitle configuration is used.
2661 *
2662 * @static
2663 * @inheritable
2664 * @property {string|Function|null}
2665 */
2666 OO.ui.mixin.IconElement.static.iconTitle = null;
2667
2668 /* Methods */
2669
2670 /**
2671 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2672 * applies to the specified icon element instead of the one created by the class. If an icon
2673 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2674 * and mixin methods will no longer affect the element.
2675 *
2676 * @param {jQuery} $icon Element to use as icon
2677 */
2678 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2679 if ( this.$icon ) {
2680 this.$icon
2681 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2682 .removeAttr( 'title' );
2683 }
2684
2685 this.$icon = $icon
2686 .addClass( 'oo-ui-iconElement-icon' )
2687 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2688 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2689 if ( this.iconTitle !== null ) {
2690 this.$icon.attr( 'title', this.iconTitle );
2691 }
2692
2693 this.updateThemeClasses();
2694 };
2695
2696 /**
2697 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2698 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2699 * for an example.
2700 *
2701 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2702 * by language code, or `null` to remove the icon.
2703 * @chainable
2704 */
2705 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2706 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2707 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2708
2709 if ( this.icon !== icon ) {
2710 if ( this.$icon ) {
2711 if ( this.icon !== null ) {
2712 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2713 }
2714 if ( icon !== null ) {
2715 this.$icon.addClass( 'oo-ui-icon-' + icon );
2716 }
2717 }
2718 this.icon = icon;
2719 }
2720
2721 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2722 if ( this.$icon ) {
2723 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2724 }
2725 this.updateThemeClasses();
2726
2727 return this;
2728 };
2729
2730 /**
2731 * Set the icon title. Use `null` to remove the title.
2732 *
2733 * @param {string|Function|null} iconTitle A text string used as the icon title,
2734 * a function that returns title text, or `null` for no title.
2735 * @chainable
2736 */
2737 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2738 iconTitle =
2739 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2740 OO.ui.resolveMsg( iconTitle ) : null;
2741
2742 if ( this.iconTitle !== iconTitle ) {
2743 this.iconTitle = iconTitle;
2744 if ( this.$icon ) {
2745 if ( this.iconTitle !== null ) {
2746 this.$icon.attr( 'title', iconTitle );
2747 } else {
2748 this.$icon.removeAttr( 'title' );
2749 }
2750 }
2751 }
2752
2753 return this;
2754 };
2755
2756 /**
2757 * Get the symbolic name of the icon.
2758 *
2759 * @return {string} Icon name
2760 */
2761 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2762 return this.icon;
2763 };
2764
2765 /**
2766 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2767 *
2768 * @return {string} Icon title text
2769 */
2770 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2771 return this.iconTitle;
2772 };
2773
2774 /**
2775 * IndicatorElement is often mixed into other classes to generate an indicator.
2776 * Indicators are small graphics that are generally used in two ways:
2777 *
2778 * - To draw attention to the status of an item. For example, an indicator might be
2779 * used to show that an item in a list has errors that need to be resolved.
2780 * - To clarify the function of a control that acts in an exceptional way (a button
2781 * that opens a menu instead of performing an action directly, for example).
2782 *
2783 * For a list of indicators included in the library, please see the
2784 * [OOUI documentation on MediaWiki] [1].
2785 *
2786 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2787 *
2788 * @abstract
2789 * @class
2790 *
2791 * @constructor
2792 * @param {Object} [config] Configuration options
2793 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2794 * configuration is omitted, the indicator element will use a generated `<span>`.
2795 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2796 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
2797 * in the library.
2798 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2799 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2800 * or a function that returns title text. The indicator title is displayed when users move
2801 * the mouse over the indicator.
2802 */
2803 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2804 // Configuration initialization
2805 config = config || {};
2806
2807 // Properties
2808 this.$indicator = null;
2809 this.indicator = null;
2810 this.indicatorTitle = null;
2811
2812 // Initialization
2813 this.setIndicator( config.indicator || this.constructor.static.indicator );
2814 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2815 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2816 };
2817
2818 /* Setup */
2819
2820 OO.initClass( OO.ui.mixin.IndicatorElement );
2821
2822 /* Static Properties */
2823
2824 /**
2825 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2826 * The static property will be overridden if the #indicator configuration is used.
2827 *
2828 * @static
2829 * @inheritable
2830 * @property {string|null}
2831 */
2832 OO.ui.mixin.IndicatorElement.static.indicator = null;
2833
2834 /**
2835 * A text string used as the indicator title, a function that returns title text, or `null`
2836 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2837 *
2838 * @static
2839 * @inheritable
2840 * @property {string|Function|null}
2841 */
2842 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2843
2844 /* Methods */
2845
2846 /**
2847 * Set the indicator element.
2848 *
2849 * If an element is already set, it will be cleaned up before setting up the new element.
2850 *
2851 * @param {jQuery} $indicator Element to use as indicator
2852 */
2853 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2854 if ( this.$indicator ) {
2855 this.$indicator
2856 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2857 .removeAttr( 'title' );
2858 }
2859
2860 this.$indicator = $indicator
2861 .addClass( 'oo-ui-indicatorElement-indicator' )
2862 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
2863 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2864 if ( this.indicatorTitle !== null ) {
2865 this.$indicator.attr( 'title', this.indicatorTitle );
2866 }
2867
2868 this.updateThemeClasses();
2869 };
2870
2871 /**
2872 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
2873 *
2874 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2875 * @chainable
2876 */
2877 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2878 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2879
2880 if ( this.indicator !== indicator ) {
2881 if ( this.$indicator ) {
2882 if ( this.indicator !== null ) {
2883 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2884 }
2885 if ( indicator !== null ) {
2886 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2887 }
2888 }
2889 this.indicator = indicator;
2890 }
2891
2892 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2893 if ( this.$indicator ) {
2894 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
2895 }
2896 this.updateThemeClasses();
2897
2898 return this;
2899 };
2900
2901 /**
2902 * Set the indicator title.
2903 *
2904 * The title is displayed when a user moves the mouse over the indicator.
2905 *
2906 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2907 * `null` for no indicator title
2908 * @chainable
2909 */
2910 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2911 indicatorTitle =
2912 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2913 OO.ui.resolveMsg( indicatorTitle ) : null;
2914
2915 if ( this.indicatorTitle !== indicatorTitle ) {
2916 this.indicatorTitle = indicatorTitle;
2917 if ( this.$indicator ) {
2918 if ( this.indicatorTitle !== null ) {
2919 this.$indicator.attr( 'title', indicatorTitle );
2920 } else {
2921 this.$indicator.removeAttr( 'title' );
2922 }
2923 }
2924 }
2925
2926 return this;
2927 };
2928
2929 /**
2930 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2931 *
2932 * @return {string} Symbolic name of indicator
2933 */
2934 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2935 return this.indicator;
2936 };
2937
2938 /**
2939 * Get the indicator title.
2940 *
2941 * The title is displayed when a user moves the mouse over the indicator.
2942 *
2943 * @return {string} Indicator title text
2944 */
2945 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2946 return this.indicatorTitle;
2947 };
2948
2949 /**
2950 * LabelElement is often mixed into other classes to generate a label, which
2951 * helps identify the function of an interface element.
2952 * See the [OOUI documentation on MediaWiki] [1] for more information.
2953 *
2954 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2955 *
2956 * @abstract
2957 * @class
2958 *
2959 * @constructor
2960 * @param {Object} [config] Configuration options
2961 * @cfg {jQuery} [$label] The label element created by the class. If this
2962 * configuration is omitted, the label element will use a generated `<span>`.
2963 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2964 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2965 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2966 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2967 */
2968 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2969 // Configuration initialization
2970 config = config || {};
2971
2972 // Properties
2973 this.$label = null;
2974 this.label = null;
2975
2976 // Initialization
2977 this.setLabel( config.label || this.constructor.static.label );
2978 this.setLabelElement( config.$label || $( '<span>' ) );
2979 };
2980
2981 /* Setup */
2982
2983 OO.initClass( OO.ui.mixin.LabelElement );
2984
2985 /* Events */
2986
2987 /**
2988 * @event labelChange
2989 * @param {string} value
2990 */
2991
2992 /* Static Properties */
2993
2994 /**
2995 * The label text. The label can be specified as a plaintext string, a function that will
2996 * produce a string in the future, or `null` for no label. The static value will
2997 * be overridden if a label is specified with the #label config option.
2998 *
2999 * @static
3000 * @inheritable
3001 * @property {string|Function|null}
3002 */
3003 OO.ui.mixin.LabelElement.static.label = null;
3004
3005 /* Static methods */
3006
3007 /**
3008 * Highlight the first occurrence of the query in the given text
3009 *
3010 * @param {string} text Text
3011 * @param {string} query Query to find
3012 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3013 * @return {jQuery} Text with the first match of the query
3014 * sub-string wrapped in highlighted span
3015 */
3016 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
3017 var i, tLen, qLen,
3018 offset = -1,
3019 $result = $( '<span>' );
3020
3021 if ( compare ) {
3022 tLen = text.length;
3023 qLen = query.length;
3024 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
3025 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
3026 offset = i;
3027 }
3028 }
3029 } else {
3030 offset = text.toLowerCase().indexOf( query.toLowerCase() );
3031 }
3032
3033 if ( !query.length || offset === -1 ) {
3034 $result.text( text );
3035 } else {
3036 $result.append(
3037 document.createTextNode( text.slice( 0, offset ) ),
3038 $( '<span>' )
3039 .addClass( 'oo-ui-labelElement-label-highlight' )
3040 .text( text.slice( offset, offset + query.length ) ),
3041 document.createTextNode( text.slice( offset + query.length ) )
3042 );
3043 }
3044 return $result.contents();
3045 };
3046
3047 /* Methods */
3048
3049 /**
3050 * Set the label element.
3051 *
3052 * If an element is already set, it will be cleaned up before setting up the new element.
3053 *
3054 * @param {jQuery} $label Element to use as label
3055 */
3056 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3057 if ( this.$label ) {
3058 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3059 }
3060
3061 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3062 this.setLabelContent( this.label );
3063 };
3064
3065 /**
3066 * Set the label.
3067 *
3068 * An empty string will result in the label being hidden. A string containing only whitespace will
3069 * be converted to a single `&nbsp;`.
3070 *
3071 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3072 * text; or null for no label
3073 * @chainable
3074 */
3075 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3076 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3077 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3078
3079 if ( this.label !== label ) {
3080 if ( this.$label ) {
3081 this.setLabelContent( label );
3082 }
3083 this.label = label;
3084 this.emit( 'labelChange' );
3085 }
3086
3087 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3088
3089 return this;
3090 };
3091
3092 /**
3093 * Set the label as plain text with a highlighted query
3094 *
3095 * @param {string} text Text label to set
3096 * @param {string} query Substring of text to highlight
3097 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3098 * @chainable
3099 */
3100 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3101 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3102 };
3103
3104 /**
3105 * Get the label.
3106 *
3107 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3108 * text; or null for no label
3109 */
3110 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3111 return this.label;
3112 };
3113
3114 /**
3115 * Set the content of the label.
3116 *
3117 * Do not call this method until after the label element has been set by #setLabelElement.
3118 *
3119 * @private
3120 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3121 * text; or null for no label
3122 */
3123 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3124 if ( typeof label === 'string' ) {
3125 if ( label.match( /^\s*$/ ) ) {
3126 // Convert whitespace only string to a single non-breaking space
3127 this.$label.html( '&nbsp;' );
3128 } else {
3129 this.$label.text( label );
3130 }
3131 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3132 this.$label.html( label.toString() );
3133 } else if ( label instanceof jQuery ) {
3134 this.$label.empty().append( label );
3135 } else {
3136 this.$label.empty();
3137 }
3138 };
3139
3140 /**
3141 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3142 * additional functionality to an element created by another class. The class provides
3143 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3144 * which are used to customize the look and feel of a widget to better describe its
3145 * importance and functionality.
3146 *
3147 * The library currently contains the following styling flags for general use:
3148 *
3149 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3150 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3151 *
3152 * The flags affect the appearance of the buttons:
3153 *
3154 * @example
3155 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3156 * var button1 = new OO.ui.ButtonWidget( {
3157 * label: 'Progressive',
3158 * flags: 'progressive'
3159 * } );
3160 * var button2 = new OO.ui.ButtonWidget( {
3161 * label: 'Destructive',
3162 * flags: 'destructive'
3163 * } );
3164 * $( 'body' ).append( button1.$element, button2.$element );
3165 *
3166 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3167 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3168 *
3169 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3170 *
3171 * @abstract
3172 * @class
3173 *
3174 * @constructor
3175 * @param {Object} [config] Configuration options
3176 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3177 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3178 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3179 * @cfg {jQuery} [$flagged] The flagged element. By default,
3180 * the flagged functionality is applied to the element created by the class ($element).
3181 * If a different element is specified, the flagged functionality will be applied to it instead.
3182 */
3183 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3184 // Configuration initialization
3185 config = config || {};
3186
3187 // Properties
3188 this.flags = {};
3189 this.$flagged = null;
3190
3191 // Initialization
3192 this.setFlags( config.flags );
3193 this.setFlaggedElement( config.$flagged || this.$element );
3194 };
3195
3196 /* Events */
3197
3198 /**
3199 * @event flag
3200 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3201 * parameter contains the name of each modified flag and indicates whether it was
3202 * added or removed.
3203 *
3204 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3205 * that the flag was added, `false` that the flag was removed.
3206 */
3207
3208 /* Methods */
3209
3210 /**
3211 * Set the flagged element.
3212 *
3213 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3214 * If an element is already set, the method will remove the mixin’s effect on that element.
3215 *
3216 * @param {jQuery} $flagged Element that should be flagged
3217 */
3218 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3219 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3220 return 'oo-ui-flaggedElement-' + flag;
3221 } );
3222
3223 if ( this.$flagged ) {
3224 this.$flagged.removeClass( classNames );
3225 }
3226
3227 this.$flagged = $flagged.addClass( classNames );
3228 };
3229
3230 /**
3231 * Check if the specified flag is set.
3232 *
3233 * @param {string} flag Name of flag
3234 * @return {boolean} The flag is set
3235 */
3236 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3237 // This may be called before the constructor, thus before this.flags is set
3238 return this.flags && ( flag in this.flags );
3239 };
3240
3241 /**
3242 * Get the names of all flags set.
3243 *
3244 * @return {string[]} Flag names
3245 */
3246 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3247 // This may be called before the constructor, thus before this.flags is set
3248 return Object.keys( this.flags || {} );
3249 };
3250
3251 /**
3252 * Clear all flags.
3253 *
3254 * @chainable
3255 * @fires flag
3256 */
3257 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3258 var flag, className,
3259 changes = {},
3260 remove = [],
3261 classPrefix = 'oo-ui-flaggedElement-';
3262
3263 for ( flag in this.flags ) {
3264 className = classPrefix + flag;
3265 changes[ flag ] = false;
3266 delete this.flags[ flag ];
3267 remove.push( className );
3268 }
3269
3270 if ( this.$flagged ) {
3271 this.$flagged.removeClass( remove );
3272 }
3273
3274 this.updateThemeClasses();
3275 this.emit( 'flag', changes );
3276
3277 return this;
3278 };
3279
3280 /**
3281 * Add one or more flags.
3282 *
3283 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3284 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3285 * be added (`true`) or removed (`false`).
3286 * @chainable
3287 * @fires flag
3288 */
3289 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3290 var i, len, flag, className,
3291 changes = {},
3292 add = [],
3293 remove = [],
3294 classPrefix = 'oo-ui-flaggedElement-';
3295
3296 if ( typeof flags === 'string' ) {
3297 className = classPrefix + flags;
3298 // Set
3299 if ( !this.flags[ flags ] ) {
3300 this.flags[ flags ] = true;
3301 add.push( className );
3302 }
3303 } else if ( Array.isArray( flags ) ) {
3304 for ( i = 0, len = flags.length; i < len; i++ ) {
3305 flag = flags[ i ];
3306 className = classPrefix + flag;
3307 // Set
3308 if ( !this.flags[ flag ] ) {
3309 changes[ flag ] = true;
3310 this.flags[ flag ] = true;
3311 add.push( className );
3312 }
3313 }
3314 } else if ( OO.isPlainObject( flags ) ) {
3315 for ( flag in flags ) {
3316 className = classPrefix + flag;
3317 if ( flags[ flag ] ) {
3318 // Set
3319 if ( !this.flags[ flag ] ) {
3320 changes[ flag ] = true;
3321 this.flags[ flag ] = true;
3322 add.push( className );
3323 }
3324 } else {
3325 // Remove
3326 if ( this.flags[ flag ] ) {
3327 changes[ flag ] = false;
3328 delete this.flags[ flag ];
3329 remove.push( className );
3330 }
3331 }
3332 }
3333 }
3334
3335 if ( this.$flagged ) {
3336 this.$flagged
3337 .addClass( add )
3338 .removeClass( remove );
3339 }
3340
3341 this.updateThemeClasses();
3342 this.emit( 'flag', changes );
3343
3344 return this;
3345 };
3346
3347 /**
3348 * TitledElement is mixed into other classes to provide a `title` attribute.
3349 * Titles are rendered by the browser and are made visible when the user moves
3350 * the mouse over the element. Titles are not visible on touch devices.
3351 *
3352 * @example
3353 * // TitledElement provides a 'title' attribute to the
3354 * // ButtonWidget class
3355 * var button = new OO.ui.ButtonWidget( {
3356 * label: 'Button with Title',
3357 * title: 'I am a button'
3358 * } );
3359 * $( 'body' ).append( button.$element );
3360 *
3361 * @abstract
3362 * @class
3363 *
3364 * @constructor
3365 * @param {Object} [config] Configuration options
3366 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3367 * If this config is omitted, the title functionality is applied to $element, the
3368 * element created by the class.
3369 * @cfg {string|Function} [title] The title text or a function that returns text. If
3370 * this config is omitted, the value of the {@link #static-title static title} property is used.
3371 */
3372 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3373 // Configuration initialization
3374 config = config || {};
3375
3376 // Properties
3377 this.$titled = null;
3378 this.title = null;
3379
3380 // Initialization
3381 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3382 this.setTitledElement( config.$titled || this.$element );
3383 };
3384
3385 /* Setup */
3386
3387 OO.initClass( OO.ui.mixin.TitledElement );
3388
3389 /* Static Properties */
3390
3391 /**
3392 * The title text, a function that returns text, or `null` for no title. The value of the static property
3393 * is overridden if the #title config option is used.
3394 *
3395 * @static
3396 * @inheritable
3397 * @property {string|Function|null}
3398 */
3399 OO.ui.mixin.TitledElement.static.title = null;
3400
3401 /* Methods */
3402
3403 /**
3404 * Set the titled element.
3405 *
3406 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3407 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3408 *
3409 * @param {jQuery} $titled Element that should use the 'titled' functionality
3410 */
3411 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3412 if ( this.$titled ) {
3413 this.$titled.removeAttr( 'title' );
3414 }
3415
3416 this.$titled = $titled;
3417 if ( this.title ) {
3418 this.updateTitle();
3419 }
3420 };
3421
3422 /**
3423 * Set title.
3424 *
3425 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3426 * @chainable
3427 */
3428 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3429 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3430 title = ( typeof title === 'string' && title.length ) ? title : null;
3431
3432 if ( this.title !== title ) {
3433 this.title = title;
3434 this.updateTitle();
3435 }
3436
3437 return this;
3438 };
3439
3440 /**
3441 * Update the title attribute, in case of changes to title or accessKey.
3442 *
3443 * @protected
3444 * @chainable
3445 */
3446 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3447 var title = this.getTitle();
3448 if ( this.$titled ) {
3449 if ( title !== null ) {
3450 // Only if this is an AccessKeyedElement
3451 if ( this.formatTitleWithAccessKey ) {
3452 title = this.formatTitleWithAccessKey( title );
3453 }
3454 this.$titled.attr( 'title', title );
3455 } else {
3456 this.$titled.removeAttr( 'title' );
3457 }
3458 }
3459 return this;
3460 };
3461
3462 /**
3463 * Get title.
3464 *
3465 * @return {string} Title string
3466 */
3467 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3468 return this.title;
3469 };
3470
3471 /**
3472 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3473 * Accesskeys allow an user to go to a specific element by using
3474 * a shortcut combination of a browser specific keys + the key
3475 * set to the field.
3476 *
3477 * @example
3478 * // AccessKeyedElement provides an 'accesskey' attribute to the
3479 * // ButtonWidget class
3480 * var button = new OO.ui.ButtonWidget( {
3481 * label: 'Button with Accesskey',
3482 * accessKey: 'k'
3483 * } );
3484 * $( 'body' ).append( button.$element );
3485 *
3486 * @abstract
3487 * @class
3488 *
3489 * @constructor
3490 * @param {Object} [config] Configuration options
3491 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3492 * If this config is omitted, the accesskey functionality is applied to $element, the
3493 * element created by the class.
3494 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3495 * this config is omitted, no accesskey will be added.
3496 */
3497 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3498 // Configuration initialization
3499 config = config || {};
3500
3501 // Properties
3502 this.$accessKeyed = null;
3503 this.accessKey = null;
3504
3505 // Initialization
3506 this.setAccessKey( config.accessKey || null );
3507 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3508
3509 // If this is also a TitledElement and it initialized before we did, we may have
3510 // to update the title with the access key
3511 if ( this.updateTitle ) {
3512 this.updateTitle();
3513 }
3514 };
3515
3516 /* Setup */
3517
3518 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3519
3520 /* Static Properties */
3521
3522 /**
3523 * The access key, a function that returns a key, or `null` for no accesskey.
3524 *
3525 * @static
3526 * @inheritable
3527 * @property {string|Function|null}
3528 */
3529 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3530
3531 /* Methods */
3532
3533 /**
3534 * Set the accesskeyed element.
3535 *
3536 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3537 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3538 *
3539 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3540 */
3541 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3542 if ( this.$accessKeyed ) {
3543 this.$accessKeyed.removeAttr( 'accesskey' );
3544 }
3545
3546 this.$accessKeyed = $accessKeyed;
3547 if ( this.accessKey ) {
3548 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3549 }
3550 };
3551
3552 /**
3553 * Set accesskey.
3554 *
3555 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3556 * @chainable
3557 */
3558 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3559 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3560
3561 if ( this.accessKey !== accessKey ) {
3562 if ( this.$accessKeyed ) {
3563 if ( accessKey !== null ) {
3564 this.$accessKeyed.attr( 'accesskey', accessKey );
3565 } else {
3566 this.$accessKeyed.removeAttr( 'accesskey' );
3567 }
3568 }
3569 this.accessKey = accessKey;
3570
3571 // Only if this is a TitledElement
3572 if ( this.updateTitle ) {
3573 this.updateTitle();
3574 }
3575 }
3576
3577 return this;
3578 };
3579
3580 /**
3581 * Get accesskey.
3582 *
3583 * @return {string} accessKey string
3584 */
3585 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3586 return this.accessKey;
3587 };
3588
3589 /**
3590 * Add information about the access key to the element's tooltip label.
3591 * (This is only public for hacky usage in FieldLayout.)
3592 *
3593 * @param {string} title Tooltip label for `title` attribute
3594 * @return {string}
3595 */
3596 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3597 var accessKey;
3598
3599 if ( !this.$accessKeyed ) {
3600 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3601 return title;
3602 }
3603 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3604 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3605 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3606 } else {
3607 accessKey = this.getAccessKey();
3608 }
3609 if ( accessKey ) {
3610 title += ' [' + accessKey + ']';
3611 }
3612 return title;
3613 };
3614
3615 /**
3616 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3617 * feels, and functionality can be customized via the class’s configuration options
3618 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3619 * and examples.
3620 *
3621 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3622 *
3623 * @example
3624 * // A button widget
3625 * var button = new OO.ui.ButtonWidget( {
3626 * label: 'Button with Icon',
3627 * icon: 'trash',
3628 * title: 'Remove'
3629 * } );
3630 * $( 'body' ).append( button.$element );
3631 *
3632 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3633 *
3634 * @class
3635 * @extends OO.ui.Widget
3636 * @mixins OO.ui.mixin.ButtonElement
3637 * @mixins OO.ui.mixin.IconElement
3638 * @mixins OO.ui.mixin.IndicatorElement
3639 * @mixins OO.ui.mixin.LabelElement
3640 * @mixins OO.ui.mixin.TitledElement
3641 * @mixins OO.ui.mixin.FlaggedElement
3642 * @mixins OO.ui.mixin.TabIndexedElement
3643 * @mixins OO.ui.mixin.AccessKeyedElement
3644 *
3645 * @constructor
3646 * @param {Object} [config] Configuration options
3647 * @cfg {boolean} [active=false] Whether button should be shown as active
3648 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3649 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3650 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3651 */
3652 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3653 // Configuration initialization
3654 config = config || {};
3655
3656 // Parent constructor
3657 OO.ui.ButtonWidget.parent.call( this, config );
3658
3659 // Mixin constructors
3660 OO.ui.mixin.ButtonElement.call( this, config );
3661 OO.ui.mixin.IconElement.call( this, config );
3662 OO.ui.mixin.IndicatorElement.call( this, config );
3663 OO.ui.mixin.LabelElement.call( this, config );
3664 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3665 OO.ui.mixin.FlaggedElement.call( this, config );
3666 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3667 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3668
3669 // Properties
3670 this.href = null;
3671 this.target = null;
3672 this.noFollow = false;
3673
3674 // Events
3675 this.connect( this, { disable: 'onDisable' } );
3676
3677 // Initialization
3678 this.$button.append( this.$icon, this.$label, this.$indicator );
3679 this.$element
3680 .addClass( 'oo-ui-buttonWidget' )
3681 .append( this.$button );
3682 this.setActive( config.active );
3683 this.setHref( config.href );
3684 this.setTarget( config.target );
3685 this.setNoFollow( config.noFollow );
3686 };
3687
3688 /* Setup */
3689
3690 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3691 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3692 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3693 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3694 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3695 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3696 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3697 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3698 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3699
3700 /* Static Properties */
3701
3702 /**
3703 * @static
3704 * @inheritdoc
3705 */
3706 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3707
3708 /**
3709 * @static
3710 * @inheritdoc
3711 */
3712 OO.ui.ButtonWidget.static.tagName = 'span';
3713
3714 /* Methods */
3715
3716 /**
3717 * Get hyperlink location.
3718 *
3719 * @return {string} Hyperlink location
3720 */
3721 OO.ui.ButtonWidget.prototype.getHref = function () {
3722 return this.href;
3723 };
3724
3725 /**
3726 * Get hyperlink target.
3727 *
3728 * @return {string} Hyperlink target
3729 */
3730 OO.ui.ButtonWidget.prototype.getTarget = function () {
3731 return this.target;
3732 };
3733
3734 /**
3735 * Get search engine traversal hint.
3736 *
3737 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3738 */
3739 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3740 return this.noFollow;
3741 };
3742
3743 /**
3744 * Set hyperlink location.
3745 *
3746 * @param {string|null} href Hyperlink location, null to remove
3747 */
3748 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3749 href = typeof href === 'string' ? href : null;
3750 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3751 href = './' + href;
3752 }
3753
3754 if ( href !== this.href ) {
3755 this.href = href;
3756 this.updateHref();
3757 }
3758
3759 return this;
3760 };
3761
3762 /**
3763 * Update the `href` attribute, in case of changes to href or
3764 * disabled state.
3765 *
3766 * @private
3767 * @chainable
3768 */
3769 OO.ui.ButtonWidget.prototype.updateHref = function () {
3770 if ( this.href !== null && !this.isDisabled() ) {
3771 this.$button.attr( 'href', this.href );
3772 } else {
3773 this.$button.removeAttr( 'href' );
3774 }
3775
3776 return this;
3777 };
3778
3779 /**
3780 * Handle disable events.
3781 *
3782 * @private
3783 * @param {boolean} disabled Element is disabled
3784 */
3785 OO.ui.ButtonWidget.prototype.onDisable = function () {
3786 this.updateHref();
3787 };
3788
3789 /**
3790 * Set hyperlink target.
3791 *
3792 * @param {string|null} target Hyperlink target, null to remove
3793 */
3794 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3795 target = typeof target === 'string' ? target : null;
3796
3797 if ( target !== this.target ) {
3798 this.target = target;
3799 if ( target !== null ) {
3800 this.$button.attr( 'target', target );
3801 } else {
3802 this.$button.removeAttr( 'target' );
3803 }
3804 }
3805
3806 return this;
3807 };
3808
3809 /**
3810 * Set search engine traversal hint.
3811 *
3812 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3813 */
3814 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3815 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3816
3817 if ( noFollow !== this.noFollow ) {
3818 this.noFollow = noFollow;
3819 if ( noFollow ) {
3820 this.$button.attr( 'rel', 'nofollow' );
3821 } else {
3822 this.$button.removeAttr( 'rel' );
3823 }
3824 }
3825
3826 return this;
3827 };
3828
3829 // Override method visibility hints from ButtonElement
3830 /**
3831 * @method setActive
3832 * @inheritdoc
3833 */
3834 /**
3835 * @method isActive
3836 * @inheritdoc
3837 */
3838
3839 /**
3840 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3841 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3842 * removed, and cleared from the group.
3843 *
3844 * @example
3845 * // Example: A ButtonGroupWidget with two buttons
3846 * var button1 = new OO.ui.PopupButtonWidget( {
3847 * label: 'Select a category',
3848 * icon: 'menu',
3849 * popup: {
3850 * $content: $( '<p>List of categories...</p>' ),
3851 * padded: true,
3852 * align: 'left'
3853 * }
3854 * } );
3855 * var button2 = new OO.ui.ButtonWidget( {
3856 * label: 'Add item'
3857 * });
3858 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3859 * items: [button1, button2]
3860 * } );
3861 * $( 'body' ).append( buttonGroup.$element );
3862 *
3863 * @class
3864 * @extends OO.ui.Widget
3865 * @mixins OO.ui.mixin.GroupElement
3866 *
3867 * @constructor
3868 * @param {Object} [config] Configuration options
3869 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3870 */
3871 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3872 // Configuration initialization
3873 config = config || {};
3874
3875 // Parent constructor
3876 OO.ui.ButtonGroupWidget.parent.call( this, config );
3877
3878 // Mixin constructors
3879 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3880
3881 // Initialization
3882 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3883 if ( Array.isArray( config.items ) ) {
3884 this.addItems( config.items );
3885 }
3886 };
3887
3888 /* Setup */
3889
3890 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3891 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3892
3893 /* Static Properties */
3894
3895 /**
3896 * @static
3897 * @inheritdoc
3898 */
3899 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3900
3901 /* Methods */
3902
3903 /**
3904 * Focus the widget
3905 *
3906 * @chainable
3907 */
3908 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3909 if ( !this.isDisabled() ) {
3910 if ( this.items[ 0 ] ) {
3911 this.items[ 0 ].focus();
3912 }
3913 }
3914 return this;
3915 };
3916
3917 /**
3918 * @inheritdoc
3919 */
3920 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3921 this.focus();
3922 };
3923
3924 /**
3925 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3926 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
3927 * for a list of icons included in the library.
3928 *
3929 * @example
3930 * // An icon widget with a label
3931 * var myIcon = new OO.ui.IconWidget( {
3932 * icon: 'help',
3933 * title: 'Help'
3934 * } );
3935 * // Create a label.
3936 * var iconLabel = new OO.ui.LabelWidget( {
3937 * label: 'Help'
3938 * } );
3939 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3940 *
3941 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3942 *
3943 * @class
3944 * @extends OO.ui.Widget
3945 * @mixins OO.ui.mixin.IconElement
3946 * @mixins OO.ui.mixin.TitledElement
3947 * @mixins OO.ui.mixin.FlaggedElement
3948 *
3949 * @constructor
3950 * @param {Object} [config] Configuration options
3951 */
3952 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3953 // Configuration initialization
3954 config = config || {};
3955
3956 // Parent constructor
3957 OO.ui.IconWidget.parent.call( this, config );
3958
3959 // Mixin constructors
3960 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3961 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3962 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3963
3964 // Initialization
3965 this.$element.addClass( 'oo-ui-iconWidget' );
3966 };
3967
3968 /* Setup */
3969
3970 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3971 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3972 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3973 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3974
3975 /* Static Properties */
3976
3977 /**
3978 * @static
3979 * @inheritdoc
3980 */
3981 OO.ui.IconWidget.static.tagName = 'span';
3982
3983 /**
3984 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3985 * attention to the status of an item or to clarify the function within a control. For a list of
3986 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
3987 *
3988 * @example
3989 * // Example of an indicator widget
3990 * var indicator1 = new OO.ui.IndicatorWidget( {
3991 * indicator: 'required'
3992 * } );
3993 *
3994 * // Create a fieldset layout to add a label
3995 * var fieldset = new OO.ui.FieldsetLayout();
3996 * fieldset.addItems( [
3997 * new OO.ui.FieldLayout( indicator1, { label: 'A required indicator:' } )
3998 * ] );
3999 * $( 'body' ).append( fieldset.$element );
4000 *
4001 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4002 *
4003 * @class
4004 * @extends OO.ui.Widget
4005 * @mixins OO.ui.mixin.IndicatorElement
4006 * @mixins OO.ui.mixin.TitledElement
4007 *
4008 * @constructor
4009 * @param {Object} [config] Configuration options
4010 */
4011 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4012 // Configuration initialization
4013 config = config || {};
4014
4015 // Parent constructor
4016 OO.ui.IndicatorWidget.parent.call( this, config );
4017
4018 // Mixin constructors
4019 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4020 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4021
4022 // Initialization
4023 this.$element.addClass( 'oo-ui-indicatorWidget' );
4024 };
4025
4026 /* Setup */
4027
4028 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4029 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4030 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4031
4032 /* Static Properties */
4033
4034 /**
4035 * @static
4036 * @inheritdoc
4037 */
4038 OO.ui.IndicatorWidget.static.tagName = 'span';
4039
4040 /**
4041 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4042 * be configured with a `label` option that is set to a string, a label node, or a function:
4043 *
4044 * - String: a plaintext string
4045 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4046 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4047 * - Function: a function that will produce a string in the future. Functions are used
4048 * in cases where the value of the label is not currently defined.
4049 *
4050 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4051 * will come into focus when the label is clicked.
4052 *
4053 * @example
4054 * // Examples of LabelWidgets
4055 * var label1 = new OO.ui.LabelWidget( {
4056 * label: 'plaintext label'
4057 * } );
4058 * var label2 = new OO.ui.LabelWidget( {
4059 * label: $( '<a href="default.html">jQuery label</a>' )
4060 * } );
4061 * // Create a fieldset layout with fields for each example
4062 * var fieldset = new OO.ui.FieldsetLayout();
4063 * fieldset.addItems( [
4064 * new OO.ui.FieldLayout( label1 ),
4065 * new OO.ui.FieldLayout( label2 )
4066 * ] );
4067 * $( 'body' ).append( fieldset.$element );
4068 *
4069 * @class
4070 * @extends OO.ui.Widget
4071 * @mixins OO.ui.mixin.LabelElement
4072 * @mixins OO.ui.mixin.TitledElement
4073 *
4074 * @constructor
4075 * @param {Object} [config] Configuration options
4076 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4077 * Clicking the label will focus the specified input field.
4078 */
4079 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4080 // Configuration initialization
4081 config = config || {};
4082
4083 // Parent constructor
4084 OO.ui.LabelWidget.parent.call( this, config );
4085
4086 // Mixin constructors
4087 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4088 OO.ui.mixin.TitledElement.call( this, config );
4089
4090 // Properties
4091 this.input = config.input;
4092
4093 // Initialization
4094 if ( this.input ) {
4095 if ( this.input.getInputId() ) {
4096 this.$element.attr( 'for', this.input.getInputId() );
4097 } else {
4098 this.$label.on( 'click', function () {
4099 this.input.simulateLabelClick();
4100 }.bind( this ) );
4101 }
4102 }
4103 this.$element.addClass( 'oo-ui-labelWidget' );
4104 };
4105
4106 /* Setup */
4107
4108 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4109 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4110 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4111
4112 /* Static Properties */
4113
4114 /**
4115 * @static
4116 * @inheritdoc
4117 */
4118 OO.ui.LabelWidget.static.tagName = 'label';
4119
4120 /**
4121 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4122 * and that they should wait before proceeding. The pending state is visually represented with a pending
4123 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4124 * field of a {@link OO.ui.TextInputWidget text input widget}.
4125 *
4126 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4127 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4128 * in process dialogs.
4129 *
4130 * @example
4131 * function MessageDialog( config ) {
4132 * MessageDialog.parent.call( this, config );
4133 * }
4134 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4135 *
4136 * MessageDialog.static.name = 'myMessageDialog';
4137 * MessageDialog.static.actions = [
4138 * { action: 'save', label: 'Done', flags: 'primary' },
4139 * { label: 'Cancel', flags: 'safe' }
4140 * ];
4141 *
4142 * MessageDialog.prototype.initialize = function () {
4143 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4144 * this.content = new OO.ui.PanelLayout( { padded: true } );
4145 * 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>' );
4146 * this.$body.append( this.content.$element );
4147 * };
4148 * MessageDialog.prototype.getBodyHeight = function () {
4149 * return 100;
4150 * }
4151 * MessageDialog.prototype.getActionProcess = function ( action ) {
4152 * var dialog = this;
4153 * if ( action === 'save' ) {
4154 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4155 * return new OO.ui.Process()
4156 * .next( 1000 )
4157 * .next( function () {
4158 * dialog.getActions().get({actions: 'save'})[0].popPending();
4159 * } );
4160 * }
4161 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4162 * };
4163 *
4164 * var windowManager = new OO.ui.WindowManager();
4165 * $( 'body' ).append( windowManager.$element );
4166 *
4167 * var dialog = new MessageDialog();
4168 * windowManager.addWindows( [ dialog ] );
4169 * windowManager.openWindow( dialog );
4170 *
4171 * @abstract
4172 * @class
4173 *
4174 * @constructor
4175 * @param {Object} [config] Configuration options
4176 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4177 */
4178 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4179 // Configuration initialization
4180 config = config || {};
4181
4182 // Properties
4183 this.pending = 0;
4184 this.$pending = null;
4185
4186 // Initialisation
4187 this.setPendingElement( config.$pending || this.$element );
4188 };
4189
4190 /* Setup */
4191
4192 OO.initClass( OO.ui.mixin.PendingElement );
4193
4194 /* Methods */
4195
4196 /**
4197 * Set the pending element (and clean up any existing one).
4198 *
4199 * @param {jQuery} $pending The element to set to pending.
4200 */
4201 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4202 if ( this.$pending ) {
4203 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4204 }
4205
4206 this.$pending = $pending;
4207 if ( this.pending > 0 ) {
4208 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4209 }
4210 };
4211
4212 /**
4213 * Check if an element is pending.
4214 *
4215 * @return {boolean} Element is pending
4216 */
4217 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4218 return !!this.pending;
4219 };
4220
4221 /**
4222 * Increase the pending counter. The pending state will remain active until the counter is zero
4223 * (i.e., the number of calls to #pushPending and #popPending is the same).
4224 *
4225 * @chainable
4226 */
4227 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4228 if ( this.pending === 0 ) {
4229 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4230 this.updateThemeClasses();
4231 }
4232 this.pending++;
4233
4234 return this;
4235 };
4236
4237 /**
4238 * Decrease the pending counter. The pending state will remain active until the counter is zero
4239 * (i.e., the number of calls to #pushPending and #popPending is the same).
4240 *
4241 * @chainable
4242 */
4243 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4244 if ( this.pending === 1 ) {
4245 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4246 this.updateThemeClasses();
4247 }
4248 this.pending = Math.max( 0, this.pending - 1 );
4249
4250 return this;
4251 };
4252
4253 /**
4254 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4255 * in the document (for example, in an OO.ui.Window's $overlay).
4256 *
4257 * The elements's position is automatically calculated and maintained when window is resized or the
4258 * page is scrolled. If you reposition the container manually, you have to call #position to make
4259 * sure the element is still placed correctly.
4260 *
4261 * As positioning is only possible when both the element and the container are attached to the DOM
4262 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4263 * the #toggle method to display a floating popup, for example.
4264 *
4265 * @abstract
4266 * @class
4267 *
4268 * @constructor
4269 * @param {Object} [config] Configuration options
4270 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4271 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4272 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4273 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4274 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4275 * 'top': Align the top edge with $floatableContainer's top edge
4276 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4277 * 'center': Vertically align the center with $floatableContainer's center
4278 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4279 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4280 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4281 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4282 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4283 * 'center': Horizontally align the center with $floatableContainer's center
4284 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4285 * is out of view
4286 */
4287 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4288 // Configuration initialization
4289 config = config || {};
4290
4291 // Properties
4292 this.$floatable = null;
4293 this.$floatableContainer = null;
4294 this.$floatableWindow = null;
4295 this.$floatableClosestScrollable = null;
4296 this.floatableOutOfView = false;
4297 this.onFloatableScrollHandler = this.position.bind( this );
4298 this.onFloatableWindowResizeHandler = this.position.bind( this );
4299
4300 // Initialization
4301 this.setFloatableContainer( config.$floatableContainer );
4302 this.setFloatableElement( config.$floatable || this.$element );
4303 this.setVerticalPosition( config.verticalPosition || 'below' );
4304 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4305 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4306 };
4307
4308 /* Methods */
4309
4310 /**
4311 * Set floatable element.
4312 *
4313 * If an element is already set, it will be cleaned up before setting up the new element.
4314 *
4315 * @param {jQuery} $floatable Element to make floatable
4316 */
4317 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4318 if ( this.$floatable ) {
4319 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4320 this.$floatable.css( { left: '', top: '' } );
4321 }
4322
4323 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4324 this.position();
4325 };
4326
4327 /**
4328 * Set floatable container.
4329 *
4330 * The element will be positioned relative to the specified container.
4331 *
4332 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4333 */
4334 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4335 this.$floatableContainer = $floatableContainer;
4336 if ( this.$floatable ) {
4337 this.position();
4338 }
4339 };
4340
4341 /**
4342 * Change how the element is positioned vertically.
4343 *
4344 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4345 */
4346 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4347 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4348 throw new Error( 'Invalid value for vertical position: ' + position );
4349 }
4350 if ( this.verticalPosition !== position ) {
4351 this.verticalPosition = position;
4352 if ( this.$floatable ) {
4353 this.position();
4354 }
4355 }
4356 };
4357
4358 /**
4359 * Change how the element is positioned horizontally.
4360 *
4361 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4362 */
4363 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4364 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4365 throw new Error( 'Invalid value for horizontal position: ' + position );
4366 }
4367 if ( this.horizontalPosition !== position ) {
4368 this.horizontalPosition = position;
4369 if ( this.$floatable ) {
4370 this.position();
4371 }
4372 }
4373 };
4374
4375 /**
4376 * Toggle positioning.
4377 *
4378 * Do not turn positioning on until after the element is attached to the DOM and visible.
4379 *
4380 * @param {boolean} [positioning] Enable positioning, omit to toggle
4381 * @chainable
4382 */
4383 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4384 var closestScrollableOfContainer;
4385
4386 if ( !this.$floatable || !this.$floatableContainer ) {
4387 return this;
4388 }
4389
4390 positioning = positioning === undefined ? !this.positioning : !!positioning;
4391
4392 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4393 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4394 this.warnedUnattached = true;
4395 }
4396
4397 if ( this.positioning !== positioning ) {
4398 this.positioning = positioning;
4399
4400 this.needsCustomPosition =
4401 this.verticalPostion !== 'below' ||
4402 this.horizontalPosition !== 'start' ||
4403 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4404
4405 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4406 // If the scrollable is the root, we have to listen to scroll events
4407 // on the window because of browser inconsistencies.
4408 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4409 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4410 }
4411
4412 if ( positioning ) {
4413 this.$floatableWindow = $( this.getElementWindow() );
4414 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4415
4416 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4417 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4418
4419 // Initial position after visible
4420 this.position();
4421 } else {
4422 if ( this.$floatableWindow ) {
4423 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4424 this.$floatableWindow = null;
4425 }
4426
4427 if ( this.$floatableClosestScrollable ) {
4428 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4429 this.$floatableClosestScrollable = null;
4430 }
4431
4432 this.$floatable.css( { left: '', right: '', top: '' } );
4433 }
4434 }
4435
4436 return this;
4437 };
4438
4439 /**
4440 * Check whether the bottom edge of the given element is within the viewport of the given container.
4441 *
4442 * @private
4443 * @param {jQuery} $element
4444 * @param {jQuery} $container
4445 * @return {boolean}
4446 */
4447 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4448 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4449 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4450 direction = $element.css( 'direction' );
4451
4452 elemRect = $element[ 0 ].getBoundingClientRect();
4453 if ( $container[ 0 ] === window ) {
4454 viewportSpacing = OO.ui.getViewportSpacing();
4455 contRect = {
4456 top: 0,
4457 left: 0,
4458 right: document.documentElement.clientWidth,
4459 bottom: document.documentElement.clientHeight
4460 };
4461 contRect.top += viewportSpacing.top;
4462 contRect.left += viewportSpacing.left;
4463 contRect.right -= viewportSpacing.right;
4464 contRect.bottom -= viewportSpacing.bottom;
4465 } else {
4466 contRect = $container[ 0 ].getBoundingClientRect();
4467 }
4468
4469 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4470 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4471 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4472 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4473 if ( direction === 'rtl' ) {
4474 startEdgeInBounds = rightEdgeInBounds;
4475 endEdgeInBounds = leftEdgeInBounds;
4476 } else {
4477 startEdgeInBounds = leftEdgeInBounds;
4478 endEdgeInBounds = rightEdgeInBounds;
4479 }
4480
4481 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4482 return false;
4483 }
4484 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4485 return false;
4486 }
4487 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4488 return false;
4489 }
4490 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4491 return false;
4492 }
4493
4494 // The other positioning values are all about being inside the container,
4495 // so in those cases all we care about is that any part of the container is visible.
4496 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4497 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4498 };
4499
4500 /**
4501 * Check if the floatable is hidden to the user because it was offscreen.
4502 *
4503 * @return {boolean} Floatable is out of view
4504 */
4505 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4506 return this.floatableOutOfView;
4507 };
4508
4509 /**
4510 * Position the floatable below its container.
4511 *
4512 * This should only be done when both of them are attached to the DOM and visible.
4513 *
4514 * @chainable
4515 */
4516 OO.ui.mixin.FloatableElement.prototype.position = function () {
4517 if ( !this.positioning ) {
4518 return this;
4519 }
4520
4521 if ( !(
4522 // To continue, some things need to be true:
4523 // The element must actually be in the DOM
4524 this.isElementAttached() && (
4525 // The closest scrollable is the current window
4526 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4527 // OR is an element in the element's DOM
4528 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4529 )
4530 ) ) {
4531 // Abort early if important parts of the widget are no longer attached to the DOM
4532 return this;
4533 }
4534
4535 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4536 if ( this.floatableOutOfView ) {
4537 this.$floatable.addClass( 'oo-ui-element-hidden' );
4538 return this;
4539 } else {
4540 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4541 }
4542
4543 if ( !this.needsCustomPosition ) {
4544 return this;
4545 }
4546
4547 this.$floatable.css( this.computePosition() );
4548
4549 // We updated the position, so re-evaluate the clipping state.
4550 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4551 // will not notice the need to update itself.)
4552 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4553 // it not listen to the right events in the right places?
4554 if ( this.clip ) {
4555 this.clip();
4556 }
4557
4558 return this;
4559 };
4560
4561 /**
4562 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4563 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4564 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4565 *
4566 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4567 */
4568 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4569 var isBody, scrollableX, scrollableY, containerPos,
4570 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4571 newPos = { top: '', left: '', bottom: '', right: '' },
4572 direction = this.$floatableContainer.css( 'direction' ),
4573 $offsetParent = this.$floatable.offsetParent();
4574
4575 if ( $offsetParent.is( 'html' ) ) {
4576 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4577 // <html> element, but they do work on the <body>
4578 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4579 }
4580 isBody = $offsetParent.is( 'body' );
4581 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4582 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4583
4584 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4585 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4586 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4587 // or if it isn't scrollable
4588 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4589 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4590
4591 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4592 // if the <body> has a margin
4593 containerPos = isBody ?
4594 this.$floatableContainer.offset() :
4595 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4596 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4597 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4598 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4599 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4600
4601 if ( this.verticalPosition === 'below' ) {
4602 newPos.top = containerPos.bottom;
4603 } else if ( this.verticalPosition === 'above' ) {
4604 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4605 } else if ( this.verticalPosition === 'top' ) {
4606 newPos.top = containerPos.top;
4607 } else if ( this.verticalPosition === 'bottom' ) {
4608 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4609 } else if ( this.verticalPosition === 'center' ) {
4610 newPos.top = containerPos.top +
4611 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4612 }
4613
4614 if ( this.horizontalPosition === 'before' ) {
4615 newPos.end = containerPos.start;
4616 } else if ( this.horizontalPosition === 'after' ) {
4617 newPos.start = containerPos.end;
4618 } else if ( this.horizontalPosition === 'start' ) {
4619 newPos.start = containerPos.start;
4620 } else if ( this.horizontalPosition === 'end' ) {
4621 newPos.end = containerPos.end;
4622 } else if ( this.horizontalPosition === 'center' ) {
4623 newPos.left = containerPos.left +
4624 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4625 }
4626
4627 if ( newPos.start !== undefined ) {
4628 if ( direction === 'rtl' ) {
4629 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4630 } else {
4631 newPos.left = newPos.start;
4632 }
4633 delete newPos.start;
4634 }
4635 if ( newPos.end !== undefined ) {
4636 if ( direction === 'rtl' ) {
4637 newPos.left = newPos.end;
4638 } else {
4639 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4640 }
4641 delete newPos.end;
4642 }
4643
4644 // Account for scroll position
4645 if ( newPos.top !== '' ) {
4646 newPos.top += scrollTop;
4647 }
4648 if ( newPos.bottom !== '' ) {
4649 newPos.bottom -= scrollTop;
4650 }
4651 if ( newPos.left !== '' ) {
4652 newPos.left += scrollLeft;
4653 }
4654 if ( newPos.right !== '' ) {
4655 newPos.right -= scrollLeft;
4656 }
4657
4658 // Account for scrollbar gutter
4659 if ( newPos.bottom !== '' ) {
4660 newPos.bottom -= horizScrollbarHeight;
4661 }
4662 if ( direction === 'rtl' ) {
4663 if ( newPos.left !== '' ) {
4664 newPos.left -= vertScrollbarWidth;
4665 }
4666 } else {
4667 if ( newPos.right !== '' ) {
4668 newPos.right -= vertScrollbarWidth;
4669 }
4670 }
4671
4672 return newPos;
4673 };
4674
4675 /**
4676 * Element that can be automatically clipped to visible boundaries.
4677 *
4678 * Whenever the element's natural height changes, you have to call
4679 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4680 * clipping correctly.
4681 *
4682 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4683 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4684 * then #$clippable will be given a fixed reduced height and/or width and will be made
4685 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4686 * but you can build a static footer by setting #$clippableContainer to an element that contains
4687 * #$clippable and the footer.
4688 *
4689 * @abstract
4690 * @class
4691 *
4692 * @constructor
4693 * @param {Object} [config] Configuration options
4694 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4695 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4696 * omit to use #$clippable
4697 */
4698 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4699 // Configuration initialization
4700 config = config || {};
4701
4702 // Properties
4703 this.$clippable = null;
4704 this.$clippableContainer = null;
4705 this.clipping = false;
4706 this.clippedHorizontally = false;
4707 this.clippedVertically = false;
4708 this.$clippableScrollableContainer = null;
4709 this.$clippableScroller = null;
4710 this.$clippableWindow = null;
4711 this.idealWidth = null;
4712 this.idealHeight = null;
4713 this.onClippableScrollHandler = this.clip.bind( this );
4714 this.onClippableWindowResizeHandler = this.clip.bind( this );
4715
4716 // Initialization
4717 if ( config.$clippableContainer ) {
4718 this.setClippableContainer( config.$clippableContainer );
4719 }
4720 this.setClippableElement( config.$clippable || this.$element );
4721 };
4722
4723 /* Methods */
4724
4725 /**
4726 * Set clippable element.
4727 *
4728 * If an element is already set, it will be cleaned up before setting up the new element.
4729 *
4730 * @param {jQuery} $clippable Element to make clippable
4731 */
4732 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4733 if ( this.$clippable ) {
4734 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4735 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4736 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4737 }
4738
4739 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4740 this.clip();
4741 };
4742
4743 /**
4744 * Set clippable container.
4745 *
4746 * This is the container that will be measured when deciding whether to clip. When clipping,
4747 * #$clippable will be resized in order to keep the clippable container fully visible.
4748 *
4749 * If the clippable container is unset, #$clippable will be used.
4750 *
4751 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4752 */
4753 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4754 this.$clippableContainer = $clippableContainer;
4755 if ( this.$clippable ) {
4756 this.clip();
4757 }
4758 };
4759
4760 /**
4761 * Toggle clipping.
4762 *
4763 * Do not turn clipping on until after the element is attached to the DOM and visible.
4764 *
4765 * @param {boolean} [clipping] Enable clipping, omit to toggle
4766 * @chainable
4767 */
4768 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4769 clipping = clipping === undefined ? !this.clipping : !!clipping;
4770
4771 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4772 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4773 this.warnedUnattached = true;
4774 }
4775
4776 if ( this.clipping !== clipping ) {
4777 this.clipping = clipping;
4778 if ( clipping ) {
4779 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4780 // If the clippable container is the root, we have to listen to scroll events and check
4781 // jQuery.scrollTop on the window because of browser inconsistencies
4782 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4783 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4784 this.$clippableScrollableContainer;
4785 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4786 this.$clippableWindow = $( this.getElementWindow() )
4787 .on( 'resize', this.onClippableWindowResizeHandler );
4788 // Initial clip after visible
4789 this.clip();
4790 } else {
4791 this.$clippable.css( {
4792 width: '',
4793 height: '',
4794 maxWidth: '',
4795 maxHeight: '',
4796 overflowX: '',
4797 overflowY: ''
4798 } );
4799 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4800
4801 this.$clippableScrollableContainer = null;
4802 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4803 this.$clippableScroller = null;
4804 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4805 this.$clippableWindow = null;
4806 }
4807 }
4808
4809 return this;
4810 };
4811
4812 /**
4813 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4814 *
4815 * @return {boolean} Element will be clipped to the visible area
4816 */
4817 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4818 return this.clipping;
4819 };
4820
4821 /**
4822 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4823 *
4824 * @return {boolean} Part of the element is being clipped
4825 */
4826 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4827 return this.clippedHorizontally || this.clippedVertically;
4828 };
4829
4830 /**
4831 * Check if the right of the element is being clipped by the nearest scrollable container.
4832 *
4833 * @return {boolean} Part of the element is being clipped
4834 */
4835 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4836 return this.clippedHorizontally;
4837 };
4838
4839 /**
4840 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4841 *
4842 * @return {boolean} Part of the element is being clipped
4843 */
4844 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4845 return this.clippedVertically;
4846 };
4847
4848 /**
4849 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4850 *
4851 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4852 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4853 */
4854 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4855 this.idealWidth = width;
4856 this.idealHeight = height;
4857
4858 if ( !this.clipping ) {
4859 // Update dimensions
4860 this.$clippable.css( { width: width, height: height } );
4861 }
4862 // While clipping, idealWidth and idealHeight are not considered
4863 };
4864
4865 /**
4866 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4867 * ClippableElement will clip the opposite side when reducing element's width.
4868 *
4869 * Classes that mix in ClippableElement should override this to return 'right' if their
4870 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4871 * If your class also mixes in FloatableElement, this is handled automatically.
4872 *
4873 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4874 * always in pixels, even if they were unset or set to 'auto'.)
4875 *
4876 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4877 *
4878 * @return {string} 'left' or 'right'
4879 */
4880 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4881 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4882 return 'right';
4883 }
4884 return 'left';
4885 };
4886
4887 /**
4888 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4889 * ClippableElement will clip the opposite side when reducing element's width.
4890 *
4891 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4892 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4893 * If your class also mixes in FloatableElement, this is handled automatically.
4894 *
4895 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4896 * always in pixels, even if they were unset or set to 'auto'.)
4897 *
4898 * When in doubt, 'top' is a sane fallback.
4899 *
4900 * @return {string} 'top' or 'bottom'
4901 */
4902 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
4903 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
4904 return 'bottom';
4905 }
4906 return 'top';
4907 };
4908
4909 /**
4910 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4911 * when the element's natural height changes.
4912 *
4913 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4914 * overlapped by, the visible area of the nearest scrollable container.
4915 *
4916 * Because calling clip() when the natural height changes isn't always possible, we also set
4917 * max-height when the element isn't being clipped. This means that if the element tries to grow
4918 * beyond the edge, something reasonable will happen before clip() is called.
4919 *
4920 * @chainable
4921 */
4922 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4923 var extraHeight, extraWidth, viewportSpacing,
4924 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4925 naturalWidth, naturalHeight, clipWidth, clipHeight,
4926 $item, itemRect, $viewport, viewportRect, availableRect,
4927 direction, vertScrollbarWidth, horizScrollbarHeight,
4928 // Extra tolerance so that the sloppy code below doesn't result in results that are off
4929 // by one or two pixels. (And also so that we have space to display drop shadows.)
4930 // Chosen by fair dice roll.
4931 buffer = 7;
4932
4933 if ( !this.clipping ) {
4934 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4935 return this;
4936 }
4937
4938 function rectIntersection( a, b ) {
4939 var out = {};
4940 out.top = Math.max( a.top, b.top );
4941 out.left = Math.max( a.left, b.left );
4942 out.bottom = Math.min( a.bottom, b.bottom );
4943 out.right = Math.min( a.right, b.right );
4944 return out;
4945 }
4946
4947 viewportSpacing = OO.ui.getViewportSpacing();
4948
4949 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4950 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
4951 // Dimensions of the browser window, rather than the element!
4952 viewportRect = {
4953 top: 0,
4954 left: 0,
4955 right: document.documentElement.clientWidth,
4956 bottom: document.documentElement.clientHeight
4957 };
4958 viewportRect.top += viewportSpacing.top;
4959 viewportRect.left += viewportSpacing.left;
4960 viewportRect.right -= viewportSpacing.right;
4961 viewportRect.bottom -= viewportSpacing.bottom;
4962 } else {
4963 $viewport = this.$clippableScrollableContainer;
4964 viewportRect = $viewport[ 0 ].getBoundingClientRect();
4965 // Convert into a plain object
4966 viewportRect = $.extend( {}, viewportRect );
4967 }
4968
4969 // Account for scrollbar gutter
4970 direction = $viewport.css( 'direction' );
4971 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
4972 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
4973 viewportRect.bottom -= horizScrollbarHeight;
4974 if ( direction === 'rtl' ) {
4975 viewportRect.left += vertScrollbarWidth;
4976 } else {
4977 viewportRect.right -= vertScrollbarWidth;
4978 }
4979
4980 // Add arbitrary tolerance
4981 viewportRect.top += buffer;
4982 viewportRect.left += buffer;
4983 viewportRect.right -= buffer;
4984 viewportRect.bottom -= buffer;
4985
4986 $item = this.$clippableContainer || this.$clippable;
4987
4988 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
4989 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
4990
4991 itemRect = $item[ 0 ].getBoundingClientRect();
4992 // Convert into a plain object
4993 itemRect = $.extend( {}, itemRect );
4994
4995 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
4996 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
4997 if ( this.getHorizontalAnchorEdge() === 'right' ) {
4998 itemRect.left = viewportRect.left;
4999 } else {
5000 itemRect.right = viewportRect.right;
5001 }
5002 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5003 itemRect.top = viewportRect.top;
5004 } else {
5005 itemRect.bottom = viewportRect.bottom;
5006 }
5007
5008 availableRect = rectIntersection( viewportRect, itemRect );
5009
5010 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5011 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5012 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5013 desiredWidth = Math.min( desiredWidth,
5014 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5015 desiredHeight = Math.min( desiredHeight,
5016 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5017 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5018 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5019 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5020 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5021 clipWidth = allotedWidth < naturalWidth;
5022 clipHeight = allotedHeight < naturalHeight;
5023
5024 if ( clipWidth ) {
5025 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5026 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5027 this.$clippable.css( 'overflowX', 'scroll' );
5028 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5029 this.$clippable.css( {
5030 width: Math.max( 0, allotedWidth ),
5031 maxWidth: ''
5032 } );
5033 } else {
5034 this.$clippable.css( {
5035 overflowX: '',
5036 width: this.idealWidth || '',
5037 maxWidth: Math.max( 0, allotedWidth )
5038 } );
5039 }
5040 if ( clipHeight ) {
5041 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5042 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5043 this.$clippable.css( 'overflowY', 'scroll' );
5044 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5045 this.$clippable.css( {
5046 height: Math.max( 0, allotedHeight ),
5047 maxHeight: ''
5048 } );
5049 } else {
5050 this.$clippable.css( {
5051 overflowY: '',
5052 height: this.idealHeight || '',
5053 maxHeight: Math.max( 0, allotedHeight )
5054 } );
5055 }
5056
5057 // If we stopped clipping in at least one of the dimensions
5058 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5059 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5060 }
5061
5062 this.clippedHorizontally = clipWidth;
5063 this.clippedVertically = clipHeight;
5064
5065 return this;
5066 };
5067
5068 /**
5069 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5070 * By default, each popup has an anchor that points toward its origin.
5071 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5072 *
5073 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5074 *
5075 * @example
5076 * // A popup widget.
5077 * var popup = new OO.ui.PopupWidget( {
5078 * $content: $( '<p>Hi there!</p>' ),
5079 * padded: true,
5080 * width: 300
5081 * } );
5082 *
5083 * $( 'body' ).append( popup.$element );
5084 * // To display the popup, toggle the visibility to 'true'.
5085 * popup.toggle( true );
5086 *
5087 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5088 *
5089 * @class
5090 * @extends OO.ui.Widget
5091 * @mixins OO.ui.mixin.LabelElement
5092 * @mixins OO.ui.mixin.ClippableElement
5093 * @mixins OO.ui.mixin.FloatableElement
5094 *
5095 * @constructor
5096 * @param {Object} [config] Configuration options
5097 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5098 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5099 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5100 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5101 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5102 * of $floatableContainer
5103 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5104 * of $floatableContainer
5105 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5106 * endwards (right/left) to the vertical center of $floatableContainer
5107 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5108 * startwards (left/right) to the vertical center of $floatableContainer
5109 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5110 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5111 * as possible while still keeping the anchor within the popup;
5112 * if position is before/after, move the popup as far downwards as possible.
5113 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5114 * as possible while still keeping the anchor within the popup;
5115 * if position in before/after, move the popup as far upwards as possible.
5116 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5117 * of the popup with the center of $floatableContainer.
5118 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5119 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5120 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5121 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5122 * desired direction to display the popup without clipping
5123 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5124 * See the [OOUI docs on MediaWiki][3] for an example.
5125 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5126 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5127 * @cfg {jQuery} [$content] Content to append to the popup's body
5128 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5129 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5130 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5131 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5132 * for an example.
5133 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5134 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5135 * button.
5136 * @cfg {boolean} [padded=false] Add padding to the popup's body
5137 */
5138 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5139 // Configuration initialization
5140 config = config || {};
5141
5142 // Parent constructor
5143 OO.ui.PopupWidget.parent.call( this, config );
5144
5145 // Properties (must be set before ClippableElement constructor call)
5146 this.$body = $( '<div>' );
5147 this.$popup = $( '<div>' );
5148
5149 // Mixin constructors
5150 OO.ui.mixin.LabelElement.call( this, config );
5151 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5152 $clippable: this.$body,
5153 $clippableContainer: this.$popup
5154 } ) );
5155 OO.ui.mixin.FloatableElement.call( this, config );
5156
5157 // Properties
5158 this.$anchor = $( '<div>' );
5159 // If undefined, will be computed lazily in computePosition()
5160 this.$container = config.$container;
5161 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5162 this.autoClose = !!config.autoClose;
5163 this.transitionTimeout = null;
5164 this.anchored = false;
5165 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5166 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5167
5168 // Initialization
5169 this.setSize( config.width, config.height );
5170 this.toggleAnchor( config.anchor === undefined || config.anchor );
5171 this.setAlignment( config.align || 'center' );
5172 this.setPosition( config.position || 'below' );
5173 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5174 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5175 this.$body.addClass( 'oo-ui-popupWidget-body' );
5176 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5177 this.$popup
5178 .addClass( 'oo-ui-popupWidget-popup' )
5179 .append( this.$body );
5180 this.$element
5181 .addClass( 'oo-ui-popupWidget' )
5182 .append( this.$popup, this.$anchor );
5183 // Move content, which was added to #$element by OO.ui.Widget, to the body
5184 // FIXME This is gross, we should use '$body' or something for the config
5185 if ( config.$content instanceof jQuery ) {
5186 this.$body.append( config.$content );
5187 }
5188
5189 if ( config.padded ) {
5190 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5191 }
5192
5193 if ( config.head ) {
5194 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5195 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5196 this.$head = $( '<div>' )
5197 .addClass( 'oo-ui-popupWidget-head' )
5198 .append( this.$label, this.closeButton.$element );
5199 this.$popup.prepend( this.$head );
5200 }
5201
5202 if ( config.$footer ) {
5203 this.$footer = $( '<div>' )
5204 .addClass( 'oo-ui-popupWidget-footer' )
5205 .append( config.$footer );
5206 this.$popup.append( this.$footer );
5207 }
5208
5209 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5210 // that reference properties not initialized at that time of parent class construction
5211 // TODO: Find a better way to handle post-constructor setup
5212 this.visible = false;
5213 this.$element.addClass( 'oo-ui-element-hidden' );
5214 };
5215
5216 /* Setup */
5217
5218 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5219 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5220 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5221 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5222
5223 /* Events */
5224
5225 /**
5226 * @event ready
5227 *
5228 * The popup is ready: it is visible and has been positioned and clipped.
5229 */
5230
5231 /* Methods */
5232
5233 /**
5234 * Handles document mouse down events.
5235 *
5236 * @private
5237 * @param {MouseEvent} e Mouse down event
5238 */
5239 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5240 if (
5241 this.isVisible() &&
5242 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5243 ) {
5244 this.toggle( false );
5245 }
5246 };
5247
5248 // Deprecated alias since 0.28.3
5249 OO.ui.PopupWidget.prototype.onMouseDown = function () {
5250 OO.ui.warnDeprecation( 'onMouseDown is deprecated, use onDocumentMouseDown instead' );
5251 this.onDocumentMouseDown.apply( this, arguments );
5252 };
5253
5254 /**
5255 * Bind document mouse down listener.
5256 *
5257 * @private
5258 */
5259 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5260 // Capture clicks outside popup
5261 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5262 // We add 'click' event because iOS safari needs to respond to this event.
5263 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5264 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5265 // of occasionally not emitting 'click' properly, that event seems to be the standard
5266 // that it should be emitting, so we add it to this and will operate the event handler
5267 // on whichever of these events was triggered first
5268 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5269 };
5270
5271 // Deprecated alias since 0.28.3
5272 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5273 OO.ui.warnDeprecation( 'bindMouseDownListener is deprecated, use bindDocumentMouseDownListener instead' );
5274 this.bindDocumentMouseDownListener.apply( this, arguments );
5275 };
5276
5277 /**
5278 * Handles close button click events.
5279 *
5280 * @private
5281 */
5282 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5283 if ( this.isVisible() ) {
5284 this.toggle( false );
5285 }
5286 };
5287
5288 /**
5289 * Unbind document mouse down listener.
5290 *
5291 * @private
5292 */
5293 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5294 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5295 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5296 };
5297
5298 // Deprecated alias since 0.28.3
5299 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5300 OO.ui.warnDeprecation( 'unbindMouseDownListener is deprecated, use unbindDocumentMouseDownListener instead' );
5301 this.unbindDocumentMouseDownListener.apply( this, arguments );
5302 };
5303
5304 /**
5305 * Handles document key down events.
5306 *
5307 * @private
5308 * @param {KeyboardEvent} e Key down event
5309 */
5310 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5311 if (
5312 e.which === OO.ui.Keys.ESCAPE &&
5313 this.isVisible()
5314 ) {
5315 this.toggle( false );
5316 e.preventDefault();
5317 e.stopPropagation();
5318 }
5319 };
5320
5321 /**
5322 * Bind document key down listener.
5323 *
5324 * @private
5325 */
5326 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5327 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5328 };
5329
5330 // Deprecated alias since 0.28.3
5331 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5332 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
5333 this.bindDocumentKeyDownListener.apply( this, arguments );
5334 };
5335
5336 /**
5337 * Unbind document key down listener.
5338 *
5339 * @private
5340 */
5341 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5342 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5343 };
5344
5345 // Deprecated alias since 0.28.3
5346 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5347 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
5348 this.unbindDocumentKeyDownListener.apply( this, arguments );
5349 };
5350
5351 /**
5352 * Show, hide, or toggle the visibility of the anchor.
5353 *
5354 * @param {boolean} [show] Show anchor, omit to toggle
5355 */
5356 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5357 show = show === undefined ? !this.anchored : !!show;
5358
5359 if ( this.anchored !== show ) {
5360 if ( show ) {
5361 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5362 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5363 } else {
5364 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5365 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5366 }
5367 this.anchored = show;
5368 }
5369 };
5370
5371 /**
5372 * Change which edge the anchor appears on.
5373 *
5374 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5375 */
5376 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5377 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5378 throw new Error( 'Invalid value for edge: ' + edge );
5379 }
5380 if ( this.anchorEdge !== null ) {
5381 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5382 }
5383 this.anchorEdge = edge;
5384 if ( this.anchored ) {
5385 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5386 }
5387 };
5388
5389 /**
5390 * Check if the anchor is visible.
5391 *
5392 * @return {boolean} Anchor is visible
5393 */
5394 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5395 return this.anchored;
5396 };
5397
5398 /**
5399 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5400 * `.toggle( true )` after its #$element is attached to the DOM.
5401 *
5402 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5403 * it in the right place and with the right dimensions only work correctly while it is attached.
5404 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5405 * strictly enforced, so currently it only generates a warning in the browser console.
5406 *
5407 * @fires ready
5408 * @inheritdoc
5409 */
5410 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5411 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5412 show = show === undefined ? !this.isVisible() : !!show;
5413
5414 change = show !== this.isVisible();
5415
5416 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5417 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5418 this.warnedUnattached = true;
5419 }
5420 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5421 // Fall back to the parent node if the floatableContainer is not set
5422 this.setFloatableContainer( this.$element.parent() );
5423 }
5424
5425 if ( change && show && this.autoFlip ) {
5426 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5427 // (e.g. if the user scrolled).
5428 this.isAutoFlipped = false;
5429 }
5430
5431 // Parent method
5432 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5433
5434 if ( change ) {
5435 this.togglePositioning( show && !!this.$floatableContainer );
5436
5437 if ( show ) {
5438 if ( this.autoClose ) {
5439 this.bindDocumentMouseDownListener();
5440 this.bindDocumentKeyDownListener();
5441 }
5442 this.updateDimensions();
5443 this.toggleClipping( true );
5444
5445 if ( this.autoFlip ) {
5446 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5447 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5448 // If opening the popup in the normal direction causes it to be clipped, open
5449 // in the opposite one instead
5450 normalHeight = this.$element.height();
5451 this.isAutoFlipped = !this.isAutoFlipped;
5452 this.position();
5453 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5454 // If that also causes it to be clipped, open in whichever direction
5455 // we have more space
5456 oppositeHeight = this.$element.height();
5457 if ( oppositeHeight < normalHeight ) {
5458 this.isAutoFlipped = !this.isAutoFlipped;
5459 this.position();
5460 }
5461 }
5462 }
5463 }
5464 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5465 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5466 // If opening the popup in the normal direction causes it to be clipped, open
5467 // in the opposite one instead
5468 normalWidth = this.$element.width();
5469 this.isAutoFlipped = !this.isAutoFlipped;
5470 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5471 // which causes positioning to be off. Toggle clipping back and fort to work around.
5472 this.toggleClipping( false );
5473 this.position();
5474 this.toggleClipping( true );
5475 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5476 // If that also causes it to be clipped, open in whichever direction
5477 // we have more space
5478 oppositeWidth = this.$element.width();
5479 if ( oppositeWidth < normalWidth ) {
5480 this.isAutoFlipped = !this.isAutoFlipped;
5481 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5482 // which causes positioning to be off. Toggle clipping back and fort to work around.
5483 this.toggleClipping( false );
5484 this.position();
5485 this.toggleClipping( true );
5486 }
5487 }
5488 }
5489 }
5490 }
5491
5492 this.emit( 'ready' );
5493 } else {
5494 this.toggleClipping( false );
5495 if ( this.autoClose ) {
5496 this.unbindDocumentMouseDownListener();
5497 this.unbindDocumentKeyDownListener();
5498 }
5499 }
5500 }
5501
5502 return this;
5503 };
5504
5505 /**
5506 * Set the size of the popup.
5507 *
5508 * Changing the size may also change the popup's position depending on the alignment.
5509 *
5510 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5511 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5512 * @param {boolean} [transition=false] Use a smooth transition
5513 * @chainable
5514 */
5515 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5516 this.width = width !== undefined ? width : 320;
5517 this.height = height !== undefined ? height : null;
5518 if ( this.isVisible() ) {
5519 this.updateDimensions( transition );
5520 }
5521 };
5522
5523 /**
5524 * Update the size and position.
5525 *
5526 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5527 * be called automatically.
5528 *
5529 * @param {boolean} [transition=false] Use a smooth transition
5530 * @chainable
5531 */
5532 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5533 var widget = this;
5534
5535 // Prevent transition from being interrupted
5536 clearTimeout( this.transitionTimeout );
5537 if ( transition ) {
5538 // Enable transition
5539 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5540 }
5541
5542 this.position();
5543
5544 if ( transition ) {
5545 // Prevent transitioning after transition is complete
5546 this.transitionTimeout = setTimeout( function () {
5547 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5548 }, 200 );
5549 } else {
5550 // Prevent transitioning immediately
5551 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5552 }
5553 };
5554
5555 /**
5556 * @inheritdoc
5557 */
5558 OO.ui.PopupWidget.prototype.computePosition = function () {
5559 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5560 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5561 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5562 popupPos = {},
5563 anchorCss = { left: '', right: '', top: '', bottom: '' },
5564 popupPositionOppositeMap = {
5565 above: 'below',
5566 below: 'above',
5567 before: 'after',
5568 after: 'before'
5569 },
5570 alignMap = {
5571 ltr: {
5572 'force-left': 'backwards',
5573 'force-right': 'forwards'
5574 },
5575 rtl: {
5576 'force-left': 'forwards',
5577 'force-right': 'backwards'
5578 }
5579 },
5580 anchorEdgeMap = {
5581 above: 'bottom',
5582 below: 'top',
5583 before: 'end',
5584 after: 'start'
5585 },
5586 hPosMap = {
5587 forwards: 'start',
5588 center: 'center',
5589 backwards: this.anchored ? 'before' : 'end'
5590 },
5591 vPosMap = {
5592 forwards: 'top',
5593 center: 'center',
5594 backwards: 'bottom'
5595 };
5596
5597 if ( !this.$container ) {
5598 // Lazy-initialize $container if not specified in constructor
5599 this.$container = $( this.getClosestScrollableElementContainer() );
5600 }
5601 direction = this.$container.css( 'direction' );
5602
5603 // Set height and width before we do anything else, since it might cause our measurements
5604 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5605 this.$popup.css( {
5606 width: this.width !== null ? this.width : 'auto',
5607 height: this.height !== null ? this.height : 'auto'
5608 } );
5609
5610 align = alignMap[ direction ][ this.align ] || this.align;
5611 popupPosition = this.popupPosition;
5612 if ( this.isAutoFlipped ) {
5613 popupPosition = popupPositionOppositeMap[ popupPosition ];
5614 }
5615
5616 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5617 vertical = popupPosition === 'before' || popupPosition === 'after';
5618 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5619 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5620 near = vertical ? 'top' : 'left';
5621 far = vertical ? 'bottom' : 'right';
5622 sizeProp = vertical ? 'Height' : 'Width';
5623 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5624
5625 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5626 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5627 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5628
5629 // Parent method
5630 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5631 // Find out which property FloatableElement used for positioning, and adjust that value
5632 positionProp = vertical ?
5633 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5634 ( parentPosition.left !== '' ? 'left' : 'right' );
5635
5636 // Figure out where the near and far edges of the popup and $floatableContainer are
5637 floatablePos = this.$floatableContainer.offset();
5638 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5639 // Measure where the offsetParent is and compute our position based on that and parentPosition
5640 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5641 { top: 0, left: 0 } :
5642 this.$element.offsetParent().offset();
5643
5644 if ( positionProp === near ) {
5645 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5646 popupPos[ far ] = popupPos[ near ] + popupSize;
5647 } else {
5648 popupPos[ far ] = offsetParentPos[ near ] +
5649 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5650 popupPos[ near ] = popupPos[ far ] - popupSize;
5651 }
5652
5653 if ( this.anchored ) {
5654 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5655 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5656 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5657
5658 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5659 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5660 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5661 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5662 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5663 // Not enough space for the anchor on the start side; pull the popup startwards
5664 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5665 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5666 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5667 // Not enough space for the anchor on the end side; pull the popup endwards
5668 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5669 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5670 } else {
5671 positionAdjustment = 0;
5672 }
5673 } else {
5674 positionAdjustment = 0;
5675 }
5676
5677 // Check if the popup will go beyond the edge of this.$container
5678 containerPos = this.$container[ 0 ] === document.documentElement ?
5679 { top: 0, left: 0 } :
5680 this.$container.offset();
5681 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5682 if ( this.$container[ 0 ] === document.documentElement ) {
5683 viewportSpacing = OO.ui.getViewportSpacing();
5684 containerPos[ near ] += viewportSpacing[ near ];
5685 containerPos[ far ] -= viewportSpacing[ far ];
5686 }
5687 // Take into account how much the popup will move because of the adjustments we're going to make
5688 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5689 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5690 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5691 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5692 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5693 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5694 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5695 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5696 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5697 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5698 }
5699
5700 if ( this.anchored ) {
5701 // Adjust anchorOffset for positionAdjustment
5702 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5703
5704 // Position the anchor
5705 anchorCss[ start ] = anchorOffset;
5706 this.$anchor.css( anchorCss );
5707 }
5708
5709 // Move the popup if needed
5710 parentPosition[ positionProp ] += positionAdjustment;
5711
5712 return parentPosition;
5713 };
5714
5715 /**
5716 * Set popup alignment
5717 *
5718 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5719 * `backwards` or `forwards`.
5720 */
5721 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5722 // Validate alignment
5723 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5724 this.align = align;
5725 } else {
5726 this.align = 'center';
5727 }
5728 this.position();
5729 };
5730
5731 /**
5732 * Get popup alignment
5733 *
5734 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5735 * `backwards` or `forwards`.
5736 */
5737 OO.ui.PopupWidget.prototype.getAlignment = function () {
5738 return this.align;
5739 };
5740
5741 /**
5742 * Change the positioning of the popup.
5743 *
5744 * @param {string} position 'above', 'below', 'before' or 'after'
5745 */
5746 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5747 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5748 position = 'below';
5749 }
5750 this.popupPosition = position;
5751 this.position();
5752 };
5753
5754 /**
5755 * Get popup positioning.
5756 *
5757 * @return {string} 'above', 'below', 'before' or 'after'
5758 */
5759 OO.ui.PopupWidget.prototype.getPosition = function () {
5760 return this.popupPosition;
5761 };
5762
5763 /**
5764 * Set popup auto-flipping.
5765 *
5766 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5767 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5768 * desired direction to display the popup without clipping
5769 */
5770 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5771 autoFlip = !!autoFlip;
5772
5773 if ( this.autoFlip !== autoFlip ) {
5774 this.autoFlip = autoFlip;
5775 }
5776 };
5777
5778 /**
5779 * Set which elements will not close the popup when clicked.
5780 *
5781 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5782 *
5783 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5784 */
5785 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5786 this.$autoCloseIgnore = $autoCloseIgnore;
5787 };
5788
5789 /**
5790 * Get an ID of the body element, this can be used as the
5791 * `aria-describedby` attribute for an input field.
5792 *
5793 * @return {string} The ID of the body element
5794 */
5795 OO.ui.PopupWidget.prototype.getBodyId = function () {
5796 var id = this.$body.attr( 'id' );
5797 if ( id === undefined ) {
5798 id = OO.ui.generateElementId();
5799 this.$body.attr( 'id', id );
5800 }
5801 return id;
5802 };
5803
5804 /**
5805 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5806 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5807 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5808 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5809 *
5810 * @abstract
5811 * @class
5812 *
5813 * @constructor
5814 * @param {Object} [config] Configuration options
5815 * @cfg {Object} [popup] Configuration to pass to popup
5816 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5817 */
5818 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5819 // Configuration initialization
5820 config = config || {};
5821
5822 // Properties
5823 this.popup = new OO.ui.PopupWidget( $.extend(
5824 {
5825 autoClose: true,
5826 $floatableContainer: this.$element
5827 },
5828 config.popup,
5829 {
5830 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5831 }
5832 ) );
5833 };
5834
5835 /* Methods */
5836
5837 /**
5838 * Get popup.
5839 *
5840 * @return {OO.ui.PopupWidget} Popup widget
5841 */
5842 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5843 return this.popup;
5844 };
5845
5846 /**
5847 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5848 * which is used to display additional information or options.
5849 *
5850 * @example
5851 * // Example of a popup button.
5852 * var popupButton = new OO.ui.PopupButtonWidget( {
5853 * label: 'Popup button with options',
5854 * icon: 'menu',
5855 * popup: {
5856 * $content: $( '<p>Additional options here.</p>' ),
5857 * padded: true,
5858 * align: 'force-left'
5859 * }
5860 * } );
5861 * // Append the button to the DOM.
5862 * $( 'body' ).append( popupButton.$element );
5863 *
5864 * @class
5865 * @extends OO.ui.ButtonWidget
5866 * @mixins OO.ui.mixin.PopupElement
5867 *
5868 * @constructor
5869 * @param {Object} [config] Configuration options
5870 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5871 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5872 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5873 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5874 */
5875 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5876 // Configuration initialization
5877 config = config || {};
5878
5879 // Parent constructor
5880 OO.ui.PopupButtonWidget.parent.call( this, config );
5881
5882 // Mixin constructors
5883 OO.ui.mixin.PopupElement.call( this, config );
5884
5885 // Properties
5886 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5887
5888 // Events
5889 this.connect( this, { click: 'onAction' } );
5890
5891 // Initialization
5892 this.$element
5893 .addClass( 'oo-ui-popupButtonWidget' );
5894 this.popup.$element
5895 .addClass( 'oo-ui-popupButtonWidget-popup' )
5896 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5897 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5898 this.$overlay.append( this.popup.$element );
5899 };
5900
5901 /* Setup */
5902
5903 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5904 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5905
5906 /* Methods */
5907
5908 /**
5909 * Handle the button action being triggered.
5910 *
5911 * @private
5912 */
5913 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5914 this.popup.toggle();
5915 };
5916
5917 /**
5918 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5919 *
5920 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5921 *
5922 * @private
5923 * @abstract
5924 * @class
5925 * @mixins OO.ui.mixin.GroupElement
5926 *
5927 * @constructor
5928 * @param {Object} [config] Configuration options
5929 */
5930 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5931 // Mixin constructors
5932 OO.ui.mixin.GroupElement.call( this, config );
5933 };
5934
5935 /* Setup */
5936
5937 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5938
5939 /* Methods */
5940
5941 /**
5942 * Set the disabled state of the widget.
5943 *
5944 * This will also update the disabled state of child widgets.
5945 *
5946 * @param {boolean} disabled Disable widget
5947 * @chainable
5948 */
5949 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5950 var i, len;
5951
5952 // Parent method
5953 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5954 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5955
5956 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5957 if ( this.items ) {
5958 for ( i = 0, len = this.items.length; i < len; i++ ) {
5959 this.items[ i ].updateDisabled();
5960 }
5961 }
5962
5963 return this;
5964 };
5965
5966 /**
5967 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5968 *
5969 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5970 * allows bidirectional communication.
5971 *
5972 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5973 *
5974 * @private
5975 * @abstract
5976 * @class
5977 *
5978 * @constructor
5979 */
5980 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5981 //
5982 };
5983
5984 /* Methods */
5985
5986 /**
5987 * Check if widget is disabled.
5988 *
5989 * Checks parent if present, making disabled state inheritable.
5990 *
5991 * @return {boolean} Widget is disabled
5992 */
5993 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5994 return this.disabled ||
5995 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5996 };
5997
5998 /**
5999 * Set group element is in.
6000 *
6001 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6002 * @chainable
6003 */
6004 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6005 // Parent method
6006 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6007 OO.ui.Element.prototype.setElementGroup.call( this, group );
6008
6009 // Initialize item disabled states
6010 this.updateDisabled();
6011
6012 return this;
6013 };
6014
6015 /**
6016 * OptionWidgets are special elements that can be selected and configured with data. The
6017 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6018 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6019 * and examples, please see the [OOUI documentation on MediaWiki][1].
6020 *
6021 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6022 *
6023 * @class
6024 * @extends OO.ui.Widget
6025 * @mixins OO.ui.mixin.ItemWidget
6026 * @mixins OO.ui.mixin.LabelElement
6027 * @mixins OO.ui.mixin.FlaggedElement
6028 * @mixins OO.ui.mixin.AccessKeyedElement
6029 *
6030 * @constructor
6031 * @param {Object} [config] Configuration options
6032 */
6033 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6034 // Configuration initialization
6035 config = config || {};
6036
6037 // Parent constructor
6038 OO.ui.OptionWidget.parent.call( this, config );
6039
6040 // Mixin constructors
6041 OO.ui.mixin.ItemWidget.call( this );
6042 OO.ui.mixin.LabelElement.call( this, config );
6043 OO.ui.mixin.FlaggedElement.call( this, config );
6044 OO.ui.mixin.AccessKeyedElement.call( this, config );
6045
6046 // Properties
6047 this.selected = false;
6048 this.highlighted = false;
6049 this.pressed = false;
6050
6051 // Initialization
6052 this.$element
6053 .data( 'oo-ui-optionWidget', this )
6054 // Allow programmatic focussing (and by accesskey), but not tabbing
6055 .attr( 'tabindex', '-1' )
6056 .attr( 'role', 'option' )
6057 .attr( 'aria-selected', 'false' )
6058 .addClass( 'oo-ui-optionWidget' )
6059 .append( this.$label );
6060 };
6061
6062 /* Setup */
6063
6064 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6065 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6066 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6067 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6068 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6069
6070 /* Static Properties */
6071
6072 /**
6073 * Whether this option can be selected. See #setSelected.
6074 *
6075 * @static
6076 * @inheritable
6077 * @property {boolean}
6078 */
6079 OO.ui.OptionWidget.static.selectable = true;
6080
6081 /**
6082 * Whether this option can be highlighted. See #setHighlighted.
6083 *
6084 * @static
6085 * @inheritable
6086 * @property {boolean}
6087 */
6088 OO.ui.OptionWidget.static.highlightable = true;
6089
6090 /**
6091 * Whether this option can be pressed. See #setPressed.
6092 *
6093 * @static
6094 * @inheritable
6095 * @property {boolean}
6096 */
6097 OO.ui.OptionWidget.static.pressable = true;
6098
6099 /**
6100 * Whether this option will be scrolled into view when it is selected.
6101 *
6102 * @static
6103 * @inheritable
6104 * @property {boolean}
6105 */
6106 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6107
6108 /* Methods */
6109
6110 /**
6111 * Check if the option can be selected.
6112 *
6113 * @return {boolean} Item is selectable
6114 */
6115 OO.ui.OptionWidget.prototype.isSelectable = function () {
6116 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6117 };
6118
6119 /**
6120 * Check if the option can be highlighted. A highlight indicates that the option
6121 * may be selected when a user presses enter or clicks. Disabled items cannot
6122 * be highlighted.
6123 *
6124 * @return {boolean} Item is highlightable
6125 */
6126 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6127 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6128 };
6129
6130 /**
6131 * Check if the option can be pressed. The pressed state occurs when a user mouses
6132 * down on an item, but has not yet let go of the mouse.
6133 *
6134 * @return {boolean} Item is pressable
6135 */
6136 OO.ui.OptionWidget.prototype.isPressable = function () {
6137 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6138 };
6139
6140 /**
6141 * Check if the option is selected.
6142 *
6143 * @return {boolean} Item is selected
6144 */
6145 OO.ui.OptionWidget.prototype.isSelected = function () {
6146 return this.selected;
6147 };
6148
6149 /**
6150 * Check if the option is highlighted. A highlight indicates that the
6151 * item may be selected when a user presses enter or clicks.
6152 *
6153 * @return {boolean} Item is highlighted
6154 */
6155 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6156 return this.highlighted;
6157 };
6158
6159 /**
6160 * Check if the option is pressed. The pressed state occurs when a user mouses
6161 * down on an item, but has not yet let go of the mouse. The item may appear
6162 * selected, but it will not be selected until the user releases the mouse.
6163 *
6164 * @return {boolean} Item is pressed
6165 */
6166 OO.ui.OptionWidget.prototype.isPressed = function () {
6167 return this.pressed;
6168 };
6169
6170 /**
6171 * Set the option’s selected state. In general, all modifications to the selection
6172 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6173 * method instead of this method.
6174 *
6175 * @param {boolean} [state=false] Select option
6176 * @chainable
6177 */
6178 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6179 if ( this.constructor.static.selectable ) {
6180 this.selected = !!state;
6181 this.$element
6182 .toggleClass( 'oo-ui-optionWidget-selected', state )
6183 .attr( 'aria-selected', state.toString() );
6184 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6185 this.scrollElementIntoView();
6186 }
6187 this.updateThemeClasses();
6188 }
6189 return this;
6190 };
6191
6192 /**
6193 * Set the option’s highlighted state. In general, all programmatic
6194 * modifications to the highlight should be handled by the
6195 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6196 * method instead of this method.
6197 *
6198 * @param {boolean} [state=false] Highlight option
6199 * @chainable
6200 */
6201 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6202 if ( this.constructor.static.highlightable ) {
6203 this.highlighted = !!state;
6204 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6205 this.updateThemeClasses();
6206 }
6207 return this;
6208 };
6209
6210 /**
6211 * Set the option’s pressed state. In general, all
6212 * programmatic modifications to the pressed state should be handled by the
6213 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6214 * method instead of this method.
6215 *
6216 * @param {boolean} [state=false] Press option
6217 * @chainable
6218 */
6219 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6220 if ( this.constructor.static.pressable ) {
6221 this.pressed = !!state;
6222 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6223 this.updateThemeClasses();
6224 }
6225 return this;
6226 };
6227
6228 /**
6229 * Get text to match search strings against.
6230 *
6231 * The default implementation returns the label text, but subclasses
6232 * can override this to provide more complex behavior.
6233 *
6234 * @return {string|boolean} String to match search string against
6235 */
6236 OO.ui.OptionWidget.prototype.getMatchText = function () {
6237 var label = this.getLabel();
6238 return typeof label === 'string' ? label : this.$label.text();
6239 };
6240
6241 /**
6242 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6243 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6244 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6245 * menu selects}.
6246 *
6247 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6248 * information, please see the [OOUI documentation on MediaWiki][1].
6249 *
6250 * @example
6251 * // Example of a select widget with three options
6252 * var select = new OO.ui.SelectWidget( {
6253 * items: [
6254 * new OO.ui.OptionWidget( {
6255 * data: 'a',
6256 * label: 'Option One',
6257 * } ),
6258 * new OO.ui.OptionWidget( {
6259 * data: 'b',
6260 * label: 'Option Two',
6261 * } ),
6262 * new OO.ui.OptionWidget( {
6263 * data: 'c',
6264 * label: 'Option Three',
6265 * } )
6266 * ]
6267 * } );
6268 * $( 'body' ).append( select.$element );
6269 *
6270 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6271 *
6272 * @abstract
6273 * @class
6274 * @extends OO.ui.Widget
6275 * @mixins OO.ui.mixin.GroupWidget
6276 *
6277 * @constructor
6278 * @param {Object} [config] Configuration options
6279 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6280 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6281 * the [OOUI documentation on MediaWiki] [2] for examples.
6282 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6283 */
6284 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6285 // Configuration initialization
6286 config = config || {};
6287
6288 // Parent constructor
6289 OO.ui.SelectWidget.parent.call( this, config );
6290
6291 // Mixin constructors
6292 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6293
6294 // Properties
6295 this.pressed = false;
6296 this.selecting = null;
6297 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6298 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6299 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6300 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6301 this.keyPressBuffer = '';
6302 this.keyPressBufferTimer = null;
6303 this.blockMouseOverEvents = 0;
6304
6305 // Events
6306 this.connect( this, {
6307 toggle: 'onToggle'
6308 } );
6309 this.$element.on( {
6310 focusin: this.onFocus.bind( this ),
6311 mousedown: this.onMouseDown.bind( this ),
6312 mouseover: this.onMouseOver.bind( this ),
6313 mouseleave: this.onMouseLeave.bind( this )
6314 } );
6315
6316 // Initialization
6317 this.$element
6318 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6319 .attr( 'role', 'listbox' );
6320 this.setFocusOwner( this.$element );
6321 if ( Array.isArray( config.items ) ) {
6322 this.addItems( config.items );
6323 }
6324 };
6325
6326 /* Setup */
6327
6328 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6329 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6330
6331 /* Events */
6332
6333 /**
6334 * @event highlight
6335 *
6336 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6337 *
6338 * @param {OO.ui.OptionWidget|null} item Highlighted item
6339 */
6340
6341 /**
6342 * @event press
6343 *
6344 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6345 * pressed state of an option.
6346 *
6347 * @param {OO.ui.OptionWidget|null} item Pressed item
6348 */
6349
6350 /**
6351 * @event select
6352 *
6353 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6354 *
6355 * @param {OO.ui.OptionWidget|null} item Selected item
6356 */
6357
6358 /**
6359 * @event choose
6360 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6361 * @param {OO.ui.OptionWidget} item Chosen item
6362 */
6363
6364 /**
6365 * @event add
6366 *
6367 * An `add` event is emitted when options are added to the select with the #addItems method.
6368 *
6369 * @param {OO.ui.OptionWidget[]} items Added items
6370 * @param {number} index Index of insertion point
6371 */
6372
6373 /**
6374 * @event remove
6375 *
6376 * A `remove` event is emitted when options are removed from the select with the #clearItems
6377 * or #removeItems methods.
6378 *
6379 * @param {OO.ui.OptionWidget[]} items Removed items
6380 */
6381
6382 /* Methods */
6383
6384 /**
6385 * Handle focus events
6386 *
6387 * @private
6388 * @param {jQuery.Event} event
6389 */
6390 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6391 var item;
6392 if ( event.target === this.$element[ 0 ] ) {
6393 // This widget was focussed, e.g. by the user tabbing to it.
6394 // The styles for focus state depend on one of the items being selected.
6395 if ( !this.findSelectedItem() ) {
6396 item = this.findFirstSelectableItem();
6397 }
6398 } else {
6399 if ( event.target.tabIndex === -1 ) {
6400 // One of the options got focussed (and the event bubbled up here).
6401 // They can't be tabbed to, but they can be activated using accesskeys.
6402 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6403 item = this.findTargetItem( event );
6404 } else {
6405 // There is something actually user-focusable in one of the labels of the options, and the
6406 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6407 return;
6408 }
6409 }
6410
6411 if ( item ) {
6412 if ( item.constructor.static.highlightable ) {
6413 this.highlightItem( item );
6414 } else {
6415 this.selectItem( item );
6416 }
6417 }
6418
6419 if ( event.target !== this.$element[ 0 ] ) {
6420 this.$focusOwner.focus();
6421 }
6422 };
6423
6424 /**
6425 * Handle mouse down events.
6426 *
6427 * @private
6428 * @param {jQuery.Event} e Mouse down event
6429 */
6430 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6431 var item;
6432
6433 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6434 this.togglePressed( true );
6435 item = this.findTargetItem( e );
6436 if ( item && item.isSelectable() ) {
6437 this.pressItem( item );
6438 this.selecting = item;
6439 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6440 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6441 }
6442 }
6443 return false;
6444 };
6445
6446 /**
6447 * Handle document mouse up events.
6448 *
6449 * @private
6450 * @param {MouseEvent} e Mouse up event
6451 */
6452 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6453 var item;
6454
6455 this.togglePressed( false );
6456 if ( !this.selecting ) {
6457 item = this.findTargetItem( e );
6458 if ( item && item.isSelectable() ) {
6459 this.selecting = item;
6460 }
6461 }
6462 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6463 this.pressItem( null );
6464 this.chooseItem( this.selecting );
6465 this.selecting = null;
6466 }
6467
6468 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6469 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6470
6471 return false;
6472 };
6473
6474 // Deprecated alias since 0.28.3
6475 OO.ui.SelectWidget.prototype.onMouseUp = function () {
6476 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
6477 this.onDocumentMouseUp.apply( this, arguments );
6478 };
6479
6480 /**
6481 * Handle document mouse move events.
6482 *
6483 * @private
6484 * @param {MouseEvent} e Mouse move event
6485 */
6486 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6487 var item;
6488
6489 if ( !this.isDisabled() && this.pressed ) {
6490 item = this.findTargetItem( e );
6491 if ( item && item !== this.selecting && item.isSelectable() ) {
6492 this.pressItem( item );
6493 this.selecting = item;
6494 }
6495 }
6496 };
6497
6498 // Deprecated alias since 0.28.3
6499 OO.ui.SelectWidget.prototype.onMouseMove = function () {
6500 OO.ui.warnDeprecation( 'onMouseMove is deprecated, use onDocumentMouseMove instead' );
6501 this.onDocumentMouseMove.apply( this, arguments );
6502 };
6503
6504 /**
6505 * Handle mouse over events.
6506 *
6507 * @private
6508 * @param {jQuery.Event} e Mouse over event
6509 */
6510 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6511 var item;
6512 if ( this.blockMouseOverEvents ) {
6513 return;
6514 }
6515 if ( !this.isDisabled() ) {
6516 item = this.findTargetItem( e );
6517 this.highlightItem( item && item.isHighlightable() ? item : null );
6518 }
6519 return false;
6520 };
6521
6522 /**
6523 * Handle mouse leave events.
6524 *
6525 * @private
6526 * @param {jQuery.Event} e Mouse over event
6527 */
6528 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6529 if ( !this.isDisabled() ) {
6530 this.highlightItem( null );
6531 }
6532 return false;
6533 };
6534
6535 /**
6536 * Handle document key down events.
6537 *
6538 * @protected
6539 * @param {KeyboardEvent} e Key down event
6540 */
6541 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6542 var nextItem,
6543 handled = false,
6544 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6545
6546 if ( !this.isDisabled() && this.isVisible() ) {
6547 switch ( e.keyCode ) {
6548 case OO.ui.Keys.ENTER:
6549 if ( currentItem && currentItem.constructor.static.highlightable ) {
6550 // Was only highlighted, now let's select it. No-op if already selected.
6551 this.chooseItem( currentItem );
6552 handled = true;
6553 }
6554 break;
6555 case OO.ui.Keys.UP:
6556 case OO.ui.Keys.LEFT:
6557 this.clearKeyPressBuffer();
6558 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6559 handled = true;
6560 break;
6561 case OO.ui.Keys.DOWN:
6562 case OO.ui.Keys.RIGHT:
6563 this.clearKeyPressBuffer();
6564 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6565 handled = true;
6566 break;
6567 case OO.ui.Keys.ESCAPE:
6568 case OO.ui.Keys.TAB:
6569 if ( currentItem && currentItem.constructor.static.highlightable ) {
6570 currentItem.setHighlighted( false );
6571 }
6572 this.unbindDocumentKeyDownListener();
6573 this.unbindDocumentKeyPressListener();
6574 // Don't prevent tabbing away / defocusing
6575 handled = false;
6576 break;
6577 }
6578
6579 if ( nextItem ) {
6580 if ( nextItem.constructor.static.highlightable ) {
6581 this.highlightItem( nextItem );
6582 } else {
6583 this.chooseItem( nextItem );
6584 }
6585 this.scrollItemIntoView( nextItem );
6586 }
6587
6588 if ( handled ) {
6589 e.preventDefault();
6590 e.stopPropagation();
6591 }
6592 }
6593 };
6594
6595 // Deprecated alias since 0.28.3
6596 OO.ui.SelectWidget.prototype.onKeyDown = function () {
6597 OO.ui.warnDeprecation( 'onKeyDown is deprecated, use onDocumentKeyDown instead' );
6598 this.onDocumentKeyDown.apply( this, arguments );
6599 };
6600
6601 /**
6602 * Bind document key down listener.
6603 *
6604 * @protected
6605 */
6606 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6607 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6608 };
6609
6610 // Deprecated alias since 0.28.3
6611 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6612 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
6613 this.bindDocumentKeyDownListener.apply( this, arguments );
6614 };
6615
6616 /**
6617 * Unbind document key down listener.
6618 *
6619 * @protected
6620 */
6621 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6622 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6623 };
6624
6625 // Deprecated alias since 0.28.3
6626 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6627 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
6628 this.unbindDocumentKeyDownListener.apply( this, arguments );
6629 };
6630
6631 /**
6632 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6633 *
6634 * @param {OO.ui.OptionWidget} item Item to scroll into view
6635 */
6636 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6637 var widget = this;
6638 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6639 // and around 100-150 ms after it is finished.
6640 this.blockMouseOverEvents++;
6641 item.scrollElementIntoView().done( function () {
6642 setTimeout( function () {
6643 widget.blockMouseOverEvents--;
6644 }, 200 );
6645 } );
6646 };
6647
6648 /**
6649 * Clear the key-press buffer
6650 *
6651 * @protected
6652 */
6653 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6654 if ( this.keyPressBufferTimer ) {
6655 clearTimeout( this.keyPressBufferTimer );
6656 this.keyPressBufferTimer = null;
6657 }
6658 this.keyPressBuffer = '';
6659 };
6660
6661 /**
6662 * Handle key press events.
6663 *
6664 * @protected
6665 * @param {KeyboardEvent} e Key press event
6666 */
6667 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6668 var c, filter, item;
6669
6670 if ( !e.charCode ) {
6671 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6672 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6673 return false;
6674 }
6675 return;
6676 }
6677 if ( String.fromCodePoint ) {
6678 c = String.fromCodePoint( e.charCode );
6679 } else {
6680 c = String.fromCharCode( e.charCode );
6681 }
6682
6683 if ( this.keyPressBufferTimer ) {
6684 clearTimeout( this.keyPressBufferTimer );
6685 }
6686 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6687
6688 item = this.findHighlightedItem() || this.findSelectedItem();
6689
6690 if ( this.keyPressBuffer === c ) {
6691 // Common (if weird) special case: typing "xxxx" will cycle through all
6692 // the items beginning with "x".
6693 if ( item ) {
6694 item = this.findRelativeSelectableItem( item, 1 );
6695 }
6696 } else {
6697 this.keyPressBuffer += c;
6698 }
6699
6700 filter = this.getItemMatcher( this.keyPressBuffer, false );
6701 if ( !item || !filter( item ) ) {
6702 item = this.findRelativeSelectableItem( item, 1, filter );
6703 }
6704 if ( item ) {
6705 if ( this.isVisible() && item.constructor.static.highlightable ) {
6706 this.highlightItem( item );
6707 } else {
6708 this.chooseItem( item );
6709 }
6710 this.scrollItemIntoView( item );
6711 }
6712
6713 e.preventDefault();
6714 e.stopPropagation();
6715 };
6716
6717 // Deprecated alias since 0.28.3
6718 OO.ui.SelectWidget.prototype.onKeyPress = function () {
6719 OO.ui.warnDeprecation( 'onKeyPress is deprecated, use onDocumentKeyPress instead' );
6720 this.onDocumentKeyPress.apply( this, arguments );
6721 };
6722
6723 /**
6724 * Get a matcher for the specific string
6725 *
6726 * @protected
6727 * @param {string} s String to match against items
6728 * @param {boolean} [exact=false] Only accept exact matches
6729 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6730 */
6731 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6732 var re;
6733
6734 if ( s.normalize ) {
6735 s = s.normalize();
6736 }
6737 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6738 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6739 if ( exact ) {
6740 re += '\\s*$';
6741 }
6742 re = new RegExp( re, 'i' );
6743 return function ( item ) {
6744 var matchText = item.getMatchText();
6745 if ( matchText.normalize ) {
6746 matchText = matchText.normalize();
6747 }
6748 return re.test( matchText );
6749 };
6750 };
6751
6752 /**
6753 * Bind document key press listener.
6754 *
6755 * @protected
6756 */
6757 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6758 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6759 };
6760
6761 // Deprecated alias since 0.28.3
6762 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6763 OO.ui.warnDeprecation( 'bindKeyPressListener is deprecated, use bindDocumentKeyPressListener instead' );
6764 this.bindDocumentKeyPressListener.apply( this, arguments );
6765 };
6766
6767 /**
6768 * Unbind document key down listener.
6769 *
6770 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6771 * implementation.
6772 *
6773 * @protected
6774 */
6775 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6776 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6777 this.clearKeyPressBuffer();
6778 };
6779
6780 // Deprecated alias since 0.28.3
6781 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6782 OO.ui.warnDeprecation( 'unbindDocumentKeyPressListener is deprecated, use unbindDocumentKeyPressListener instead' );
6783 this.unbindDocumentKeyPressListener.apply( this, arguments );
6784 };
6785
6786 /**
6787 * Visibility change handler
6788 *
6789 * @protected
6790 * @param {boolean} visible
6791 */
6792 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6793 if ( !visible ) {
6794 this.clearKeyPressBuffer();
6795 }
6796 };
6797
6798 /**
6799 * Get the closest item to a jQuery.Event.
6800 *
6801 * @private
6802 * @param {jQuery.Event} e
6803 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6804 */
6805 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6806 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6807 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6808 return null;
6809 }
6810 return $option.data( 'oo-ui-optionWidget' ) || null;
6811 };
6812
6813 /**
6814 * Find selected item.
6815 *
6816 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6817 */
6818 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6819 var i, len;
6820
6821 for ( i = 0, len = this.items.length; i < len; i++ ) {
6822 if ( this.items[ i ].isSelected() ) {
6823 return this.items[ i ];
6824 }
6825 }
6826 return null;
6827 };
6828
6829 /**
6830 * Find highlighted item.
6831 *
6832 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6833 */
6834 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6835 var i, len;
6836
6837 for ( i = 0, len = this.items.length; i < len; i++ ) {
6838 if ( this.items[ i ].isHighlighted() ) {
6839 return this.items[ i ];
6840 }
6841 }
6842 return null;
6843 };
6844
6845 /**
6846 * Toggle pressed state.
6847 *
6848 * Press is a state that occurs when a user mouses down on an item, but
6849 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6850 * until the user releases the mouse.
6851 *
6852 * @param {boolean} pressed An option is being pressed
6853 */
6854 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6855 if ( pressed === undefined ) {
6856 pressed = !this.pressed;
6857 }
6858 if ( pressed !== this.pressed ) {
6859 this.$element
6860 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6861 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6862 this.pressed = pressed;
6863 }
6864 };
6865
6866 /**
6867 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6868 * and any existing highlight will be removed. The highlight is mutually exclusive.
6869 *
6870 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6871 * @fires highlight
6872 * @chainable
6873 */
6874 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6875 var i, len, highlighted,
6876 changed = false;
6877
6878 for ( i = 0, len = this.items.length; i < len; i++ ) {
6879 highlighted = this.items[ i ] === item;
6880 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6881 this.items[ i ].setHighlighted( highlighted );
6882 changed = true;
6883 }
6884 }
6885 if ( changed ) {
6886 if ( item ) {
6887 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6888 } else {
6889 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6890 }
6891 this.emit( 'highlight', item );
6892 }
6893
6894 return this;
6895 };
6896
6897 /**
6898 * Fetch an item by its label.
6899 *
6900 * @param {string} label Label of the item to select.
6901 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6902 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6903 */
6904 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6905 var i, item, found,
6906 len = this.items.length,
6907 filter = this.getItemMatcher( label, true );
6908
6909 for ( i = 0; i < len; i++ ) {
6910 item = this.items[ i ];
6911 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6912 return item;
6913 }
6914 }
6915
6916 if ( prefix ) {
6917 found = null;
6918 filter = this.getItemMatcher( label, false );
6919 for ( i = 0; i < len; i++ ) {
6920 item = this.items[ i ];
6921 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6922 if ( found ) {
6923 return null;
6924 }
6925 found = item;
6926 }
6927 }
6928 if ( found ) {
6929 return found;
6930 }
6931 }
6932
6933 return null;
6934 };
6935
6936 /**
6937 * Programmatically select an option by its label. If the item does not exist,
6938 * all options will be deselected.
6939 *
6940 * @param {string} [label] Label of the item to select.
6941 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6942 * @fires select
6943 * @chainable
6944 */
6945 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6946 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6947 if ( label === undefined || !itemFromLabel ) {
6948 return this.selectItem();
6949 }
6950 return this.selectItem( itemFromLabel );
6951 };
6952
6953 /**
6954 * Programmatically select an option by its data. If the `data` parameter is omitted,
6955 * or if the item does not exist, all options will be deselected.
6956 *
6957 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6958 * @fires select
6959 * @chainable
6960 */
6961 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6962 var itemFromData = this.findItemFromData( data );
6963 if ( data === undefined || !itemFromData ) {
6964 return this.selectItem();
6965 }
6966 return this.selectItem( itemFromData );
6967 };
6968
6969 /**
6970 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6971 * all options will be deselected.
6972 *
6973 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6974 * @fires select
6975 * @chainable
6976 */
6977 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6978 var i, len, selected,
6979 changed = false;
6980
6981 for ( i = 0, len = this.items.length; i < len; i++ ) {
6982 selected = this.items[ i ] === item;
6983 if ( this.items[ i ].isSelected() !== selected ) {
6984 this.items[ i ].setSelected( selected );
6985 changed = true;
6986 }
6987 }
6988 if ( changed ) {
6989 if ( item && !item.constructor.static.highlightable ) {
6990 if ( item ) {
6991 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6992 } else {
6993 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6994 }
6995 }
6996 this.emit( 'select', item );
6997 }
6998
6999 return this;
7000 };
7001
7002 /**
7003 * Press an item.
7004 *
7005 * Press is a state that occurs when a user mouses down on an item, but has not
7006 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7007 * releases the mouse.
7008 *
7009 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7010 * @fires press
7011 * @chainable
7012 */
7013 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7014 var i, len, pressed,
7015 changed = false;
7016
7017 for ( i = 0, len = this.items.length; i < len; i++ ) {
7018 pressed = this.items[ i ] === item;
7019 if ( this.items[ i ].isPressed() !== pressed ) {
7020 this.items[ i ].setPressed( pressed );
7021 changed = true;
7022 }
7023 }
7024 if ( changed ) {
7025 this.emit( 'press', item );
7026 }
7027
7028 return this;
7029 };
7030
7031 /**
7032 * Choose an item.
7033 *
7034 * Note that ‘choose’ should never be modified programmatically. A user can choose
7035 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7036 * use the #selectItem method.
7037 *
7038 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7039 * when users choose an item with the keyboard or mouse.
7040 *
7041 * @param {OO.ui.OptionWidget} item Item to choose
7042 * @fires choose
7043 * @chainable
7044 */
7045 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7046 if ( item ) {
7047 this.selectItem( item );
7048 this.emit( 'choose', item );
7049 }
7050
7051 return this;
7052 };
7053
7054 /**
7055 * Find an option by its position relative to the specified item (or to the start of the option array,
7056 * if item is `null`). The direction in which to search through the option array is specified with a
7057 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
7058 * `null` if there are no options in the array.
7059 *
7060 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
7061 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7062 * @param {Function} [filter] Only consider items for which this function returns
7063 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7064 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7065 */
7066 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7067 var currentIndex, nextIndex, i,
7068 increase = direction > 0 ? 1 : -1,
7069 len = this.items.length;
7070
7071 if ( item instanceof OO.ui.OptionWidget ) {
7072 currentIndex = this.items.indexOf( item );
7073 nextIndex = ( currentIndex + increase + len ) % len;
7074 } else {
7075 // If no item is selected and moving forward, start at the beginning.
7076 // If moving backward, start at the end.
7077 nextIndex = direction > 0 ? 0 : len - 1;
7078 }
7079
7080 for ( i = 0; i < len; i++ ) {
7081 item = this.items[ nextIndex ];
7082 if (
7083 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7084 ( !filter || filter( item ) )
7085 ) {
7086 return item;
7087 }
7088 nextIndex = ( nextIndex + increase + len ) % len;
7089 }
7090 return null;
7091 };
7092
7093 /**
7094 * Find the next selectable item or `null` if there are no selectable items.
7095 * Disabled options and menu-section markers and breaks are not selectable.
7096 *
7097 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7098 */
7099 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7100 return this.findRelativeSelectableItem( null, 1 );
7101 };
7102
7103 /**
7104 * Add an array of options to the select. Optionally, an index number can be used to
7105 * specify an insertion point.
7106 *
7107 * @param {OO.ui.OptionWidget[]} items Items to add
7108 * @param {number} [index] Index to insert items after
7109 * @fires add
7110 * @chainable
7111 */
7112 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7113 // Mixin method
7114 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7115
7116 // Always provide an index, even if it was omitted
7117 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7118
7119 return this;
7120 };
7121
7122 /**
7123 * Remove the specified array of options from the select. Options will be detached
7124 * from the DOM, not removed, so they can be reused later. To remove all options from
7125 * the select, you may wish to use the #clearItems method instead.
7126 *
7127 * @param {OO.ui.OptionWidget[]} items Items to remove
7128 * @fires remove
7129 * @chainable
7130 */
7131 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7132 var i, len, item;
7133
7134 // Deselect items being removed
7135 for ( i = 0, len = items.length; i < len; i++ ) {
7136 item = items[ i ];
7137 if ( item.isSelected() ) {
7138 this.selectItem( null );
7139 }
7140 }
7141
7142 // Mixin method
7143 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7144
7145 this.emit( 'remove', items );
7146
7147 return this;
7148 };
7149
7150 /**
7151 * Clear all options from the select. Options will be detached from the DOM, not removed,
7152 * so that they can be reused later. To remove a subset of options from the select, use
7153 * the #removeItems method.
7154 *
7155 * @fires remove
7156 * @chainable
7157 */
7158 OO.ui.SelectWidget.prototype.clearItems = function () {
7159 var items = this.items.slice();
7160
7161 // Mixin method
7162 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7163
7164 // Clear selection
7165 this.selectItem( null );
7166
7167 this.emit( 'remove', items );
7168
7169 return this;
7170 };
7171
7172 /**
7173 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7174 *
7175 * Currently this is just used to set `aria-activedescendant` on it.
7176 *
7177 * @protected
7178 * @param {jQuery} $focusOwner
7179 */
7180 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7181 this.$focusOwner = $focusOwner;
7182 };
7183
7184 /**
7185 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7186 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7187 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7188 * options. For more information about options and selects, please see the
7189 * [OOUI documentation on MediaWiki][1].
7190 *
7191 * @example
7192 * // Decorated options in a select widget
7193 * var select = new OO.ui.SelectWidget( {
7194 * items: [
7195 * new OO.ui.DecoratedOptionWidget( {
7196 * data: 'a',
7197 * label: 'Option with icon',
7198 * icon: 'help'
7199 * } ),
7200 * new OO.ui.DecoratedOptionWidget( {
7201 * data: 'b',
7202 * label: 'Option with indicator',
7203 * indicator: 'next'
7204 * } )
7205 * ]
7206 * } );
7207 * $( 'body' ).append( select.$element );
7208 *
7209 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7210 *
7211 * @class
7212 * @extends OO.ui.OptionWidget
7213 * @mixins OO.ui.mixin.IconElement
7214 * @mixins OO.ui.mixin.IndicatorElement
7215 *
7216 * @constructor
7217 * @param {Object} [config] Configuration options
7218 */
7219 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7220 // Parent constructor
7221 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7222
7223 // Mixin constructors
7224 OO.ui.mixin.IconElement.call( this, config );
7225 OO.ui.mixin.IndicatorElement.call( this, config );
7226
7227 // Initialization
7228 this.$element
7229 .addClass( 'oo-ui-decoratedOptionWidget' )
7230 .prepend( this.$icon )
7231 .append( this.$indicator );
7232 };
7233
7234 /* Setup */
7235
7236 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7237 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7238 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7239
7240 /**
7241 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7242 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7243 * the [OOUI documentation on MediaWiki] [1] for more information.
7244 *
7245 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7246 *
7247 * @class
7248 * @extends OO.ui.DecoratedOptionWidget
7249 *
7250 * @constructor
7251 * @param {Object} [config] Configuration options
7252 */
7253 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7254 // Parent constructor
7255 OO.ui.MenuOptionWidget.parent.call( this, config );
7256
7257 // Properties
7258 this.checkIcon = new OO.ui.IconWidget( {
7259 icon: 'check',
7260 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7261 } );
7262
7263 // Initialization
7264 this.$element
7265 .prepend( this.checkIcon.$element )
7266 .addClass( 'oo-ui-menuOptionWidget' );
7267 };
7268
7269 /* Setup */
7270
7271 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7272
7273 /* Static Properties */
7274
7275 /**
7276 * @static
7277 * @inheritdoc
7278 */
7279 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7280
7281 /**
7282 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7283 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7284 *
7285 * @example
7286 * var myDropdown = new OO.ui.DropdownWidget( {
7287 * menu: {
7288 * items: [
7289 * new OO.ui.MenuSectionOptionWidget( {
7290 * label: 'Dogs'
7291 * } ),
7292 * new OO.ui.MenuOptionWidget( {
7293 * data: 'corgi',
7294 * label: 'Welsh Corgi'
7295 * } ),
7296 * new OO.ui.MenuOptionWidget( {
7297 * data: 'poodle',
7298 * label: 'Standard Poodle'
7299 * } ),
7300 * new OO.ui.MenuSectionOptionWidget( {
7301 * label: 'Cats'
7302 * } ),
7303 * new OO.ui.MenuOptionWidget( {
7304 * data: 'lion',
7305 * label: 'Lion'
7306 * } )
7307 * ]
7308 * }
7309 * } );
7310 * $( 'body' ).append( myDropdown.$element );
7311 *
7312 * @class
7313 * @extends OO.ui.DecoratedOptionWidget
7314 *
7315 * @constructor
7316 * @param {Object} [config] Configuration options
7317 */
7318 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7319 // Parent constructor
7320 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7321
7322 // Initialization
7323 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7324 .removeAttr( 'role aria-selected' );
7325 };
7326
7327 /* Setup */
7328
7329 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7330
7331 /* Static Properties */
7332
7333 /**
7334 * @static
7335 * @inheritdoc
7336 */
7337 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7338
7339 /**
7340 * @static
7341 * @inheritdoc
7342 */
7343 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7344
7345 /**
7346 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7347 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7348 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7349 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7350 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7351 * and customized to be opened, closed, and displayed as needed.
7352 *
7353 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7354 * mouse outside the menu.
7355 *
7356 * Menus also have support for keyboard interaction:
7357 *
7358 * - Enter/Return key: choose and select a menu option
7359 * - Up-arrow key: highlight the previous menu option
7360 * - Down-arrow key: highlight the next menu option
7361 * - Esc key: hide the menu
7362 *
7363 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7364 *
7365 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7366 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7367 *
7368 * @class
7369 * @extends OO.ui.SelectWidget
7370 * @mixins OO.ui.mixin.ClippableElement
7371 * @mixins OO.ui.mixin.FloatableElement
7372 *
7373 * @constructor
7374 * @param {Object} [config] Configuration options
7375 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7376 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7377 * and {@link OO.ui.mixin.LookupElement LookupElement}
7378 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7379 * the text the user types. This config is used by {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7380 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7381 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7382 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7383 * that button, unless the button (or its parent widget) is passed in here.
7384 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7385 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7386 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7387 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7388 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7389 * @cfg {number} [width] Width of the menu
7390 */
7391 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7392 // Configuration initialization
7393 config = config || {};
7394
7395 // Parent constructor
7396 OO.ui.MenuSelectWidget.parent.call( this, config );
7397
7398 // Mixin constructors
7399 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7400 OO.ui.mixin.FloatableElement.call( this, config );
7401
7402 // Initial vertical positions other than 'center' will result in
7403 // the menu being flipped if there is not enough space in the container.
7404 // Store the original position so we know what to reset to.
7405 this.originalVerticalPosition = this.verticalPosition;
7406
7407 // Properties
7408 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7409 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7410 this.filterFromInput = !!config.filterFromInput;
7411 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7412 this.$widget = config.widget ? config.widget.$element : null;
7413 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7414 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7415 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7416 this.highlightOnFilter = !!config.highlightOnFilter;
7417 this.width = config.width;
7418
7419 // Initialization
7420 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7421 if ( config.widget ) {
7422 this.setFocusOwner( config.widget.$tabIndexed );
7423 }
7424
7425 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7426 // that reference properties not initialized at that time of parent class construction
7427 // TODO: Find a better way to handle post-constructor setup
7428 this.visible = false;
7429 this.$element.addClass( 'oo-ui-element-hidden' );
7430 };
7431
7432 /* Setup */
7433
7434 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7435 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7436 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7437
7438 /* Events */
7439
7440 /**
7441 * @event ready
7442 *
7443 * The menu is ready: it is visible and has been positioned and clipped.
7444 */
7445
7446 /* Static properties */
7447
7448 /**
7449 * Positions to flip to if there isn't room in the container for the
7450 * menu in a specific direction.
7451 *
7452 * @property {Object.<string,string>}
7453 */
7454 OO.ui.MenuSelectWidget.static.flippedPositions = {
7455 below: 'above',
7456 above: 'below',
7457 top: 'bottom',
7458 bottom: 'top'
7459 };
7460
7461 /* Methods */
7462
7463 /**
7464 * Handles document mouse down events.
7465 *
7466 * @protected
7467 * @param {MouseEvent} e Mouse down event
7468 */
7469 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7470 if (
7471 this.isVisible() &&
7472 !OO.ui.contains(
7473 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7474 e.target,
7475 true
7476 )
7477 ) {
7478 this.toggle( false );
7479 }
7480 };
7481
7482 /**
7483 * @inheritdoc
7484 */
7485 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7486 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7487
7488 if ( !this.isDisabled() && this.isVisible() ) {
7489 switch ( e.keyCode ) {
7490 case OO.ui.Keys.LEFT:
7491 case OO.ui.Keys.RIGHT:
7492 // Do nothing if a text field is associated, arrow keys will be handled natively
7493 if ( !this.$input ) {
7494 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7495 }
7496 break;
7497 case OO.ui.Keys.ESCAPE:
7498 case OO.ui.Keys.TAB:
7499 if ( currentItem ) {
7500 currentItem.setHighlighted( false );
7501 }
7502 this.toggle( false );
7503 // Don't prevent tabbing away, prevent defocusing
7504 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7505 e.preventDefault();
7506 e.stopPropagation();
7507 }
7508 break;
7509 default:
7510 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7511 return;
7512 }
7513 }
7514 };
7515
7516 /**
7517 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7518 * or after items were added/removed (always).
7519 *
7520 * @protected
7521 */
7522 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7523 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7524 anyVisible = false,
7525 len = this.items.length,
7526 showAll = !this.isVisible(),
7527 exactMatch = false;
7528
7529 if ( this.$input && this.filterFromInput ) {
7530 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7531 exactFilter = this.getItemMatcher( this.$input.val(), true );
7532 // Hide non-matching options, and also hide section headers if all options
7533 // in their section are hidden.
7534 for ( i = 0; i < len; i++ ) {
7535 item = this.items[ i ];
7536 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7537 if ( section ) {
7538 // If the previous section was empty, hide its header
7539 section.toggle( showAll || !sectionEmpty );
7540 }
7541 section = item;
7542 sectionEmpty = true;
7543 } else if ( item instanceof OO.ui.OptionWidget ) {
7544 visible = showAll || filter( item );
7545 exactMatch = exactMatch || exactFilter( item );
7546 anyVisible = anyVisible || visible;
7547 sectionEmpty = sectionEmpty && !visible;
7548 item.toggle( visible );
7549 }
7550 }
7551 // Process the final section
7552 if ( section ) {
7553 section.toggle( showAll || !sectionEmpty );
7554 }
7555
7556 if ( anyVisible && this.items.length && !exactMatch ) {
7557 this.scrollItemIntoView( this.items[ 0 ] );
7558 }
7559
7560 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7561
7562 if ( this.highlightOnFilter ) {
7563 // Highlight the first item on the list
7564 item = null;
7565 items = this.getItems();
7566 for ( i = 0; i < items.length; i++ ) {
7567 if ( items[ i ].isVisible() ) {
7568 item = items[ i ];
7569 break;
7570 }
7571 }
7572 this.highlightItem( item );
7573 }
7574
7575 }
7576
7577 // Reevaluate clipping
7578 this.clip();
7579 };
7580
7581 /**
7582 * @inheritdoc
7583 */
7584 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7585 if ( this.$input ) {
7586 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7587 } else {
7588 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7589 }
7590 };
7591
7592 /**
7593 * @inheritdoc
7594 */
7595 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7596 if ( this.$input ) {
7597 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7598 } else {
7599 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7600 }
7601 };
7602
7603 /**
7604 * @inheritdoc
7605 */
7606 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7607 if ( this.$input ) {
7608 if ( this.filterFromInput ) {
7609 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7610 this.updateItemVisibility();
7611 }
7612 } else {
7613 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7614 }
7615 };
7616
7617 /**
7618 * @inheritdoc
7619 */
7620 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7621 if ( this.$input ) {
7622 if ( this.filterFromInput ) {
7623 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7624 this.updateItemVisibility();
7625 }
7626 } else {
7627 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7628 }
7629 };
7630
7631 /**
7632 * Choose an item.
7633 *
7634 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7635 *
7636 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7637 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7638 *
7639 * @param {OO.ui.OptionWidget} item Item to choose
7640 * @chainable
7641 */
7642 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7643 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7644 if ( this.hideOnChoose ) {
7645 this.toggle( false );
7646 }
7647 return this;
7648 };
7649
7650 /**
7651 * @inheritdoc
7652 */
7653 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7654 // Parent method
7655 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7656
7657 this.updateItemVisibility();
7658
7659 return this;
7660 };
7661
7662 /**
7663 * @inheritdoc
7664 */
7665 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7666 // Parent method
7667 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7668
7669 this.updateItemVisibility();
7670
7671 return this;
7672 };
7673
7674 /**
7675 * @inheritdoc
7676 */
7677 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7678 // Parent method
7679 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7680
7681 this.updateItemVisibility();
7682
7683 return this;
7684 };
7685
7686 /**
7687 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7688 * `.toggle( true )` after its #$element is attached to the DOM.
7689 *
7690 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7691 * it in the right place and with the right dimensions only work correctly while it is attached.
7692 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7693 * strictly enforced, so currently it only generates a warning in the browser console.
7694 *
7695 * @fires ready
7696 * @inheritdoc
7697 */
7698 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7699 var change, originalHeight, flippedHeight;
7700
7701 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7702 change = visible !== this.isVisible();
7703
7704 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7705 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7706 this.warnedUnattached = true;
7707 }
7708
7709 if ( change && visible ) {
7710 // Reset position before showing the popup again. It's possible we no longer need to flip
7711 // (e.g. if the user scrolled).
7712 this.setVerticalPosition( this.originalVerticalPosition );
7713 }
7714
7715 // Parent method
7716 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7717
7718 if ( change ) {
7719 if ( visible ) {
7720
7721 if ( this.width ) {
7722 this.setIdealSize( this.width );
7723 } else if ( this.$floatableContainer ) {
7724 this.$clippable.css( 'width', 'auto' );
7725 this.setIdealSize(
7726 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7727 // Dropdown is smaller than handle so expand to width
7728 this.$floatableContainer[ 0 ].offsetWidth :
7729 // Dropdown is larger than handle so auto size
7730 'auto'
7731 );
7732 this.$clippable.css( 'width', '' );
7733 }
7734
7735 this.togglePositioning( !!this.$floatableContainer );
7736 this.toggleClipping( true );
7737
7738 this.bindDocumentKeyDownListener();
7739 this.bindDocumentKeyPressListener();
7740
7741 if (
7742 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7743 this.originalVerticalPosition !== 'center'
7744 ) {
7745 // If opening the menu in one direction causes it to be clipped, flip it
7746 originalHeight = this.$element.height();
7747 this.setVerticalPosition(
7748 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7749 );
7750 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7751 // If flipping also causes it to be clipped, open in whichever direction
7752 // we have more space
7753 flippedHeight = this.$element.height();
7754 if ( originalHeight > flippedHeight ) {
7755 this.setVerticalPosition( this.originalVerticalPosition );
7756 }
7757 }
7758 }
7759 // Note that we do not flip the menu's opening direction if the clipping changes
7760 // later (e.g. after the user scrolls), that seems like it would be annoying
7761
7762 this.$focusOwner.attr( 'aria-expanded', 'true' );
7763
7764 if ( this.findSelectedItem() ) {
7765 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7766 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7767 }
7768
7769 // Auto-hide
7770 if ( this.autoHide ) {
7771 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7772 }
7773
7774 this.emit( 'ready' );
7775 } else {
7776 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7777 this.unbindDocumentKeyDownListener();
7778 this.unbindDocumentKeyPressListener();
7779 this.$focusOwner.attr( 'aria-expanded', 'false' );
7780 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7781 this.togglePositioning( false );
7782 this.toggleClipping( false );
7783 }
7784 }
7785
7786 return this;
7787 };
7788
7789 /**
7790 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7791 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7792 * users can interact with it.
7793 *
7794 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7795 * OO.ui.DropdownInputWidget instead.
7796 *
7797 * @example
7798 * // Example: A DropdownWidget with a menu that contains three options
7799 * var dropDown = new OO.ui.DropdownWidget( {
7800 * label: 'Dropdown menu: Select a menu option',
7801 * menu: {
7802 * items: [
7803 * new OO.ui.MenuOptionWidget( {
7804 * data: 'a',
7805 * label: 'First'
7806 * } ),
7807 * new OO.ui.MenuOptionWidget( {
7808 * data: 'b',
7809 * label: 'Second'
7810 * } ),
7811 * new OO.ui.MenuOptionWidget( {
7812 * data: 'c',
7813 * label: 'Third'
7814 * } )
7815 * ]
7816 * }
7817 * } );
7818 *
7819 * $( 'body' ).append( dropDown.$element );
7820 *
7821 * dropDown.getMenu().selectItemByData( 'b' );
7822 *
7823 * dropDown.getMenu().findSelectedItem().getData(); // returns 'b'
7824 *
7825 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7826 *
7827 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7828 *
7829 * @class
7830 * @extends OO.ui.Widget
7831 * @mixins OO.ui.mixin.IconElement
7832 * @mixins OO.ui.mixin.IndicatorElement
7833 * @mixins OO.ui.mixin.LabelElement
7834 * @mixins OO.ui.mixin.TitledElement
7835 * @mixins OO.ui.mixin.TabIndexedElement
7836 *
7837 * @constructor
7838 * @param {Object} [config] Configuration options
7839 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7840 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7841 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7842 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7843 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
7844 */
7845 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7846 // Configuration initialization
7847 config = $.extend( { indicator: 'down' }, config );
7848
7849 // Parent constructor
7850 OO.ui.DropdownWidget.parent.call( this, config );
7851
7852 // Properties (must be set before TabIndexedElement constructor call)
7853 this.$handle = $( '<span>' );
7854 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7855
7856 // Mixin constructors
7857 OO.ui.mixin.IconElement.call( this, config );
7858 OO.ui.mixin.IndicatorElement.call( this, config );
7859 OO.ui.mixin.LabelElement.call( this, config );
7860 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7861 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7862
7863 // Properties
7864 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7865 widget: this,
7866 $floatableContainer: this.$element
7867 }, config.menu ) );
7868
7869 // Events
7870 this.$handle.on( {
7871 click: this.onClick.bind( this ),
7872 keydown: this.onKeyDown.bind( this ),
7873 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7874 keypress: this.menu.onKeyPressHandler,
7875 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7876 } );
7877 this.menu.connect( this, {
7878 select: 'onMenuSelect',
7879 toggle: 'onMenuToggle'
7880 } );
7881
7882 // Initialization
7883 this.$handle
7884 .addClass( 'oo-ui-dropdownWidget-handle' )
7885 .attr( {
7886 role: 'combobox',
7887 'aria-owns': this.menu.getElementId(),
7888 'aria-autocomplete': 'list'
7889 } )
7890 .append( this.$icon, this.$label, this.$indicator );
7891 this.$element
7892 .addClass( 'oo-ui-dropdownWidget' )
7893 .append( this.$handle );
7894 this.$overlay.append( this.menu.$element );
7895 };
7896
7897 /* Setup */
7898
7899 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7900 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7901 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7902 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7903 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7904 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7905
7906 /* Methods */
7907
7908 /**
7909 * Get the menu.
7910 *
7911 * @return {OO.ui.MenuSelectWidget} Menu of widget
7912 */
7913 OO.ui.DropdownWidget.prototype.getMenu = function () {
7914 return this.menu;
7915 };
7916
7917 /**
7918 * Handles menu select events.
7919 *
7920 * @private
7921 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7922 */
7923 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7924 var selectedLabel;
7925
7926 if ( !item ) {
7927 this.setLabel( null );
7928 return;
7929 }
7930
7931 selectedLabel = item.getLabel();
7932
7933 // If the label is a DOM element, clone it, because setLabel will append() it
7934 if ( selectedLabel instanceof jQuery ) {
7935 selectedLabel = selectedLabel.clone();
7936 }
7937
7938 this.setLabel( selectedLabel );
7939 };
7940
7941 /**
7942 * Handle menu toggle events.
7943 *
7944 * @private
7945 * @param {boolean} isVisible Open state of the menu
7946 */
7947 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7948 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7949 this.$handle.attr(
7950 'aria-expanded',
7951 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7952 );
7953 };
7954
7955 /**
7956 * Handle mouse click events.
7957 *
7958 * @private
7959 * @param {jQuery.Event} e Mouse click event
7960 */
7961 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7962 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7963 this.menu.toggle();
7964 }
7965 return false;
7966 };
7967
7968 /**
7969 * Handle key down events.
7970 *
7971 * @private
7972 * @param {jQuery.Event} e Key down event
7973 */
7974 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7975 if (
7976 !this.isDisabled() &&
7977 (
7978 e.which === OO.ui.Keys.ENTER ||
7979 (
7980 e.which === OO.ui.Keys.SPACE &&
7981 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7982 // Space only closes the menu is the user is not typing to search.
7983 this.menu.keyPressBuffer === ''
7984 ) ||
7985 (
7986 !this.menu.isVisible() &&
7987 (
7988 e.which === OO.ui.Keys.UP ||
7989 e.which === OO.ui.Keys.DOWN
7990 )
7991 )
7992 )
7993 ) {
7994 this.menu.toggle();
7995 return false;
7996 }
7997 };
7998
7999 /**
8000 * RadioOptionWidget is an option widget that looks like a radio button.
8001 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8002 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8003 *
8004 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8005 *
8006 * @class
8007 * @extends OO.ui.OptionWidget
8008 *
8009 * @constructor
8010 * @param {Object} [config] Configuration options
8011 */
8012 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8013 // Configuration initialization
8014 config = config || {};
8015
8016 // Properties (must be done before parent constructor which calls #setDisabled)
8017 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8018
8019 // Parent constructor
8020 OO.ui.RadioOptionWidget.parent.call( this, config );
8021
8022 // Initialization
8023 // Remove implicit role, we're handling it ourselves
8024 this.radio.$input.attr( 'role', 'presentation' );
8025 this.$element
8026 .addClass( 'oo-ui-radioOptionWidget' )
8027 .attr( 'role', 'radio' )
8028 .attr( 'aria-checked', 'false' )
8029 .removeAttr( 'aria-selected' )
8030 .prepend( this.radio.$element );
8031 };
8032
8033 /* Setup */
8034
8035 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8036
8037 /* Static Properties */
8038
8039 /**
8040 * @static
8041 * @inheritdoc
8042 */
8043 OO.ui.RadioOptionWidget.static.highlightable = false;
8044
8045 /**
8046 * @static
8047 * @inheritdoc
8048 */
8049 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8050
8051 /**
8052 * @static
8053 * @inheritdoc
8054 */
8055 OO.ui.RadioOptionWidget.static.pressable = false;
8056
8057 /**
8058 * @static
8059 * @inheritdoc
8060 */
8061 OO.ui.RadioOptionWidget.static.tagName = 'label';
8062
8063 /* Methods */
8064
8065 /**
8066 * @inheritdoc
8067 */
8068 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8069 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8070
8071 this.radio.setSelected( state );
8072 this.$element
8073 .attr( 'aria-checked', state.toString() )
8074 .removeAttr( 'aria-selected' );
8075
8076 return this;
8077 };
8078
8079 /**
8080 * @inheritdoc
8081 */
8082 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8083 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8084
8085 this.radio.setDisabled( this.isDisabled() );
8086
8087 return this;
8088 };
8089
8090 /**
8091 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8092 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8093 * an interface for adding, removing and selecting options.
8094 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8095 *
8096 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8097 * OO.ui.RadioSelectInputWidget instead.
8098 *
8099 * @example
8100 * // A RadioSelectWidget with RadioOptions.
8101 * var option1 = new OO.ui.RadioOptionWidget( {
8102 * data: 'a',
8103 * label: 'Selected radio option'
8104 * } );
8105 *
8106 * var option2 = new OO.ui.RadioOptionWidget( {
8107 * data: 'b',
8108 * label: 'Unselected radio option'
8109 * } );
8110 *
8111 * var radioSelect=new OO.ui.RadioSelectWidget( {
8112 * items: [ option1, option2 ]
8113 * } );
8114 *
8115 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8116 * radioSelect.selectItem( option1 );
8117 *
8118 * $( 'body' ).append( radioSelect.$element );
8119 *
8120 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8121
8122 *
8123 * @class
8124 * @extends OO.ui.SelectWidget
8125 * @mixins OO.ui.mixin.TabIndexedElement
8126 *
8127 * @constructor
8128 * @param {Object} [config] Configuration options
8129 */
8130 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8131 // Parent constructor
8132 OO.ui.RadioSelectWidget.parent.call( this, config );
8133
8134 // Mixin constructors
8135 OO.ui.mixin.TabIndexedElement.call( this, config );
8136
8137 // Events
8138 this.$element.on( {
8139 focus: this.bindDocumentKeyDownListener.bind( this ),
8140 blur: this.unbindDocumentKeyDownListener.bind( this )
8141 } );
8142
8143 // Initialization
8144 this.$element
8145 .addClass( 'oo-ui-radioSelectWidget' )
8146 .attr( 'role', 'radiogroup' );
8147 };
8148
8149 /* Setup */
8150
8151 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8152 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8153
8154 /**
8155 * MultioptionWidgets are special elements that can be selected and configured with data. The
8156 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8157 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8158 * and examples, please see the [OOUI documentation on MediaWiki][1].
8159 *
8160 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8161 *
8162 * @class
8163 * @extends OO.ui.Widget
8164 * @mixins OO.ui.mixin.ItemWidget
8165 * @mixins OO.ui.mixin.LabelElement
8166 *
8167 * @constructor
8168 * @param {Object} [config] Configuration options
8169 * @cfg {boolean} [selected=false] Whether the option is initially selected
8170 */
8171 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8172 // Configuration initialization
8173 config = config || {};
8174
8175 // Parent constructor
8176 OO.ui.MultioptionWidget.parent.call( this, config );
8177
8178 // Mixin constructors
8179 OO.ui.mixin.ItemWidget.call( this );
8180 OO.ui.mixin.LabelElement.call( this, config );
8181
8182 // Properties
8183 this.selected = null;
8184
8185 // Initialization
8186 this.$element
8187 .addClass( 'oo-ui-multioptionWidget' )
8188 .append( this.$label );
8189 this.setSelected( config.selected );
8190 };
8191
8192 /* Setup */
8193
8194 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8195 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8196 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8197
8198 /* Events */
8199
8200 /**
8201 * @event change
8202 *
8203 * A change event is emitted when the selected state of the option changes.
8204 *
8205 * @param {boolean} selected Whether the option is now selected
8206 */
8207
8208 /* Methods */
8209
8210 /**
8211 * Check if the option is selected.
8212 *
8213 * @return {boolean} Item is selected
8214 */
8215 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8216 return this.selected;
8217 };
8218
8219 /**
8220 * Set the option’s selected state. In general, all modifications to the selection
8221 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8222 * method instead of this method.
8223 *
8224 * @param {boolean} [state=false] Select option
8225 * @chainable
8226 */
8227 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8228 state = !!state;
8229 if ( this.selected !== state ) {
8230 this.selected = state;
8231 this.emit( 'change', state );
8232 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8233 }
8234 return this;
8235 };
8236
8237 /**
8238 * MultiselectWidget allows selecting multiple options from a list.
8239 *
8240 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8241 *
8242 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8243 *
8244 * @class
8245 * @abstract
8246 * @extends OO.ui.Widget
8247 * @mixins OO.ui.mixin.GroupWidget
8248 *
8249 * @constructor
8250 * @param {Object} [config] Configuration options
8251 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8252 */
8253 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8254 // Parent constructor
8255 OO.ui.MultiselectWidget.parent.call( this, config );
8256
8257 // Configuration initialization
8258 config = config || {};
8259
8260 // Mixin constructors
8261 OO.ui.mixin.GroupWidget.call( this, config );
8262
8263 // Events
8264 this.aggregate( { change: 'select' } );
8265 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8266 // by GroupElement only when items are added/removed
8267 this.connect( this, { select: [ 'emit', 'change' ] } );
8268
8269 // Initialization
8270 if ( config.items ) {
8271 this.addItems( config.items );
8272 }
8273 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8274 this.$element.addClass( 'oo-ui-multiselectWidget' )
8275 .append( this.$group );
8276 };
8277
8278 /* Setup */
8279
8280 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8281 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8282
8283 /* Events */
8284
8285 /**
8286 * @event change
8287 *
8288 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8289 */
8290
8291 /**
8292 * @event select
8293 *
8294 * A select event is emitted when an item is selected or deselected.
8295 */
8296
8297 /* Methods */
8298
8299 /**
8300 * Find options that are selected.
8301 *
8302 * @return {OO.ui.MultioptionWidget[]} Selected options
8303 */
8304 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8305 return this.items.filter( function ( item ) {
8306 return item.isSelected();
8307 } );
8308 };
8309
8310 /**
8311 * Find the data of options that are selected.
8312 *
8313 * @return {Object[]|string[]} Values of selected options
8314 */
8315 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8316 return this.findSelectedItems().map( function ( item ) {
8317 return item.data;
8318 } );
8319 };
8320
8321 /**
8322 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8323 *
8324 * @param {OO.ui.MultioptionWidget[]} items Items to select
8325 * @chainable
8326 */
8327 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8328 this.items.forEach( function ( item ) {
8329 var selected = items.indexOf( item ) !== -1;
8330 item.setSelected( selected );
8331 } );
8332 return this;
8333 };
8334
8335 /**
8336 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8337 *
8338 * @param {Object[]|string[]} datas Values of items to select
8339 * @chainable
8340 */
8341 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8342 var items,
8343 widget = this;
8344 items = datas.map( function ( data ) {
8345 return widget.findItemFromData( data );
8346 } );
8347 this.selectItems( items );
8348 return this;
8349 };
8350
8351 /**
8352 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8353 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8354 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8355 *
8356 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8357 *
8358 * @class
8359 * @extends OO.ui.MultioptionWidget
8360 *
8361 * @constructor
8362 * @param {Object} [config] Configuration options
8363 */
8364 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8365 // Configuration initialization
8366 config = config || {};
8367
8368 // Properties (must be done before parent constructor which calls #setDisabled)
8369 this.checkbox = new OO.ui.CheckboxInputWidget();
8370
8371 // Parent constructor
8372 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8373
8374 // Events
8375 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8376 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8377
8378 // Initialization
8379 this.$element
8380 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8381 .prepend( this.checkbox.$element );
8382 };
8383
8384 /* Setup */
8385
8386 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8387
8388 /* Static Properties */
8389
8390 /**
8391 * @static
8392 * @inheritdoc
8393 */
8394 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8395
8396 /* Methods */
8397
8398 /**
8399 * Handle checkbox selected state change.
8400 *
8401 * @private
8402 */
8403 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8404 this.setSelected( this.checkbox.isSelected() );
8405 };
8406
8407 /**
8408 * @inheritdoc
8409 */
8410 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8411 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8412 this.checkbox.setSelected( state );
8413 return this;
8414 };
8415
8416 /**
8417 * @inheritdoc
8418 */
8419 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8420 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8421 this.checkbox.setDisabled( this.isDisabled() );
8422 return this;
8423 };
8424
8425 /**
8426 * Focus the widget.
8427 */
8428 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8429 this.checkbox.focus();
8430 };
8431
8432 /**
8433 * Handle key down events.
8434 *
8435 * @protected
8436 * @param {jQuery.Event} e
8437 */
8438 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8439 var
8440 element = this.getElementGroup(),
8441 nextItem;
8442
8443 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8444 nextItem = element.getRelativeFocusableItem( this, -1 );
8445 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8446 nextItem = element.getRelativeFocusableItem( this, 1 );
8447 }
8448
8449 if ( nextItem ) {
8450 e.preventDefault();
8451 nextItem.focus();
8452 }
8453 };
8454
8455 /**
8456 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8457 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8458 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8459 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8460 *
8461 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8462 * OO.ui.CheckboxMultiselectInputWidget instead.
8463 *
8464 * @example
8465 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8466 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8467 * data: 'a',
8468 * selected: true,
8469 * label: 'Selected checkbox'
8470 * } );
8471 *
8472 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8473 * data: 'b',
8474 * label: 'Unselected checkbox'
8475 * } );
8476 *
8477 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8478 * items: [ option1, option2 ]
8479 * } );
8480 *
8481 * $( 'body' ).append( multiselect.$element );
8482 *
8483 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8484 *
8485 * @class
8486 * @extends OO.ui.MultiselectWidget
8487 *
8488 * @constructor
8489 * @param {Object} [config] Configuration options
8490 */
8491 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8492 // Parent constructor
8493 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8494
8495 // Properties
8496 this.$lastClicked = null;
8497
8498 // Events
8499 this.$group.on( 'click', this.onClick.bind( this ) );
8500
8501 // Initialization
8502 this.$element
8503 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8504 };
8505
8506 /* Setup */
8507
8508 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8509
8510 /* Methods */
8511
8512 /**
8513 * Get an option by its position relative to the specified item (or to the start of the option array,
8514 * if item is `null`). The direction in which to search through the option array is specified with a
8515 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8516 * `null` if there are no options in the array.
8517 *
8518 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8519 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8520 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8521 */
8522 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8523 var currentIndex, nextIndex, i,
8524 increase = direction > 0 ? 1 : -1,
8525 len = this.items.length;
8526
8527 if ( item ) {
8528 currentIndex = this.items.indexOf( item );
8529 nextIndex = ( currentIndex + increase + len ) % len;
8530 } else {
8531 // If no item is selected and moving forward, start at the beginning.
8532 // If moving backward, start at the end.
8533 nextIndex = direction > 0 ? 0 : len - 1;
8534 }
8535
8536 for ( i = 0; i < len; i++ ) {
8537 item = this.items[ nextIndex ];
8538 if ( item && !item.isDisabled() ) {
8539 return item;
8540 }
8541 nextIndex = ( nextIndex + increase + len ) % len;
8542 }
8543 return null;
8544 };
8545
8546 /**
8547 * Handle click events on checkboxes.
8548 *
8549 * @param {jQuery.Event} e
8550 */
8551 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8552 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8553 $lastClicked = this.$lastClicked,
8554 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8555 .not( '.oo-ui-widget-disabled' );
8556
8557 // Allow selecting multiple options at once by Shift-clicking them
8558 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8559 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8560 lastClickedIndex = $options.index( $lastClicked );
8561 nowClickedIndex = $options.index( $nowClicked );
8562 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8563 // browser. In either case we don't need custom handling.
8564 if ( nowClickedIndex !== lastClickedIndex ) {
8565 items = this.items;
8566 wasSelected = items[ nowClickedIndex ].isSelected();
8567 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8568
8569 // This depends on the DOM order of the items and the order of the .items array being the same.
8570 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8571 if ( !items[ i ].isDisabled() ) {
8572 items[ i ].setSelected( !wasSelected );
8573 }
8574 }
8575 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8576 // handling first, then set our value. The order in which events happen is different for
8577 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8578 // non-click actions that change the checkboxes.
8579 e.preventDefault();
8580 setTimeout( function () {
8581 if ( !items[ nowClickedIndex ].isDisabled() ) {
8582 items[ nowClickedIndex ].setSelected( !wasSelected );
8583 }
8584 } );
8585 }
8586 }
8587
8588 if ( $nowClicked.length ) {
8589 this.$lastClicked = $nowClicked;
8590 }
8591 };
8592
8593 /**
8594 * Focus the widget
8595 *
8596 * @chainable
8597 */
8598 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8599 var item;
8600 if ( !this.isDisabled() ) {
8601 item = this.getRelativeFocusableItem( null, 1 );
8602 if ( item ) {
8603 item.focus();
8604 }
8605 }
8606 return this;
8607 };
8608
8609 /**
8610 * @inheritdoc
8611 */
8612 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8613 this.focus();
8614 };
8615
8616 /**
8617 * Progress bars visually display the status of an operation, such as a download,
8618 * and can be either determinate or indeterminate:
8619 *
8620 * - **determinate** process bars show the percent of an operation that is complete.
8621 *
8622 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8623 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8624 * not use percentages.
8625 *
8626 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8627 *
8628 * @example
8629 * // Examples of determinate and indeterminate progress bars.
8630 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8631 * progress: 33
8632 * } );
8633 * var progressBar2 = new OO.ui.ProgressBarWidget();
8634 *
8635 * // Create a FieldsetLayout to layout progress bars
8636 * var fieldset = new OO.ui.FieldsetLayout;
8637 * fieldset.addItems( [
8638 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8639 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8640 * ] );
8641 * $( 'body' ).append( fieldset.$element );
8642 *
8643 * @class
8644 * @extends OO.ui.Widget
8645 *
8646 * @constructor
8647 * @param {Object} [config] Configuration options
8648 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8649 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8650 * By default, the progress bar is indeterminate.
8651 */
8652 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8653 // Configuration initialization
8654 config = config || {};
8655
8656 // Parent constructor
8657 OO.ui.ProgressBarWidget.parent.call( this, config );
8658
8659 // Properties
8660 this.$bar = $( '<div>' );
8661 this.progress = null;
8662
8663 // Initialization
8664 this.setProgress( config.progress !== undefined ? config.progress : false );
8665 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8666 this.$element
8667 .attr( {
8668 role: 'progressbar',
8669 'aria-valuemin': 0,
8670 'aria-valuemax': 100
8671 } )
8672 .addClass( 'oo-ui-progressBarWidget' )
8673 .append( this.$bar );
8674 };
8675
8676 /* Setup */
8677
8678 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8679
8680 /* Static Properties */
8681
8682 /**
8683 * @static
8684 * @inheritdoc
8685 */
8686 OO.ui.ProgressBarWidget.static.tagName = 'div';
8687
8688 /* Methods */
8689
8690 /**
8691 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8692 *
8693 * @return {number|boolean} Progress percent
8694 */
8695 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8696 return this.progress;
8697 };
8698
8699 /**
8700 * Set the percent of the process completed or `false` for an indeterminate process.
8701 *
8702 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8703 */
8704 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8705 this.progress = progress;
8706
8707 if ( progress !== false ) {
8708 this.$bar.css( 'width', this.progress + '%' );
8709 this.$element.attr( 'aria-valuenow', this.progress );
8710 } else {
8711 this.$bar.css( 'width', '' );
8712 this.$element.removeAttr( 'aria-valuenow' );
8713 }
8714 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8715 };
8716
8717 /**
8718 * InputWidget is the base class for all input widgets, which
8719 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8720 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8721 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8722 *
8723 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8724 *
8725 * @abstract
8726 * @class
8727 * @extends OO.ui.Widget
8728 * @mixins OO.ui.mixin.FlaggedElement
8729 * @mixins OO.ui.mixin.TabIndexedElement
8730 * @mixins OO.ui.mixin.TitledElement
8731 * @mixins OO.ui.mixin.AccessKeyedElement
8732 *
8733 * @constructor
8734 * @param {Object} [config] Configuration options
8735 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8736 * @cfg {string} [value=''] The value of the input.
8737 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8738 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8739 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8740 * before it is accepted.
8741 */
8742 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8743 // Configuration initialization
8744 config = config || {};
8745
8746 // Parent constructor
8747 OO.ui.InputWidget.parent.call( this, config );
8748
8749 // Properties
8750 // See #reusePreInfuseDOM about config.$input
8751 this.$input = config.$input || this.getInputElement( config );
8752 this.value = '';
8753 this.inputFilter = config.inputFilter;
8754
8755 // Mixin constructors
8756 OO.ui.mixin.FlaggedElement.call( this, config );
8757 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8758 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8759 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8760
8761 // Events
8762 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8763
8764 // Initialization
8765 this.$input
8766 .addClass( 'oo-ui-inputWidget-input' )
8767 .attr( 'name', config.name )
8768 .prop( 'disabled', this.isDisabled() );
8769 this.$element
8770 .addClass( 'oo-ui-inputWidget' )
8771 .append( this.$input );
8772 this.setValue( config.value );
8773 if ( config.dir ) {
8774 this.setDir( config.dir );
8775 }
8776 if ( config.inputId !== undefined ) {
8777 this.setInputId( config.inputId );
8778 }
8779 };
8780
8781 /* Setup */
8782
8783 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8784 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8785 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8786 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8787 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8788
8789 /* Static Methods */
8790
8791 /**
8792 * @inheritdoc
8793 */
8794 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8795 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8796 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8797 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8798 return config;
8799 };
8800
8801 /**
8802 * @inheritdoc
8803 */
8804 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8805 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8806 if ( config.$input && config.$input.length ) {
8807 state.value = config.$input.val();
8808 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8809 state.focus = config.$input.is( ':focus' );
8810 }
8811 return state;
8812 };
8813
8814 /* Events */
8815
8816 /**
8817 * @event change
8818 *
8819 * A change event is emitted when the value of the input changes.
8820 *
8821 * @param {string} value
8822 */
8823
8824 /* Methods */
8825
8826 /**
8827 * Get input element.
8828 *
8829 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8830 * different circumstances. The element must have a `value` property (like form elements).
8831 *
8832 * @protected
8833 * @param {Object} config Configuration options
8834 * @return {jQuery} Input element
8835 */
8836 OO.ui.InputWidget.prototype.getInputElement = function () {
8837 return $( '<input>' );
8838 };
8839
8840 /**
8841 * Handle potentially value-changing events.
8842 *
8843 * @private
8844 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8845 */
8846 OO.ui.InputWidget.prototype.onEdit = function () {
8847 var widget = this;
8848 if ( !this.isDisabled() ) {
8849 // Allow the stack to clear so the value will be updated
8850 setTimeout( function () {
8851 widget.setValue( widget.$input.val() );
8852 } );
8853 }
8854 };
8855
8856 /**
8857 * Get the value of the input.
8858 *
8859 * @return {string} Input value
8860 */
8861 OO.ui.InputWidget.prototype.getValue = function () {
8862 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8863 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8864 var value = this.$input.val();
8865 if ( this.value !== value ) {
8866 this.setValue( value );
8867 }
8868 return this.value;
8869 };
8870
8871 /**
8872 * Set the directionality of the input.
8873 *
8874 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8875 * @chainable
8876 */
8877 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8878 this.$input.prop( 'dir', dir );
8879 return this;
8880 };
8881
8882 /**
8883 * Set the value of the input.
8884 *
8885 * @param {string} value New value
8886 * @fires change
8887 * @chainable
8888 */
8889 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8890 value = this.cleanUpValue( value );
8891 // Update the DOM if it has changed. Note that with cleanUpValue, it
8892 // is possible for the DOM value to change without this.value changing.
8893 if ( this.$input.val() !== value ) {
8894 this.$input.val( value );
8895 }
8896 if ( this.value !== value ) {
8897 this.value = value;
8898 this.emit( 'change', this.value );
8899 }
8900 // The first time that the value is set (probably while constructing the widget),
8901 // remember it in defaultValue. This property can be later used to check whether
8902 // the value of the input has been changed since it was created.
8903 if ( this.defaultValue === undefined ) {
8904 this.defaultValue = this.value;
8905 this.$input[ 0 ].defaultValue = this.defaultValue;
8906 }
8907 return this;
8908 };
8909
8910 /**
8911 * Clean up incoming value.
8912 *
8913 * Ensures value is a string, and converts undefined and null to empty string.
8914 *
8915 * @private
8916 * @param {string} value Original value
8917 * @return {string} Cleaned up value
8918 */
8919 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8920 if ( value === undefined || value === null ) {
8921 return '';
8922 } else if ( this.inputFilter ) {
8923 return this.inputFilter( String( value ) );
8924 } else {
8925 return String( value );
8926 }
8927 };
8928
8929 /**
8930 * @inheritdoc
8931 */
8932 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8933 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8934 if ( this.$input ) {
8935 this.$input.prop( 'disabled', this.isDisabled() );
8936 }
8937 return this;
8938 };
8939
8940 /**
8941 * Set the 'id' attribute of the `<input>` element.
8942 *
8943 * @param {string} id
8944 * @chainable
8945 */
8946 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8947 this.$input.attr( 'id', id );
8948 return this;
8949 };
8950
8951 /**
8952 * @inheritdoc
8953 */
8954 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8955 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8956 if ( state.value !== undefined && state.value !== this.getValue() ) {
8957 this.setValue( state.value );
8958 }
8959 if ( state.focus ) {
8960 this.focus();
8961 }
8962 };
8963
8964 /**
8965 * Data widget intended for creating 'hidden'-type inputs.
8966 *
8967 * @class
8968 * @extends OO.ui.Widget
8969 *
8970 * @constructor
8971 * @param {Object} [config] Configuration options
8972 * @cfg {string} [value=''] The value of the input.
8973 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8974 */
8975 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8976 // Configuration initialization
8977 config = $.extend( { value: '', name: '' }, config );
8978
8979 // Parent constructor
8980 OO.ui.HiddenInputWidget.parent.call( this, config );
8981
8982 // Initialization
8983 this.$element.attr( {
8984 type: 'hidden',
8985 value: config.value,
8986 name: config.name
8987 } );
8988 this.$element.removeAttr( 'aria-disabled' );
8989 };
8990
8991 /* Setup */
8992
8993 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8994
8995 /* Static Properties */
8996
8997 /**
8998 * @static
8999 * @inheritdoc
9000 */
9001 OO.ui.HiddenInputWidget.static.tagName = 'input';
9002
9003 /**
9004 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9005 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9006 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9007 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9008 * [OOUI documentation on MediaWiki] [1] for more information.
9009 *
9010 * @example
9011 * // A ButtonInputWidget rendered as an HTML button, the default.
9012 * var button = new OO.ui.ButtonInputWidget( {
9013 * label: 'Input button',
9014 * icon: 'check',
9015 * value: 'check'
9016 * } );
9017 * $( 'body' ).append( button.$element );
9018 *
9019 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9020 *
9021 * @class
9022 * @extends OO.ui.InputWidget
9023 * @mixins OO.ui.mixin.ButtonElement
9024 * @mixins OO.ui.mixin.IconElement
9025 * @mixins OO.ui.mixin.IndicatorElement
9026 * @mixins OO.ui.mixin.LabelElement
9027 * @mixins OO.ui.mixin.TitledElement
9028 *
9029 * @constructor
9030 * @param {Object} [config] Configuration options
9031 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
9032 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9033 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
9034 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
9035 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9036 */
9037 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9038 // Configuration initialization
9039 config = $.extend( { type: 'button', useInputTag: false }, config );
9040
9041 // See InputWidget#reusePreInfuseDOM about config.$input
9042 if ( config.$input ) {
9043 config.$input.empty();
9044 }
9045
9046 // Properties (must be set before parent constructor, which calls #setValue)
9047 this.useInputTag = config.useInputTag;
9048
9049 // Parent constructor
9050 OO.ui.ButtonInputWidget.parent.call( this, config );
9051
9052 // Mixin constructors
9053 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
9054 OO.ui.mixin.IconElement.call( this, config );
9055 OO.ui.mixin.IndicatorElement.call( this, config );
9056 OO.ui.mixin.LabelElement.call( this, config );
9057 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
9058
9059 // Initialization
9060 if ( !config.useInputTag ) {
9061 this.$input.append( this.$icon, this.$label, this.$indicator );
9062 }
9063 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9064 };
9065
9066 /* Setup */
9067
9068 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9069 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9070 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9071 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9072 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9073 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
9074
9075 /* Static Properties */
9076
9077 /**
9078 * @static
9079 * @inheritdoc
9080 */
9081 OO.ui.ButtonInputWidget.static.tagName = 'span';
9082
9083 /* Methods */
9084
9085 /**
9086 * @inheritdoc
9087 * @protected
9088 */
9089 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9090 var type;
9091 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9092 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9093 };
9094
9095 /**
9096 * Set label value.
9097 *
9098 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9099 *
9100 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9101 * text, or `null` for no label
9102 * @chainable
9103 */
9104 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9105 if ( typeof label === 'function' ) {
9106 label = OO.ui.resolveMsg( label );
9107 }
9108
9109 if ( this.useInputTag ) {
9110 // Discard non-plaintext labels
9111 if ( typeof label !== 'string' ) {
9112 label = '';
9113 }
9114
9115 this.$input.val( label );
9116 }
9117
9118 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9119 };
9120
9121 /**
9122 * Set the value of the input.
9123 *
9124 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9125 * they do not support {@link #value values}.
9126 *
9127 * @param {string} value New value
9128 * @chainable
9129 */
9130 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9131 if ( !this.useInputTag ) {
9132 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9133 }
9134 return this;
9135 };
9136
9137 /**
9138 * @inheritdoc
9139 */
9140 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9141 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9142 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9143 return null;
9144 };
9145
9146 /**
9147 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9148 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9149 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9150 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9151 *
9152 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9153 *
9154 * @example
9155 * // An example of selected, unselected, and disabled checkbox inputs
9156 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9157 * value: 'a',
9158 * selected: true
9159 * } );
9160 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9161 * value: 'b'
9162 * } );
9163 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9164 * value:'c',
9165 * disabled: true
9166 * } );
9167 * // Create a fieldset layout with fields for each checkbox.
9168 * var fieldset = new OO.ui.FieldsetLayout( {
9169 * label: 'Checkboxes'
9170 * } );
9171 * fieldset.addItems( [
9172 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9173 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9174 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9175 * ] );
9176 * $( 'body' ).append( fieldset.$element );
9177 *
9178 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9179 *
9180 * @class
9181 * @extends OO.ui.InputWidget
9182 *
9183 * @constructor
9184 * @param {Object} [config] Configuration options
9185 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9186 */
9187 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9188 // Configuration initialization
9189 config = config || {};
9190
9191 // Parent constructor
9192 OO.ui.CheckboxInputWidget.parent.call( this, config );
9193
9194 // Properties
9195 this.checkIcon = new OO.ui.IconWidget( {
9196 icon: 'check',
9197 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9198 } );
9199
9200 // Initialization
9201 this.$element
9202 .addClass( 'oo-ui-checkboxInputWidget' )
9203 // Required for pretty styling in WikimediaUI theme
9204 .append( this.checkIcon.$element );
9205 this.setSelected( config.selected !== undefined ? config.selected : false );
9206 };
9207
9208 /* Setup */
9209
9210 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9211
9212 /* Static Properties */
9213
9214 /**
9215 * @static
9216 * @inheritdoc
9217 */
9218 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9219
9220 /* Static Methods */
9221
9222 /**
9223 * @inheritdoc
9224 */
9225 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9226 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9227 state.checked = config.$input.prop( 'checked' );
9228 return state;
9229 };
9230
9231 /* Methods */
9232
9233 /**
9234 * @inheritdoc
9235 * @protected
9236 */
9237 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9238 return $( '<input>' ).attr( 'type', 'checkbox' );
9239 };
9240
9241 /**
9242 * @inheritdoc
9243 */
9244 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9245 var widget = this;
9246 if ( !this.isDisabled() ) {
9247 // Allow the stack to clear so the value will be updated
9248 setTimeout( function () {
9249 widget.setSelected( widget.$input.prop( 'checked' ) );
9250 } );
9251 }
9252 };
9253
9254 /**
9255 * Set selection state of this checkbox.
9256 *
9257 * @param {boolean} state `true` for selected
9258 * @chainable
9259 */
9260 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9261 state = !!state;
9262 if ( this.selected !== state ) {
9263 this.selected = state;
9264 this.$input.prop( 'checked', this.selected );
9265 this.emit( 'change', this.selected );
9266 }
9267 // The first time that the selection state is set (probably while constructing the widget),
9268 // remember it in defaultSelected. This property can be later used to check whether
9269 // the selection state of the input has been changed since it was created.
9270 if ( this.defaultSelected === undefined ) {
9271 this.defaultSelected = this.selected;
9272 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9273 }
9274 return this;
9275 };
9276
9277 /**
9278 * Check if this checkbox is selected.
9279 *
9280 * @return {boolean} Checkbox is selected
9281 */
9282 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9283 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9284 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9285 var selected = this.$input.prop( 'checked' );
9286 if ( this.selected !== selected ) {
9287 this.setSelected( selected );
9288 }
9289 return this.selected;
9290 };
9291
9292 /**
9293 * @inheritdoc
9294 */
9295 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9296 if ( !this.isDisabled() ) {
9297 this.$input.click();
9298 }
9299 this.focus();
9300 };
9301
9302 /**
9303 * @inheritdoc
9304 */
9305 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9306 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9307 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9308 this.setSelected( state.checked );
9309 }
9310 };
9311
9312 /**
9313 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9314 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9315 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9316 * more information about input widgets.
9317 *
9318 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9319 * are no options. If no `value` configuration option is provided, the first option is selected.
9320 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9321 *
9322 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9323 *
9324 * @example
9325 * // Example: A DropdownInputWidget with three options
9326 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9327 * options: [
9328 * { data: 'a', label: 'First' },
9329 * { data: 'b', label: 'Second'},
9330 * { data: 'c', label: 'Third' }
9331 * ]
9332 * } );
9333 * $( 'body' ).append( dropdownInput.$element );
9334 *
9335 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9336 *
9337 * @class
9338 * @extends OO.ui.InputWidget
9339 *
9340 * @constructor
9341 * @param {Object} [config] Configuration options
9342 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9343 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9344 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9345 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9346 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9347 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9348 */
9349 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9350 // Configuration initialization
9351 config = config || {};
9352
9353 // Properties (must be done before parent constructor which calls #setDisabled)
9354 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9355 {
9356 $overlay: config.$overlay
9357 },
9358 config.dropdown
9359 ) );
9360 // Set up the options before parent constructor, which uses them to validate config.value.
9361 // Use this instead of setOptions() because this.$input is not set up yet.
9362 this.setOptionsData( config.options || [] );
9363
9364 // Parent constructor
9365 OO.ui.DropdownInputWidget.parent.call( this, config );
9366
9367 // Events
9368 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9369
9370 // Initialization
9371 this.$element
9372 .addClass( 'oo-ui-dropdownInputWidget' )
9373 .append( this.dropdownWidget.$element );
9374 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9375 };
9376
9377 /* Setup */
9378
9379 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9380
9381 /* Methods */
9382
9383 /**
9384 * @inheritdoc
9385 * @protected
9386 */
9387 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9388 return $( '<select>' );
9389 };
9390
9391 /**
9392 * Handles menu select events.
9393 *
9394 * @private
9395 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9396 */
9397 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9398 this.setValue( item ? item.getData() : '' );
9399 };
9400
9401 /**
9402 * @inheritdoc
9403 */
9404 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9405 var selected;
9406 value = this.cleanUpValue( value );
9407 // Only allow setting values that are actually present in the dropdown
9408 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9409 this.dropdownWidget.getMenu().findFirstSelectableItem();
9410 this.dropdownWidget.getMenu().selectItem( selected );
9411 value = selected ? selected.getData() : '';
9412 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9413 if ( this.optionsDirty ) {
9414 // We reached this from the constructor or from #setOptions.
9415 // We have to update the <select> element.
9416 this.updateOptionsInterface();
9417 }
9418 return this;
9419 };
9420
9421 /**
9422 * @inheritdoc
9423 */
9424 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9425 this.dropdownWidget.setDisabled( state );
9426 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9427 return this;
9428 };
9429
9430 /**
9431 * Set the options available for this input.
9432 *
9433 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9434 * @chainable
9435 */
9436 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9437 var value = this.getValue();
9438
9439 this.setOptionsData( options );
9440
9441 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9442 // In case the previous value is no longer an available option, select the first valid one.
9443 this.setValue( value );
9444
9445 return this;
9446 };
9447
9448 /**
9449 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9450 *
9451 * This method may be called before the parent constructor, so various properties may not be
9452 * intialized yet.
9453 *
9454 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9455 * @private
9456 */
9457 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9458 var
9459 optionWidgets,
9460 widget = this;
9461
9462 this.optionsDirty = true;
9463
9464 optionWidgets = options.map( function ( opt ) {
9465 var optValue;
9466
9467 if ( opt.optgroup !== undefined ) {
9468 return widget.createMenuSectionOptionWidget( opt.optgroup );
9469 }
9470
9471 optValue = widget.cleanUpValue( opt.data );
9472 return widget.createMenuOptionWidget(
9473 optValue,
9474 opt.label !== undefined ? opt.label : optValue
9475 );
9476
9477 } );
9478
9479 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9480 };
9481
9482 /**
9483 * Create a menu option widget.
9484 *
9485 * @protected
9486 * @param {string} data Item data
9487 * @param {string} label Item label
9488 * @return {OO.ui.MenuOptionWidget} Option widget
9489 */
9490 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9491 return new OO.ui.MenuOptionWidget( {
9492 data: data,
9493 label: label
9494 } );
9495 };
9496
9497 /**
9498 * Create a menu section option widget.
9499 *
9500 * @protected
9501 * @param {string} label Section item label
9502 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9503 */
9504 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9505 return new OO.ui.MenuSectionOptionWidget( {
9506 label: label
9507 } );
9508 };
9509
9510 /**
9511 * Update the user-visible interface to match the internal list of options and value.
9512 *
9513 * This method must only be called after the parent constructor.
9514 *
9515 * @private
9516 */
9517 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9518 var
9519 $optionsContainer = this.$input,
9520 defaultValue = this.defaultValue,
9521 widget = this;
9522
9523 this.$input.empty();
9524
9525 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9526 var $optionNode;
9527
9528 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9529 $optionNode = $( '<option>' )
9530 .attr( 'value', optionWidget.getData() )
9531 .text( optionWidget.getLabel() );
9532
9533 // Remember original selection state. This property can be later used to check whether
9534 // the selection state of the input has been changed since it was created.
9535 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9536
9537 $optionsContainer.append( $optionNode );
9538 } else {
9539 $optionNode = $( '<optgroup>' )
9540 .attr( 'label', optionWidget.getLabel() );
9541 widget.$input.append( $optionNode );
9542 $optionsContainer = $optionNode;
9543 }
9544 } );
9545
9546 this.optionsDirty = false;
9547 };
9548
9549 /**
9550 * @inheritdoc
9551 */
9552 OO.ui.DropdownInputWidget.prototype.focus = function () {
9553 this.dropdownWidget.focus();
9554 return this;
9555 };
9556
9557 /**
9558 * @inheritdoc
9559 */
9560 OO.ui.DropdownInputWidget.prototype.blur = function () {
9561 this.dropdownWidget.blur();
9562 return this;
9563 };
9564
9565 /**
9566 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9567 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9568 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9569 * please see the [OOUI documentation on MediaWiki][1].
9570 *
9571 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9572 *
9573 * @example
9574 * // An example of selected, unselected, and disabled radio inputs
9575 * var radio1 = new OO.ui.RadioInputWidget( {
9576 * value: 'a',
9577 * selected: true
9578 * } );
9579 * var radio2 = new OO.ui.RadioInputWidget( {
9580 * value: 'b'
9581 * } );
9582 * var radio3 = new OO.ui.RadioInputWidget( {
9583 * value: 'c',
9584 * disabled: true
9585 * } );
9586 * // Create a fieldset layout with fields for each radio button.
9587 * var fieldset = new OO.ui.FieldsetLayout( {
9588 * label: 'Radio inputs'
9589 * } );
9590 * fieldset.addItems( [
9591 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9592 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9593 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9594 * ] );
9595 * $( 'body' ).append( fieldset.$element );
9596 *
9597 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9598 *
9599 * @class
9600 * @extends OO.ui.InputWidget
9601 *
9602 * @constructor
9603 * @param {Object} [config] Configuration options
9604 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9605 */
9606 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9607 // Configuration initialization
9608 config = config || {};
9609
9610 // Parent constructor
9611 OO.ui.RadioInputWidget.parent.call( this, config );
9612
9613 // Initialization
9614 this.$element
9615 .addClass( 'oo-ui-radioInputWidget' )
9616 // Required for pretty styling in WikimediaUI theme
9617 .append( $( '<span>' ) );
9618 this.setSelected( config.selected !== undefined ? config.selected : false );
9619 };
9620
9621 /* Setup */
9622
9623 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9624
9625 /* Static Properties */
9626
9627 /**
9628 * @static
9629 * @inheritdoc
9630 */
9631 OO.ui.RadioInputWidget.static.tagName = 'span';
9632
9633 /* Static Methods */
9634
9635 /**
9636 * @inheritdoc
9637 */
9638 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9639 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9640 state.checked = config.$input.prop( 'checked' );
9641 return state;
9642 };
9643
9644 /* Methods */
9645
9646 /**
9647 * @inheritdoc
9648 * @protected
9649 */
9650 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9651 return $( '<input>' ).attr( 'type', 'radio' );
9652 };
9653
9654 /**
9655 * @inheritdoc
9656 */
9657 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9658 // RadioInputWidget doesn't track its state.
9659 };
9660
9661 /**
9662 * Set selection state of this radio button.
9663 *
9664 * @param {boolean} state `true` for selected
9665 * @chainable
9666 */
9667 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9668 // RadioInputWidget doesn't track its state.
9669 this.$input.prop( 'checked', state );
9670 // The first time that the selection state is set (probably while constructing the widget),
9671 // remember it in defaultSelected. This property can be later used to check whether
9672 // the selection state of the input has been changed since it was created.
9673 if ( this.defaultSelected === undefined ) {
9674 this.defaultSelected = state;
9675 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9676 }
9677 return this;
9678 };
9679
9680 /**
9681 * Check if this radio button is selected.
9682 *
9683 * @return {boolean} Radio is selected
9684 */
9685 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9686 return this.$input.prop( 'checked' );
9687 };
9688
9689 /**
9690 * @inheritdoc
9691 */
9692 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9693 if ( !this.isDisabled() ) {
9694 this.$input.click();
9695 }
9696 this.focus();
9697 };
9698
9699 /**
9700 * @inheritdoc
9701 */
9702 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9703 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9704 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9705 this.setSelected( state.checked );
9706 }
9707 };
9708
9709 /**
9710 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9711 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9712 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9713 * more information about input widgets.
9714 *
9715 * This and OO.ui.DropdownInputWidget support the same configuration options.
9716 *
9717 * @example
9718 * // Example: A RadioSelectInputWidget with three options
9719 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9720 * options: [
9721 * { data: 'a', label: 'First' },
9722 * { data: 'b', label: 'Second'},
9723 * { data: 'c', label: 'Third' }
9724 * ]
9725 * } );
9726 * $( 'body' ).append( radioSelectInput.$element );
9727 *
9728 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9729 *
9730 * @class
9731 * @extends OO.ui.InputWidget
9732 *
9733 * @constructor
9734 * @param {Object} [config] Configuration options
9735 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9736 */
9737 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9738 // Configuration initialization
9739 config = config || {};
9740
9741 // Properties (must be done before parent constructor which calls #setDisabled)
9742 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9743 // Set up the options before parent constructor, which uses them to validate config.value.
9744 // Use this instead of setOptions() because this.$input is not set up yet
9745 this.setOptionsData( config.options || [] );
9746
9747 // Parent constructor
9748 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9749
9750 // Events
9751 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9752
9753 // Initialization
9754 this.$element
9755 .addClass( 'oo-ui-radioSelectInputWidget' )
9756 .append( this.radioSelectWidget.$element );
9757 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9758 };
9759
9760 /* Setup */
9761
9762 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9763
9764 /* Static Methods */
9765
9766 /**
9767 * @inheritdoc
9768 */
9769 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9770 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9771 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9772 return state;
9773 };
9774
9775 /**
9776 * @inheritdoc
9777 */
9778 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9779 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9780 // Cannot reuse the `<input type=radio>` set
9781 delete config.$input;
9782 return config;
9783 };
9784
9785 /* Methods */
9786
9787 /**
9788 * @inheritdoc
9789 * @protected
9790 */
9791 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9792 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9793 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9794 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9795 };
9796
9797 /**
9798 * Handles menu select events.
9799 *
9800 * @private
9801 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9802 */
9803 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9804 this.setValue( item.getData() );
9805 };
9806
9807 /**
9808 * @inheritdoc
9809 */
9810 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9811 var selected;
9812 value = this.cleanUpValue( value );
9813 // Only allow setting values that are actually present in the dropdown
9814 selected = this.radioSelectWidget.findItemFromData( value ) ||
9815 this.radioSelectWidget.findFirstSelectableItem();
9816 this.radioSelectWidget.selectItem( selected );
9817 value = selected ? selected.getData() : '';
9818 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9819 return this;
9820 };
9821
9822 /**
9823 * @inheritdoc
9824 */
9825 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9826 this.radioSelectWidget.setDisabled( state );
9827 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9828 return this;
9829 };
9830
9831 /**
9832 * Set the options available for this input.
9833 *
9834 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9835 * @chainable
9836 */
9837 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9838 var value = this.getValue();
9839
9840 this.setOptionsData( options );
9841
9842 // Re-set the value to update the visible interface (RadioSelectWidget).
9843 // In case the previous value is no longer an available option, select the first valid one.
9844 this.setValue( value );
9845
9846 return this;
9847 };
9848
9849 /**
9850 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9851 *
9852 * This method may be called before the parent constructor, so various properties may not be
9853 * intialized yet.
9854 *
9855 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9856 * @private
9857 */
9858 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
9859 var widget = this;
9860
9861 this.radioSelectWidget
9862 .clearItems()
9863 .addItems( options.map( function ( opt ) {
9864 var optValue = widget.cleanUpValue( opt.data );
9865 return new OO.ui.RadioOptionWidget( {
9866 data: optValue,
9867 label: opt.label !== undefined ? opt.label : optValue
9868 } );
9869 } ) );
9870 };
9871
9872 /**
9873 * @inheritdoc
9874 */
9875 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9876 this.radioSelectWidget.focus();
9877 return this;
9878 };
9879
9880 /**
9881 * @inheritdoc
9882 */
9883 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9884 this.radioSelectWidget.blur();
9885 return this;
9886 };
9887
9888 /**
9889 * CheckboxMultiselectInputWidget is a
9890 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9891 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9892 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
9893 * more information about input widgets.
9894 *
9895 * @example
9896 * // Example: A CheckboxMultiselectInputWidget with three options
9897 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9898 * options: [
9899 * { data: 'a', label: 'First' },
9900 * { data: 'b', label: 'Second'},
9901 * { data: 'c', label: 'Third' }
9902 * ]
9903 * } );
9904 * $( 'body' ).append( multiselectInput.$element );
9905 *
9906 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9907 *
9908 * @class
9909 * @extends OO.ui.InputWidget
9910 *
9911 * @constructor
9912 * @param {Object} [config] Configuration options
9913 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9914 */
9915 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9916 // Configuration initialization
9917 config = config || {};
9918
9919 // Properties (must be done before parent constructor which calls #setDisabled)
9920 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9921 // Must be set before the #setOptionsData call below
9922 this.inputName = config.name;
9923 // Set up the options before parent constructor, which uses them to validate config.value.
9924 // Use this instead of setOptions() because this.$input is not set up yet
9925 this.setOptionsData( config.options || [] );
9926
9927 // Parent constructor
9928 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9929
9930 // Events
9931 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
9932
9933 // Initialization
9934 this.$element
9935 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9936 .append( this.checkboxMultiselectWidget.$element );
9937 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9938 this.$input.detach();
9939 };
9940
9941 /* Setup */
9942
9943 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9944
9945 /* Static Methods */
9946
9947 /**
9948 * @inheritdoc
9949 */
9950 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9951 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9952 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9953 .toArray().map( function ( el ) { return el.value; } );
9954 return state;
9955 };
9956
9957 /**
9958 * @inheritdoc
9959 */
9960 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9961 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9962 // Cannot reuse the `<input type=checkbox>` set
9963 delete config.$input;
9964 return config;
9965 };
9966
9967 /* Methods */
9968
9969 /**
9970 * @inheritdoc
9971 * @protected
9972 */
9973 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9974 // Actually unused
9975 return $( '<unused>' );
9976 };
9977
9978 /**
9979 * Handles CheckboxMultiselectWidget select events.
9980 *
9981 * @private
9982 */
9983 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
9984 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
9985 };
9986
9987 /**
9988 * @inheritdoc
9989 */
9990 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9991 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9992 .toArray().map( function ( el ) { return el.value; } );
9993 if ( this.value !== value ) {
9994 this.setValue( value );
9995 }
9996 return this.value;
9997 };
9998
9999 /**
10000 * @inheritdoc
10001 */
10002 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10003 value = this.cleanUpValue( value );
10004 this.checkboxMultiselectWidget.selectItemsByData( value );
10005 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10006 if ( this.optionsDirty ) {
10007 // We reached this from the constructor or from #setOptions.
10008 // We have to update the <select> element.
10009 this.updateOptionsInterface();
10010 }
10011 return this;
10012 };
10013
10014 /**
10015 * Clean up incoming value.
10016 *
10017 * @param {string[]} value Original value
10018 * @return {string[]} Cleaned up value
10019 */
10020 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10021 var i, singleValue,
10022 cleanValue = [];
10023 if ( !Array.isArray( value ) ) {
10024 return cleanValue;
10025 }
10026 for ( i = 0; i < value.length; i++ ) {
10027 singleValue =
10028 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
10029 // Remove options that we don't have here
10030 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10031 continue;
10032 }
10033 cleanValue.push( singleValue );
10034 }
10035 return cleanValue;
10036 };
10037
10038 /**
10039 * @inheritdoc
10040 */
10041 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10042 this.checkboxMultiselectWidget.setDisabled( state );
10043 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10044 return this;
10045 };
10046
10047 /**
10048 * Set the options available for this input.
10049 *
10050 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
10051 * @chainable
10052 */
10053 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10054 var value = this.getValue();
10055
10056 this.setOptionsData( options );
10057
10058 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10059 // This will also get rid of any stale options that we just removed.
10060 this.setValue( value );
10061
10062 return this;
10063 };
10064
10065 /**
10066 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10067 *
10068 * This method may be called before the parent constructor, so various properties may not be
10069 * intialized yet.
10070 *
10071 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10072 * @private
10073 */
10074 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10075 var widget = this;
10076
10077 this.optionsDirty = true;
10078
10079 this.checkboxMultiselectWidget
10080 .clearItems()
10081 .addItems( options.map( function ( opt ) {
10082 var optValue, item, optDisabled;
10083 optValue =
10084 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
10085 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10086 item = new OO.ui.CheckboxMultioptionWidget( {
10087 data: optValue,
10088 label: opt.label !== undefined ? opt.label : optValue,
10089 disabled: optDisabled
10090 } );
10091 // Set the 'name' and 'value' for form submission
10092 item.checkbox.$input.attr( 'name', widget.inputName );
10093 item.checkbox.setValue( optValue );
10094 return item;
10095 } ) );
10096 };
10097
10098 /**
10099 * Update the user-visible interface to match the internal list of options and value.
10100 *
10101 * This method must only be called after the parent constructor.
10102 *
10103 * @private
10104 */
10105 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10106 var defaultValue = this.defaultValue;
10107
10108 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10109 // Remember original selection state. This property can be later used to check whether
10110 // the selection state of the input has been changed since it was created.
10111 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10112 item.checkbox.defaultSelected = isDefault;
10113 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10114 } );
10115
10116 this.optionsDirty = false;
10117 };
10118
10119 /**
10120 * @inheritdoc
10121 */
10122 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10123 this.checkboxMultiselectWidget.focus();
10124 return this;
10125 };
10126
10127 /**
10128 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10129 * size of the field as well as its presentation. In addition, these widgets can be configured
10130 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10131 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10132 * which modifies incoming values rather than validating them.
10133 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10134 *
10135 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10136 *
10137 * @example
10138 * // Example of a text input widget
10139 * var textInput = new OO.ui.TextInputWidget( {
10140 * value: 'Text input'
10141 * } )
10142 * $( 'body' ).append( textInput.$element );
10143 *
10144 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10145 *
10146 * @class
10147 * @extends OO.ui.InputWidget
10148 * @mixins OO.ui.mixin.IconElement
10149 * @mixins OO.ui.mixin.IndicatorElement
10150 * @mixins OO.ui.mixin.PendingElement
10151 * @mixins OO.ui.mixin.LabelElement
10152 *
10153 * @constructor
10154 * @param {Object} [config] Configuration options
10155 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10156 * 'email', 'url' or 'number'.
10157 * @cfg {string} [placeholder] Placeholder text
10158 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10159 * instruct the browser to focus this widget.
10160 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10161 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10162 *
10163 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10164 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10165 * many emojis) count as 2 characters each.
10166 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10167 * the value or placeholder text: `'before'` or `'after'`
10168 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10169 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10170 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10171 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10172 * leaving it up to the browser).
10173 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10174 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10175 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10176 * value for it to be considered valid; when Function, a function receiving the value as parameter
10177 * that must return true, or promise resolving to true, for it to be considered valid.
10178 */
10179 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10180 // Configuration initialization
10181 config = $.extend( {
10182 type: 'text',
10183 labelPosition: 'after'
10184 }, config );
10185
10186 // Parent constructor
10187 OO.ui.TextInputWidget.parent.call( this, config );
10188
10189 // Mixin constructors
10190 OO.ui.mixin.IconElement.call( this, config );
10191 OO.ui.mixin.IndicatorElement.call( this, config );
10192 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10193 OO.ui.mixin.LabelElement.call( this, config );
10194
10195 // Properties
10196 this.type = this.getSaneType( config );
10197 this.readOnly = false;
10198 this.required = false;
10199 this.validate = null;
10200 this.styleHeight = null;
10201 this.scrollWidth = null;
10202
10203 this.setValidation( config.validate );
10204 this.setLabelPosition( config.labelPosition );
10205
10206 // Events
10207 this.$input.on( {
10208 keypress: this.onKeyPress.bind( this ),
10209 blur: this.onBlur.bind( this ),
10210 focus: this.onFocus.bind( this )
10211 } );
10212 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10213 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10214 this.on( 'labelChange', this.updatePosition.bind( this ) );
10215 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10216
10217 // Initialization
10218 this.$element
10219 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10220 .append( this.$icon, this.$indicator );
10221 this.setReadOnly( !!config.readOnly );
10222 this.setRequired( !!config.required );
10223 if ( config.placeholder !== undefined ) {
10224 this.$input.attr( 'placeholder', config.placeholder );
10225 }
10226 if ( config.maxLength !== undefined ) {
10227 this.$input.attr( 'maxlength', config.maxLength );
10228 }
10229 if ( config.autofocus ) {
10230 this.$input.attr( 'autofocus', 'autofocus' );
10231 }
10232 if ( config.autocomplete === false ) {
10233 this.$input.attr( 'autocomplete', 'off' );
10234 // Turning off autocompletion also disables "form caching" when the user navigates to a
10235 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10236 $( window ).on( {
10237 beforeunload: function () {
10238 this.$input.removeAttr( 'autocomplete' );
10239 }.bind( this ),
10240 pageshow: function () {
10241 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10242 // whole page... it shouldn't hurt, though.
10243 this.$input.attr( 'autocomplete', 'off' );
10244 }.bind( this )
10245 } );
10246 }
10247 if ( config.spellcheck !== undefined ) {
10248 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10249 }
10250 if ( this.label ) {
10251 this.isWaitingToBeAttached = true;
10252 this.installParentChangeDetector();
10253 }
10254 };
10255
10256 /* Setup */
10257
10258 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10259 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10260 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10261 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10262 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10263
10264 /* Static Properties */
10265
10266 OO.ui.TextInputWidget.static.validationPatterns = {
10267 'non-empty': /.+/,
10268 integer: /^\d+$/
10269 };
10270
10271 /* Events */
10272
10273 /**
10274 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10275 *
10276 * @event enter
10277 */
10278
10279 /* Methods */
10280
10281 /**
10282 * Handle icon mouse down events.
10283 *
10284 * @private
10285 * @param {jQuery.Event} e Mouse down event
10286 */
10287 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10288 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10289 this.focus();
10290 return false;
10291 }
10292 };
10293
10294 /**
10295 * Handle indicator mouse down events.
10296 *
10297 * @private
10298 * @param {jQuery.Event} e Mouse down event
10299 */
10300 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10301 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10302 this.focus();
10303 return false;
10304 }
10305 };
10306
10307 /**
10308 * Handle key press events.
10309 *
10310 * @private
10311 * @param {jQuery.Event} e Key press event
10312 * @fires enter If enter key is pressed
10313 */
10314 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10315 if ( e.which === OO.ui.Keys.ENTER ) {
10316 this.emit( 'enter', e );
10317 }
10318 };
10319
10320 /**
10321 * Handle blur events.
10322 *
10323 * @private
10324 * @param {jQuery.Event} e Blur event
10325 */
10326 OO.ui.TextInputWidget.prototype.onBlur = function () {
10327 this.setValidityFlag();
10328 };
10329
10330 /**
10331 * Handle focus events.
10332 *
10333 * @private
10334 * @param {jQuery.Event} e Focus event
10335 */
10336 OO.ui.TextInputWidget.prototype.onFocus = function () {
10337 if ( this.isWaitingToBeAttached ) {
10338 // If we've received focus, then we must be attached to the document, and if
10339 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10340 this.onElementAttach();
10341 }
10342 this.setValidityFlag( true );
10343 };
10344
10345 /**
10346 * Handle element attach events.
10347 *
10348 * @private
10349 * @param {jQuery.Event} e Element attach event
10350 */
10351 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10352 this.isWaitingToBeAttached = false;
10353 // Any previously calculated size is now probably invalid if we reattached elsewhere
10354 this.valCache = null;
10355 this.positionLabel();
10356 };
10357
10358 /**
10359 * Handle debounced change events.
10360 *
10361 * @param {string} value
10362 * @private
10363 */
10364 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10365 this.setValidityFlag();
10366 };
10367
10368 /**
10369 * Check if the input is {@link #readOnly read-only}.
10370 *
10371 * @return {boolean}
10372 */
10373 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10374 return this.readOnly;
10375 };
10376
10377 /**
10378 * Set the {@link #readOnly read-only} state of the input.
10379 *
10380 * @param {boolean} state Make input read-only
10381 * @chainable
10382 */
10383 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10384 this.readOnly = !!state;
10385 this.$input.prop( 'readOnly', this.readOnly );
10386 return this;
10387 };
10388
10389 /**
10390 * Check if the input is {@link #required required}.
10391 *
10392 * @return {boolean}
10393 */
10394 OO.ui.TextInputWidget.prototype.isRequired = function () {
10395 return this.required;
10396 };
10397
10398 /**
10399 * Set the {@link #required required} state of the input.
10400 *
10401 * @param {boolean} state Make input required
10402 * @chainable
10403 */
10404 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10405 this.required = !!state;
10406 if ( this.required ) {
10407 this.$input
10408 .prop( 'required', true )
10409 .attr( 'aria-required', 'true' );
10410 if ( this.getIndicator() === null ) {
10411 this.setIndicator( 'required' );
10412 }
10413 } else {
10414 this.$input
10415 .prop( 'required', false )
10416 .removeAttr( 'aria-required' );
10417 if ( this.getIndicator() === 'required' ) {
10418 this.setIndicator( null );
10419 }
10420 }
10421 return this;
10422 };
10423
10424 /**
10425 * Support function for making #onElementAttach work across browsers.
10426 *
10427 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10428 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10429 *
10430 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10431 * first time that the element gets attached to the documented.
10432 */
10433 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10434 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10435 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10436 widget = this;
10437
10438 if ( MutationObserver ) {
10439 // The new way. If only it wasn't so ugly.
10440
10441 if ( this.isElementAttached() ) {
10442 // Widget is attached already, do nothing. This breaks the functionality of this function when
10443 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10444 // would require observation of the whole document, which would hurt performance of other,
10445 // more important code.
10446 return;
10447 }
10448
10449 // Find topmost node in the tree
10450 topmostNode = this.$element[ 0 ];
10451 while ( topmostNode.parentNode ) {
10452 topmostNode = topmostNode.parentNode;
10453 }
10454
10455 // We have no way to detect the $element being attached somewhere without observing the entire
10456 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10457 // parent node of $element, and instead detect when $element is removed from it (and thus
10458 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10459 // doesn't get attached, we end up back here and create the parent.
10460
10461 mutationObserver = new MutationObserver( function ( mutations ) {
10462 var i, j, removedNodes;
10463 for ( i = 0; i < mutations.length; i++ ) {
10464 removedNodes = mutations[ i ].removedNodes;
10465 for ( j = 0; j < removedNodes.length; j++ ) {
10466 if ( removedNodes[ j ] === topmostNode ) {
10467 setTimeout( onRemove, 0 );
10468 return;
10469 }
10470 }
10471 }
10472 } );
10473
10474 onRemove = function () {
10475 // If the node was attached somewhere else, report it
10476 if ( widget.isElementAttached() ) {
10477 widget.onElementAttach();
10478 }
10479 mutationObserver.disconnect();
10480 widget.installParentChangeDetector();
10481 };
10482
10483 // Create a fake parent and observe it
10484 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10485 mutationObserver.observe( fakeParentNode, { childList: true } );
10486 } else {
10487 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10488 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10489 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10490 }
10491 };
10492
10493 /**
10494 * @inheritdoc
10495 * @protected
10496 */
10497 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10498 if ( this.getSaneType( config ) === 'number' ) {
10499 return $( '<input>' )
10500 .attr( 'step', 'any' )
10501 .attr( 'type', 'number' );
10502 } else {
10503 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10504 }
10505 };
10506
10507 /**
10508 * Get sanitized value for 'type' for given config.
10509 *
10510 * @param {Object} config Configuration options
10511 * @return {string|null}
10512 * @protected
10513 */
10514 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10515 var allowedTypes = [
10516 'text',
10517 'password',
10518 'email',
10519 'url',
10520 'number'
10521 ];
10522 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10523 };
10524
10525 /**
10526 * Focus the input and select a specified range within the text.
10527 *
10528 * @param {number} from Select from offset
10529 * @param {number} [to] Select to offset, defaults to from
10530 * @chainable
10531 */
10532 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10533 var isBackwards, start, end,
10534 input = this.$input[ 0 ];
10535
10536 to = to || from;
10537
10538 isBackwards = to < from;
10539 start = isBackwards ? to : from;
10540 end = isBackwards ? from : to;
10541
10542 this.focus();
10543
10544 try {
10545 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10546 } catch ( e ) {
10547 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10548 // Rather than expensively check if the input is attached every time, just check
10549 // if it was the cause of an error being thrown. If not, rethrow the error.
10550 if ( this.getElementDocument().body.contains( input ) ) {
10551 throw e;
10552 }
10553 }
10554 return this;
10555 };
10556
10557 /**
10558 * Get an object describing the current selection range in a directional manner
10559 *
10560 * @return {Object} Object containing 'from' and 'to' offsets
10561 */
10562 OO.ui.TextInputWidget.prototype.getRange = function () {
10563 var input = this.$input[ 0 ],
10564 start = input.selectionStart,
10565 end = input.selectionEnd,
10566 isBackwards = input.selectionDirection === 'backward';
10567
10568 return {
10569 from: isBackwards ? end : start,
10570 to: isBackwards ? start : end
10571 };
10572 };
10573
10574 /**
10575 * Get the length of the text input value.
10576 *
10577 * This could differ from the length of #getValue if the
10578 * value gets filtered
10579 *
10580 * @return {number} Input length
10581 */
10582 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10583 return this.$input[ 0 ].value.length;
10584 };
10585
10586 /**
10587 * Focus the input and select the entire text.
10588 *
10589 * @chainable
10590 */
10591 OO.ui.TextInputWidget.prototype.select = function () {
10592 return this.selectRange( 0, this.getInputLength() );
10593 };
10594
10595 /**
10596 * Focus the input and move the cursor to the start.
10597 *
10598 * @chainable
10599 */
10600 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10601 return this.selectRange( 0 );
10602 };
10603
10604 /**
10605 * Focus the input and move the cursor to the end.
10606 *
10607 * @chainable
10608 */
10609 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10610 return this.selectRange( this.getInputLength() );
10611 };
10612
10613 /**
10614 * Insert new content into the input.
10615 *
10616 * @param {string} content Content to be inserted
10617 * @chainable
10618 */
10619 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10620 var start, end,
10621 range = this.getRange(),
10622 value = this.getValue();
10623
10624 start = Math.min( range.from, range.to );
10625 end = Math.max( range.from, range.to );
10626
10627 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10628 this.selectRange( start + content.length );
10629 return this;
10630 };
10631
10632 /**
10633 * Insert new content either side of a selection.
10634 *
10635 * @param {string} pre Content to be inserted before the selection
10636 * @param {string} post Content to be inserted after the selection
10637 * @chainable
10638 */
10639 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10640 var start, end,
10641 range = this.getRange(),
10642 offset = pre.length;
10643
10644 start = Math.min( range.from, range.to );
10645 end = Math.max( range.from, range.to );
10646
10647 this.selectRange( start ).insertContent( pre );
10648 this.selectRange( offset + end ).insertContent( post );
10649
10650 this.selectRange( offset + start, offset + end );
10651 return this;
10652 };
10653
10654 /**
10655 * Set the validation pattern.
10656 *
10657 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10658 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10659 * value must contain only numbers).
10660 *
10661 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10662 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10663 */
10664 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10665 if ( validate instanceof RegExp || validate instanceof Function ) {
10666 this.validate = validate;
10667 } else {
10668 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10669 }
10670 };
10671
10672 /**
10673 * Sets the 'invalid' flag appropriately.
10674 *
10675 * @param {boolean} [isValid] Optionally override validation result
10676 */
10677 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10678 var widget = this,
10679 setFlag = function ( valid ) {
10680 if ( !valid ) {
10681 widget.$input.attr( 'aria-invalid', 'true' );
10682 } else {
10683 widget.$input.removeAttr( 'aria-invalid' );
10684 }
10685 widget.setFlags( { invalid: !valid } );
10686 };
10687
10688 if ( isValid !== undefined ) {
10689 setFlag( isValid );
10690 } else {
10691 this.getValidity().then( function () {
10692 setFlag( true );
10693 }, function () {
10694 setFlag( false );
10695 } );
10696 }
10697 };
10698
10699 /**
10700 * Get the validity of current value.
10701 *
10702 * This method returns a promise that resolves if the value is valid and rejects if
10703 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10704 *
10705 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10706 */
10707 OO.ui.TextInputWidget.prototype.getValidity = function () {
10708 var result;
10709
10710 function rejectOrResolve( valid ) {
10711 if ( valid ) {
10712 return $.Deferred().resolve().promise();
10713 } else {
10714 return $.Deferred().reject().promise();
10715 }
10716 }
10717
10718 // Check browser validity and reject if it is invalid
10719 if (
10720 this.$input[ 0 ].checkValidity !== undefined &&
10721 this.$input[ 0 ].checkValidity() === false
10722 ) {
10723 return rejectOrResolve( false );
10724 }
10725
10726 // Run our checks if the browser thinks the field is valid
10727 if ( this.validate instanceof Function ) {
10728 result = this.validate( this.getValue() );
10729 if ( result && $.isFunction( result.promise ) ) {
10730 return result.promise().then( function ( valid ) {
10731 return rejectOrResolve( valid );
10732 } );
10733 } else {
10734 return rejectOrResolve( result );
10735 }
10736 } else {
10737 return rejectOrResolve( this.getValue().match( this.validate ) );
10738 }
10739 };
10740
10741 /**
10742 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10743 *
10744 * @param {string} labelPosition Label position, 'before' or 'after'
10745 * @chainable
10746 */
10747 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10748 this.labelPosition = labelPosition;
10749 if ( this.label ) {
10750 // If there is no label and we only change the position, #updatePosition is a no-op,
10751 // but it takes really a lot of work to do nothing.
10752 this.updatePosition();
10753 }
10754 return this;
10755 };
10756
10757 /**
10758 * Update the position of the inline label.
10759 *
10760 * This method is called by #setLabelPosition, and can also be called on its own if
10761 * something causes the label to be mispositioned.
10762 *
10763 * @chainable
10764 */
10765 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10766 var after = this.labelPosition === 'after';
10767
10768 this.$element
10769 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10770 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10771
10772 this.valCache = null;
10773 this.scrollWidth = null;
10774 this.positionLabel();
10775
10776 return this;
10777 };
10778
10779 /**
10780 * Position the label by setting the correct padding on the input.
10781 *
10782 * @private
10783 * @chainable
10784 */
10785 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10786 var after, rtl, property, newCss;
10787
10788 if ( this.isWaitingToBeAttached ) {
10789 // #onElementAttach will be called soon, which calls this method
10790 return this;
10791 }
10792
10793 newCss = {
10794 'padding-right': '',
10795 'padding-left': ''
10796 };
10797
10798 if ( this.label ) {
10799 this.$element.append( this.$label );
10800 } else {
10801 this.$label.detach();
10802 // Clear old values if present
10803 this.$input.css( newCss );
10804 return;
10805 }
10806
10807 after = this.labelPosition === 'after';
10808 rtl = this.$element.css( 'direction' ) === 'rtl';
10809 property = after === rtl ? 'padding-left' : 'padding-right';
10810
10811 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10812 // We have to clear the padding on the other side, in case the element direction changed
10813 this.$input.css( newCss );
10814
10815 return this;
10816 };
10817
10818 /**
10819 * @class
10820 * @extends OO.ui.TextInputWidget
10821 *
10822 * @constructor
10823 * @param {Object} [config] Configuration options
10824 */
10825 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10826 config = $.extend( {
10827 icon: 'search'
10828 }, config );
10829
10830 // Parent constructor
10831 OO.ui.SearchInputWidget.parent.call( this, config );
10832
10833 // Events
10834 this.connect( this, {
10835 change: 'onChange'
10836 } );
10837
10838 // Initialization
10839 this.updateSearchIndicator();
10840 this.connect( this, {
10841 disable: 'onDisable'
10842 } );
10843 };
10844
10845 /* Setup */
10846
10847 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10848
10849 /* Methods */
10850
10851 /**
10852 * @inheritdoc
10853 * @protected
10854 */
10855 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10856 return 'search';
10857 };
10858
10859 /**
10860 * @inheritdoc
10861 */
10862 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10863 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10864 // Clear the text field
10865 this.setValue( '' );
10866 this.focus();
10867 return false;
10868 }
10869 };
10870
10871 /**
10872 * Update the 'clear' indicator displayed on type: 'search' text
10873 * fields, hiding it when the field is already empty or when it's not
10874 * editable.
10875 */
10876 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10877 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10878 this.setIndicator( null );
10879 } else {
10880 this.setIndicator( 'clear' );
10881 }
10882 };
10883
10884 /**
10885 * Handle change events.
10886 *
10887 * @private
10888 */
10889 OO.ui.SearchInputWidget.prototype.onChange = function () {
10890 this.updateSearchIndicator();
10891 };
10892
10893 /**
10894 * Handle disable events.
10895 *
10896 * @param {boolean} disabled Element is disabled
10897 * @private
10898 */
10899 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10900 this.updateSearchIndicator();
10901 };
10902
10903 /**
10904 * @inheritdoc
10905 */
10906 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10907 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10908 this.updateSearchIndicator();
10909 return this;
10910 };
10911
10912 /**
10913 * @class
10914 * @extends OO.ui.TextInputWidget
10915 *
10916 * @constructor
10917 * @param {Object} [config] Configuration options
10918 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10919 * specifies minimum number of rows to display.
10920 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10921 * Use the #maxRows config to specify a maximum number of displayed rows.
10922 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
10923 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10924 */
10925 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10926 config = $.extend( {
10927 type: 'text'
10928 }, config );
10929 // Parent constructor
10930 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10931
10932 // Properties
10933 this.autosize = !!config.autosize;
10934 this.minRows = config.rows !== undefined ? config.rows : '';
10935 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10936
10937 // Clone for resizing
10938 if ( this.autosize ) {
10939 this.$clone = this.$input
10940 .clone()
10941 .removeAttr( 'id' )
10942 .removeAttr( 'name' )
10943 .insertAfter( this.$input )
10944 .attr( 'aria-hidden', 'true' )
10945 .addClass( 'oo-ui-element-hidden' );
10946 }
10947
10948 // Events
10949 this.connect( this, {
10950 change: 'onChange'
10951 } );
10952
10953 // Initialization
10954 if ( config.rows ) {
10955 this.$input.attr( 'rows', config.rows );
10956 }
10957 if ( this.autosize ) {
10958 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
10959 this.isWaitingToBeAttached = true;
10960 this.installParentChangeDetector();
10961 }
10962 };
10963
10964 /* Setup */
10965
10966 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10967
10968 /* Static Methods */
10969
10970 /**
10971 * @inheritdoc
10972 */
10973 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10974 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10975 state.scrollTop = config.$input.scrollTop();
10976 return state;
10977 };
10978
10979 /* Methods */
10980
10981 /**
10982 * @inheritdoc
10983 */
10984 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10985 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10986 this.adjustSize();
10987 };
10988
10989 /**
10990 * Handle change events.
10991 *
10992 * @private
10993 */
10994 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10995 this.adjustSize();
10996 };
10997
10998 /**
10999 * @inheritdoc
11000 */
11001 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11002 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11003 this.adjustSize();
11004 };
11005
11006 /**
11007 * @inheritdoc
11008 *
11009 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11010 */
11011 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11012 if (
11013 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11014 // Some platforms emit keycode 10 for ctrl+enter in a textarea
11015 e.which === 10
11016 ) {
11017 this.emit( 'enter', e );
11018 }
11019 };
11020
11021 /**
11022 * Automatically adjust the size of the text input.
11023 *
11024 * This only affects multiline inputs that are {@link #autosize autosized}.
11025 *
11026 * @chainable
11027 * @fires resize
11028 */
11029 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11030 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11031 idealHeight, newHeight, scrollWidth, property;
11032
11033 if ( this.$input.val() !== this.valCache ) {
11034 if ( this.autosize ) {
11035 this.$clone
11036 .val( this.$input.val() )
11037 .attr( 'rows', this.minRows )
11038 // Set inline height property to 0 to measure scroll height
11039 .css( 'height', 0 );
11040
11041 this.$clone.removeClass( 'oo-ui-element-hidden' );
11042
11043 this.valCache = this.$input.val();
11044
11045 scrollHeight = this.$clone[ 0 ].scrollHeight;
11046
11047 // Remove inline height property to measure natural heights
11048 this.$clone.css( 'height', '' );
11049 innerHeight = this.$clone.innerHeight();
11050 outerHeight = this.$clone.outerHeight();
11051
11052 // Measure max rows height
11053 this.$clone
11054 .attr( 'rows', this.maxRows )
11055 .css( 'height', 'auto' )
11056 .val( '' );
11057 maxInnerHeight = this.$clone.innerHeight();
11058
11059 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11060 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11061 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11062 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11063
11064 this.$clone.addClass( 'oo-ui-element-hidden' );
11065
11066 // Only apply inline height when expansion beyond natural height is needed
11067 // Use the difference between the inner and outer height as a buffer
11068 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11069 if ( newHeight !== this.styleHeight ) {
11070 this.$input.css( 'height', newHeight );
11071 this.styleHeight = newHeight;
11072 this.emit( 'resize' );
11073 }
11074 }
11075 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11076 if ( scrollWidth !== this.scrollWidth ) {
11077 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11078 // Reset
11079 this.$label.css( { right: '', left: '' } );
11080 this.$indicator.css( { right: '', left: '' } );
11081
11082 if ( scrollWidth ) {
11083 this.$indicator.css( property, scrollWidth );
11084 if ( this.labelPosition === 'after' ) {
11085 this.$label.css( property, scrollWidth );
11086 }
11087 }
11088
11089 this.scrollWidth = scrollWidth;
11090 this.positionLabel();
11091 }
11092 }
11093 return this;
11094 };
11095
11096 /**
11097 * @inheritdoc
11098 * @protected
11099 */
11100 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11101 return $( '<textarea>' );
11102 };
11103
11104 /**
11105 * Check if the input automatically adjusts its size.
11106 *
11107 * @return {boolean}
11108 */
11109 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11110 return !!this.autosize;
11111 };
11112
11113 /**
11114 * @inheritdoc
11115 */
11116 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11117 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11118 if ( state.scrollTop !== undefined ) {
11119 this.$input.scrollTop( state.scrollTop );
11120 }
11121 };
11122
11123 /**
11124 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11125 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11126 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11127 *
11128 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11129 * option, that option will appear to be selected.
11130 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11131 * input field.
11132 *
11133 * After the user chooses an option, its `data` will be used as a new value for the widget.
11134 * A `label` also can be specified for each option: if given, it will be shown instead of the
11135 * `data` in the dropdown menu.
11136 *
11137 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11138 *
11139 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11140 *
11141 * @example
11142 * // Example: A ComboBoxInputWidget.
11143 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11144 * value: 'Option 1',
11145 * options: [
11146 * { data: 'Option 1' },
11147 * { data: 'Option 2' },
11148 * { data: 'Option 3' }
11149 * ]
11150 * } );
11151 * $( 'body' ).append( comboBox.$element );
11152 *
11153 * @example
11154 * // Example: A ComboBoxInputWidget with additional option labels.
11155 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11156 * value: 'Option 1',
11157 * options: [
11158 * {
11159 * data: 'Option 1',
11160 * label: 'Option One'
11161 * },
11162 * {
11163 * data: 'Option 2',
11164 * label: 'Option Two'
11165 * },
11166 * {
11167 * data: 'Option 3',
11168 * label: 'Option Three'
11169 * }
11170 * ]
11171 * } );
11172 * $( 'body' ).append( comboBox.$element );
11173 *
11174 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11175 *
11176 * @class
11177 * @extends OO.ui.TextInputWidget
11178 *
11179 * @constructor
11180 * @param {Object} [config] Configuration options
11181 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11182 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11183 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11184 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11185 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11186 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11187 */
11188 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11189 // Configuration initialization
11190 config = $.extend( {
11191 autocomplete: false
11192 }, config );
11193
11194 // ComboBoxInputWidget shouldn't support `multiline`
11195 config.multiline = false;
11196
11197 // See InputWidget#reusePreInfuseDOM about `config.$input`
11198 if ( config.$input ) {
11199 config.$input.removeAttr( 'list' );
11200 }
11201
11202 // Parent constructor
11203 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11204
11205 // Properties
11206 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11207 this.dropdownButton = new OO.ui.ButtonWidget( {
11208 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11209 indicator: 'down',
11210 disabled: this.disabled
11211 } );
11212 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11213 {
11214 widget: this,
11215 input: this,
11216 $floatableContainer: this.$element,
11217 disabled: this.isDisabled()
11218 },
11219 config.menu
11220 ) );
11221
11222 // Events
11223 this.connect( this, {
11224 change: 'onInputChange',
11225 enter: 'onInputEnter'
11226 } );
11227 this.dropdownButton.connect( this, {
11228 click: 'onDropdownButtonClick'
11229 } );
11230 this.menu.connect( this, {
11231 choose: 'onMenuChoose',
11232 add: 'onMenuItemsChange',
11233 remove: 'onMenuItemsChange',
11234 toggle: 'onMenuToggle'
11235 } );
11236
11237 // Initialization
11238 this.$input.attr( {
11239 role: 'combobox',
11240 'aria-owns': this.menu.getElementId(),
11241 'aria-autocomplete': 'list'
11242 } );
11243 // Do not override options set via config.menu.items
11244 if ( config.options !== undefined ) {
11245 this.setOptions( config.options );
11246 }
11247 this.$field = $( '<div>' )
11248 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11249 .append( this.$input, this.dropdownButton.$element );
11250 this.$element
11251 .addClass( 'oo-ui-comboBoxInputWidget' )
11252 .append( this.$field );
11253 this.$overlay.append( this.menu.$element );
11254 this.onMenuItemsChange();
11255 };
11256
11257 /* Setup */
11258
11259 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11260
11261 /* Methods */
11262
11263 /**
11264 * Get the combobox's menu.
11265 *
11266 * @return {OO.ui.MenuSelectWidget} Menu widget
11267 */
11268 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11269 return this.menu;
11270 };
11271
11272 /**
11273 * Get the combobox's text input widget.
11274 *
11275 * @return {OO.ui.TextInputWidget} Text input widget
11276 */
11277 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11278 return this;
11279 };
11280
11281 /**
11282 * Handle input change events.
11283 *
11284 * @private
11285 * @param {string} value New value
11286 */
11287 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11288 var match = this.menu.findItemFromData( value );
11289
11290 this.menu.selectItem( match );
11291 if ( this.menu.findHighlightedItem() ) {
11292 this.menu.highlightItem( match );
11293 }
11294
11295 if ( !this.isDisabled() ) {
11296 this.menu.toggle( true );
11297 }
11298 };
11299
11300 /**
11301 * Handle input enter events.
11302 *
11303 * @private
11304 */
11305 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11306 if ( !this.isDisabled() ) {
11307 this.menu.toggle( false );
11308 }
11309 };
11310
11311 /**
11312 * Handle button click events.
11313 *
11314 * @private
11315 */
11316 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11317 this.menu.toggle();
11318 this.focus();
11319 };
11320
11321 /**
11322 * Handle menu choose events.
11323 *
11324 * @private
11325 * @param {OO.ui.OptionWidget} item Chosen item
11326 */
11327 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11328 this.setValue( item.getData() );
11329 };
11330
11331 /**
11332 * Handle menu item change events.
11333 *
11334 * @private
11335 */
11336 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11337 var match = this.menu.findItemFromData( this.getValue() );
11338 this.menu.selectItem( match );
11339 if ( this.menu.findHighlightedItem() ) {
11340 this.menu.highlightItem( match );
11341 }
11342 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11343 };
11344
11345 /**
11346 * Handle menu toggle events.
11347 *
11348 * @private
11349 * @param {boolean} isVisible Open state of the menu
11350 */
11351 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11352 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11353 };
11354
11355 /**
11356 * @inheritdoc
11357 */
11358 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11359 // Parent method
11360 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11361
11362 if ( this.dropdownButton ) {
11363 this.dropdownButton.setDisabled( this.isDisabled() );
11364 }
11365 if ( this.menu ) {
11366 this.menu.setDisabled( this.isDisabled() );
11367 }
11368
11369 return this;
11370 };
11371
11372 /**
11373 * Set the options available for this input.
11374 *
11375 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11376 * @chainable
11377 */
11378 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11379 this.getMenu()
11380 .clearItems()
11381 .addItems( options.map( function ( opt ) {
11382 return new OO.ui.MenuOptionWidget( {
11383 data: opt.data,
11384 label: opt.label !== undefined ? opt.label : opt.data
11385 } );
11386 } ) );
11387
11388 return this;
11389 };
11390
11391 /**
11392 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11393 * which is a widget that is specified by reference before any optional configuration settings.
11394 *
11395 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11396 *
11397 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11398 * A left-alignment is used for forms with many fields.
11399 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11400 * A right-alignment is used for long but familiar forms which users tab through,
11401 * verifying the current field with a quick glance at the label.
11402 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11403 * that users fill out from top to bottom.
11404 * - **inline**: The label is placed after the field-widget and aligned to the left.
11405 * An inline-alignment is best used with checkboxes or radio buttons.
11406 *
11407 * Help text can either be:
11408 *
11409 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11410 * - shown as a subtle explanation below the label.
11411 *
11412 * If the help text is brief, or is essential to always espose it, set `helpInline` to `true`. If it
11413 * is long or not essential, leave `helpInline` to its default, `false`.
11414 *
11415 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11416 *
11417 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11418 *
11419 * @class
11420 * @extends OO.ui.Layout
11421 * @mixins OO.ui.mixin.LabelElement
11422 * @mixins OO.ui.mixin.TitledElement
11423 *
11424 * @constructor
11425 * @param {OO.ui.Widget} fieldWidget Field widget
11426 * @param {Object} [config] Configuration options
11427 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11428 * or 'inline'
11429 * @cfg {Array} [errors] Error messages about the widget, which will be
11430 * displayed below the widget.
11431 * The array may contain strings or OO.ui.HtmlSnippet instances.
11432 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11433 * below the widget.
11434 * The array may contain strings or OO.ui.HtmlSnippet instances.
11435 * These are more visible than `help` messages when `helpInline` is set, and so
11436 * might be good for transient messages.
11437 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11438 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11439 * corner of the rendered field; clicking it will display the text in a popup.
11440 * If `helpInline` is `true`, then a subtle description will be shown after the
11441 * label.
11442 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11443 * or shown when the "help" icon is clicked.
11444 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11445 * `help` is given.
11446 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11447 *
11448 * @throws {Error} An error is thrown if no widget is specified
11449 */
11450 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11451 // Allow passing positional parameters inside the config object
11452 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11453 config = fieldWidget;
11454 fieldWidget = config.fieldWidget;
11455 }
11456
11457 // Make sure we have required constructor arguments
11458 if ( fieldWidget === undefined ) {
11459 throw new Error( 'Widget not found' );
11460 }
11461
11462 // Configuration initialization
11463 config = $.extend( { align: 'left', helpInline: false }, config );
11464
11465 // Parent constructor
11466 OO.ui.FieldLayout.parent.call( this, config );
11467
11468 // Mixin constructors
11469 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11470 $label: $( '<label>' )
11471 } ) );
11472 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11473
11474 // Properties
11475 this.fieldWidget = fieldWidget;
11476 this.errors = [];
11477 this.notices = [];
11478 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11479 this.$messages = $( '<ul>' );
11480 this.$header = $( '<span>' );
11481 this.$body = $( '<div>' );
11482 this.align = null;
11483 this.helpInline = config.helpInline;
11484
11485 // Events
11486 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11487
11488 // Initialization
11489 this.$help = config.help ?
11490 this.createHelpElement( config.help, config.$overlay ) :
11491 $( [] );
11492 if ( this.fieldWidget.getInputId() ) {
11493 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11494 if ( this.helpInline ) {
11495 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11496 }
11497 } else {
11498 this.$label.on( 'click', function () {
11499 this.fieldWidget.simulateLabelClick();
11500 }.bind( this ) );
11501 if ( this.helpInline ) {
11502 this.$help.on( 'click', function () {
11503 this.fieldWidget.simulateLabelClick();
11504 }.bind( this ) );
11505 }
11506 }
11507 this.$element
11508 .addClass( 'oo-ui-fieldLayout' )
11509 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11510 .append( this.$body );
11511 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11512 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11513 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11514 this.$field
11515 .addClass( 'oo-ui-fieldLayout-field' )
11516 .append( this.fieldWidget.$element );
11517
11518 this.setErrors( config.errors || [] );
11519 this.setNotices( config.notices || [] );
11520 this.setAlignment( config.align );
11521 // Call this again to take into account the widget's accessKey
11522 this.updateTitle();
11523 };
11524
11525 /* Setup */
11526
11527 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11528 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11529 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11530
11531 /* Methods */
11532
11533 /**
11534 * Handle field disable events.
11535 *
11536 * @private
11537 * @param {boolean} value Field is disabled
11538 */
11539 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11540 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11541 };
11542
11543 /**
11544 * Get the widget contained by the field.
11545 *
11546 * @return {OO.ui.Widget} Field widget
11547 */
11548 OO.ui.FieldLayout.prototype.getField = function () {
11549 return this.fieldWidget;
11550 };
11551
11552 /**
11553 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11554 * #setAlignment). Return `false` if it can't or if this can't be determined.
11555 *
11556 * @return {boolean}
11557 */
11558 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11559 // This is very simplistic, but should be good enough.
11560 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11561 };
11562
11563 /**
11564 * @protected
11565 * @param {string} kind 'error' or 'notice'
11566 * @param {string|OO.ui.HtmlSnippet} text
11567 * @return {jQuery}
11568 */
11569 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11570 var $listItem, $icon, message;
11571 $listItem = $( '<li>' );
11572 if ( kind === 'error' ) {
11573 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11574 $listItem.attr( 'role', 'alert' );
11575 } else if ( kind === 'notice' ) {
11576 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11577 } else {
11578 $icon = '';
11579 }
11580 message = new OO.ui.LabelWidget( { label: text } );
11581 $listItem
11582 .append( $icon, message.$element )
11583 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11584 return $listItem;
11585 };
11586
11587 /**
11588 * Set the field alignment mode.
11589 *
11590 * @private
11591 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11592 * @chainable
11593 */
11594 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11595 if ( value !== this.align ) {
11596 // Default to 'left'
11597 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11598 value = 'left';
11599 }
11600 // Validate
11601 if ( value === 'inline' && !this.isFieldInline() ) {
11602 value = 'top';
11603 }
11604 // Reorder elements
11605
11606 if ( this.helpInline ) {
11607 if ( value === 'top' ) {
11608 this.$header.append( this.$label );
11609 this.$body.append( this.$header, this.$field, this.$help );
11610 } else if ( value === 'inline' ) {
11611 this.$header.append( this.$label, this.$help );
11612 this.$body.append( this.$field, this.$header );
11613 } else {
11614 this.$header.append( this.$label, this.$help );
11615 this.$body.append( this.$header, this.$field );
11616 }
11617 } else {
11618 if ( value === 'top' ) {
11619 this.$header.append( this.$help, this.$label );
11620 this.$body.append( this.$header, this.$field );
11621 } else if ( value === 'inline' ) {
11622 this.$header.append( this.$help, this.$label );
11623 this.$body.append( this.$field, this.$header );
11624 } else {
11625 this.$header.append( this.$label );
11626 this.$body.append( this.$header, this.$help, this.$field );
11627 }
11628 }
11629 // Set classes. The following classes can be used here:
11630 // * oo-ui-fieldLayout-align-left
11631 // * oo-ui-fieldLayout-align-right
11632 // * oo-ui-fieldLayout-align-top
11633 // * oo-ui-fieldLayout-align-inline
11634 if ( this.align ) {
11635 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11636 }
11637 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11638 this.align = value;
11639 }
11640
11641 return this;
11642 };
11643
11644 /**
11645 * Set the list of error messages.
11646 *
11647 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11648 * The array may contain strings or OO.ui.HtmlSnippet instances.
11649 * @chainable
11650 */
11651 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11652 this.errors = errors.slice();
11653 this.updateMessages();
11654 return this;
11655 };
11656
11657 /**
11658 * Set the list of notice messages.
11659 *
11660 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11661 * The array may contain strings or OO.ui.HtmlSnippet instances.
11662 * @chainable
11663 */
11664 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11665 this.notices = notices.slice();
11666 this.updateMessages();
11667 return this;
11668 };
11669
11670 /**
11671 * Update the rendering of error and notice messages.
11672 *
11673 * @private
11674 */
11675 OO.ui.FieldLayout.prototype.updateMessages = function () {
11676 var i;
11677 this.$messages.empty();
11678
11679 if ( this.errors.length || this.notices.length ) {
11680 this.$body.after( this.$messages );
11681 } else {
11682 this.$messages.remove();
11683 return;
11684 }
11685
11686 for ( i = 0; i < this.notices.length; i++ ) {
11687 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11688 }
11689 for ( i = 0; i < this.errors.length; i++ ) {
11690 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11691 }
11692 };
11693
11694 /**
11695 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11696 * (This is a bit of a hack.)
11697 *
11698 * @protected
11699 * @param {string} title Tooltip label for 'title' attribute
11700 * @return {string}
11701 */
11702 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11703 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11704 return this.fieldWidget.formatTitleWithAccessKey( title );
11705 }
11706 return title;
11707 };
11708
11709 /**
11710 * Creates and returns the help element. Also sets the `aria-describedby`
11711 * attribute on the main element of the `fieldWidget`.
11712 *
11713 * @private
11714 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11715 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11716 * @return {jQuery} The element that should become `this.$help`.
11717 */
11718 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11719 var helpId, helpWidget;
11720
11721 if ( this.helpInline ) {
11722 helpWidget = new OO.ui.LabelWidget( {
11723 label: help,
11724 classes: [ 'oo-ui-inline-help' ]
11725 } );
11726
11727 helpId = helpWidget.getElementId();
11728 } else {
11729 helpWidget = new OO.ui.PopupButtonWidget( {
11730 $overlay: $overlay,
11731 popup: {
11732 padded: true
11733 },
11734 classes: [ 'oo-ui-fieldLayout-help' ],
11735 framed: false,
11736 icon: 'info',
11737 label: OO.ui.msg( 'ooui-field-help' )
11738 } );
11739 if ( help instanceof OO.ui.HtmlSnippet ) {
11740 helpWidget.getPopup().$body.html( help.toString() );
11741 } else {
11742 helpWidget.getPopup().$body.text( help );
11743 }
11744
11745 helpId = helpWidget.getPopup().getBodyId();
11746 }
11747
11748 // Set the 'aria-describedby' attribute on the fieldWidget
11749 // Preference given to an input or a button
11750 (
11751 this.fieldWidget.$input ||
11752 this.fieldWidget.$button ||
11753 this.fieldWidget.$element
11754 ).attr( 'aria-describedby', helpId );
11755
11756 return helpWidget.$element;
11757 };
11758
11759 /**
11760 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11761 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11762 * is required and is specified before any optional configuration settings.
11763 *
11764 * Labels can be aligned in one of four ways:
11765 *
11766 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11767 * A left-alignment is used for forms with many fields.
11768 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11769 * A right-alignment is used for long but familiar forms which users tab through,
11770 * verifying the current field with a quick glance at the label.
11771 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11772 * that users fill out from top to bottom.
11773 * - **inline**: The label is placed after the field-widget and aligned to the left.
11774 * An inline-alignment is best used with checkboxes or radio buttons.
11775 *
11776 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11777 * text is specified.
11778 *
11779 * @example
11780 * // Example of an ActionFieldLayout
11781 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11782 * new OO.ui.TextInputWidget( {
11783 * placeholder: 'Field widget'
11784 * } ),
11785 * new OO.ui.ButtonWidget( {
11786 * label: 'Button'
11787 * } ),
11788 * {
11789 * label: 'An ActionFieldLayout. This label is aligned top',
11790 * align: 'top',
11791 * help: 'This is help text'
11792 * }
11793 * );
11794 *
11795 * $( 'body' ).append( actionFieldLayout.$element );
11796 *
11797 * @class
11798 * @extends OO.ui.FieldLayout
11799 *
11800 * @constructor
11801 * @param {OO.ui.Widget} fieldWidget Field widget
11802 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11803 * @param {Object} config
11804 */
11805 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11806 // Allow passing positional parameters inside the config object
11807 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11808 config = fieldWidget;
11809 fieldWidget = config.fieldWidget;
11810 buttonWidget = config.buttonWidget;
11811 }
11812
11813 // Parent constructor
11814 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11815
11816 // Properties
11817 this.buttonWidget = buttonWidget;
11818 this.$button = $( '<span>' );
11819 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11820
11821 // Initialization
11822 this.$element
11823 .addClass( 'oo-ui-actionFieldLayout' );
11824 this.$button
11825 .addClass( 'oo-ui-actionFieldLayout-button' )
11826 .append( this.buttonWidget.$element );
11827 this.$input
11828 .addClass( 'oo-ui-actionFieldLayout-input' )
11829 .append( this.fieldWidget.$element );
11830 this.$field
11831 .append( this.$input, this.$button );
11832 };
11833
11834 /* Setup */
11835
11836 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11837
11838 /**
11839 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11840 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11841 * configured with a label as well. For more information and examples,
11842 * please see the [OOUI documentation on MediaWiki][1].
11843 *
11844 * @example
11845 * // Example of a fieldset layout
11846 * var input1 = new OO.ui.TextInputWidget( {
11847 * placeholder: 'A text input field'
11848 * } );
11849 *
11850 * var input2 = new OO.ui.TextInputWidget( {
11851 * placeholder: 'A text input field'
11852 * } );
11853 *
11854 * var fieldset = new OO.ui.FieldsetLayout( {
11855 * label: 'Example of a fieldset layout'
11856 * } );
11857 *
11858 * fieldset.addItems( [
11859 * new OO.ui.FieldLayout( input1, {
11860 * label: 'Field One'
11861 * } ),
11862 * new OO.ui.FieldLayout( input2, {
11863 * label: 'Field Two'
11864 * } )
11865 * ] );
11866 * $( 'body' ).append( fieldset.$element );
11867 *
11868 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11869 *
11870 * @class
11871 * @extends OO.ui.Layout
11872 * @mixins OO.ui.mixin.IconElement
11873 * @mixins OO.ui.mixin.LabelElement
11874 * @mixins OO.ui.mixin.GroupElement
11875 *
11876 * @constructor
11877 * @param {Object} [config] Configuration options
11878 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11879 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11880 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11881 * For important messages, you are advised to use `notices`, as they are always shown.
11882 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11883 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11884 */
11885 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11886 // Configuration initialization
11887 config = config || {};
11888
11889 // Parent constructor
11890 OO.ui.FieldsetLayout.parent.call( this, config );
11891
11892 // Mixin constructors
11893 OO.ui.mixin.IconElement.call( this, config );
11894 OO.ui.mixin.LabelElement.call( this, config );
11895 OO.ui.mixin.GroupElement.call( this, config );
11896
11897 // Properties
11898 this.$header = $( '<legend>' );
11899 if ( config.help ) {
11900 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11901 $overlay: config.$overlay,
11902 popup: {
11903 padded: true
11904 },
11905 classes: [ 'oo-ui-fieldsetLayout-help' ],
11906 framed: false,
11907 icon: 'info',
11908 label: OO.ui.msg( 'ooui-field-help' )
11909 } );
11910 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11911 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11912 } else {
11913 this.popupButtonWidget.getPopup().$body.text( config.help );
11914 }
11915 this.$help = this.popupButtonWidget.$element;
11916 } else {
11917 this.$help = $( [] );
11918 }
11919
11920 // Initialization
11921 this.$header
11922 .addClass( 'oo-ui-fieldsetLayout-header' )
11923 .append( this.$icon, this.$label, this.$help );
11924 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11925 this.$element
11926 .addClass( 'oo-ui-fieldsetLayout' )
11927 .prepend( this.$header, this.$group );
11928 if ( Array.isArray( config.items ) ) {
11929 this.addItems( config.items );
11930 }
11931 };
11932
11933 /* Setup */
11934
11935 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11936 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11937 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11938 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11939
11940 /* Static Properties */
11941
11942 /**
11943 * @static
11944 * @inheritdoc
11945 */
11946 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11947
11948 /**
11949 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11950 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11951 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11952 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
11953 *
11954 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11955 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11956 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11957 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11958 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11959 * often have simplified APIs to match the capabilities of HTML forms.
11960 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
11961 *
11962 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
11963 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11964 *
11965 * @example
11966 * // Example of a form layout that wraps a fieldset layout
11967 * var input1 = new OO.ui.TextInputWidget( {
11968 * placeholder: 'Username'
11969 * } );
11970 * var input2 = new OO.ui.TextInputWidget( {
11971 * placeholder: 'Password',
11972 * type: 'password'
11973 * } );
11974 * var submit = new OO.ui.ButtonInputWidget( {
11975 * label: 'Submit'
11976 * } );
11977 *
11978 * var fieldset = new OO.ui.FieldsetLayout( {
11979 * label: 'A form layout'
11980 * } );
11981 * fieldset.addItems( [
11982 * new OO.ui.FieldLayout( input1, {
11983 * label: 'Username',
11984 * align: 'top'
11985 * } ),
11986 * new OO.ui.FieldLayout( input2, {
11987 * label: 'Password',
11988 * align: 'top'
11989 * } ),
11990 * new OO.ui.FieldLayout( submit )
11991 * ] );
11992 * var form = new OO.ui.FormLayout( {
11993 * items: [ fieldset ],
11994 * action: '/api/formhandler',
11995 * method: 'get'
11996 * } )
11997 * $( 'body' ).append( form.$element );
11998 *
11999 * @class
12000 * @extends OO.ui.Layout
12001 * @mixins OO.ui.mixin.GroupElement
12002 *
12003 * @constructor
12004 * @param {Object} [config] Configuration options
12005 * @cfg {string} [method] HTML form `method` attribute
12006 * @cfg {string} [action] HTML form `action` attribute
12007 * @cfg {string} [enctype] HTML form `enctype` attribute
12008 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12009 */
12010 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12011 var action;
12012
12013 // Configuration initialization
12014 config = config || {};
12015
12016 // Parent constructor
12017 OO.ui.FormLayout.parent.call( this, config );
12018
12019 // Mixin constructors
12020 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12021
12022 // Events
12023 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12024
12025 // Make sure the action is safe
12026 action = config.action;
12027 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12028 action = './' + action;
12029 }
12030
12031 // Initialization
12032 this.$element
12033 .addClass( 'oo-ui-formLayout' )
12034 .attr( {
12035 method: config.method,
12036 action: action,
12037 enctype: config.enctype
12038 } );
12039 if ( Array.isArray( config.items ) ) {
12040 this.addItems( config.items );
12041 }
12042 };
12043
12044 /* Setup */
12045
12046 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12047 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12048
12049 /* Events */
12050
12051 /**
12052 * A 'submit' event is emitted when the form is submitted.
12053 *
12054 * @event submit
12055 */
12056
12057 /* Static Properties */
12058
12059 /**
12060 * @static
12061 * @inheritdoc
12062 */
12063 OO.ui.FormLayout.static.tagName = 'form';
12064
12065 /* Methods */
12066
12067 /**
12068 * Handle form submit events.
12069 *
12070 * @private
12071 * @param {jQuery.Event} e Submit event
12072 * @fires submit
12073 */
12074 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12075 if ( this.emit( 'submit' ) ) {
12076 return false;
12077 }
12078 };
12079
12080 /**
12081 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12082 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12083 *
12084 * @example
12085 * // Example of a panel layout
12086 * var panel = new OO.ui.PanelLayout( {
12087 * expanded: false,
12088 * framed: true,
12089 * padded: true,
12090 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12091 * } );
12092 * $( 'body' ).append( panel.$element );
12093 *
12094 * @class
12095 * @extends OO.ui.Layout
12096 *
12097 * @constructor
12098 * @param {Object} [config] Configuration options
12099 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12100 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12101 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12102 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12103 */
12104 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12105 // Configuration initialization
12106 config = $.extend( {
12107 scrollable: false,
12108 padded: false,
12109 expanded: true,
12110 framed: false
12111 }, config );
12112
12113 // Parent constructor
12114 OO.ui.PanelLayout.parent.call( this, config );
12115
12116 // Initialization
12117 this.$element.addClass( 'oo-ui-panelLayout' );
12118 if ( config.scrollable ) {
12119 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12120 }
12121 if ( config.padded ) {
12122 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12123 }
12124 if ( config.expanded ) {
12125 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12126 }
12127 if ( config.framed ) {
12128 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12129 }
12130 };
12131
12132 /* Setup */
12133
12134 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12135
12136 /* Methods */
12137
12138 /**
12139 * Focus the panel layout
12140 *
12141 * The default implementation just focuses the first focusable element in the panel
12142 */
12143 OO.ui.PanelLayout.prototype.focus = function () {
12144 OO.ui.findFocusable( this.$element ).focus();
12145 };
12146
12147 /**
12148 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12149 * items), with small margins between them. Convenient when you need to put a number of block-level
12150 * widgets on a single line next to each other.
12151 *
12152 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12153 *
12154 * @example
12155 * // HorizontalLayout with a text input and a label
12156 * var layout = new OO.ui.HorizontalLayout( {
12157 * items: [
12158 * new OO.ui.LabelWidget( { label: 'Label' } ),
12159 * new OO.ui.TextInputWidget( { value: 'Text' } )
12160 * ]
12161 * } );
12162 * $( 'body' ).append( layout.$element );
12163 *
12164 * @class
12165 * @extends OO.ui.Layout
12166 * @mixins OO.ui.mixin.GroupElement
12167 *
12168 * @constructor
12169 * @param {Object} [config] Configuration options
12170 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12171 */
12172 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12173 // Configuration initialization
12174 config = config || {};
12175
12176 // Parent constructor
12177 OO.ui.HorizontalLayout.parent.call( this, config );
12178
12179 // Mixin constructors
12180 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12181
12182 // Initialization
12183 this.$element.addClass( 'oo-ui-horizontalLayout' );
12184 if ( Array.isArray( config.items ) ) {
12185 this.addItems( config.items );
12186 }
12187 };
12188
12189 /* Setup */
12190
12191 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12192 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12193
12194 /**
12195 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12196 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12197 * (to adjust the value in increments) to allow the user to enter a number.
12198 *
12199 * @example
12200 * // Example: A NumberInputWidget.
12201 * var numberInput = new OO.ui.NumberInputWidget( {
12202 * label: 'NumberInputWidget',
12203 * input: { value: 5 },
12204 * min: 1,
12205 * max: 10
12206 * } );
12207 * $( 'body' ).append( numberInput.$element );
12208 *
12209 * @class
12210 * @extends OO.ui.TextInputWidget
12211 *
12212 * @constructor
12213 * @param {Object} [config] Configuration options
12214 * @cfg {Object} [minusButton] Configuration options to pass to the
12215 * {@link OO.ui.ButtonWidget decrementing button widget}.
12216 * @cfg {Object} [plusButton] Configuration options to pass to the
12217 * {@link OO.ui.ButtonWidget incrementing button widget}.
12218 * @cfg {number} [min=-Infinity] Minimum allowed value
12219 * @cfg {number} [max=Infinity] Maximum allowed value
12220 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12221 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12222 * Defaults to `step` if specified, otherwise `1`.
12223 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12224 * Defaults to 10 times `buttonStep`.
12225 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12226 */
12227 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12228 var $field = $( '<div>' )
12229 .addClass( 'oo-ui-numberInputWidget-field' );
12230
12231 // Configuration initialization
12232 config = $.extend( {
12233 min: -Infinity,
12234 max: Infinity,
12235 showButtons: true
12236 }, config );
12237
12238 // For backward compatibility
12239 $.extend( config, config.input );
12240 this.input = this;
12241
12242 // Parent constructor
12243 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12244 type: 'number'
12245 } ) );
12246
12247 if ( config.showButtons ) {
12248 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12249 {
12250 disabled: this.isDisabled(),
12251 tabIndex: -1,
12252 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12253 icon: 'subtract'
12254 },
12255 config.minusButton
12256 ) );
12257 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12258 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12259 {
12260 disabled: this.isDisabled(),
12261 tabIndex: -1,
12262 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12263 icon: 'add'
12264 },
12265 config.plusButton
12266 ) );
12267 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12268 }
12269
12270 // Events
12271 this.$input.on( {
12272 keydown: this.onKeyDown.bind( this ),
12273 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12274 } );
12275 if ( config.showButtons ) {
12276 this.plusButton.connect( this, {
12277 click: [ 'onButtonClick', +1 ]
12278 } );
12279 this.minusButton.connect( this, {
12280 click: [ 'onButtonClick', -1 ]
12281 } );
12282 }
12283
12284 // Build the field
12285 $field.append( this.$input );
12286 if ( config.showButtons ) {
12287 $field
12288 .prepend( this.minusButton.$element )
12289 .append( this.plusButton.$element );
12290 }
12291
12292 // Initialization
12293 if ( config.allowInteger || config.isInteger ) {
12294 // Backward compatibility
12295 config.step = 1;
12296 }
12297 this.setRange( config.min, config.max );
12298 this.setStep( config.buttonStep, config.pageStep, config.step );
12299 // Set the validation method after we set step and range
12300 // so that it doesn't immediately call setValidityFlag
12301 this.setValidation( this.validateNumber.bind( this ) );
12302
12303 this.$element
12304 .addClass( 'oo-ui-numberInputWidget' )
12305 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12306 .append( $field );
12307 };
12308
12309 /* Setup */
12310
12311 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12312
12313 /* Methods */
12314
12315 // Backward compatibility
12316 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12317 this.setStep( flag ? 1 : null );
12318 };
12319 // Backward compatibility
12320 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12321
12322 // Backward compatibility
12323 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12324 return this.step === 1;
12325 };
12326 // Backward compatibility
12327 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12328
12329 /**
12330 * Set the range of allowed values
12331 *
12332 * @param {number} min Minimum allowed value
12333 * @param {number} max Maximum allowed value
12334 */
12335 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12336 if ( min > max ) {
12337 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12338 }
12339 this.min = min;
12340 this.max = max;
12341 this.$input.attr( 'min', this.min );
12342 this.$input.attr( 'max', this.max );
12343 this.setValidityFlag();
12344 };
12345
12346 /**
12347 * Get the current range
12348 *
12349 * @return {number[]} Minimum and maximum values
12350 */
12351 OO.ui.NumberInputWidget.prototype.getRange = function () {
12352 return [ this.min, this.max ];
12353 };
12354
12355 /**
12356 * Set the stepping deltas
12357 *
12358 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12359 * Defaults to `step` if specified, otherwise `1`.
12360 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12361 * Defaults to 10 times `buttonStep`.
12362 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12363 */
12364 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12365 if ( buttonStep === undefined ) {
12366 buttonStep = step || 1;
12367 }
12368 if ( pageStep === undefined ) {
12369 pageStep = 10 * buttonStep;
12370 }
12371 if ( step !== null && step <= 0 ) {
12372 throw new Error( 'Step value, if given, must be positive' );
12373 }
12374 if ( buttonStep <= 0 ) {
12375 throw new Error( 'Button step value must be positive' );
12376 }
12377 if ( pageStep <= 0 ) {
12378 throw new Error( 'Page step value must be positive' );
12379 }
12380 this.step = step;
12381 this.buttonStep = buttonStep;
12382 this.pageStep = pageStep;
12383 this.$input.attr( 'step', this.step || 'any' );
12384 this.setValidityFlag();
12385 };
12386
12387 /**
12388 * @inheritdoc
12389 */
12390 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12391 if ( value === '' ) {
12392 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12393 // so here we make sure an 'empty' value is actually displayed as such.
12394 this.$input.val( '' );
12395 }
12396 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12397 };
12398
12399 /**
12400 * Get the current stepping values
12401 *
12402 * @return {number[]} Button step, page step, and validity step
12403 */
12404 OO.ui.NumberInputWidget.prototype.getStep = function () {
12405 return [ this.buttonStep, this.pageStep, this.step ];
12406 };
12407
12408 /**
12409 * Get the current value of the widget as a number
12410 *
12411 * @return {number} May be NaN, or an invalid number
12412 */
12413 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12414 return +this.getValue();
12415 };
12416
12417 /**
12418 * Adjust the value of the widget
12419 *
12420 * @param {number} delta Adjustment amount
12421 */
12422 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12423 var n, v = this.getNumericValue();
12424
12425 delta = +delta;
12426 if ( isNaN( delta ) || !isFinite( delta ) ) {
12427 throw new Error( 'Delta must be a finite number' );
12428 }
12429
12430 if ( isNaN( v ) ) {
12431 n = 0;
12432 } else {
12433 n = v + delta;
12434 n = Math.max( Math.min( n, this.max ), this.min );
12435 if ( this.step ) {
12436 n = Math.round( n / this.step ) * this.step;
12437 }
12438 }
12439
12440 if ( n !== v ) {
12441 this.setValue( n );
12442 }
12443 };
12444 /**
12445 * Validate input
12446 *
12447 * @private
12448 * @param {string} value Field value
12449 * @return {boolean}
12450 */
12451 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12452 var n = +value;
12453 if ( value === '' ) {
12454 return !this.isRequired();
12455 }
12456
12457 if ( isNaN( n ) || !isFinite( n ) ) {
12458 return false;
12459 }
12460
12461 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12462 return false;
12463 }
12464
12465 if ( n < this.min || n > this.max ) {
12466 return false;
12467 }
12468
12469 return true;
12470 };
12471
12472 /**
12473 * Handle mouse click events.
12474 *
12475 * @private
12476 * @param {number} dir +1 or -1
12477 */
12478 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12479 this.adjustValue( dir * this.buttonStep );
12480 };
12481
12482 /**
12483 * Handle mouse wheel events.
12484 *
12485 * @private
12486 * @param {jQuery.Event} event
12487 */
12488 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12489 var delta = 0;
12490
12491 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12492 // Standard 'wheel' event
12493 if ( event.originalEvent.deltaMode !== undefined ) {
12494 this.sawWheelEvent = true;
12495 }
12496 if ( event.originalEvent.deltaY ) {
12497 delta = -event.originalEvent.deltaY;
12498 } else if ( event.originalEvent.deltaX ) {
12499 delta = event.originalEvent.deltaX;
12500 }
12501
12502 // Non-standard events
12503 if ( !this.sawWheelEvent ) {
12504 if ( event.originalEvent.wheelDeltaX ) {
12505 delta = -event.originalEvent.wheelDeltaX;
12506 } else if ( event.originalEvent.wheelDeltaY ) {
12507 delta = event.originalEvent.wheelDeltaY;
12508 } else if ( event.originalEvent.wheelDelta ) {
12509 delta = event.originalEvent.wheelDelta;
12510 } else if ( event.originalEvent.detail ) {
12511 delta = -event.originalEvent.detail;
12512 }
12513 }
12514
12515 if ( delta ) {
12516 delta = delta < 0 ? -1 : 1;
12517 this.adjustValue( delta * this.buttonStep );
12518 }
12519
12520 return false;
12521 }
12522 };
12523
12524 /**
12525 * Handle key down events.
12526 *
12527 * @private
12528 * @param {jQuery.Event} e Key down event
12529 */
12530 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12531 if ( !this.isDisabled() ) {
12532 switch ( e.which ) {
12533 case OO.ui.Keys.UP:
12534 this.adjustValue( this.buttonStep );
12535 return false;
12536 case OO.ui.Keys.DOWN:
12537 this.adjustValue( -this.buttonStep );
12538 return false;
12539 case OO.ui.Keys.PAGEUP:
12540 this.adjustValue( this.pageStep );
12541 return false;
12542 case OO.ui.Keys.PAGEDOWN:
12543 this.adjustValue( -this.pageStep );
12544 return false;
12545 }
12546 }
12547 };
12548
12549 /**
12550 * @inheritdoc
12551 */
12552 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12553 // Parent method
12554 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12555
12556 if ( this.minusButton ) {
12557 this.minusButton.setDisabled( this.isDisabled() );
12558 }
12559 if ( this.plusButton ) {
12560 this.plusButton.setDisabled( this.isDisabled() );
12561 }
12562
12563 return this;
12564 };
12565
12566 }( OO ) );
12567
12568 //# sourceMappingURL=oojs-ui-core.js.map.json