Merge "selenium: invoke jobs to enforce eventual consistency"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.28.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-09-11T23:05:15Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
15 /**
16 * Namespace for all classes, static methods and static properties.
17 *
18 * @class
19 * @singleton
20 */
21 OO.ui = {};
22
23 OO.ui.bind = $.proxy;
24
25 /**
26 * @property {Object}
27 */
28 OO.ui.Keys = {
29 UNDEFINED: 0,
30 BACKSPACE: 8,
31 DELETE: 46,
32 LEFT: 37,
33 RIGHT: 39,
34 UP: 38,
35 DOWN: 40,
36 ENTER: 13,
37 END: 35,
38 HOME: 36,
39 TAB: 9,
40 PAGEUP: 33,
41 PAGEDOWN: 34,
42 ESCAPE: 27,
43 SHIFT: 16,
44 SPACE: 32
45 };
46
47 /**
48 * Constants for MouseEvent.which
49 *
50 * @property {Object}
51 */
52 OO.ui.MouseButtons = {
53 LEFT: 1,
54 MIDDLE: 2,
55 RIGHT: 3
56 };
57
58 /**
59 * @property {number}
60 * @private
61 */
62 OO.ui.elementId = 0;
63
64 /**
65 * Generate a unique ID for element
66 *
67 * @return {string} ID
68 */
69 OO.ui.generateElementId = function () {
70 OO.ui.elementId++;
71 return 'ooui-' + OO.ui.elementId;
72 };
73
74 /**
75 * Check if an element is focusable.
76 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 *
78 * @param {jQuery} $element Element to test
79 * @return {boolean} Element is focusable
80 */
81 OO.ui.isFocusableElement = function ( $element ) {
82 var nodeName,
83 element = $element[ 0 ];
84
85 // Anything disabled is not focusable
86 if ( element.disabled ) {
87 return false;
88 }
89
90 // Check if the element is visible
91 if ( !(
92 // This is quicker than calling $element.is( ':visible' )
93 $.expr.pseudos.visible( element ) &&
94 // Check that all parents are visible
95 !$element.parents().addBack().filter( function () {
96 return $.css( this, 'visibility' ) === 'hidden';
97 } ).length
98 ) ) {
99 return false;
100 }
101
102 // Check if the element is ContentEditable, which is the string 'true'
103 if ( element.contentEditable === 'true' ) {
104 return true;
105 }
106
107 // Anything with a non-negative numeric tabIndex is focusable.
108 // Use .prop to avoid browser bugs
109 if ( $element.prop( 'tabIndex' ) >= 0 ) {
110 return true;
111 }
112
113 // Some element types are naturally focusable
114 // (indexOf is much faster than regex in Chrome and about the
115 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
116 nodeName = element.nodeName.toLowerCase();
117 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
118 return true;
119 }
120
121 // Links and areas are focusable if they have an href
122 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
123 return true;
124 }
125
126 return false;
127 };
128
129 /**
130 * Find a focusable child
131 *
132 * @param {jQuery} $container Container to search in
133 * @param {boolean} [backwards] Search backwards
134 * @return {jQuery} Focusable child, or an empty jQuery object if none found
135 */
136 OO.ui.findFocusable = function ( $container, backwards ) {
137 var $focusable = $( [] ),
138 // $focusableCandidates is a superset of things that
139 // could get matched by isFocusableElement
140 $focusableCandidates = $container
141 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142
143 if ( backwards ) {
144 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 }
146
147 $focusableCandidates.each( function () {
148 var $this = $( this );
149 if ( OO.ui.isFocusableElement( $this ) ) {
150 $focusable = $this;
151 return false;
152 }
153 } );
154 return $focusable;
155 };
156
157 /**
158 * Get the user's language and any fallback languages.
159 *
160 * These language codes are used to localize user interface elements in the user's language.
161 *
162 * In environments that provide a localization system, this function should be overridden to
163 * return the user's language(s). The default implementation returns English (en) only.
164 *
165 * @return {string[]} Language codes, in descending order of priority
166 */
167 OO.ui.getUserLanguages = function () {
168 return [ 'en' ];
169 };
170
171 /**
172 * Get a value in an object keyed by language code.
173 *
174 * @param {Object.<string,Mixed>} obj Object keyed by language code
175 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
176 * @param {string} [fallback] Fallback code, used if no matching language can be found
177 * @return {Mixed} Local value
178 */
179 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
180 var i, len, langs;
181
182 // Requested language
183 if ( obj[ lang ] ) {
184 return obj[ lang ];
185 }
186 // Known user language
187 langs = OO.ui.getUserLanguages();
188 for ( i = 0, len = langs.length; i < len; i++ ) {
189 lang = langs[ i ];
190 if ( obj[ lang ] ) {
191 return obj[ lang ];
192 }
193 }
194 // Fallback language
195 if ( obj[ fallback ] ) {
196 return obj[ fallback ];
197 }
198 // First existing language
199 for ( lang in obj ) {
200 return obj[ lang ];
201 }
202
203 return undefined;
204 };
205
206 /**
207 * Check if a node is contained within another node
208 *
209 * Similar to jQuery#contains except a list of containers can be supplied
210 * and a boolean argument allows you to include the container in the match list
211 *
212 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
213 * @param {HTMLElement} contained Node to find
214 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
273 }
274 };
275
276 /**
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
280 *
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @param {Object} [config] Configuration options
337 * @return {OO.ui.Element}
338 * The `OO.ui.Element` corresponding to this (infusable) document node.
339 */
340 OO.ui.infuse = function ( idOrNode, config ) {
341 return OO.ui.Element.static.infuse( idOrNode, config );
342 };
343
344 ( function () {
345 /**
346 * Message store for the default implementation of OO.ui.msg
347 *
348 * Environments that provide a localization system should not use this, but should override
349 * OO.ui.msg altogether.
350 *
351 * @private
352 */
353 var messages = {
354 // Tool tip for a button that moves items in a list down one place
355 'ooui-outline-control-move-down': 'Move item down',
356 // Tool tip for a button that moves items in a list up one place
357 'ooui-outline-control-move-up': 'Move item up',
358 // Tool tip for a button that removes items from a list
359 'ooui-outline-control-remove': 'Remove item',
360 // Label for the toolbar group that contains a list of all other available tools
361 'ooui-toolbar-more': 'More',
362 // Label for the fake tool that expands the full list of tools in a toolbar group
363 'ooui-toolgroup-expand': 'More',
364 // Label for the fake tool that collapses the full list of tools in a toolbar group
365 'ooui-toolgroup-collapse': 'Fewer',
366 // Default label for the tooltip for the button that removes a tag item
367 'ooui-item-remove': 'Remove',
368 // Default label for the accept button of a confirmation dialog
369 'ooui-dialog-message-accept': 'OK',
370 // Default label for the reject button of a confirmation dialog
371 'ooui-dialog-message-reject': 'Cancel',
372 // Title for process dialog error description
373 'ooui-dialog-process-error': 'Something went wrong',
374 // Label for process dialog dismiss error button, visible when describing errors
375 'ooui-dialog-process-dismiss': 'Dismiss',
376 // Label for process dialog retry action button, visible when describing only recoverable errors
377 'ooui-dialog-process-retry': 'Try again',
378 // Label for process dialog retry action button, visible when describing only warnings
379 'ooui-dialog-process-continue': 'Continue',
380 // Label for the file selection widget's select file button
381 'ooui-selectfile-button-select': 'Select a file',
382 // Label for the file selection widget if file selection is not supported
383 'ooui-selectfile-not-supported': 'File selection is not supported',
384 // Label for the file selection widget when no file is currently selected
385 'ooui-selectfile-placeholder': 'No file is selected',
386 // Label for the file selection widget's drop target
387 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
388 };
389
390 /**
391 * Get a localized message.
392 *
393 * After the message key, message parameters may optionally be passed. In the default implementation,
394 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
395 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
396 * they support unnamed, ordered message parameters.
397 *
398 * In environments that provide a localization system, this function should be overridden to
399 * return the message translated in the user's language. The default implementation always returns
400 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
401 * follows.
402 *
403 * @example
404 * var i, iLen, button,
405 * messagePath = 'oojs-ui/dist/i18n/',
406 * languages = [ $.i18n().locale, 'ur', 'en' ],
407 * languageMap = {};
408 *
409 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
410 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
411 * }
412 *
413 * $.i18n().load( languageMap ).done( function() {
414 * // Replace the built-in `msg` only once we've loaded the internationalization.
415 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
416 * // you put off creating any widgets until this promise is complete, no English
417 * // will be displayed.
418 * OO.ui.msg = $.i18n;
419 *
420 * // A button displaying "OK" in the default locale
421 * button = new OO.ui.ButtonWidget( {
422 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
423 * icon: 'check'
424 * } );
425 * $( 'body' ).append( button.$element );
426 *
427 * // A button displaying "OK" in Urdu
428 * $.i18n().locale = 'ur';
429 * button = new OO.ui.ButtonWidget( {
430 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
431 * icon: 'check'
432 * } );
433 * $( 'body' ).append( button.$element );
434 * } );
435 *
436 * @param {string} key Message key
437 * @param {...Mixed} [params] Message parameters
438 * @return {string} Translated message with parameters substituted
439 */
440 OO.ui.msg = function ( key ) {
441 var message = messages[ key ],
442 params = Array.prototype.slice.call( arguments, 1 );
443 if ( typeof message === 'string' ) {
444 // Perform $1 substitution
445 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
446 var i = parseInt( n, 10 );
447 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
448 } );
449 } else {
450 // Return placeholder if message not found
451 message = '[' + key + ']';
452 }
453 return message;
454 };
455 }() );
456
457 /**
458 * Package a message and arguments for deferred resolution.
459 *
460 * Use this when you are statically specifying a message and the message may not yet be present.
461 *
462 * @param {string} key Message key
463 * @param {...Mixed} [params] Message parameters
464 * @return {Function} Function that returns the resolved message when executed
465 */
466 OO.ui.deferMsg = function () {
467 var args = arguments;
468 return function () {
469 return OO.ui.msg.apply( OO.ui, args );
470 };
471 };
472
473 /**
474 * Resolve a message.
475 *
476 * If the message is a function it will be executed, otherwise it will pass through directly.
477 *
478 * @param {Function|string} msg Deferred message, or message text
479 * @return {string} Resolved message
480 */
481 OO.ui.resolveMsg = function ( msg ) {
482 if ( $.isFunction( msg ) ) {
483 return msg();
484 }
485 return msg;
486 };
487
488 /**
489 * @param {string} url
490 * @return {boolean}
491 */
492 OO.ui.isSafeUrl = function ( url ) {
493 // Keep this function in sync with php/Tag.php
494 var i, protocolWhitelist;
495
496 function stringStartsWith( haystack, needle ) {
497 return haystack.substr( 0, needle.length ) === needle;
498 }
499
500 protocolWhitelist = [
501 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
502 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
503 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
504 ];
505
506 if ( url === '' ) {
507 return true;
508 }
509
510 for ( i = 0; i < protocolWhitelist.length; i++ ) {
511 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
512 return true;
513 }
514 }
515
516 // This matches '//' too
517 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
518 return true;
519 }
520 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
521 return true;
522 }
523
524 return false;
525 };
526
527 /**
528 * Check if the user has a 'mobile' device.
529 *
530 * For our purposes this means the user is primarily using an
531 * on-screen keyboard, touch input instead of a mouse and may
532 * have a physically small display.
533 *
534 * It is left up to implementors to decide how to compute this
535 * so the default implementation always returns false.
536 *
537 * @return {boolean} User is on a mobile device
538 */
539 OO.ui.isMobile = function () {
540 return false;
541 };
542
543 /**
544 * Get the additional spacing that should be taken into account when displaying elements that are
545 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
546 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
547 *
548 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
549 * the extra spacing from that edge of viewport (in pixels)
550 */
551 OO.ui.getViewportSpacing = function () {
552 return {
553 top: 0,
554 right: 0,
555 bottom: 0,
556 left: 0
557 };
558 };
559
560 /**
561 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
562 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
563 *
564 * @return {jQuery} Default overlay node
565 */
566 OO.ui.getDefaultOverlay = function () {
567 if ( !OO.ui.$defaultOverlay ) {
568 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
569 $( 'body' ).append( OO.ui.$defaultOverlay );
570 }
571 return OO.ui.$defaultOverlay;
572 };
573
574 /*!
575 * Mixin namespace.
576 */
577
578 /**
579 * Namespace for OOUI mixins.
580 *
581 * Mixins are named according to the type of object they are intended to
582 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
583 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
584 * is intended to be mixed in to an instance of OO.ui.Widget.
585 *
586 * @class
587 * @singleton
588 */
589 OO.ui.mixin = {};
590
591 /**
592 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
593 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
594 * connected to them and can't be interacted with.
595 *
596 * @abstract
597 * @class
598 *
599 * @constructor
600 * @param {Object} [config] Configuration options
601 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
602 * to the top level (e.g., the outermost div) of the element. See the [OOUI documentation on MediaWiki][2]
603 * for an example.
604 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
605 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
606 * @cfg {string} [text] Text to insert
607 * @cfg {Array} [content] An array of content elements to append (after #text).
608 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
609 * Instances of OO.ui.Element will have their $element appended.
610 * @cfg {jQuery} [$content] Content elements to append (after #text).
611 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
612 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
613 * Data can also be specified with the #setData method.
614 */
615 OO.ui.Element = function OoUiElement( config ) {
616 if ( OO.ui.isDemo ) {
617 this.initialConfig = config;
618 }
619 // Configuration initialization
620 config = config || {};
621
622 // Properties
623 this.$ = $;
624 this.elementId = null;
625 this.visible = true;
626 this.data = config.data;
627 this.$element = config.$element ||
628 $( document.createElement( this.getTagName() ) );
629 this.elementGroup = null;
630
631 // Initialization
632 if ( Array.isArray( config.classes ) ) {
633 this.$element.addClass( config.classes.join( ' ' ) );
634 }
635 if ( config.id ) {
636 this.setElementId( config.id );
637 }
638 if ( config.text ) {
639 this.$element.text( config.text );
640 }
641 if ( config.content ) {
642 // The `content` property treats plain strings as text; use an
643 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
644 // appropriate $element appended.
645 this.$element.append( config.content.map( function ( v ) {
646 if ( typeof v === 'string' ) {
647 // Escape string so it is properly represented in HTML.
648 return document.createTextNode( v );
649 } else if ( v instanceof OO.ui.HtmlSnippet ) {
650 // Bypass escaping.
651 return v.toString();
652 } else if ( v instanceof OO.ui.Element ) {
653 return v.$element;
654 }
655 return v;
656 } ) );
657 }
658 if ( config.$content ) {
659 // The `$content` property treats plain strings as HTML.
660 this.$element.append( config.$content );
661 }
662 };
663
664 /* Setup */
665
666 OO.initClass( OO.ui.Element );
667
668 /* Static Properties */
669
670 /**
671 * The name of the HTML tag used by the element.
672 *
673 * The static value may be ignored if the #getTagName method is overridden.
674 *
675 * @static
676 * @inheritable
677 * @property {string}
678 */
679 OO.ui.Element.static.tagName = 'div';
680
681 /* Static Methods */
682
683 /**
684 * Reconstitute a JavaScript object corresponding to a widget created
685 * by the PHP implementation.
686 *
687 * @param {string|HTMLElement|jQuery} idOrNode
688 * A DOM id (if a string) or node for the widget to infuse.
689 * @param {Object} [config] Configuration options
690 * @return {OO.ui.Element}
691 * The `OO.ui.Element` corresponding to this (infusable) document node.
692 * For `Tag` objects emitted on the HTML side (used occasionally for content)
693 * the value returned is a newly-created Element wrapping around the existing
694 * DOM node.
695 */
696 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
697 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
698 // Verify that the type matches up.
699 // FIXME: uncomment after T89721 is fixed, see T90929.
700 /*
701 if ( !( obj instanceof this['class'] ) ) {
702 throw new Error( 'Infusion type mismatch!' );
703 }
704 */
705 return obj;
706 };
707
708 /**
709 * Implementation helper for `infuse`; skips the type check and has an
710 * extra property so that only the top-level invocation touches the DOM.
711 *
712 * @private
713 * @param {string|HTMLElement|jQuery} idOrNode
714 * @param {Object} [config] Configuration options
715 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
716 * when the top-level widget of this infusion is inserted into DOM,
717 * replacing the original node; only used internally.
718 * @return {OO.ui.Element}
719 */
720 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
721 // look for a cached result of a previous infusion.
722 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
723 if ( typeof idOrNode === 'string' ) {
724 id = idOrNode;
725 $elem = $( document.getElementById( id ) );
726 } else {
727 $elem = $( idOrNode );
728 id = $elem.attr( 'id' );
729 }
730 if ( !$elem.length ) {
731 if ( typeof idOrNode === 'string' ) {
732 error = 'Widget not found: ' + idOrNode;
733 } else if ( idOrNode && idOrNode.selector ) {
734 error = 'Widget not found: ' + idOrNode.selector;
735 } else {
736 error = 'Widget not found';
737 }
738 throw new Error( error );
739 }
740 if ( $elem[ 0 ].oouiInfused ) {
741 $elem = $elem[ 0 ].oouiInfused;
742 }
743 data = $elem.data( 'ooui-infused' );
744 if ( data ) {
745 // cached!
746 if ( data === true ) {
747 throw new Error( 'Circular dependency! ' + id );
748 }
749 if ( domPromise ) {
750 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
751 state = data.constructor.static.gatherPreInfuseState( $elem, data );
752 // restore dynamic state after the new element is re-inserted into DOM under infused parent
753 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
754 infusedChildren = $elem.data( 'ooui-infused-children' );
755 if ( infusedChildren && infusedChildren.length ) {
756 infusedChildren.forEach( function ( data ) {
757 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
758 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
759 } );
760 }
761 }
762 return data;
763 }
764 data = $elem.attr( 'data-ooui' );
765 if ( !data ) {
766 throw new Error( 'No infusion data found: ' + id );
767 }
768 try {
769 data = JSON.parse( data );
770 } catch ( _ ) {
771 data = null;
772 }
773 if ( !( data && data._ ) ) {
774 throw new Error( 'No valid infusion data found: ' + id );
775 }
776 if ( data._ === 'Tag' ) {
777 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
778 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
779 }
780 parts = data._.split( '.' );
781 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
782 if ( cls === undefined ) {
783 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
784 }
785
786 // Verify that we're creating an OO.ui.Element instance
787 parent = cls.parent;
788
789 while ( parent !== undefined ) {
790 if ( parent === OO.ui.Element ) {
791 // Safe
792 break;
793 }
794
795 parent = parent.parent;
796 }
797
798 if ( parent !== OO.ui.Element ) {
799 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
800 }
801
802 if ( !domPromise ) {
803 top = $.Deferred();
804 domPromise = top.promise();
805 }
806 $elem.data( 'ooui-infused', true ); // prevent loops
807 data.id = id; // implicit
808 infusedChildren = [];
809 data = OO.copy( data, null, function deserialize( value ) {
810 var infused;
811 if ( OO.isPlainObject( value ) ) {
812 if ( value.tag ) {
813 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
814 infusedChildren.push( infused );
815 // Flatten the structure
816 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
817 infused.$element.removeData( 'ooui-infused-children' );
818 return infused;
819 }
820 if ( value.html !== undefined ) {
821 return new OO.ui.HtmlSnippet( value.html );
822 }
823 }
824 } );
825 // allow widgets to reuse parts of the DOM
826 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
827 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
828 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
829 // rebuild widget
830 // eslint-disable-next-line new-cap
831 obj = new cls( $.extend( {}, config, data ) );
832 // If anyone is holding a reference to the old DOM element,
833 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
834 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
835 $elem[ 0 ].oouiInfused = obj.$element;
836 // now replace old DOM with this new DOM.
837 if ( top ) {
838 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
839 // so only mutate the DOM if we need to.
840 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
841 $elem.replaceWith( obj.$element );
842 }
843 top.resolve();
844 }
845 obj.$element.data( 'ooui-infused', obj );
846 obj.$element.data( 'ooui-infused-children', infusedChildren );
847 // set the 'data-ooui' attribute so we can identify infused widgets
848 obj.$element.attr( 'data-ooui', '' );
849 // restore dynamic state after the new element is inserted into DOM
850 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
851 return obj;
852 };
853
854 /**
855 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
856 *
857 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
858 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
859 * constructor, which will be given the enhanced config.
860 *
861 * @protected
862 * @param {HTMLElement} node
863 * @param {Object} config
864 * @return {Object}
865 */
866 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
867 return config;
868 };
869
870 /**
871 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
872 * (and its children) that represent an Element of the same class and the given configuration,
873 * generated by the PHP implementation.
874 *
875 * This method is called just before `node` is detached from the DOM. The return value of this
876 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
877 * is inserted into DOM to replace `node`.
878 *
879 * @protected
880 * @param {HTMLElement} node
881 * @param {Object} config
882 * @return {Object}
883 */
884 OO.ui.Element.static.gatherPreInfuseState = function () {
885 return {};
886 };
887
888 /**
889 * Get a jQuery function within a specific document.
890 *
891 * @static
892 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
893 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
894 * not in an iframe
895 * @return {Function} Bound jQuery function
896 */
897 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
898 function wrapper( selector ) {
899 return $( selector, wrapper.context );
900 }
901
902 wrapper.context = this.getDocument( context );
903
904 if ( $iframe ) {
905 wrapper.$iframe = $iframe;
906 }
907
908 return wrapper;
909 };
910
911 /**
912 * Get the document of an element.
913 *
914 * @static
915 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
916 * @return {HTMLDocument|null} Document object
917 */
918 OO.ui.Element.static.getDocument = function ( obj ) {
919 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
920 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
921 // Empty jQuery selections might have a context
922 obj.context ||
923 // HTMLElement
924 obj.ownerDocument ||
925 // Window
926 obj.document ||
927 // HTMLDocument
928 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
929 null;
930 };
931
932 /**
933 * Get the window of an element or document.
934 *
935 * @static
936 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
937 * @return {Window} Window object
938 */
939 OO.ui.Element.static.getWindow = function ( obj ) {
940 var doc = this.getDocument( obj );
941 return doc.defaultView;
942 };
943
944 /**
945 * Get the direction of an element or document.
946 *
947 * @static
948 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
949 * @return {string} Text direction, either 'ltr' or 'rtl'
950 */
951 OO.ui.Element.static.getDir = function ( obj ) {
952 var isDoc, isWin;
953
954 if ( obj instanceof jQuery ) {
955 obj = obj[ 0 ];
956 }
957 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
958 isWin = obj.document !== undefined;
959 if ( isDoc || isWin ) {
960 if ( isWin ) {
961 obj = obj.document;
962 }
963 obj = obj.body;
964 }
965 return $( obj ).css( 'direction' );
966 };
967
968 /**
969 * Get the offset between two frames.
970 *
971 * TODO: Make this function not use recursion.
972 *
973 * @static
974 * @param {Window} from Window of the child frame
975 * @param {Window} [to=window] Window of the parent frame
976 * @param {Object} [offset] Offset to start with, used internally
977 * @return {Object} Offset object, containing left and top properties
978 */
979 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
980 var i, len, frames, frame, rect;
981
982 if ( !to ) {
983 to = window;
984 }
985 if ( !offset ) {
986 offset = { top: 0, left: 0 };
987 }
988 if ( from.parent === from ) {
989 return offset;
990 }
991
992 // Get iframe element
993 frames = from.parent.document.getElementsByTagName( 'iframe' );
994 for ( i = 0, len = frames.length; i < len; i++ ) {
995 if ( frames[ i ].contentWindow === from ) {
996 frame = frames[ i ];
997 break;
998 }
999 }
1000
1001 // Recursively accumulate offset values
1002 if ( frame ) {
1003 rect = frame.getBoundingClientRect();
1004 offset.left += rect.left;
1005 offset.top += rect.top;
1006 if ( from !== to ) {
1007 this.getFrameOffset( from.parent, offset );
1008 }
1009 }
1010 return offset;
1011 };
1012
1013 /**
1014 * Get the offset between two elements.
1015 *
1016 * The two elements may be in a different frame, but in that case the frame $element is in must
1017 * be contained in the frame $anchor is in.
1018 *
1019 * @static
1020 * @param {jQuery} $element Element whose position to get
1021 * @param {jQuery} $anchor Element to get $element's position relative to
1022 * @return {Object} Translated position coordinates, containing top and left properties
1023 */
1024 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1025 var iframe, iframePos,
1026 pos = $element.offset(),
1027 anchorPos = $anchor.offset(),
1028 elementDocument = this.getDocument( $element ),
1029 anchorDocument = this.getDocument( $anchor );
1030
1031 // If $element isn't in the same document as $anchor, traverse up
1032 while ( elementDocument !== anchorDocument ) {
1033 iframe = elementDocument.defaultView.frameElement;
1034 if ( !iframe ) {
1035 throw new Error( '$element frame is not contained in $anchor frame' );
1036 }
1037 iframePos = $( iframe ).offset();
1038 pos.left += iframePos.left;
1039 pos.top += iframePos.top;
1040 elementDocument = iframe.ownerDocument;
1041 }
1042 pos.left -= anchorPos.left;
1043 pos.top -= anchorPos.top;
1044 return pos;
1045 };
1046
1047 /**
1048 * Get element border sizes.
1049 *
1050 * @static
1051 * @param {HTMLElement} el Element to measure
1052 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1053 */
1054 OO.ui.Element.static.getBorders = function ( el ) {
1055 var doc = el.ownerDocument,
1056 win = doc.defaultView,
1057 style = win.getComputedStyle( el, null ),
1058 $el = $( el ),
1059 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1060 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1061 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1062 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1063
1064 return {
1065 top: top,
1066 left: left,
1067 bottom: bottom,
1068 right: right
1069 };
1070 };
1071
1072 /**
1073 * Get dimensions of an element or window.
1074 *
1075 * @static
1076 * @param {HTMLElement|Window} el Element to measure
1077 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1078 */
1079 OO.ui.Element.static.getDimensions = function ( el ) {
1080 var $el, $win,
1081 doc = el.ownerDocument || el.document,
1082 win = doc.defaultView;
1083
1084 if ( win === el || el === doc.documentElement ) {
1085 $win = $( win );
1086 return {
1087 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1088 scroll: {
1089 top: $win.scrollTop(),
1090 left: $win.scrollLeft()
1091 },
1092 scrollbar: { right: 0, bottom: 0 },
1093 rect: {
1094 top: 0,
1095 left: 0,
1096 bottom: $win.innerHeight(),
1097 right: $win.innerWidth()
1098 }
1099 };
1100 } else {
1101 $el = $( el );
1102 return {
1103 borders: this.getBorders( el ),
1104 scroll: {
1105 top: $el.scrollTop(),
1106 left: $el.scrollLeft()
1107 },
1108 scrollbar: {
1109 right: $el.innerWidth() - el.clientWidth,
1110 bottom: $el.innerHeight() - el.clientHeight
1111 },
1112 rect: el.getBoundingClientRect()
1113 };
1114 }
1115 };
1116
1117 /**
1118 * Get the number of pixels that an element's content is scrolled to the left.
1119 *
1120 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1121 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1122 *
1123 * This function smooths out browser inconsistencies (nicely described in the README at
1124 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1125 * with Firefox's 'scrollLeft', which seems the sanest.
1126 *
1127 * @static
1128 * @method
1129 * @param {HTMLElement|Window} el Element to measure
1130 * @return {number} Scroll position from the left.
1131 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1132 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1133 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1134 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1135 */
1136 OO.ui.Element.static.getScrollLeft = ( function () {
1137 var rtlScrollType = null;
1138
1139 function test() {
1140 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1141 definer = $definer[ 0 ];
1142
1143 $definer.appendTo( 'body' );
1144 if ( definer.scrollLeft > 0 ) {
1145 // Safari, Chrome
1146 rtlScrollType = 'default';
1147 } else {
1148 definer.scrollLeft = 1;
1149 if ( definer.scrollLeft === 0 ) {
1150 // Firefox, old Opera
1151 rtlScrollType = 'negative';
1152 } else {
1153 // Internet Explorer, Edge
1154 rtlScrollType = 'reverse';
1155 }
1156 }
1157 $definer.remove();
1158 }
1159
1160 return function getScrollLeft( el ) {
1161 var isRoot = el.window === el ||
1162 el === el.ownerDocument.body ||
1163 el === el.ownerDocument.documentElement,
1164 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1165 // All browsers use the correct scroll type ('negative') on the root, so don't
1166 // do any fixups when looking at the root element
1167 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1168
1169 if ( direction === 'rtl' ) {
1170 if ( rtlScrollType === null ) {
1171 test();
1172 }
1173 if ( rtlScrollType === 'reverse' ) {
1174 scrollLeft = -scrollLeft;
1175 } else if ( rtlScrollType === 'default' ) {
1176 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1177 }
1178 }
1179
1180 return scrollLeft;
1181 };
1182 }() );
1183
1184 /**
1185 * Get the root scrollable element of given element's document.
1186 *
1187 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1188 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1189 * lets us use 'body' or 'documentElement' based on what is working.
1190 *
1191 * https://code.google.com/p/chromium/issues/detail?id=303131
1192 *
1193 * @static
1194 * @param {HTMLElement} el Element to find root scrollable parent for
1195 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1196 * depending on browser
1197 */
1198 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1199 var scrollTop, body;
1200
1201 if ( OO.ui.scrollableElement === undefined ) {
1202 body = el.ownerDocument.body;
1203 scrollTop = body.scrollTop;
1204 body.scrollTop = 1;
1205
1206 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1207 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1208 if ( Math.round( body.scrollTop ) === 1 ) {
1209 body.scrollTop = scrollTop;
1210 OO.ui.scrollableElement = 'body';
1211 } else {
1212 OO.ui.scrollableElement = 'documentElement';
1213 }
1214 }
1215
1216 return el.ownerDocument[ OO.ui.scrollableElement ];
1217 };
1218
1219 /**
1220 * Get closest scrollable container.
1221 *
1222 * Traverses up until either a scrollable element or the root is reached, in which case the root
1223 * scrollable element will be returned (see #getRootScrollableElement).
1224 *
1225 * @static
1226 * @param {HTMLElement} el Element to find scrollable container for
1227 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1228 * @return {HTMLElement} Closest scrollable container
1229 */
1230 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1231 var i, val,
1232 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1233 // 'overflow-y' have different values, so we need to check the separate properties.
1234 props = [ 'overflow-x', 'overflow-y' ],
1235 $parent = $( el ).parent();
1236
1237 if ( dimension === 'x' || dimension === 'y' ) {
1238 props = [ 'overflow-' + dimension ];
1239 }
1240
1241 // Special case for the document root (which doesn't really have any scrollable container, since
1242 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1243 if ( $( el ).is( 'html, body' ) ) {
1244 return this.getRootScrollableElement( el );
1245 }
1246
1247 while ( $parent.length ) {
1248 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1249 return $parent[ 0 ];
1250 }
1251 i = props.length;
1252 while ( i-- ) {
1253 val = $parent.css( props[ i ] );
1254 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1255 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1256 // unintentionally perform a scroll in such case even if the application doesn't scroll
1257 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1258 // This could cause funny issues...
1259 if ( val === 'auto' || val === 'scroll' ) {
1260 return $parent[ 0 ];
1261 }
1262 }
1263 $parent = $parent.parent();
1264 }
1265 // The element is unattached... return something mostly sane
1266 return this.getRootScrollableElement( el );
1267 };
1268
1269 /**
1270 * Scroll element into view.
1271 *
1272 * @static
1273 * @param {HTMLElement} el Element to scroll into view
1274 * @param {Object} [config] Configuration options
1275 * @param {string} [config.duration='fast'] jQuery animation duration value
1276 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1277 * to scroll in both directions
1278 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1279 */
1280 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1281 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1282 deferred = $.Deferred();
1283
1284 // Configuration initialization
1285 config = config || {};
1286
1287 animations = {};
1288 container = this.getClosestScrollableContainer( el, config.direction );
1289 $container = $( container );
1290 elementDimensions = this.getDimensions( el );
1291 containerDimensions = this.getDimensions( container );
1292 $window = $( this.getWindow( el ) );
1293
1294 // Compute the element's position relative to the container
1295 if ( $container.is( 'html, body' ) ) {
1296 // If the scrollable container is the root, this is easy
1297 position = {
1298 top: elementDimensions.rect.top,
1299 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1300 left: elementDimensions.rect.left,
1301 right: $window.innerWidth() - elementDimensions.rect.right
1302 };
1303 } else {
1304 // Otherwise, we have to subtract el's coordinates from container's coordinates
1305 position = {
1306 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1307 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1308 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1309 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1310 };
1311 }
1312
1313 if ( !config.direction || config.direction === 'y' ) {
1314 if ( position.top < 0 ) {
1315 animations.scrollTop = containerDimensions.scroll.top + position.top;
1316 } else if ( position.top > 0 && position.bottom < 0 ) {
1317 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1318 }
1319 }
1320 if ( !config.direction || config.direction === 'x' ) {
1321 if ( position.left < 0 ) {
1322 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1323 } else if ( position.left > 0 && position.right < 0 ) {
1324 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1325 }
1326 }
1327 if ( !$.isEmptyObject( animations ) ) {
1328 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1329 $container.queue( function ( next ) {
1330 deferred.resolve();
1331 next();
1332 } );
1333 } else {
1334 deferred.resolve();
1335 }
1336 return deferred.promise();
1337 };
1338
1339 /**
1340 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1341 * and reserve space for them, because it probably doesn't.
1342 *
1343 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1344 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1345 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1346 * and then reattach (or show) them back.
1347 *
1348 * @static
1349 * @param {HTMLElement} el Element to reconsider the scrollbars on
1350 */
1351 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1352 var i, len, scrollLeft, scrollTop, nodes = [];
1353 // Save scroll position
1354 scrollLeft = el.scrollLeft;
1355 scrollTop = el.scrollTop;
1356 // Detach all children
1357 while ( el.firstChild ) {
1358 nodes.push( el.firstChild );
1359 el.removeChild( el.firstChild );
1360 }
1361 // Force reflow
1362 void el.offsetHeight;
1363 // Reattach all children
1364 for ( i = 0, len = nodes.length; i < len; i++ ) {
1365 el.appendChild( nodes[ i ] );
1366 }
1367 // Restore scroll position (no-op if scrollbars disappeared)
1368 el.scrollLeft = scrollLeft;
1369 el.scrollTop = scrollTop;
1370 };
1371
1372 /* Methods */
1373
1374 /**
1375 * Toggle visibility of an element.
1376 *
1377 * @param {boolean} [show] Make element visible, omit to toggle visibility
1378 * @fires visible
1379 * @chainable
1380 */
1381 OO.ui.Element.prototype.toggle = function ( show ) {
1382 show = show === undefined ? !this.visible : !!show;
1383
1384 if ( show !== this.isVisible() ) {
1385 this.visible = show;
1386 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1387 this.emit( 'toggle', show );
1388 }
1389
1390 return this;
1391 };
1392
1393 /**
1394 * Check if element is visible.
1395 *
1396 * @return {boolean} element is visible
1397 */
1398 OO.ui.Element.prototype.isVisible = function () {
1399 return this.visible;
1400 };
1401
1402 /**
1403 * Get element data.
1404 *
1405 * @return {Mixed} Element data
1406 */
1407 OO.ui.Element.prototype.getData = function () {
1408 return this.data;
1409 };
1410
1411 /**
1412 * Set element data.
1413 *
1414 * @param {Mixed} data Element data
1415 * @chainable
1416 */
1417 OO.ui.Element.prototype.setData = function ( data ) {
1418 this.data = data;
1419 return this;
1420 };
1421
1422 /**
1423 * Set the element has an 'id' attribute.
1424 *
1425 * @param {string} id
1426 * @chainable
1427 */
1428 OO.ui.Element.prototype.setElementId = function ( id ) {
1429 this.elementId = id;
1430 this.$element.attr( 'id', id );
1431 return this;
1432 };
1433
1434 /**
1435 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1436 * and return its value.
1437 *
1438 * @return {string}
1439 */
1440 OO.ui.Element.prototype.getElementId = function () {
1441 if ( this.elementId === null ) {
1442 this.setElementId( OO.ui.generateElementId() );
1443 }
1444 return this.elementId;
1445 };
1446
1447 /**
1448 * Check if element supports one or more methods.
1449 *
1450 * @param {string|string[]} methods Method or list of methods to check
1451 * @return {boolean} All methods are supported
1452 */
1453 OO.ui.Element.prototype.supports = function ( methods ) {
1454 var i, len,
1455 support = 0;
1456
1457 methods = Array.isArray( methods ) ? methods : [ methods ];
1458 for ( i = 0, len = methods.length; i < len; i++ ) {
1459 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1460 support++;
1461 }
1462 }
1463
1464 return methods.length === support;
1465 };
1466
1467 /**
1468 * Update the theme-provided classes.
1469 *
1470 * @localdoc This is called in element mixins and widget classes any time state changes.
1471 * Updating is debounced, minimizing overhead of changing multiple attributes and
1472 * guaranteeing that theme updates do not occur within an element's constructor
1473 */
1474 OO.ui.Element.prototype.updateThemeClasses = function () {
1475 OO.ui.theme.queueUpdateElementClasses( this );
1476 };
1477
1478 /**
1479 * Get the HTML tag name.
1480 *
1481 * Override this method to base the result on instance information.
1482 *
1483 * @return {string} HTML tag name
1484 */
1485 OO.ui.Element.prototype.getTagName = function () {
1486 return this.constructor.static.tagName;
1487 };
1488
1489 /**
1490 * Check if the element is attached to the DOM
1491 *
1492 * @return {boolean} The element is attached to the DOM
1493 */
1494 OO.ui.Element.prototype.isElementAttached = function () {
1495 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1496 };
1497
1498 /**
1499 * Get the DOM document.
1500 *
1501 * @return {HTMLDocument} Document object
1502 */
1503 OO.ui.Element.prototype.getElementDocument = function () {
1504 // Don't cache this in other ways either because subclasses could can change this.$element
1505 return OO.ui.Element.static.getDocument( this.$element );
1506 };
1507
1508 /**
1509 * Get the DOM window.
1510 *
1511 * @return {Window} Window object
1512 */
1513 OO.ui.Element.prototype.getElementWindow = function () {
1514 return OO.ui.Element.static.getWindow( this.$element );
1515 };
1516
1517 /**
1518 * Get closest scrollable container.
1519 *
1520 * @return {HTMLElement} Closest scrollable container
1521 */
1522 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1523 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1524 };
1525
1526 /**
1527 * Get group element is in.
1528 *
1529 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1530 */
1531 OO.ui.Element.prototype.getElementGroup = function () {
1532 return this.elementGroup;
1533 };
1534
1535 /**
1536 * Set group element is in.
1537 *
1538 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1539 * @chainable
1540 */
1541 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1542 this.elementGroup = group;
1543 return this;
1544 };
1545
1546 /**
1547 * Scroll element into view.
1548 *
1549 * @param {Object} [config] Configuration options
1550 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1551 */
1552 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1553 if (
1554 !this.isElementAttached() ||
1555 !this.isVisible() ||
1556 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1557 ) {
1558 return $.Deferred().resolve();
1559 }
1560 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1561 };
1562
1563 /**
1564 * Restore the pre-infusion dynamic state for this widget.
1565 *
1566 * This method is called after #$element has been inserted into DOM. The parameter is the return
1567 * value of #gatherPreInfuseState.
1568 *
1569 * @protected
1570 * @param {Object} state
1571 */
1572 OO.ui.Element.prototype.restorePreInfuseState = function () {
1573 };
1574
1575 /**
1576 * Wraps an HTML snippet for use with configuration values which default
1577 * to strings. This bypasses the default html-escaping done to string
1578 * values.
1579 *
1580 * @class
1581 *
1582 * @constructor
1583 * @param {string} [content] HTML content
1584 */
1585 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1586 // Properties
1587 this.content = content;
1588 };
1589
1590 /* Setup */
1591
1592 OO.initClass( OO.ui.HtmlSnippet );
1593
1594 /* Methods */
1595
1596 /**
1597 * Render into HTML.
1598 *
1599 * @return {string} Unchanged HTML snippet.
1600 */
1601 OO.ui.HtmlSnippet.prototype.toString = function () {
1602 return this.content;
1603 };
1604
1605 /**
1606 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1607 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1608 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1609 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1610 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1611 *
1612 * @abstract
1613 * @class
1614 * @extends OO.ui.Element
1615 * @mixins OO.EventEmitter
1616 *
1617 * @constructor
1618 * @param {Object} [config] Configuration options
1619 */
1620 OO.ui.Layout = function OoUiLayout( config ) {
1621 // Configuration initialization
1622 config = config || {};
1623
1624 // Parent constructor
1625 OO.ui.Layout.parent.call( this, config );
1626
1627 // Mixin constructors
1628 OO.EventEmitter.call( this );
1629
1630 // Initialization
1631 this.$element.addClass( 'oo-ui-layout' );
1632 };
1633
1634 /* Setup */
1635
1636 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1637 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1638
1639 /**
1640 * Widgets are compositions of one or more OOUI elements that users can both view
1641 * and interact with. All widgets can be configured and modified via a standard API,
1642 * and their state can change dynamically according to a model.
1643 *
1644 * @abstract
1645 * @class
1646 * @extends OO.ui.Element
1647 * @mixins OO.EventEmitter
1648 *
1649 * @constructor
1650 * @param {Object} [config] Configuration options
1651 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1652 * appearance reflects this state.
1653 */
1654 OO.ui.Widget = function OoUiWidget( config ) {
1655 // Initialize config
1656 config = $.extend( { disabled: false }, config );
1657
1658 // Parent constructor
1659 OO.ui.Widget.parent.call( this, config );
1660
1661 // Mixin constructors
1662 OO.EventEmitter.call( this );
1663
1664 // Properties
1665 this.disabled = null;
1666 this.wasDisabled = null;
1667
1668 // Initialization
1669 this.$element.addClass( 'oo-ui-widget' );
1670 this.setDisabled( !!config.disabled );
1671 };
1672
1673 /* Setup */
1674
1675 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1676 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1677
1678 /* Events */
1679
1680 /**
1681 * @event disable
1682 *
1683 * A 'disable' event is emitted when the disabled state of the widget changes
1684 * (i.e. on disable **and** enable).
1685 *
1686 * @param {boolean} disabled Widget is disabled
1687 */
1688
1689 /**
1690 * @event toggle
1691 *
1692 * A 'toggle' event is emitted when the visibility of the widget changes.
1693 *
1694 * @param {boolean} visible Widget is visible
1695 */
1696
1697 /* Methods */
1698
1699 /**
1700 * Check if the widget is disabled.
1701 *
1702 * @return {boolean} Widget is disabled
1703 */
1704 OO.ui.Widget.prototype.isDisabled = function () {
1705 return this.disabled;
1706 };
1707
1708 /**
1709 * Set the 'disabled' state of the widget.
1710 *
1711 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1712 *
1713 * @param {boolean} disabled Disable widget
1714 * @chainable
1715 */
1716 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1717 var isDisabled;
1718
1719 this.disabled = !!disabled;
1720 isDisabled = this.isDisabled();
1721 if ( isDisabled !== this.wasDisabled ) {
1722 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1723 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1724 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1725 this.emit( 'disable', isDisabled );
1726 this.updateThemeClasses();
1727 }
1728 this.wasDisabled = isDisabled;
1729
1730 return this;
1731 };
1732
1733 /**
1734 * Update the disabled state, in case of changes in parent widget.
1735 *
1736 * @chainable
1737 */
1738 OO.ui.Widget.prototype.updateDisabled = function () {
1739 this.setDisabled( this.disabled );
1740 return this;
1741 };
1742
1743 /**
1744 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1745 * value.
1746 *
1747 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1748 * instead.
1749 *
1750 * @return {string|null} The ID of the labelable element
1751 */
1752 OO.ui.Widget.prototype.getInputId = function () {
1753 return null;
1754 };
1755
1756 /**
1757 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1758 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1759 * override this method to provide intuitive, accessible behavior.
1760 *
1761 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1762 * Individual widgets may override it too.
1763 *
1764 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1765 * directly.
1766 */
1767 OO.ui.Widget.prototype.simulateLabelClick = function () {
1768 };
1769
1770 /**
1771 * Theme logic.
1772 *
1773 * @abstract
1774 * @class
1775 *
1776 * @constructor
1777 */
1778 OO.ui.Theme = function OoUiTheme() {
1779 this.elementClassesQueue = [];
1780 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1781 };
1782
1783 /* Setup */
1784
1785 OO.initClass( OO.ui.Theme );
1786
1787 /* Methods */
1788
1789 /**
1790 * Get a list of classes to be applied to a widget.
1791 *
1792 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1793 * otherwise state transitions will not work properly.
1794 *
1795 * @param {OO.ui.Element} element Element for which to get classes
1796 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1797 */
1798 OO.ui.Theme.prototype.getElementClasses = function () {
1799 return { on: [], off: [] };
1800 };
1801
1802 /**
1803 * Update CSS classes provided by the theme.
1804 *
1805 * For elements with theme logic hooks, this should be called any time there's a state change.
1806 *
1807 * @param {OO.ui.Element} element Element for which to update classes
1808 */
1809 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1810 var $elements = $( [] ),
1811 classes = this.getElementClasses( element );
1812
1813 if ( element.$icon ) {
1814 $elements = $elements.add( element.$icon );
1815 }
1816 if ( element.$indicator ) {
1817 $elements = $elements.add( element.$indicator );
1818 }
1819
1820 $elements
1821 .removeClass( classes.off.join( ' ' ) )
1822 .addClass( classes.on.join( ' ' ) );
1823 };
1824
1825 /**
1826 * @private
1827 */
1828 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1829 var i;
1830 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1831 this.updateElementClasses( this.elementClassesQueue[ i ] );
1832 }
1833 // Clear the queue
1834 this.elementClassesQueue = [];
1835 };
1836
1837 /**
1838 * Queue #updateElementClasses to be called for this element.
1839 *
1840 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1841 * to make them synchronous.
1842 *
1843 * @param {OO.ui.Element} element Element for which to update classes
1844 */
1845 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1846 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1847 // the most common case (this method is often called repeatedly for the same element).
1848 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1849 return;
1850 }
1851 this.elementClassesQueue.push( element );
1852 this.debouncedUpdateQueuedElementClasses();
1853 };
1854
1855 /**
1856 * Get the transition duration in milliseconds for dialogs opening/closing
1857 *
1858 * The dialog should be fully rendered this many milliseconds after the
1859 * ready process has executed.
1860 *
1861 * @return {number} Transition duration in milliseconds
1862 */
1863 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1864 return 0;
1865 };
1866
1867 /**
1868 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1869 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1870 * order in which users will navigate through the focusable elements via the "tab" key.
1871 *
1872 * @example
1873 * // TabIndexedElement is mixed into the ButtonWidget class
1874 * // to provide a tabIndex property.
1875 * var button1 = new OO.ui.ButtonWidget( {
1876 * label: 'fourth',
1877 * tabIndex: 4
1878 * } );
1879 * var button2 = new OO.ui.ButtonWidget( {
1880 * label: 'second',
1881 * tabIndex: 2
1882 * } );
1883 * var button3 = new OO.ui.ButtonWidget( {
1884 * label: 'third',
1885 * tabIndex: 3
1886 * } );
1887 * var button4 = new OO.ui.ButtonWidget( {
1888 * label: 'first',
1889 * tabIndex: 1
1890 * } );
1891 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1892 *
1893 * @abstract
1894 * @class
1895 *
1896 * @constructor
1897 * @param {Object} [config] Configuration options
1898 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1899 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1900 * functionality will be applied to it instead.
1901 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1902 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1903 * to remove the element from the tab-navigation flow.
1904 */
1905 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1906 // Configuration initialization
1907 config = $.extend( { tabIndex: 0 }, config );
1908
1909 // Properties
1910 this.$tabIndexed = null;
1911 this.tabIndex = null;
1912
1913 // Events
1914 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1915
1916 // Initialization
1917 this.setTabIndex( config.tabIndex );
1918 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1919 };
1920
1921 /* Setup */
1922
1923 OO.initClass( OO.ui.mixin.TabIndexedElement );
1924
1925 /* Methods */
1926
1927 /**
1928 * Set the element that should use the tabindex functionality.
1929 *
1930 * This method is used to retarget a tabindex mixin so that its functionality applies
1931 * to the specified element. If an element is currently using the functionality, the mixin’s
1932 * effect on that element is removed before the new element is set up.
1933 *
1934 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1935 * @chainable
1936 */
1937 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1938 var tabIndex = this.tabIndex;
1939 // Remove attributes from old $tabIndexed
1940 this.setTabIndex( null );
1941 // Force update of new $tabIndexed
1942 this.$tabIndexed = $tabIndexed;
1943 this.tabIndex = tabIndex;
1944 return this.updateTabIndex();
1945 };
1946
1947 /**
1948 * Set the value of the tabindex.
1949 *
1950 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1951 * @chainable
1952 */
1953 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1954 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1955
1956 if ( this.tabIndex !== tabIndex ) {
1957 this.tabIndex = tabIndex;
1958 this.updateTabIndex();
1959 }
1960
1961 return this;
1962 };
1963
1964 /**
1965 * Update the `tabindex` attribute, in case of changes to tab index or
1966 * disabled state.
1967 *
1968 * @private
1969 * @chainable
1970 */
1971 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1972 if ( this.$tabIndexed ) {
1973 if ( this.tabIndex !== null ) {
1974 // Do not index over disabled elements
1975 this.$tabIndexed.attr( {
1976 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1977 // Support: ChromeVox and NVDA
1978 // These do not seem to inherit aria-disabled from parent elements
1979 'aria-disabled': this.isDisabled().toString()
1980 } );
1981 } else {
1982 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1983 }
1984 }
1985 return this;
1986 };
1987
1988 /**
1989 * Handle disable events.
1990 *
1991 * @private
1992 * @param {boolean} disabled Element is disabled
1993 */
1994 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1995 this.updateTabIndex();
1996 };
1997
1998 /**
1999 * Get the value of the tabindex.
2000 *
2001 * @return {number|null} Tabindex value
2002 */
2003 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2004 return this.tabIndex;
2005 };
2006
2007 /**
2008 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2009 *
2010 * If the element already has an ID then that is returned, otherwise unique ID is
2011 * generated, set on the element, and returned.
2012 *
2013 * @return {string|null} The ID of the focusable element
2014 */
2015 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2016 var id;
2017
2018 if ( !this.$tabIndexed ) {
2019 return null;
2020 }
2021 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2022 return null;
2023 }
2024
2025 id = this.$tabIndexed.attr( 'id' );
2026 if ( id === undefined ) {
2027 id = OO.ui.generateElementId();
2028 this.$tabIndexed.attr( 'id', id );
2029 }
2030
2031 return id;
2032 };
2033
2034 /**
2035 * Whether the node is 'labelable' according to the HTML spec
2036 * (i.e., whether it can be interacted with through a `<label for="…">`).
2037 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2038 *
2039 * @private
2040 * @param {jQuery} $node
2041 * @return {boolean}
2042 */
2043 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2044 var
2045 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2046 tagName = $node.prop( 'tagName' ).toLowerCase();
2047
2048 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2049 return true;
2050 }
2051 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2052 return true;
2053 }
2054 return false;
2055 };
2056
2057 /**
2058 * Focus this element.
2059 *
2060 * @chainable
2061 */
2062 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2063 if ( !this.isDisabled() ) {
2064 this.$tabIndexed.focus();
2065 }
2066 return this;
2067 };
2068
2069 /**
2070 * Blur this element.
2071 *
2072 * @chainable
2073 */
2074 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2075 this.$tabIndexed.blur();
2076 return this;
2077 };
2078
2079 /**
2080 * @inheritdoc OO.ui.Widget
2081 */
2082 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2083 this.focus();
2084 };
2085
2086 /**
2087 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2088 * interface element that can be configured with access keys for accessibility.
2089 * See the [OOUI documentation on MediaWiki] [1] for examples.
2090 *
2091 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2092 *
2093 * @abstract
2094 * @class
2095 *
2096 * @constructor
2097 * @param {Object} [config] Configuration options
2098 * @cfg {jQuery} [$button] The button element created by the class.
2099 * If this configuration is omitted, the button element will use a generated `<a>`.
2100 * @cfg {boolean} [framed=true] Render the button with a frame
2101 */
2102 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2103 // Configuration initialization
2104 config = config || {};
2105
2106 // Properties
2107 this.$button = null;
2108 this.framed = null;
2109 this.active = config.active !== undefined && config.active;
2110 this.onMouseUpHandler = this.onMouseUp.bind( this );
2111 this.onMouseDownHandler = this.onMouseDown.bind( this );
2112 this.onKeyDownHandler = this.onKeyDown.bind( this );
2113 this.onKeyUpHandler = this.onKeyUp.bind( this );
2114 this.onClickHandler = this.onClick.bind( this );
2115 this.onKeyPressHandler = this.onKeyPress.bind( this );
2116
2117 // Initialization
2118 this.$element.addClass( 'oo-ui-buttonElement' );
2119 this.toggleFramed( config.framed === undefined || config.framed );
2120 this.setButtonElement( config.$button || $( '<a>' ) );
2121 };
2122
2123 /* Setup */
2124
2125 OO.initClass( OO.ui.mixin.ButtonElement );
2126
2127 /* Static Properties */
2128
2129 /**
2130 * Cancel mouse down events.
2131 *
2132 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2133 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2134 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2135 * parent widget.
2136 *
2137 * @static
2138 * @inheritable
2139 * @property {boolean}
2140 */
2141 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2142
2143 /* Events */
2144
2145 /**
2146 * A 'click' event is emitted when the button element is clicked.
2147 *
2148 * @event click
2149 */
2150
2151 /* Methods */
2152
2153 /**
2154 * Set the button element.
2155 *
2156 * This method is used to retarget a button mixin so that its functionality applies to
2157 * the specified button element instead of the one created by the class. If a button element
2158 * is already set, the method will remove the mixin’s effect on that element.
2159 *
2160 * @param {jQuery} $button Element to use as button
2161 */
2162 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2163 if ( this.$button ) {
2164 this.$button
2165 .removeClass( 'oo-ui-buttonElement-button' )
2166 .removeAttr( 'role accesskey' )
2167 .off( {
2168 mousedown: this.onMouseDownHandler,
2169 keydown: this.onKeyDownHandler,
2170 click: this.onClickHandler,
2171 keypress: this.onKeyPressHandler
2172 } );
2173 }
2174
2175 this.$button = $button
2176 .addClass( 'oo-ui-buttonElement-button' )
2177 .on( {
2178 mousedown: this.onMouseDownHandler,
2179 keydown: this.onKeyDownHandler,
2180 click: this.onClickHandler,
2181 keypress: this.onKeyPressHandler
2182 } );
2183
2184 // Add `role="button"` on `<a>` elements, where it's needed
2185 // `toUppercase()` is added for XHTML documents
2186 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2187 this.$button.attr( 'role', 'button' );
2188 }
2189 };
2190
2191 /**
2192 * Handles mouse down events.
2193 *
2194 * @protected
2195 * @param {jQuery.Event} e Mouse down event
2196 */
2197 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2198 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2199 return;
2200 }
2201 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2202 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2203 // reliably remove the pressed class
2204 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2205 // Prevent change of focus unless specifically configured otherwise
2206 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2207 return false;
2208 }
2209 };
2210
2211 /**
2212 * Handles mouse up events.
2213 *
2214 * @protected
2215 * @param {MouseEvent} e Mouse up event
2216 */
2217 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2218 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2219 return;
2220 }
2221 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2222 // Stop listening for mouseup, since we only needed this once
2223 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2224 };
2225
2226 /**
2227 * Handles mouse click events.
2228 *
2229 * @protected
2230 * @param {jQuery.Event} e Mouse click event
2231 * @fires click
2232 */
2233 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2234 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2235 if ( this.emit( 'click' ) ) {
2236 return false;
2237 }
2238 }
2239 };
2240
2241 /**
2242 * Handles key down events.
2243 *
2244 * @protected
2245 * @param {jQuery.Event} e Key down event
2246 */
2247 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2248 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2249 return;
2250 }
2251 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2252 // Run the keyup handler no matter where the key is when the button is let go, so we can
2253 // reliably remove the pressed class
2254 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2255 };
2256
2257 /**
2258 * Handles key up events.
2259 *
2260 * @protected
2261 * @param {KeyboardEvent} e Key up event
2262 */
2263 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2264 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2265 return;
2266 }
2267 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2268 // Stop listening for keyup, since we only needed this once
2269 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2270 };
2271
2272 /**
2273 * Handles key press events.
2274 *
2275 * @protected
2276 * @param {jQuery.Event} e Key press event
2277 * @fires click
2278 */
2279 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2280 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2281 if ( this.emit( 'click' ) ) {
2282 return false;
2283 }
2284 }
2285 };
2286
2287 /**
2288 * Check if button has a frame.
2289 *
2290 * @return {boolean} Button is framed
2291 */
2292 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2293 return this.framed;
2294 };
2295
2296 /**
2297 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2298 *
2299 * @param {boolean} [framed] Make button framed, omit to toggle
2300 * @chainable
2301 */
2302 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2303 framed = framed === undefined ? !this.framed : !!framed;
2304 if ( framed !== this.framed ) {
2305 this.framed = framed;
2306 this.$element
2307 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2308 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2309 this.updateThemeClasses();
2310 }
2311
2312 return this;
2313 };
2314
2315 /**
2316 * Set the button's active state.
2317 *
2318 * The active state can be set on:
2319 *
2320 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2321 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2322 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2323 *
2324 * @protected
2325 * @param {boolean} value Make button active
2326 * @chainable
2327 */
2328 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2329 this.active = !!value;
2330 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2331 this.updateThemeClasses();
2332 return this;
2333 };
2334
2335 /**
2336 * Check if the button is active
2337 *
2338 * @protected
2339 * @return {boolean} The button is active
2340 */
2341 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2342 return this.active;
2343 };
2344
2345 /**
2346 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2347 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2348 * items from the group is done through the interface the class provides.
2349 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2350 *
2351 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2352 *
2353 * @abstract
2354 * @mixins OO.EmitterList
2355 * @class
2356 *
2357 * @constructor
2358 * @param {Object} [config] Configuration options
2359 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2360 * is omitted, the group element will use a generated `<div>`.
2361 */
2362 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2363 // Configuration initialization
2364 config = config || {};
2365
2366 // Mixin constructors
2367 OO.EmitterList.call( this, config );
2368
2369 // Properties
2370 this.$group = null;
2371
2372 // Initialization
2373 this.setGroupElement( config.$group || $( '<div>' ) );
2374 };
2375
2376 /* Setup */
2377
2378 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2379
2380 /* Events */
2381
2382 /**
2383 * @event change
2384 *
2385 * A change event is emitted when the set of selected items changes.
2386 *
2387 * @param {OO.ui.Element[]} items Items currently in the group
2388 */
2389
2390 /* Methods */
2391
2392 /**
2393 * Set the group element.
2394 *
2395 * If an element is already set, items will be moved to the new element.
2396 *
2397 * @param {jQuery} $group Element to use as group
2398 */
2399 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2400 var i, len;
2401
2402 this.$group = $group;
2403 for ( i = 0, len = this.items.length; i < len; i++ ) {
2404 this.$group.append( this.items[ i ].$element );
2405 }
2406 };
2407
2408 /**
2409 * Find an item by its data.
2410 *
2411 * Only the first item with matching data will be returned. To return all matching items,
2412 * use the #findItemsFromData method.
2413 *
2414 * @param {Object} data Item data to search for
2415 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2416 */
2417 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2418 var i, len, item,
2419 hash = OO.getHash( data );
2420
2421 for ( i = 0, len = this.items.length; i < len; i++ ) {
2422 item = this.items[ i ];
2423 if ( hash === OO.getHash( item.getData() ) ) {
2424 return item;
2425 }
2426 }
2427
2428 return null;
2429 };
2430
2431 /**
2432 * Find items by their data.
2433 *
2434 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2435 *
2436 * @param {Object} data Item data to search for
2437 * @return {OO.ui.Element[]} Items with equivalent data
2438 */
2439 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2440 var i, len, item,
2441 hash = OO.getHash( data ),
2442 items = [];
2443
2444 for ( i = 0, len = this.items.length; i < len; i++ ) {
2445 item = this.items[ i ];
2446 if ( hash === OO.getHash( item.getData() ) ) {
2447 items.push( item );
2448 }
2449 }
2450
2451 return items;
2452 };
2453
2454 /**
2455 * Add items to the group.
2456 *
2457 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2458 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2459 *
2460 * @param {OO.ui.Element[]} items An array of items to add to the group
2461 * @param {number} [index] Index of the insertion point
2462 * @chainable
2463 */
2464 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2465 // Mixin method
2466 OO.EmitterList.prototype.addItems.call( this, items, index );
2467
2468 this.emit( 'change', this.getItems() );
2469 return this;
2470 };
2471
2472 /**
2473 * @inheritdoc
2474 */
2475 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2476 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2477 this.insertItemElements( items, newIndex );
2478
2479 // Mixin method
2480 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2481
2482 return newIndex;
2483 };
2484
2485 /**
2486 * @inheritdoc
2487 */
2488 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2489 item.setElementGroup( this );
2490 this.insertItemElements( item, index );
2491
2492 // Mixin method
2493 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2494
2495 return index;
2496 };
2497
2498 /**
2499 * Insert elements into the group
2500 *
2501 * @private
2502 * @param {OO.ui.Element} itemWidget Item to insert
2503 * @param {number} index Insertion index
2504 */
2505 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2506 if ( index === undefined || index < 0 || index >= this.items.length ) {
2507 this.$group.append( itemWidget.$element );
2508 } else if ( index === 0 ) {
2509 this.$group.prepend( itemWidget.$element );
2510 } else {
2511 this.items[ index ].$element.before( itemWidget.$element );
2512 }
2513 };
2514
2515 /**
2516 * Remove the specified items from a group.
2517 *
2518 * Removed items are detached (not removed) from the DOM so that they may be reused.
2519 * To remove all items from a group, you may wish to use the #clearItems method instead.
2520 *
2521 * @param {OO.ui.Element[]} items An array of items to remove
2522 * @chainable
2523 */
2524 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2525 var i, len, item, index;
2526
2527 // Remove specific items elements
2528 for ( i = 0, len = items.length; i < len; i++ ) {
2529 item = items[ i ];
2530 index = this.items.indexOf( item );
2531 if ( index !== -1 ) {
2532 item.setElementGroup( null );
2533 item.$element.detach();
2534 }
2535 }
2536
2537 // Mixin method
2538 OO.EmitterList.prototype.removeItems.call( this, items );
2539
2540 this.emit( 'change', this.getItems() );
2541 return this;
2542 };
2543
2544 /**
2545 * Clear all items from the group.
2546 *
2547 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2548 * To remove only a subset of items from a group, use the #removeItems method.
2549 *
2550 * @chainable
2551 */
2552 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2553 var i, len;
2554
2555 // Remove all item elements
2556 for ( i = 0, len = this.items.length; i < len; i++ ) {
2557 this.items[ i ].setElementGroup( null );
2558 this.items[ i ].$element.detach();
2559 }
2560
2561 // Mixin method
2562 OO.EmitterList.prototype.clearItems.call( this );
2563
2564 this.emit( 'change', this.getItems() );
2565 return this;
2566 };
2567
2568 /**
2569 * IconElement is often mixed into other classes to generate an icon.
2570 * Icons are graphics, about the size of normal text. They are used to aid the user
2571 * in locating a control or to convey information in a space-efficient way. See the
2572 * [OOUI documentation on MediaWiki] [1] for a list of icons
2573 * included in the library.
2574 *
2575 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2576 *
2577 * @abstract
2578 * @class
2579 *
2580 * @constructor
2581 * @param {Object} [config] Configuration options
2582 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2583 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2584 * the icon element be set to an existing icon instead of the one generated by this class, set a
2585 * value using a jQuery selection. For example:
2586 *
2587 * // Use a <div> tag instead of a <span>
2588 * $icon: $("<div>")
2589 * // Use an existing icon element instead of the one generated by the class
2590 * $icon: this.$element
2591 * // Use an icon element from a child widget
2592 * $icon: this.childwidget.$element
2593 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2594 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2595 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2596 * by the user's language.
2597 *
2598 * Example of an i18n map:
2599 *
2600 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2601 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2602 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2603 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2604 * text. The icon title is displayed when users move the mouse over the icon.
2605 */
2606 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2607 // Configuration initialization
2608 config = config || {};
2609
2610 // Properties
2611 this.$icon = null;
2612 this.icon = null;
2613 this.iconTitle = null;
2614
2615 // Initialization
2616 this.setIcon( config.icon || this.constructor.static.icon );
2617 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2618 this.setIconElement( config.$icon || $( '<span>' ) );
2619 };
2620
2621 /* Setup */
2622
2623 OO.initClass( OO.ui.mixin.IconElement );
2624
2625 /* Static Properties */
2626
2627 /**
2628 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2629 * for i18n purposes and contains a `default` icon name and additional names keyed by
2630 * language code. The `default` name is used when no icon is keyed by the user's language.
2631 *
2632 * Example of an i18n map:
2633 *
2634 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2635 *
2636 * Note: the static property will be overridden if the #icon configuration is used.
2637 *
2638 * @static
2639 * @inheritable
2640 * @property {Object|string}
2641 */
2642 OO.ui.mixin.IconElement.static.icon = null;
2643
2644 /**
2645 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2646 * function that returns title text, or `null` for no title.
2647 *
2648 * The static property will be overridden if the #iconTitle configuration is used.
2649 *
2650 * @static
2651 * @inheritable
2652 * @property {string|Function|null}
2653 */
2654 OO.ui.mixin.IconElement.static.iconTitle = null;
2655
2656 /* Methods */
2657
2658 /**
2659 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2660 * applies to the specified icon element instead of the one created by the class. If an icon
2661 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2662 * and mixin methods will no longer affect the element.
2663 *
2664 * @param {jQuery} $icon Element to use as icon
2665 */
2666 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2667 if ( this.$icon ) {
2668 this.$icon
2669 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2670 .removeAttr( 'title' );
2671 }
2672
2673 this.$icon = $icon
2674 .addClass( 'oo-ui-iconElement-icon' )
2675 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2676 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2677 if ( this.iconTitle !== null ) {
2678 this.$icon.attr( 'title', this.iconTitle );
2679 }
2680
2681 this.updateThemeClasses();
2682 };
2683
2684 /**
2685 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2686 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2687 * for an example.
2688 *
2689 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2690 * by language code, or `null` to remove the icon.
2691 * @chainable
2692 */
2693 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2694 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2695 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2696
2697 if ( this.icon !== icon ) {
2698 if ( this.$icon ) {
2699 if ( this.icon !== null ) {
2700 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2701 }
2702 if ( icon !== null ) {
2703 this.$icon.addClass( 'oo-ui-icon-' + icon );
2704 }
2705 }
2706 this.icon = icon;
2707 }
2708
2709 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2710 if ( this.$icon ) {
2711 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2712 }
2713 this.updateThemeClasses();
2714
2715 return this;
2716 };
2717
2718 /**
2719 * Set the icon title. Use `null` to remove the title.
2720 *
2721 * @param {string|Function|null} iconTitle A text string used as the icon title,
2722 * a function that returns title text, or `null` for no title.
2723 * @chainable
2724 */
2725 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2726 iconTitle =
2727 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2728 OO.ui.resolveMsg( iconTitle ) : null;
2729
2730 if ( this.iconTitle !== iconTitle ) {
2731 this.iconTitle = iconTitle;
2732 if ( this.$icon ) {
2733 if ( this.iconTitle !== null ) {
2734 this.$icon.attr( 'title', iconTitle );
2735 } else {
2736 this.$icon.removeAttr( 'title' );
2737 }
2738 }
2739 }
2740
2741 return this;
2742 };
2743
2744 /**
2745 * Get the symbolic name of the icon.
2746 *
2747 * @return {string} Icon name
2748 */
2749 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2750 return this.icon;
2751 };
2752
2753 /**
2754 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2755 *
2756 * @return {string} Icon title text
2757 */
2758 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2759 return this.iconTitle;
2760 };
2761
2762 /**
2763 * IndicatorElement is often mixed into other classes to generate an indicator.
2764 * Indicators are small graphics that are generally used in two ways:
2765 *
2766 * - To draw attention to the status of an item. For example, an indicator might be
2767 * used to show that an item in a list has errors that need to be resolved.
2768 * - To clarify the function of a control that acts in an exceptional way (a button
2769 * that opens a menu instead of performing an action directly, for example).
2770 *
2771 * For a list of indicators included in the library, please see the
2772 * [OOUI documentation on MediaWiki] [1].
2773 *
2774 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2775 *
2776 * @abstract
2777 * @class
2778 *
2779 * @constructor
2780 * @param {Object} [config] Configuration options
2781 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2782 * configuration is omitted, the indicator element will use a generated `<span>`.
2783 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2784 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
2785 * in the library.
2786 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2787 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2788 * or a function that returns title text. The indicator title is displayed when users move
2789 * the mouse over the indicator.
2790 */
2791 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2792 // Configuration initialization
2793 config = config || {};
2794
2795 // Properties
2796 this.$indicator = null;
2797 this.indicator = null;
2798 this.indicatorTitle = null;
2799
2800 // Initialization
2801 this.setIndicator( config.indicator || this.constructor.static.indicator );
2802 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2803 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2804 };
2805
2806 /* Setup */
2807
2808 OO.initClass( OO.ui.mixin.IndicatorElement );
2809
2810 /* Static Properties */
2811
2812 /**
2813 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2814 * The static property will be overridden if the #indicator configuration is used.
2815 *
2816 * @static
2817 * @inheritable
2818 * @property {string|null}
2819 */
2820 OO.ui.mixin.IndicatorElement.static.indicator = null;
2821
2822 /**
2823 * A text string used as the indicator title, a function that returns title text, or `null`
2824 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2825 *
2826 * @static
2827 * @inheritable
2828 * @property {string|Function|null}
2829 */
2830 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2831
2832 /* Methods */
2833
2834 /**
2835 * Set the indicator element.
2836 *
2837 * If an element is already set, it will be cleaned up before setting up the new element.
2838 *
2839 * @param {jQuery} $indicator Element to use as indicator
2840 */
2841 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2842 if ( this.$indicator ) {
2843 this.$indicator
2844 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2845 .removeAttr( 'title' );
2846 }
2847
2848 this.$indicator = $indicator
2849 .addClass( 'oo-ui-indicatorElement-indicator' )
2850 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
2851 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2852 if ( this.indicatorTitle !== null ) {
2853 this.$indicator.attr( 'title', this.indicatorTitle );
2854 }
2855
2856 this.updateThemeClasses();
2857 };
2858
2859 /**
2860 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
2861 *
2862 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2863 * @chainable
2864 */
2865 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2866 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2867
2868 if ( this.indicator !== indicator ) {
2869 if ( this.$indicator ) {
2870 if ( this.indicator !== null ) {
2871 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2872 }
2873 if ( indicator !== null ) {
2874 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2875 }
2876 }
2877 this.indicator = indicator;
2878 }
2879
2880 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2881 if ( this.$indicator ) {
2882 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
2883 }
2884 this.updateThemeClasses();
2885
2886 return this;
2887 };
2888
2889 /**
2890 * Set the indicator title.
2891 *
2892 * The title is displayed when a user moves the mouse over the indicator.
2893 *
2894 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2895 * `null` for no indicator title
2896 * @chainable
2897 */
2898 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2899 indicatorTitle =
2900 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2901 OO.ui.resolveMsg( indicatorTitle ) : null;
2902
2903 if ( this.indicatorTitle !== indicatorTitle ) {
2904 this.indicatorTitle = indicatorTitle;
2905 if ( this.$indicator ) {
2906 if ( this.indicatorTitle !== null ) {
2907 this.$indicator.attr( 'title', indicatorTitle );
2908 } else {
2909 this.$indicator.removeAttr( 'title' );
2910 }
2911 }
2912 }
2913
2914 return this;
2915 };
2916
2917 /**
2918 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
2919 *
2920 * @return {string} Symbolic name of indicator
2921 */
2922 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2923 return this.indicator;
2924 };
2925
2926 /**
2927 * Get the indicator title.
2928 *
2929 * The title is displayed when a user moves the mouse over the indicator.
2930 *
2931 * @return {string} Indicator title text
2932 */
2933 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2934 return this.indicatorTitle;
2935 };
2936
2937 /**
2938 * LabelElement is often mixed into other classes to generate a label, which
2939 * helps identify the function of an interface element.
2940 * See the [OOUI documentation on MediaWiki] [1] for more information.
2941 *
2942 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2943 *
2944 * @abstract
2945 * @class
2946 *
2947 * @constructor
2948 * @param {Object} [config] Configuration options
2949 * @cfg {jQuery} [$label] The label element created by the class. If this
2950 * configuration is omitted, the label element will use a generated `<span>`.
2951 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2952 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2953 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2954 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2955 */
2956 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2957 // Configuration initialization
2958 config = config || {};
2959
2960 // Properties
2961 this.$label = null;
2962 this.label = null;
2963
2964 // Initialization
2965 this.setLabel( config.label || this.constructor.static.label );
2966 this.setLabelElement( config.$label || $( '<span>' ) );
2967 };
2968
2969 /* Setup */
2970
2971 OO.initClass( OO.ui.mixin.LabelElement );
2972
2973 /* Events */
2974
2975 /**
2976 * @event labelChange
2977 * @param {string} value
2978 */
2979
2980 /* Static Properties */
2981
2982 /**
2983 * The label text. The label can be specified as a plaintext string, a function that will
2984 * produce a string in the future, or `null` for no label. The static value will
2985 * be overridden if a label is specified with the #label config option.
2986 *
2987 * @static
2988 * @inheritable
2989 * @property {string|Function|null}
2990 */
2991 OO.ui.mixin.LabelElement.static.label = null;
2992
2993 /* Static methods */
2994
2995 /**
2996 * Highlight the first occurrence of the query in the given text
2997 *
2998 * @param {string} text Text
2999 * @param {string} query Query to find
3000 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3001 * @return {jQuery} Text with the first match of the query
3002 * sub-string wrapped in highlighted span
3003 */
3004 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
3005 var i, tLen, qLen,
3006 offset = -1,
3007 $result = $( '<span>' );
3008
3009 if ( compare ) {
3010 tLen = text.length;
3011 qLen = query.length;
3012 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
3013 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
3014 offset = i;
3015 }
3016 }
3017 } else {
3018 offset = text.toLowerCase().indexOf( query.toLowerCase() );
3019 }
3020
3021 if ( !query.length || offset === -1 ) {
3022 $result.text( text );
3023 } else {
3024 $result.append(
3025 document.createTextNode( text.slice( 0, offset ) ),
3026 $( '<span>' )
3027 .addClass( 'oo-ui-labelElement-label-highlight' )
3028 .text( text.slice( offset, offset + query.length ) ),
3029 document.createTextNode( text.slice( offset + query.length ) )
3030 );
3031 }
3032 return $result.contents();
3033 };
3034
3035 /* Methods */
3036
3037 /**
3038 * Set the label element.
3039 *
3040 * If an element is already set, it will be cleaned up before setting up the new element.
3041 *
3042 * @param {jQuery} $label Element to use as label
3043 */
3044 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3045 if ( this.$label ) {
3046 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3047 }
3048
3049 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3050 this.setLabelContent( this.label );
3051 };
3052
3053 /**
3054 * Set the label.
3055 *
3056 * An empty string will result in the label being hidden. A string containing only whitespace will
3057 * be converted to a single `&nbsp;`.
3058 *
3059 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3060 * text; or null for no label
3061 * @chainable
3062 */
3063 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3064 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3065 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3066
3067 if ( this.label !== label ) {
3068 if ( this.$label ) {
3069 this.setLabelContent( label );
3070 }
3071 this.label = label;
3072 this.emit( 'labelChange' );
3073 }
3074
3075 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3076
3077 return this;
3078 };
3079
3080 /**
3081 * Set the label as plain text with a highlighted query
3082 *
3083 * @param {string} text Text label to set
3084 * @param {string} query Substring of text to highlight
3085 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3086 * @chainable
3087 */
3088 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3089 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3090 };
3091
3092 /**
3093 * Get the label.
3094 *
3095 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3096 * text; or null for no label
3097 */
3098 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3099 return this.label;
3100 };
3101
3102 /**
3103 * Set the content of the label.
3104 *
3105 * Do not call this method until after the label element has been set by #setLabelElement.
3106 *
3107 * @private
3108 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3109 * text; or null for no label
3110 */
3111 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3112 if ( typeof label === 'string' ) {
3113 if ( label.match( /^\s*$/ ) ) {
3114 // Convert whitespace only string to a single non-breaking space
3115 this.$label.html( '&nbsp;' );
3116 } else {
3117 this.$label.text( label );
3118 }
3119 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3120 this.$label.html( label.toString() );
3121 } else if ( label instanceof jQuery ) {
3122 this.$label.empty().append( label );
3123 } else {
3124 this.$label.empty();
3125 }
3126 };
3127
3128 /**
3129 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3130 * additional functionality to an element created by another class. The class provides
3131 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3132 * which are used to customize the look and feel of a widget to better describe its
3133 * importance and functionality.
3134 *
3135 * The library currently contains the following styling flags for general use:
3136 *
3137 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3138 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3139 *
3140 * The flags affect the appearance of the buttons:
3141 *
3142 * @example
3143 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3144 * var button1 = new OO.ui.ButtonWidget( {
3145 * label: 'Progressive',
3146 * flags: 'progressive'
3147 * } );
3148 * var button2 = new OO.ui.ButtonWidget( {
3149 * label: 'Destructive',
3150 * flags: 'destructive'
3151 * } );
3152 * $( 'body' ).append( button1.$element, button2.$element );
3153 *
3154 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3155 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3156 *
3157 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3158 *
3159 * @abstract
3160 * @class
3161 *
3162 * @constructor
3163 * @param {Object} [config] Configuration options
3164 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3165 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3166 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3167 * @cfg {jQuery} [$flagged] The flagged element. By default,
3168 * the flagged functionality is applied to the element created by the class ($element).
3169 * If a different element is specified, the flagged functionality will be applied to it instead.
3170 */
3171 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3172 // Configuration initialization
3173 config = config || {};
3174
3175 // Properties
3176 this.flags = {};
3177 this.$flagged = null;
3178
3179 // Initialization
3180 this.setFlags( config.flags );
3181 this.setFlaggedElement( config.$flagged || this.$element );
3182 };
3183
3184 /* Events */
3185
3186 /**
3187 * @event flag
3188 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3189 * parameter contains the name of each modified flag and indicates whether it was
3190 * added or removed.
3191 *
3192 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3193 * that the flag was added, `false` that the flag was removed.
3194 */
3195
3196 /* Methods */
3197
3198 /**
3199 * Set the flagged element.
3200 *
3201 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3202 * If an element is already set, the method will remove the mixin’s effect on that element.
3203 *
3204 * @param {jQuery} $flagged Element that should be flagged
3205 */
3206 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3207 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3208 return 'oo-ui-flaggedElement-' + flag;
3209 } ).join( ' ' );
3210
3211 if ( this.$flagged ) {
3212 this.$flagged.removeClass( classNames );
3213 }
3214
3215 this.$flagged = $flagged.addClass( classNames );
3216 };
3217
3218 /**
3219 * Check if the specified flag is set.
3220 *
3221 * @param {string} flag Name of flag
3222 * @return {boolean} The flag is set
3223 */
3224 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3225 // This may be called before the constructor, thus before this.flags is set
3226 return this.flags && ( flag in this.flags );
3227 };
3228
3229 /**
3230 * Get the names of all flags set.
3231 *
3232 * @return {string[]} Flag names
3233 */
3234 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3235 // This may be called before the constructor, thus before this.flags is set
3236 return Object.keys( this.flags || {} );
3237 };
3238
3239 /**
3240 * Clear all flags.
3241 *
3242 * @chainable
3243 * @fires flag
3244 */
3245 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3246 var flag, className,
3247 changes = {},
3248 remove = [],
3249 classPrefix = 'oo-ui-flaggedElement-';
3250
3251 for ( flag in this.flags ) {
3252 className = classPrefix + flag;
3253 changes[ flag ] = false;
3254 delete this.flags[ flag ];
3255 remove.push( className );
3256 }
3257
3258 if ( this.$flagged ) {
3259 this.$flagged.removeClass( remove.join( ' ' ) );
3260 }
3261
3262 this.updateThemeClasses();
3263 this.emit( 'flag', changes );
3264
3265 return this;
3266 };
3267
3268 /**
3269 * Add one or more flags.
3270 *
3271 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3272 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3273 * be added (`true`) or removed (`false`).
3274 * @chainable
3275 * @fires flag
3276 */
3277 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3278 var i, len, flag, className,
3279 changes = {},
3280 add = [],
3281 remove = [],
3282 classPrefix = 'oo-ui-flaggedElement-';
3283
3284 if ( typeof flags === 'string' ) {
3285 className = classPrefix + flags;
3286 // Set
3287 if ( !this.flags[ flags ] ) {
3288 this.flags[ flags ] = true;
3289 add.push( className );
3290 }
3291 } else if ( Array.isArray( flags ) ) {
3292 for ( i = 0, len = flags.length; i < len; i++ ) {
3293 flag = flags[ i ];
3294 className = classPrefix + flag;
3295 // Set
3296 if ( !this.flags[ flag ] ) {
3297 changes[ flag ] = true;
3298 this.flags[ flag ] = true;
3299 add.push( className );
3300 }
3301 }
3302 } else if ( OO.isPlainObject( flags ) ) {
3303 for ( flag in flags ) {
3304 className = classPrefix + flag;
3305 if ( flags[ flag ] ) {
3306 // Set
3307 if ( !this.flags[ flag ] ) {
3308 changes[ flag ] = true;
3309 this.flags[ flag ] = true;
3310 add.push( className );
3311 }
3312 } else {
3313 // Remove
3314 if ( this.flags[ flag ] ) {
3315 changes[ flag ] = false;
3316 delete this.flags[ flag ];
3317 remove.push( className );
3318 }
3319 }
3320 }
3321 }
3322
3323 if ( this.$flagged ) {
3324 this.$flagged
3325 .addClass( add.join( ' ' ) )
3326 .removeClass( remove.join( ' ' ) );
3327 }
3328
3329 this.updateThemeClasses();
3330 this.emit( 'flag', changes );
3331
3332 return this;
3333 };
3334
3335 /**
3336 * TitledElement is mixed into other classes to provide a `title` attribute.
3337 * Titles are rendered by the browser and are made visible when the user moves
3338 * the mouse over the element. Titles are not visible on touch devices.
3339 *
3340 * @example
3341 * // TitledElement provides a 'title' attribute to the
3342 * // ButtonWidget class
3343 * var button = new OO.ui.ButtonWidget( {
3344 * label: 'Button with Title',
3345 * title: 'I am a button'
3346 * } );
3347 * $( 'body' ).append( button.$element );
3348 *
3349 * @abstract
3350 * @class
3351 *
3352 * @constructor
3353 * @param {Object} [config] Configuration options
3354 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3355 * If this config is omitted, the title functionality is applied to $element, the
3356 * element created by the class.
3357 * @cfg {string|Function} [title] The title text or a function that returns text. If
3358 * this config is omitted, the value of the {@link #static-title static title} property is used.
3359 */
3360 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3361 // Configuration initialization
3362 config = config || {};
3363
3364 // Properties
3365 this.$titled = null;
3366 this.title = null;
3367
3368 // Initialization
3369 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3370 this.setTitledElement( config.$titled || this.$element );
3371 };
3372
3373 /* Setup */
3374
3375 OO.initClass( OO.ui.mixin.TitledElement );
3376
3377 /* Static Properties */
3378
3379 /**
3380 * The title text, a function that returns text, or `null` for no title. The value of the static property
3381 * is overridden if the #title config option is used.
3382 *
3383 * @static
3384 * @inheritable
3385 * @property {string|Function|null}
3386 */
3387 OO.ui.mixin.TitledElement.static.title = null;
3388
3389 /* Methods */
3390
3391 /**
3392 * Set the titled element.
3393 *
3394 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3395 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3396 *
3397 * @param {jQuery} $titled Element that should use the 'titled' functionality
3398 */
3399 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3400 if ( this.$titled ) {
3401 this.$titled.removeAttr( 'title' );
3402 }
3403
3404 this.$titled = $titled;
3405 if ( this.title ) {
3406 this.updateTitle();
3407 }
3408 };
3409
3410 /**
3411 * Set title.
3412 *
3413 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3414 * @chainable
3415 */
3416 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3417 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3418 title = ( typeof title === 'string' && title.length ) ? title : null;
3419
3420 if ( this.title !== title ) {
3421 this.title = title;
3422 this.updateTitle();
3423 }
3424
3425 return this;
3426 };
3427
3428 /**
3429 * Update the title attribute, in case of changes to title or accessKey.
3430 *
3431 * @protected
3432 * @chainable
3433 */
3434 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3435 var title = this.getTitle();
3436 if ( this.$titled ) {
3437 if ( title !== null ) {
3438 // Only if this is an AccessKeyedElement
3439 if ( this.formatTitleWithAccessKey ) {
3440 title = this.formatTitleWithAccessKey( title );
3441 }
3442 this.$titled.attr( 'title', title );
3443 } else {
3444 this.$titled.removeAttr( 'title' );
3445 }
3446 }
3447 return this;
3448 };
3449
3450 /**
3451 * Get title.
3452 *
3453 * @return {string} Title string
3454 */
3455 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3456 return this.title;
3457 };
3458
3459 /**
3460 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3461 * Accesskeys allow an user to go to a specific element by using
3462 * a shortcut combination of a browser specific keys + the key
3463 * set to the field.
3464 *
3465 * @example
3466 * // AccessKeyedElement provides an 'accesskey' attribute to the
3467 * // ButtonWidget class
3468 * var button = new OO.ui.ButtonWidget( {
3469 * label: 'Button with Accesskey',
3470 * accessKey: 'k'
3471 * } );
3472 * $( 'body' ).append( button.$element );
3473 *
3474 * @abstract
3475 * @class
3476 *
3477 * @constructor
3478 * @param {Object} [config] Configuration options
3479 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3480 * If this config is omitted, the accesskey functionality is applied to $element, the
3481 * element created by the class.
3482 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3483 * this config is omitted, no accesskey will be added.
3484 */
3485 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3486 // Configuration initialization
3487 config = config || {};
3488
3489 // Properties
3490 this.$accessKeyed = null;
3491 this.accessKey = null;
3492
3493 // Initialization
3494 this.setAccessKey( config.accessKey || null );
3495 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3496
3497 // If this is also a TitledElement and it initialized before we did, we may have
3498 // to update the title with the access key
3499 if ( this.updateTitle ) {
3500 this.updateTitle();
3501 }
3502 };
3503
3504 /* Setup */
3505
3506 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3507
3508 /* Static Properties */
3509
3510 /**
3511 * The access key, a function that returns a key, or `null` for no accesskey.
3512 *
3513 * @static
3514 * @inheritable
3515 * @property {string|Function|null}
3516 */
3517 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3518
3519 /* Methods */
3520
3521 /**
3522 * Set the accesskeyed element.
3523 *
3524 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3525 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3526 *
3527 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3528 */
3529 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3530 if ( this.$accessKeyed ) {
3531 this.$accessKeyed.removeAttr( 'accesskey' );
3532 }
3533
3534 this.$accessKeyed = $accessKeyed;
3535 if ( this.accessKey ) {
3536 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3537 }
3538 };
3539
3540 /**
3541 * Set accesskey.
3542 *
3543 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3544 * @chainable
3545 */
3546 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3547 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3548
3549 if ( this.accessKey !== accessKey ) {
3550 if ( this.$accessKeyed ) {
3551 if ( accessKey !== null ) {
3552 this.$accessKeyed.attr( 'accesskey', accessKey );
3553 } else {
3554 this.$accessKeyed.removeAttr( 'accesskey' );
3555 }
3556 }
3557 this.accessKey = accessKey;
3558
3559 // Only if this is a TitledElement
3560 if ( this.updateTitle ) {
3561 this.updateTitle();
3562 }
3563 }
3564
3565 return this;
3566 };
3567
3568 /**
3569 * Get accesskey.
3570 *
3571 * @return {string} accessKey string
3572 */
3573 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3574 return this.accessKey;
3575 };
3576
3577 /**
3578 * Add information about the access key to the element's tooltip label.
3579 * (This is only public for hacky usage in FieldLayout.)
3580 *
3581 * @param {string} title Tooltip label for `title` attribute
3582 * @return {string}
3583 */
3584 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3585 var accessKey;
3586
3587 if ( !this.$accessKeyed ) {
3588 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3589 return title;
3590 }
3591 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3592 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3593 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3594 } else {
3595 accessKey = this.getAccessKey();
3596 }
3597 if ( accessKey ) {
3598 title += ' [' + accessKey + ']';
3599 }
3600 return title;
3601 };
3602
3603 /**
3604 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3605 * feels, and functionality can be customized via the class’s configuration options
3606 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3607 * and examples.
3608 *
3609 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3610 *
3611 * @example
3612 * // A button widget
3613 * var button = new OO.ui.ButtonWidget( {
3614 * label: 'Button with Icon',
3615 * icon: 'trash',
3616 * title: 'Remove'
3617 * } );
3618 * $( 'body' ).append( button.$element );
3619 *
3620 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3621 *
3622 * @class
3623 * @extends OO.ui.Widget
3624 * @mixins OO.ui.mixin.ButtonElement
3625 * @mixins OO.ui.mixin.IconElement
3626 * @mixins OO.ui.mixin.IndicatorElement
3627 * @mixins OO.ui.mixin.LabelElement
3628 * @mixins OO.ui.mixin.TitledElement
3629 * @mixins OO.ui.mixin.FlaggedElement
3630 * @mixins OO.ui.mixin.TabIndexedElement
3631 * @mixins OO.ui.mixin.AccessKeyedElement
3632 *
3633 * @constructor
3634 * @param {Object} [config] Configuration options
3635 * @cfg {boolean} [active=false] Whether button should be shown as active
3636 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3637 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3638 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3639 */
3640 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3641 // Configuration initialization
3642 config = config || {};
3643
3644 // Parent constructor
3645 OO.ui.ButtonWidget.parent.call( this, config );
3646
3647 // Mixin constructors
3648 OO.ui.mixin.ButtonElement.call( this, config );
3649 OO.ui.mixin.IconElement.call( this, config );
3650 OO.ui.mixin.IndicatorElement.call( this, config );
3651 OO.ui.mixin.LabelElement.call( this, config );
3652 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3653 OO.ui.mixin.FlaggedElement.call( this, config );
3654 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3655 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3656
3657 // Properties
3658 this.href = null;
3659 this.target = null;
3660 this.noFollow = false;
3661
3662 // Events
3663 this.connect( this, { disable: 'onDisable' } );
3664
3665 // Initialization
3666 this.$button.append( this.$icon, this.$label, this.$indicator );
3667 this.$element
3668 .addClass( 'oo-ui-buttonWidget' )
3669 .append( this.$button );
3670 this.setActive( config.active );
3671 this.setHref( config.href );
3672 this.setTarget( config.target );
3673 this.setNoFollow( config.noFollow );
3674 };
3675
3676 /* Setup */
3677
3678 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3679 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3680 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3681 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3682 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3683 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3684 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3685 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3686 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3687
3688 /* Static Properties */
3689
3690 /**
3691 * @static
3692 * @inheritdoc
3693 */
3694 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3695
3696 /**
3697 * @static
3698 * @inheritdoc
3699 */
3700 OO.ui.ButtonWidget.static.tagName = 'span';
3701
3702 /* Methods */
3703
3704 /**
3705 * Get hyperlink location.
3706 *
3707 * @return {string} Hyperlink location
3708 */
3709 OO.ui.ButtonWidget.prototype.getHref = function () {
3710 return this.href;
3711 };
3712
3713 /**
3714 * Get hyperlink target.
3715 *
3716 * @return {string} Hyperlink target
3717 */
3718 OO.ui.ButtonWidget.prototype.getTarget = function () {
3719 return this.target;
3720 };
3721
3722 /**
3723 * Get search engine traversal hint.
3724 *
3725 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3726 */
3727 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3728 return this.noFollow;
3729 };
3730
3731 /**
3732 * Set hyperlink location.
3733 *
3734 * @param {string|null} href Hyperlink location, null to remove
3735 */
3736 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3737 href = typeof href === 'string' ? href : null;
3738 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3739 href = './' + href;
3740 }
3741
3742 if ( href !== this.href ) {
3743 this.href = href;
3744 this.updateHref();
3745 }
3746
3747 return this;
3748 };
3749
3750 /**
3751 * Update the `href` attribute, in case of changes to href or
3752 * disabled state.
3753 *
3754 * @private
3755 * @chainable
3756 */
3757 OO.ui.ButtonWidget.prototype.updateHref = function () {
3758 if ( this.href !== null && !this.isDisabled() ) {
3759 this.$button.attr( 'href', this.href );
3760 } else {
3761 this.$button.removeAttr( 'href' );
3762 }
3763
3764 return this;
3765 };
3766
3767 /**
3768 * Handle disable events.
3769 *
3770 * @private
3771 * @param {boolean} disabled Element is disabled
3772 */
3773 OO.ui.ButtonWidget.prototype.onDisable = function () {
3774 this.updateHref();
3775 };
3776
3777 /**
3778 * Set hyperlink target.
3779 *
3780 * @param {string|null} target Hyperlink target, null to remove
3781 */
3782 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3783 target = typeof target === 'string' ? target : null;
3784
3785 if ( target !== this.target ) {
3786 this.target = target;
3787 if ( target !== null ) {
3788 this.$button.attr( 'target', target );
3789 } else {
3790 this.$button.removeAttr( 'target' );
3791 }
3792 }
3793
3794 return this;
3795 };
3796
3797 /**
3798 * Set search engine traversal hint.
3799 *
3800 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3801 */
3802 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3803 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3804
3805 if ( noFollow !== this.noFollow ) {
3806 this.noFollow = noFollow;
3807 if ( noFollow ) {
3808 this.$button.attr( 'rel', 'nofollow' );
3809 } else {
3810 this.$button.removeAttr( 'rel' );
3811 }
3812 }
3813
3814 return this;
3815 };
3816
3817 // Override method visibility hints from ButtonElement
3818 /**
3819 * @method setActive
3820 * @inheritdoc
3821 */
3822 /**
3823 * @method isActive
3824 * @inheritdoc
3825 */
3826
3827 /**
3828 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3829 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3830 * removed, and cleared from the group.
3831 *
3832 * @example
3833 * // Example: A ButtonGroupWidget with two buttons
3834 * var button1 = new OO.ui.PopupButtonWidget( {
3835 * label: 'Select a category',
3836 * icon: 'menu',
3837 * popup: {
3838 * $content: $( '<p>List of categories...</p>' ),
3839 * padded: true,
3840 * align: 'left'
3841 * }
3842 * } );
3843 * var button2 = new OO.ui.ButtonWidget( {
3844 * label: 'Add item'
3845 * });
3846 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3847 * items: [button1, button2]
3848 * } );
3849 * $( 'body' ).append( buttonGroup.$element );
3850 *
3851 * @class
3852 * @extends OO.ui.Widget
3853 * @mixins OO.ui.mixin.GroupElement
3854 *
3855 * @constructor
3856 * @param {Object} [config] Configuration options
3857 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3858 */
3859 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3860 // Configuration initialization
3861 config = config || {};
3862
3863 // Parent constructor
3864 OO.ui.ButtonGroupWidget.parent.call( this, config );
3865
3866 // Mixin constructors
3867 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3868
3869 // Initialization
3870 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3871 if ( Array.isArray( config.items ) ) {
3872 this.addItems( config.items );
3873 }
3874 };
3875
3876 /* Setup */
3877
3878 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3879 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3880
3881 /* Static Properties */
3882
3883 /**
3884 * @static
3885 * @inheritdoc
3886 */
3887 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3888
3889 /* Methods */
3890
3891 /**
3892 * Focus the widget
3893 *
3894 * @chainable
3895 */
3896 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3897 if ( !this.isDisabled() ) {
3898 if ( this.items[ 0 ] ) {
3899 this.items[ 0 ].focus();
3900 }
3901 }
3902 return this;
3903 };
3904
3905 /**
3906 * @inheritdoc
3907 */
3908 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3909 this.focus();
3910 };
3911
3912 /**
3913 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3914 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
3915 * for a list of icons included in the library.
3916 *
3917 * @example
3918 * // An icon widget with a label
3919 * var myIcon = new OO.ui.IconWidget( {
3920 * icon: 'help',
3921 * title: 'Help'
3922 * } );
3923 * // Create a label.
3924 * var iconLabel = new OO.ui.LabelWidget( {
3925 * label: 'Help'
3926 * } );
3927 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3928 *
3929 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
3930 *
3931 * @class
3932 * @extends OO.ui.Widget
3933 * @mixins OO.ui.mixin.IconElement
3934 * @mixins OO.ui.mixin.TitledElement
3935 * @mixins OO.ui.mixin.FlaggedElement
3936 *
3937 * @constructor
3938 * @param {Object} [config] Configuration options
3939 */
3940 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3941 // Configuration initialization
3942 config = config || {};
3943
3944 // Parent constructor
3945 OO.ui.IconWidget.parent.call( this, config );
3946
3947 // Mixin constructors
3948 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3949 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3950 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3951
3952 // Initialization
3953 this.$element.addClass( 'oo-ui-iconWidget' );
3954 };
3955
3956 /* Setup */
3957
3958 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3959 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3960 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3961 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3962
3963 /* Static Properties */
3964
3965 /**
3966 * @static
3967 * @inheritdoc
3968 */
3969 OO.ui.IconWidget.static.tagName = 'span';
3970
3971 /**
3972 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3973 * attention to the status of an item or to clarify the function within a control. For a list of
3974 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
3975 *
3976 * @example
3977 * // Example of an indicator widget
3978 * var indicator1 = new OO.ui.IndicatorWidget( {
3979 * indicator: 'required'
3980 * } );
3981 *
3982 * // Create a fieldset layout to add a label
3983 * var fieldset = new OO.ui.FieldsetLayout();
3984 * fieldset.addItems( [
3985 * new OO.ui.FieldLayout( indicator1, { label: 'A required indicator:' } )
3986 * ] );
3987 * $( 'body' ).append( fieldset.$element );
3988 *
3989 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3990 *
3991 * @class
3992 * @extends OO.ui.Widget
3993 * @mixins OO.ui.mixin.IndicatorElement
3994 * @mixins OO.ui.mixin.TitledElement
3995 *
3996 * @constructor
3997 * @param {Object} [config] Configuration options
3998 */
3999 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4000 // Configuration initialization
4001 config = config || {};
4002
4003 // Parent constructor
4004 OO.ui.IndicatorWidget.parent.call( this, config );
4005
4006 // Mixin constructors
4007 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4008 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4009
4010 // Initialization
4011 this.$element.addClass( 'oo-ui-indicatorWidget' );
4012 };
4013
4014 /* Setup */
4015
4016 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4017 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4018 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4019
4020 /* Static Properties */
4021
4022 /**
4023 * @static
4024 * @inheritdoc
4025 */
4026 OO.ui.IndicatorWidget.static.tagName = 'span';
4027
4028 /**
4029 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4030 * be configured with a `label` option that is set to a string, a label node, or a function:
4031 *
4032 * - String: a plaintext string
4033 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4034 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4035 * - Function: a function that will produce a string in the future. Functions are used
4036 * in cases where the value of the label is not currently defined.
4037 *
4038 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4039 * will come into focus when the label is clicked.
4040 *
4041 * @example
4042 * // Examples of LabelWidgets
4043 * var label1 = new OO.ui.LabelWidget( {
4044 * label: 'plaintext label'
4045 * } );
4046 * var label2 = new OO.ui.LabelWidget( {
4047 * label: $( '<a href="default.html">jQuery label</a>' )
4048 * } );
4049 * // Create a fieldset layout with fields for each example
4050 * var fieldset = new OO.ui.FieldsetLayout();
4051 * fieldset.addItems( [
4052 * new OO.ui.FieldLayout( label1 ),
4053 * new OO.ui.FieldLayout( label2 )
4054 * ] );
4055 * $( 'body' ).append( fieldset.$element );
4056 *
4057 * @class
4058 * @extends OO.ui.Widget
4059 * @mixins OO.ui.mixin.LabelElement
4060 * @mixins OO.ui.mixin.TitledElement
4061 *
4062 * @constructor
4063 * @param {Object} [config] Configuration options
4064 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4065 * Clicking the label will focus the specified input field.
4066 */
4067 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4068 // Configuration initialization
4069 config = config || {};
4070
4071 // Parent constructor
4072 OO.ui.LabelWidget.parent.call( this, config );
4073
4074 // Mixin constructors
4075 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4076 OO.ui.mixin.TitledElement.call( this, config );
4077
4078 // Properties
4079 this.input = config.input;
4080
4081 // Initialization
4082 if ( this.input ) {
4083 if ( this.input.getInputId() ) {
4084 this.$element.attr( 'for', this.input.getInputId() );
4085 } else {
4086 this.$label.on( 'click', function () {
4087 this.input.simulateLabelClick();
4088 }.bind( this ) );
4089 }
4090 }
4091 this.$element.addClass( 'oo-ui-labelWidget' );
4092 };
4093
4094 /* Setup */
4095
4096 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4097 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4098 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4099
4100 /* Static Properties */
4101
4102 /**
4103 * @static
4104 * @inheritdoc
4105 */
4106 OO.ui.LabelWidget.static.tagName = 'label';
4107
4108 /**
4109 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4110 * and that they should wait before proceeding. The pending state is visually represented with a pending
4111 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4112 * field of a {@link OO.ui.TextInputWidget text input widget}.
4113 *
4114 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4115 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4116 * in process dialogs.
4117 *
4118 * @example
4119 * function MessageDialog( config ) {
4120 * MessageDialog.parent.call( this, config );
4121 * }
4122 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4123 *
4124 * MessageDialog.static.name = 'myMessageDialog';
4125 * MessageDialog.static.actions = [
4126 * { action: 'save', label: 'Done', flags: 'primary' },
4127 * { label: 'Cancel', flags: 'safe' }
4128 * ];
4129 *
4130 * MessageDialog.prototype.initialize = function () {
4131 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4132 * this.content = new OO.ui.PanelLayout( { padded: true } );
4133 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
4134 * this.$body.append( this.content.$element );
4135 * };
4136 * MessageDialog.prototype.getBodyHeight = function () {
4137 * return 100;
4138 * }
4139 * MessageDialog.prototype.getActionProcess = function ( action ) {
4140 * var dialog = this;
4141 * if ( action === 'save' ) {
4142 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4143 * return new OO.ui.Process()
4144 * .next( 1000 )
4145 * .next( function () {
4146 * dialog.getActions().get({actions: 'save'})[0].popPending();
4147 * } );
4148 * }
4149 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4150 * };
4151 *
4152 * var windowManager = new OO.ui.WindowManager();
4153 * $( 'body' ).append( windowManager.$element );
4154 *
4155 * var dialog = new MessageDialog();
4156 * windowManager.addWindows( [ dialog ] );
4157 * windowManager.openWindow( dialog );
4158 *
4159 * @abstract
4160 * @class
4161 *
4162 * @constructor
4163 * @param {Object} [config] Configuration options
4164 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4165 */
4166 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4167 // Configuration initialization
4168 config = config || {};
4169
4170 // Properties
4171 this.pending = 0;
4172 this.$pending = null;
4173
4174 // Initialisation
4175 this.setPendingElement( config.$pending || this.$element );
4176 };
4177
4178 /* Setup */
4179
4180 OO.initClass( OO.ui.mixin.PendingElement );
4181
4182 /* Methods */
4183
4184 /**
4185 * Set the pending element (and clean up any existing one).
4186 *
4187 * @param {jQuery} $pending The element to set to pending.
4188 */
4189 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4190 if ( this.$pending ) {
4191 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4192 }
4193
4194 this.$pending = $pending;
4195 if ( this.pending > 0 ) {
4196 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4197 }
4198 };
4199
4200 /**
4201 * Check if an element is pending.
4202 *
4203 * @return {boolean} Element is pending
4204 */
4205 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4206 return !!this.pending;
4207 };
4208
4209 /**
4210 * Increase the pending counter. The pending state will remain active until the counter is zero
4211 * (i.e., the number of calls to #pushPending and #popPending is the same).
4212 *
4213 * @chainable
4214 */
4215 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4216 if ( this.pending === 0 ) {
4217 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4218 this.updateThemeClasses();
4219 }
4220 this.pending++;
4221
4222 return this;
4223 };
4224
4225 /**
4226 * Decrease the pending counter. The pending state will remain active until the counter is zero
4227 * (i.e., the number of calls to #pushPending and #popPending is the same).
4228 *
4229 * @chainable
4230 */
4231 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4232 if ( this.pending === 1 ) {
4233 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4234 this.updateThemeClasses();
4235 }
4236 this.pending = Math.max( 0, this.pending - 1 );
4237
4238 return this;
4239 };
4240
4241 /**
4242 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4243 * in the document (for example, in an OO.ui.Window's $overlay).
4244 *
4245 * The elements's position is automatically calculated and maintained when window is resized or the
4246 * page is scrolled. If you reposition the container manually, you have to call #position to make
4247 * sure the element is still placed correctly.
4248 *
4249 * As positioning is only possible when both the element and the container are attached to the DOM
4250 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4251 * the #toggle method to display a floating popup, for example.
4252 *
4253 * @abstract
4254 * @class
4255 *
4256 * @constructor
4257 * @param {Object} [config] Configuration options
4258 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4259 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4260 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4261 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4262 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4263 * 'top': Align the top edge with $floatableContainer's top edge
4264 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4265 * 'center': Vertically align the center with $floatableContainer's center
4266 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4267 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4268 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4269 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4270 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4271 * 'center': Horizontally align the center with $floatableContainer's center
4272 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4273 * is out of view
4274 */
4275 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4276 // Configuration initialization
4277 config = config || {};
4278
4279 // Properties
4280 this.$floatable = null;
4281 this.$floatableContainer = null;
4282 this.$floatableWindow = null;
4283 this.$floatableClosestScrollable = null;
4284 this.floatableOutOfView = false;
4285 this.onFloatableScrollHandler = this.position.bind( this );
4286 this.onFloatableWindowResizeHandler = this.position.bind( this );
4287
4288 // Initialization
4289 this.setFloatableContainer( config.$floatableContainer );
4290 this.setFloatableElement( config.$floatable || this.$element );
4291 this.setVerticalPosition( config.verticalPosition || 'below' );
4292 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4293 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4294 };
4295
4296 /* Methods */
4297
4298 /**
4299 * Set floatable element.
4300 *
4301 * If an element is already set, it will be cleaned up before setting up the new element.
4302 *
4303 * @param {jQuery} $floatable Element to make floatable
4304 */
4305 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4306 if ( this.$floatable ) {
4307 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4308 this.$floatable.css( { left: '', top: '' } );
4309 }
4310
4311 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4312 this.position();
4313 };
4314
4315 /**
4316 * Set floatable container.
4317 *
4318 * The element will be positioned relative to the specified container.
4319 *
4320 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4321 */
4322 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4323 this.$floatableContainer = $floatableContainer;
4324 if ( this.$floatable ) {
4325 this.position();
4326 }
4327 };
4328
4329 /**
4330 * Change how the element is positioned vertically.
4331 *
4332 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4333 */
4334 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4335 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4336 throw new Error( 'Invalid value for vertical position: ' + position );
4337 }
4338 if ( this.verticalPosition !== position ) {
4339 this.verticalPosition = position;
4340 if ( this.$floatable ) {
4341 this.position();
4342 }
4343 }
4344 };
4345
4346 /**
4347 * Change how the element is positioned horizontally.
4348 *
4349 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4350 */
4351 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4352 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4353 throw new Error( 'Invalid value for horizontal position: ' + position );
4354 }
4355 if ( this.horizontalPosition !== position ) {
4356 this.horizontalPosition = position;
4357 if ( this.$floatable ) {
4358 this.position();
4359 }
4360 }
4361 };
4362
4363 /**
4364 * Toggle positioning.
4365 *
4366 * Do not turn positioning on until after the element is attached to the DOM and visible.
4367 *
4368 * @param {boolean} [positioning] Enable positioning, omit to toggle
4369 * @chainable
4370 */
4371 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4372 var closestScrollableOfContainer;
4373
4374 if ( !this.$floatable || !this.$floatableContainer ) {
4375 return this;
4376 }
4377
4378 positioning = positioning === undefined ? !this.positioning : !!positioning;
4379
4380 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4381 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4382 this.warnedUnattached = true;
4383 }
4384
4385 if ( this.positioning !== positioning ) {
4386 this.positioning = positioning;
4387
4388 this.needsCustomPosition =
4389 this.verticalPostion !== 'below' ||
4390 this.horizontalPosition !== 'start' ||
4391 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4392
4393 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4394 // If the scrollable is the root, we have to listen to scroll events
4395 // on the window because of browser inconsistencies.
4396 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4397 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4398 }
4399
4400 if ( positioning ) {
4401 this.$floatableWindow = $( this.getElementWindow() );
4402 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4403
4404 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4405 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4406
4407 // Initial position after visible
4408 this.position();
4409 } else {
4410 if ( this.$floatableWindow ) {
4411 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4412 this.$floatableWindow = null;
4413 }
4414
4415 if ( this.$floatableClosestScrollable ) {
4416 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4417 this.$floatableClosestScrollable = null;
4418 }
4419
4420 this.$floatable.css( { left: '', right: '', top: '' } );
4421 }
4422 }
4423
4424 return this;
4425 };
4426
4427 /**
4428 * Check whether the bottom edge of the given element is within the viewport of the given container.
4429 *
4430 * @private
4431 * @param {jQuery} $element
4432 * @param {jQuery} $container
4433 * @return {boolean}
4434 */
4435 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4436 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4437 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4438 direction = $element.css( 'direction' );
4439
4440 elemRect = $element[ 0 ].getBoundingClientRect();
4441 if ( $container[ 0 ] === window ) {
4442 viewportSpacing = OO.ui.getViewportSpacing();
4443 contRect = {
4444 top: 0,
4445 left: 0,
4446 right: document.documentElement.clientWidth,
4447 bottom: document.documentElement.clientHeight
4448 };
4449 contRect.top += viewportSpacing.top;
4450 contRect.left += viewportSpacing.left;
4451 contRect.right -= viewportSpacing.right;
4452 contRect.bottom -= viewportSpacing.bottom;
4453 } else {
4454 contRect = $container[ 0 ].getBoundingClientRect();
4455 }
4456
4457 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4458 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4459 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4460 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4461 if ( direction === 'rtl' ) {
4462 startEdgeInBounds = rightEdgeInBounds;
4463 endEdgeInBounds = leftEdgeInBounds;
4464 } else {
4465 startEdgeInBounds = leftEdgeInBounds;
4466 endEdgeInBounds = rightEdgeInBounds;
4467 }
4468
4469 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4470 return false;
4471 }
4472 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4473 return false;
4474 }
4475 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4476 return false;
4477 }
4478 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4479 return false;
4480 }
4481
4482 // The other positioning values are all about being inside the container,
4483 // so in those cases all we care about is that any part of the container is visible.
4484 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4485 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4486 };
4487
4488 /**
4489 * Check if the floatable is hidden to the user because it was offscreen.
4490 *
4491 * @return {boolean} Floatable is out of view
4492 */
4493 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4494 return this.floatableOutOfView;
4495 };
4496
4497 /**
4498 * Position the floatable below its container.
4499 *
4500 * This should only be done when both of them are attached to the DOM and visible.
4501 *
4502 * @chainable
4503 */
4504 OO.ui.mixin.FloatableElement.prototype.position = function () {
4505 if ( !this.positioning ) {
4506 return this;
4507 }
4508
4509 if ( !(
4510 // To continue, some things need to be true:
4511 // The element must actually be in the DOM
4512 this.isElementAttached() && (
4513 // The closest scrollable is the current window
4514 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4515 // OR is an element in the element's DOM
4516 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4517 )
4518 ) ) {
4519 // Abort early if important parts of the widget are no longer attached to the DOM
4520 return this;
4521 }
4522
4523 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4524 if ( this.floatableOutOfView ) {
4525 this.$floatable.addClass( 'oo-ui-element-hidden' );
4526 return this;
4527 } else {
4528 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4529 }
4530
4531 if ( !this.needsCustomPosition ) {
4532 return this;
4533 }
4534
4535 this.$floatable.css( this.computePosition() );
4536
4537 // We updated the position, so re-evaluate the clipping state.
4538 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4539 // will not notice the need to update itself.)
4540 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4541 // it not listen to the right events in the right places?
4542 if ( this.clip ) {
4543 this.clip();
4544 }
4545
4546 return this;
4547 };
4548
4549 /**
4550 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4551 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4552 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4553 *
4554 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4555 */
4556 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4557 var isBody, scrollableX, scrollableY, containerPos,
4558 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4559 newPos = { top: '', left: '', bottom: '', right: '' },
4560 direction = this.$floatableContainer.css( 'direction' ),
4561 $offsetParent = this.$floatable.offsetParent();
4562
4563 if ( $offsetParent.is( 'html' ) ) {
4564 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4565 // <html> element, but they do work on the <body>
4566 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4567 }
4568 isBody = $offsetParent.is( 'body' );
4569 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4570 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4571
4572 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4573 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4574 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4575 // or if it isn't scrollable
4576 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4577 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4578
4579 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4580 // if the <body> has a margin
4581 containerPos = isBody ?
4582 this.$floatableContainer.offset() :
4583 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4584 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4585 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4586 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4587 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4588
4589 if ( this.verticalPosition === 'below' ) {
4590 newPos.top = containerPos.bottom;
4591 } else if ( this.verticalPosition === 'above' ) {
4592 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4593 } else if ( this.verticalPosition === 'top' ) {
4594 newPos.top = containerPos.top;
4595 } else if ( this.verticalPosition === 'bottom' ) {
4596 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4597 } else if ( this.verticalPosition === 'center' ) {
4598 newPos.top = containerPos.top +
4599 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4600 }
4601
4602 if ( this.horizontalPosition === 'before' ) {
4603 newPos.end = containerPos.start;
4604 } else if ( this.horizontalPosition === 'after' ) {
4605 newPos.start = containerPos.end;
4606 } else if ( this.horizontalPosition === 'start' ) {
4607 newPos.start = containerPos.start;
4608 } else if ( this.horizontalPosition === 'end' ) {
4609 newPos.end = containerPos.end;
4610 } else if ( this.horizontalPosition === 'center' ) {
4611 newPos.left = containerPos.left +
4612 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4613 }
4614
4615 if ( newPos.start !== undefined ) {
4616 if ( direction === 'rtl' ) {
4617 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4618 } else {
4619 newPos.left = newPos.start;
4620 }
4621 delete newPos.start;
4622 }
4623 if ( newPos.end !== undefined ) {
4624 if ( direction === 'rtl' ) {
4625 newPos.left = newPos.end;
4626 } else {
4627 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4628 }
4629 delete newPos.end;
4630 }
4631
4632 // Account for scroll position
4633 if ( newPos.top !== '' ) {
4634 newPos.top += scrollTop;
4635 }
4636 if ( newPos.bottom !== '' ) {
4637 newPos.bottom -= scrollTop;
4638 }
4639 if ( newPos.left !== '' ) {
4640 newPos.left += scrollLeft;
4641 }
4642 if ( newPos.right !== '' ) {
4643 newPos.right -= scrollLeft;
4644 }
4645
4646 // Account for scrollbar gutter
4647 if ( newPos.bottom !== '' ) {
4648 newPos.bottom -= horizScrollbarHeight;
4649 }
4650 if ( direction === 'rtl' ) {
4651 if ( newPos.left !== '' ) {
4652 newPos.left -= vertScrollbarWidth;
4653 }
4654 } else {
4655 if ( newPos.right !== '' ) {
4656 newPos.right -= vertScrollbarWidth;
4657 }
4658 }
4659
4660 return newPos;
4661 };
4662
4663 /**
4664 * Element that can be automatically clipped to visible boundaries.
4665 *
4666 * Whenever the element's natural height changes, you have to call
4667 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4668 * clipping correctly.
4669 *
4670 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4671 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4672 * then #$clippable will be given a fixed reduced height and/or width and will be made
4673 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4674 * but you can build a static footer by setting #$clippableContainer to an element that contains
4675 * #$clippable and the footer.
4676 *
4677 * @abstract
4678 * @class
4679 *
4680 * @constructor
4681 * @param {Object} [config] Configuration options
4682 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4683 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4684 * omit to use #$clippable
4685 */
4686 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4687 // Configuration initialization
4688 config = config || {};
4689
4690 // Properties
4691 this.$clippable = null;
4692 this.$clippableContainer = null;
4693 this.clipping = false;
4694 this.clippedHorizontally = false;
4695 this.clippedVertically = false;
4696 this.$clippableScrollableContainer = null;
4697 this.$clippableScroller = null;
4698 this.$clippableWindow = null;
4699 this.idealWidth = null;
4700 this.idealHeight = null;
4701 this.onClippableScrollHandler = this.clip.bind( this );
4702 this.onClippableWindowResizeHandler = this.clip.bind( this );
4703
4704 // Initialization
4705 if ( config.$clippableContainer ) {
4706 this.setClippableContainer( config.$clippableContainer );
4707 }
4708 this.setClippableElement( config.$clippable || this.$element );
4709 };
4710
4711 /* Methods */
4712
4713 /**
4714 * Set clippable element.
4715 *
4716 * If an element is already set, it will be cleaned up before setting up the new element.
4717 *
4718 * @param {jQuery} $clippable Element to make clippable
4719 */
4720 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4721 if ( this.$clippable ) {
4722 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4723 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4724 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4725 }
4726
4727 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4728 this.clip();
4729 };
4730
4731 /**
4732 * Set clippable container.
4733 *
4734 * This is the container that will be measured when deciding whether to clip. When clipping,
4735 * #$clippable will be resized in order to keep the clippable container fully visible.
4736 *
4737 * If the clippable container is unset, #$clippable will be used.
4738 *
4739 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4740 */
4741 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4742 this.$clippableContainer = $clippableContainer;
4743 if ( this.$clippable ) {
4744 this.clip();
4745 }
4746 };
4747
4748 /**
4749 * Toggle clipping.
4750 *
4751 * Do not turn clipping on until after the element is attached to the DOM and visible.
4752 *
4753 * @param {boolean} [clipping] Enable clipping, omit to toggle
4754 * @chainable
4755 */
4756 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4757 clipping = clipping === undefined ? !this.clipping : !!clipping;
4758
4759 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4760 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4761 this.warnedUnattached = true;
4762 }
4763
4764 if ( this.clipping !== clipping ) {
4765 this.clipping = clipping;
4766 if ( clipping ) {
4767 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4768 // If the clippable container is the root, we have to listen to scroll events and check
4769 // jQuery.scrollTop on the window because of browser inconsistencies
4770 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4771 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4772 this.$clippableScrollableContainer;
4773 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4774 this.$clippableWindow = $( this.getElementWindow() )
4775 .on( 'resize', this.onClippableWindowResizeHandler );
4776 // Initial clip after visible
4777 this.clip();
4778 } else {
4779 this.$clippable.css( {
4780 width: '',
4781 height: '',
4782 maxWidth: '',
4783 maxHeight: '',
4784 overflowX: '',
4785 overflowY: ''
4786 } );
4787 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4788
4789 this.$clippableScrollableContainer = null;
4790 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4791 this.$clippableScroller = null;
4792 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4793 this.$clippableWindow = null;
4794 }
4795 }
4796
4797 return this;
4798 };
4799
4800 /**
4801 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4802 *
4803 * @return {boolean} Element will be clipped to the visible area
4804 */
4805 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4806 return this.clipping;
4807 };
4808
4809 /**
4810 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4811 *
4812 * @return {boolean} Part of the element is being clipped
4813 */
4814 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4815 return this.clippedHorizontally || this.clippedVertically;
4816 };
4817
4818 /**
4819 * Check if the right of the element is being clipped by the nearest scrollable container.
4820 *
4821 * @return {boolean} Part of the element is being clipped
4822 */
4823 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4824 return this.clippedHorizontally;
4825 };
4826
4827 /**
4828 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4829 *
4830 * @return {boolean} Part of the element is being clipped
4831 */
4832 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4833 return this.clippedVertically;
4834 };
4835
4836 /**
4837 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4838 *
4839 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4840 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4841 */
4842 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4843 this.idealWidth = width;
4844 this.idealHeight = height;
4845
4846 if ( !this.clipping ) {
4847 // Update dimensions
4848 this.$clippable.css( { width: width, height: height } );
4849 }
4850 // While clipping, idealWidth and idealHeight are not considered
4851 };
4852
4853 /**
4854 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4855 * ClippableElement will clip the opposite side when reducing element's width.
4856 *
4857 * Classes that mix in ClippableElement should override this to return 'right' if their
4858 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4859 * If your class also mixes in FloatableElement, this is handled automatically.
4860 *
4861 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4862 * always in pixels, even if they were unset or set to 'auto'.)
4863 *
4864 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4865 *
4866 * @return {string} 'left' or 'right'
4867 */
4868 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4869 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4870 return 'right';
4871 }
4872 return 'left';
4873 };
4874
4875 /**
4876 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4877 * ClippableElement will clip the opposite side when reducing element's width.
4878 *
4879 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4880 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4881 * If your class also mixes in FloatableElement, this is handled automatically.
4882 *
4883 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4884 * always in pixels, even if they were unset or set to 'auto'.)
4885 *
4886 * When in doubt, 'top' is a sane fallback.
4887 *
4888 * @return {string} 'top' or 'bottom'
4889 */
4890 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
4891 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
4892 return 'bottom';
4893 }
4894 return 'top';
4895 };
4896
4897 /**
4898 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4899 * when the element's natural height changes.
4900 *
4901 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4902 * overlapped by, the visible area of the nearest scrollable container.
4903 *
4904 * Because calling clip() when the natural height changes isn't always possible, we also set
4905 * max-height when the element isn't being clipped. This means that if the element tries to grow
4906 * beyond the edge, something reasonable will happen before clip() is called.
4907 *
4908 * @chainable
4909 */
4910 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4911 var extraHeight, extraWidth, viewportSpacing,
4912 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4913 naturalWidth, naturalHeight, clipWidth, clipHeight,
4914 $item, itemRect, $viewport, viewportRect, availableRect,
4915 direction, vertScrollbarWidth, horizScrollbarHeight,
4916 // Extra tolerance so that the sloppy code below doesn't result in results that are off
4917 // by one or two pixels. (And also so that we have space to display drop shadows.)
4918 // Chosen by fair dice roll.
4919 buffer = 7;
4920
4921 if ( !this.clipping ) {
4922 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4923 return this;
4924 }
4925
4926 function rectIntersection( a, b ) {
4927 var out = {};
4928 out.top = Math.max( a.top, b.top );
4929 out.left = Math.max( a.left, b.left );
4930 out.bottom = Math.min( a.bottom, b.bottom );
4931 out.right = Math.min( a.right, b.right );
4932 return out;
4933 }
4934
4935 viewportSpacing = OO.ui.getViewportSpacing();
4936
4937 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4938 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
4939 // Dimensions of the browser window, rather than the element!
4940 viewportRect = {
4941 top: 0,
4942 left: 0,
4943 right: document.documentElement.clientWidth,
4944 bottom: document.documentElement.clientHeight
4945 };
4946 viewportRect.top += viewportSpacing.top;
4947 viewportRect.left += viewportSpacing.left;
4948 viewportRect.right -= viewportSpacing.right;
4949 viewportRect.bottom -= viewportSpacing.bottom;
4950 } else {
4951 $viewport = this.$clippableScrollableContainer;
4952 viewportRect = $viewport[ 0 ].getBoundingClientRect();
4953 // Convert into a plain object
4954 viewportRect = $.extend( {}, viewportRect );
4955 }
4956
4957 // Account for scrollbar gutter
4958 direction = $viewport.css( 'direction' );
4959 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
4960 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
4961 viewportRect.bottom -= horizScrollbarHeight;
4962 if ( direction === 'rtl' ) {
4963 viewportRect.left += vertScrollbarWidth;
4964 } else {
4965 viewportRect.right -= vertScrollbarWidth;
4966 }
4967
4968 // Add arbitrary tolerance
4969 viewportRect.top += buffer;
4970 viewportRect.left += buffer;
4971 viewportRect.right -= buffer;
4972 viewportRect.bottom -= buffer;
4973
4974 $item = this.$clippableContainer || this.$clippable;
4975
4976 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
4977 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
4978
4979 itemRect = $item[ 0 ].getBoundingClientRect();
4980 // Convert into a plain object
4981 itemRect = $.extend( {}, itemRect );
4982
4983 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
4984 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
4985 if ( this.getHorizontalAnchorEdge() === 'right' ) {
4986 itemRect.left = viewportRect.left;
4987 } else {
4988 itemRect.right = viewportRect.right;
4989 }
4990 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
4991 itemRect.top = viewportRect.top;
4992 } else {
4993 itemRect.bottom = viewportRect.bottom;
4994 }
4995
4996 availableRect = rectIntersection( viewportRect, itemRect );
4997
4998 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
4999 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5000 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5001 desiredWidth = Math.min( desiredWidth,
5002 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5003 desiredHeight = Math.min( desiredHeight,
5004 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5005 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5006 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5007 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5008 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5009 clipWidth = allotedWidth < naturalWidth;
5010 clipHeight = allotedHeight < naturalHeight;
5011
5012 if ( clipWidth ) {
5013 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5014 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5015 this.$clippable.css( 'overflowX', 'scroll' );
5016 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5017 this.$clippable.css( {
5018 width: Math.max( 0, allotedWidth ),
5019 maxWidth: ''
5020 } );
5021 } else {
5022 this.$clippable.css( {
5023 overflowX: '',
5024 width: this.idealWidth || '',
5025 maxWidth: Math.max( 0, allotedWidth )
5026 } );
5027 }
5028 if ( clipHeight ) {
5029 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5030 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5031 this.$clippable.css( 'overflowY', 'scroll' );
5032 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5033 this.$clippable.css( {
5034 height: Math.max( 0, allotedHeight ),
5035 maxHeight: ''
5036 } );
5037 } else {
5038 this.$clippable.css( {
5039 overflowY: '',
5040 height: this.idealHeight || '',
5041 maxHeight: Math.max( 0, allotedHeight )
5042 } );
5043 }
5044
5045 // If we stopped clipping in at least one of the dimensions
5046 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5047 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5048 }
5049
5050 this.clippedHorizontally = clipWidth;
5051 this.clippedVertically = clipHeight;
5052
5053 return this;
5054 };
5055
5056 /**
5057 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5058 * By default, each popup has an anchor that points toward its origin.
5059 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5060 *
5061 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5062 *
5063 * @example
5064 * // A popup widget.
5065 * var popup = new OO.ui.PopupWidget( {
5066 * $content: $( '<p>Hi there!</p>' ),
5067 * padded: true,
5068 * width: 300
5069 * } );
5070 *
5071 * $( 'body' ).append( popup.$element );
5072 * // To display the popup, toggle the visibility to 'true'.
5073 * popup.toggle( true );
5074 *
5075 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5076 *
5077 * @class
5078 * @extends OO.ui.Widget
5079 * @mixins OO.ui.mixin.LabelElement
5080 * @mixins OO.ui.mixin.ClippableElement
5081 * @mixins OO.ui.mixin.FloatableElement
5082 *
5083 * @constructor
5084 * @param {Object} [config] Configuration options
5085 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5086 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5087 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5088 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5089 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5090 * of $floatableContainer
5091 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5092 * of $floatableContainer
5093 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5094 * endwards (right/left) to the vertical center of $floatableContainer
5095 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5096 * startwards (left/right) to the vertical center of $floatableContainer
5097 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5098 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5099 * as possible while still keeping the anchor within the popup;
5100 * if position is before/after, move the popup as far downwards as possible.
5101 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5102 * as possible while still keeping the anchor within the popup;
5103 * if position in before/after, move the popup as far upwards as possible.
5104 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5105 * of the popup with the center of $floatableContainer.
5106 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5107 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5108 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5109 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5110 * desired direction to display the popup without clipping
5111 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5112 * See the [OOUI docs on MediaWiki][3] for an example.
5113 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5114 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5115 * @cfg {jQuery} [$content] Content to append to the popup's body
5116 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5117 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5118 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5119 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5120 * for an example.
5121 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5122 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5123 * button.
5124 * @cfg {boolean} [padded=false] Add padding to the popup's body
5125 */
5126 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5127 // Configuration initialization
5128 config = config || {};
5129
5130 // Parent constructor
5131 OO.ui.PopupWidget.parent.call( this, config );
5132
5133 // Properties (must be set before ClippableElement constructor call)
5134 this.$body = $( '<div>' );
5135 this.$popup = $( '<div>' );
5136
5137 // Mixin constructors
5138 OO.ui.mixin.LabelElement.call( this, config );
5139 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5140 $clippable: this.$body,
5141 $clippableContainer: this.$popup
5142 } ) );
5143 OO.ui.mixin.FloatableElement.call( this, config );
5144
5145 // Properties
5146 this.$anchor = $( '<div>' );
5147 // If undefined, will be computed lazily in computePosition()
5148 this.$container = config.$container;
5149 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5150 this.autoClose = !!config.autoClose;
5151 this.transitionTimeout = null;
5152 this.anchored = false;
5153 this.onMouseDownHandler = this.onMouseDown.bind( this );
5154 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5155
5156 // Initialization
5157 this.setSize( config.width, config.height );
5158 this.toggleAnchor( config.anchor === undefined || config.anchor );
5159 this.setAlignment( config.align || 'center' );
5160 this.setPosition( config.position || 'below' );
5161 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5162 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5163 this.$body.addClass( 'oo-ui-popupWidget-body' );
5164 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5165 this.$popup
5166 .addClass( 'oo-ui-popupWidget-popup' )
5167 .append( this.$body );
5168 this.$element
5169 .addClass( 'oo-ui-popupWidget' )
5170 .append( this.$popup, this.$anchor );
5171 // Move content, which was added to #$element by OO.ui.Widget, to the body
5172 // FIXME This is gross, we should use '$body' or something for the config
5173 if ( config.$content instanceof jQuery ) {
5174 this.$body.append( config.$content );
5175 }
5176
5177 if ( config.padded ) {
5178 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5179 }
5180
5181 if ( config.head ) {
5182 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5183 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5184 this.$head = $( '<div>' )
5185 .addClass( 'oo-ui-popupWidget-head' )
5186 .append( this.$label, this.closeButton.$element );
5187 this.$popup.prepend( this.$head );
5188 }
5189
5190 if ( config.$footer ) {
5191 this.$footer = $( '<div>' )
5192 .addClass( 'oo-ui-popupWidget-footer' )
5193 .append( config.$footer );
5194 this.$popup.append( this.$footer );
5195 }
5196
5197 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5198 // that reference properties not initialized at that time of parent class construction
5199 // TODO: Find a better way to handle post-constructor setup
5200 this.visible = false;
5201 this.$element.addClass( 'oo-ui-element-hidden' );
5202 };
5203
5204 /* Setup */
5205
5206 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5207 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5208 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5209 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5210
5211 /* Events */
5212
5213 /**
5214 * @event ready
5215 *
5216 * The popup is ready: it is visible and has been positioned and clipped.
5217 */
5218
5219 /* Methods */
5220
5221 /**
5222 * Handles mouse down events.
5223 *
5224 * @private
5225 * @param {MouseEvent} e Mouse down event
5226 */
5227 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5228 if (
5229 this.isVisible() &&
5230 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5231 ) {
5232 this.toggle( false );
5233 }
5234 };
5235
5236 /**
5237 * Bind mouse down listener.
5238 *
5239 * @private
5240 */
5241 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5242 // Capture clicks outside popup
5243 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5244 // We add 'click' event because iOS safari needs to respond to this event.
5245 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5246 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5247 // of occasionally not emitting 'click' properly, that event seems to be the standard
5248 // that it should be emitting, so we add it to this and will operate the event handler
5249 // on whichever of these events was triggered first
5250 this.getElementDocument().addEventListener( 'click', this.onMouseDownHandler, true );
5251 };
5252
5253 /**
5254 * Handles close button click events.
5255 *
5256 * @private
5257 */
5258 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5259 if ( this.isVisible() ) {
5260 this.toggle( false );
5261 }
5262 };
5263
5264 /**
5265 * Unbind mouse down listener.
5266 *
5267 * @private
5268 */
5269 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5270 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5271 this.getElementDocument().removeEventListener( 'click', this.onMouseDownHandler, true );
5272 };
5273
5274 /**
5275 * Handles key down events.
5276 *
5277 * @private
5278 * @param {KeyboardEvent} e Key down event
5279 */
5280 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5281 if (
5282 e.which === OO.ui.Keys.ESCAPE &&
5283 this.isVisible()
5284 ) {
5285 this.toggle( false );
5286 e.preventDefault();
5287 e.stopPropagation();
5288 }
5289 };
5290
5291 /**
5292 * Bind key down listener.
5293 *
5294 * @private
5295 */
5296 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5297 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5298 };
5299
5300 /**
5301 * Unbind key down listener.
5302 *
5303 * @private
5304 */
5305 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5306 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5307 };
5308
5309 /**
5310 * Show, hide, or toggle the visibility of the anchor.
5311 *
5312 * @param {boolean} [show] Show anchor, omit to toggle
5313 */
5314 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5315 show = show === undefined ? !this.anchored : !!show;
5316
5317 if ( this.anchored !== show ) {
5318 if ( show ) {
5319 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5320 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5321 } else {
5322 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5323 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5324 }
5325 this.anchored = show;
5326 }
5327 };
5328
5329 /**
5330 * Change which edge the anchor appears on.
5331 *
5332 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5333 */
5334 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5335 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5336 throw new Error( 'Invalid value for edge: ' + edge );
5337 }
5338 if ( this.anchorEdge !== null ) {
5339 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5340 }
5341 this.anchorEdge = edge;
5342 if ( this.anchored ) {
5343 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5344 }
5345 };
5346
5347 /**
5348 * Check if the anchor is visible.
5349 *
5350 * @return {boolean} Anchor is visible
5351 */
5352 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5353 return this.anchored;
5354 };
5355
5356 /**
5357 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5358 * `.toggle( true )` after its #$element is attached to the DOM.
5359 *
5360 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5361 * it in the right place and with the right dimensions only work correctly while it is attached.
5362 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5363 * strictly enforced, so currently it only generates a warning in the browser console.
5364 *
5365 * @fires ready
5366 * @inheritdoc
5367 */
5368 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5369 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5370 show = show === undefined ? !this.isVisible() : !!show;
5371
5372 change = show !== this.isVisible();
5373
5374 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5375 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5376 this.warnedUnattached = true;
5377 }
5378 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5379 // Fall back to the parent node if the floatableContainer is not set
5380 this.setFloatableContainer( this.$element.parent() );
5381 }
5382
5383 if ( change && show && this.autoFlip ) {
5384 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5385 // (e.g. if the user scrolled).
5386 this.isAutoFlipped = false;
5387 }
5388
5389 // Parent method
5390 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5391
5392 if ( change ) {
5393 this.togglePositioning( show && !!this.$floatableContainer );
5394
5395 if ( show ) {
5396 if ( this.autoClose ) {
5397 this.bindMouseDownListener();
5398 this.bindKeyDownListener();
5399 }
5400 this.updateDimensions();
5401 this.toggleClipping( true );
5402
5403 if ( this.autoFlip ) {
5404 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5405 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5406 // If opening the popup in the normal direction causes it to be clipped, open
5407 // in the opposite one instead
5408 normalHeight = this.$element.height();
5409 this.isAutoFlipped = !this.isAutoFlipped;
5410 this.position();
5411 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5412 // If that also causes it to be clipped, open in whichever direction
5413 // we have more space
5414 oppositeHeight = this.$element.height();
5415 if ( oppositeHeight < normalHeight ) {
5416 this.isAutoFlipped = !this.isAutoFlipped;
5417 this.position();
5418 }
5419 }
5420 }
5421 }
5422 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5423 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5424 // If opening the popup in the normal direction causes it to be clipped, open
5425 // in the opposite one instead
5426 normalWidth = this.$element.width();
5427 this.isAutoFlipped = !this.isAutoFlipped;
5428 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5429 // which causes positioning to be off. Toggle clipping back and fort to work around.
5430 this.toggleClipping( false );
5431 this.position();
5432 this.toggleClipping( true );
5433 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5434 // If that also causes it to be clipped, open in whichever direction
5435 // we have more space
5436 oppositeWidth = this.$element.width();
5437 if ( oppositeWidth < normalWidth ) {
5438 this.isAutoFlipped = !this.isAutoFlipped;
5439 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5440 // which causes positioning to be off. Toggle clipping back and fort to work around.
5441 this.toggleClipping( false );
5442 this.position();
5443 this.toggleClipping( true );
5444 }
5445 }
5446 }
5447 }
5448 }
5449
5450 this.emit( 'ready' );
5451 } else {
5452 this.toggleClipping( false );
5453 if ( this.autoClose ) {
5454 this.unbindMouseDownListener();
5455 this.unbindKeyDownListener();
5456 }
5457 }
5458 }
5459
5460 return this;
5461 };
5462
5463 /**
5464 * Set the size of the popup.
5465 *
5466 * Changing the size may also change the popup's position depending on the alignment.
5467 *
5468 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5469 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5470 * @param {boolean} [transition=false] Use a smooth transition
5471 * @chainable
5472 */
5473 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5474 this.width = width !== undefined ? width : 320;
5475 this.height = height !== undefined ? height : null;
5476 if ( this.isVisible() ) {
5477 this.updateDimensions( transition );
5478 }
5479 };
5480
5481 /**
5482 * Update the size and position.
5483 *
5484 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5485 * be called automatically.
5486 *
5487 * @param {boolean} [transition=false] Use a smooth transition
5488 * @chainable
5489 */
5490 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5491 var widget = this;
5492
5493 // Prevent transition from being interrupted
5494 clearTimeout( this.transitionTimeout );
5495 if ( transition ) {
5496 // Enable transition
5497 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5498 }
5499
5500 this.position();
5501
5502 if ( transition ) {
5503 // Prevent transitioning after transition is complete
5504 this.transitionTimeout = setTimeout( function () {
5505 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5506 }, 200 );
5507 } else {
5508 // Prevent transitioning immediately
5509 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5510 }
5511 };
5512
5513 /**
5514 * @inheritdoc
5515 */
5516 OO.ui.PopupWidget.prototype.computePosition = function () {
5517 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5518 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5519 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5520 popupPos = {},
5521 anchorCss = { left: '', right: '', top: '', bottom: '' },
5522 popupPositionOppositeMap = {
5523 above: 'below',
5524 below: 'above',
5525 before: 'after',
5526 after: 'before'
5527 },
5528 alignMap = {
5529 ltr: {
5530 'force-left': 'backwards',
5531 'force-right': 'forwards'
5532 },
5533 rtl: {
5534 'force-left': 'forwards',
5535 'force-right': 'backwards'
5536 }
5537 },
5538 anchorEdgeMap = {
5539 above: 'bottom',
5540 below: 'top',
5541 before: 'end',
5542 after: 'start'
5543 },
5544 hPosMap = {
5545 forwards: 'start',
5546 center: 'center',
5547 backwards: this.anchored ? 'before' : 'end'
5548 },
5549 vPosMap = {
5550 forwards: 'top',
5551 center: 'center',
5552 backwards: 'bottom'
5553 };
5554
5555 if ( !this.$container ) {
5556 // Lazy-initialize $container if not specified in constructor
5557 this.$container = $( this.getClosestScrollableElementContainer() );
5558 }
5559 direction = this.$container.css( 'direction' );
5560
5561 // Set height and width before we do anything else, since it might cause our measurements
5562 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5563 this.$popup.css( {
5564 width: this.width !== null ? this.width : 'auto',
5565 height: this.height !== null ? this.height : 'auto'
5566 } );
5567
5568 align = alignMap[ direction ][ this.align ] || this.align;
5569 popupPosition = this.popupPosition;
5570 if ( this.isAutoFlipped ) {
5571 popupPosition = popupPositionOppositeMap[ popupPosition ];
5572 }
5573
5574 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5575 vertical = popupPosition === 'before' || popupPosition === 'after';
5576 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5577 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5578 near = vertical ? 'top' : 'left';
5579 far = vertical ? 'bottom' : 'right';
5580 sizeProp = vertical ? 'Height' : 'Width';
5581 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5582
5583 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5584 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5585 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5586
5587 // Parent method
5588 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5589 // Find out which property FloatableElement used for positioning, and adjust that value
5590 positionProp = vertical ?
5591 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5592 ( parentPosition.left !== '' ? 'left' : 'right' );
5593
5594 // Figure out where the near and far edges of the popup and $floatableContainer are
5595 floatablePos = this.$floatableContainer.offset();
5596 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5597 // Measure where the offsetParent is and compute our position based on that and parentPosition
5598 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5599 { top: 0, left: 0 } :
5600 this.$element.offsetParent().offset();
5601
5602 if ( positionProp === near ) {
5603 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5604 popupPos[ far ] = popupPos[ near ] + popupSize;
5605 } else {
5606 popupPos[ far ] = offsetParentPos[ near ] +
5607 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5608 popupPos[ near ] = popupPos[ far ] - popupSize;
5609 }
5610
5611 if ( this.anchored ) {
5612 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5613 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5614 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5615
5616 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5617 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5618 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5619 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5620 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5621 // Not enough space for the anchor on the start side; pull the popup startwards
5622 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5623 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5624 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5625 // Not enough space for the anchor on the end side; pull the popup endwards
5626 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5627 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5628 } else {
5629 positionAdjustment = 0;
5630 }
5631 } else {
5632 positionAdjustment = 0;
5633 }
5634
5635 // Check if the popup will go beyond the edge of this.$container
5636 containerPos = this.$container[ 0 ] === document.documentElement ?
5637 { top: 0, left: 0 } :
5638 this.$container.offset();
5639 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5640 if ( this.$container[ 0 ] === document.documentElement ) {
5641 viewportSpacing = OO.ui.getViewportSpacing();
5642 containerPos[ near ] += viewportSpacing[ near ];
5643 containerPos[ far ] -= viewportSpacing[ far ];
5644 }
5645 // Take into account how much the popup will move because of the adjustments we're going to make
5646 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5647 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5648 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5649 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5650 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5651 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5652 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5653 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5654 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5655 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5656 }
5657
5658 if ( this.anchored ) {
5659 // Adjust anchorOffset for positionAdjustment
5660 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5661
5662 // Position the anchor
5663 anchorCss[ start ] = anchorOffset;
5664 this.$anchor.css( anchorCss );
5665 }
5666
5667 // Move the popup if needed
5668 parentPosition[ positionProp ] += positionAdjustment;
5669
5670 return parentPosition;
5671 };
5672
5673 /**
5674 * Set popup alignment
5675 *
5676 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5677 * `backwards` or `forwards`.
5678 */
5679 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5680 // Validate alignment
5681 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5682 this.align = align;
5683 } else {
5684 this.align = 'center';
5685 }
5686 this.position();
5687 };
5688
5689 /**
5690 * Get popup alignment
5691 *
5692 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5693 * `backwards` or `forwards`.
5694 */
5695 OO.ui.PopupWidget.prototype.getAlignment = function () {
5696 return this.align;
5697 };
5698
5699 /**
5700 * Change the positioning of the popup.
5701 *
5702 * @param {string} position 'above', 'below', 'before' or 'after'
5703 */
5704 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5705 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5706 position = 'below';
5707 }
5708 this.popupPosition = position;
5709 this.position();
5710 };
5711
5712 /**
5713 * Get popup positioning.
5714 *
5715 * @return {string} 'above', 'below', 'before' or 'after'
5716 */
5717 OO.ui.PopupWidget.prototype.getPosition = function () {
5718 return this.popupPosition;
5719 };
5720
5721 /**
5722 * Set popup auto-flipping.
5723 *
5724 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5725 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5726 * desired direction to display the popup without clipping
5727 */
5728 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5729 autoFlip = !!autoFlip;
5730
5731 if ( this.autoFlip !== autoFlip ) {
5732 this.autoFlip = autoFlip;
5733 }
5734 };
5735
5736 /**
5737 * Set which elements will not close the popup when clicked.
5738 *
5739 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5740 *
5741 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5742 */
5743 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5744 this.$autoCloseIgnore = $autoCloseIgnore;
5745 };
5746
5747 /**
5748 * Get an ID of the body element, this can be used as the
5749 * `aria-describedby` attribute for an input field.
5750 *
5751 * @return {string} The ID of the body element
5752 */
5753 OO.ui.PopupWidget.prototype.getBodyId = function () {
5754 var id = this.$body.attr( 'id' );
5755 if ( id === undefined ) {
5756 id = OO.ui.generateElementId();
5757 this.$body.attr( 'id', id );
5758 }
5759 return id;
5760 };
5761
5762 /**
5763 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5764 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5765 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5766 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5767 *
5768 * @abstract
5769 * @class
5770 *
5771 * @constructor
5772 * @param {Object} [config] Configuration options
5773 * @cfg {Object} [popup] Configuration to pass to popup
5774 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5775 */
5776 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5777 // Configuration initialization
5778 config = config || {};
5779
5780 // Properties
5781 this.popup = new OO.ui.PopupWidget( $.extend(
5782 {
5783 autoClose: true,
5784 $floatableContainer: this.$element
5785 },
5786 config.popup,
5787 {
5788 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5789 }
5790 ) );
5791 };
5792
5793 /* Methods */
5794
5795 /**
5796 * Get popup.
5797 *
5798 * @return {OO.ui.PopupWidget} Popup widget
5799 */
5800 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5801 return this.popup;
5802 };
5803
5804 /**
5805 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5806 * which is used to display additional information or options.
5807 *
5808 * @example
5809 * // Example of a popup button.
5810 * var popupButton = new OO.ui.PopupButtonWidget( {
5811 * label: 'Popup button with options',
5812 * icon: 'menu',
5813 * popup: {
5814 * $content: $( '<p>Additional options here.</p>' ),
5815 * padded: true,
5816 * align: 'force-left'
5817 * }
5818 * } );
5819 * // Append the button to the DOM.
5820 * $( 'body' ).append( popupButton.$element );
5821 *
5822 * @class
5823 * @extends OO.ui.ButtonWidget
5824 * @mixins OO.ui.mixin.PopupElement
5825 *
5826 * @constructor
5827 * @param {Object} [config] Configuration options
5828 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5829 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5830 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5831 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5832 */
5833 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5834 // Configuration initialization
5835 config = config || {};
5836
5837 // Parent constructor
5838 OO.ui.PopupButtonWidget.parent.call( this, config );
5839
5840 // Mixin constructors
5841 OO.ui.mixin.PopupElement.call( this, config );
5842
5843 // Properties
5844 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5845
5846 // Events
5847 this.connect( this, { click: 'onAction' } );
5848
5849 // Initialization
5850 this.$element
5851 .addClass( 'oo-ui-popupButtonWidget' );
5852 this.popup.$element
5853 .addClass( 'oo-ui-popupButtonWidget-popup' )
5854 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5855 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5856 this.$overlay.append( this.popup.$element );
5857 };
5858
5859 /* Setup */
5860
5861 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5862 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5863
5864 /* Methods */
5865
5866 /**
5867 * Handle the button action being triggered.
5868 *
5869 * @private
5870 */
5871 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5872 this.popup.toggle();
5873 };
5874
5875 /**
5876 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5877 *
5878 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5879 *
5880 * @private
5881 * @abstract
5882 * @class
5883 * @mixins OO.ui.mixin.GroupElement
5884 *
5885 * @constructor
5886 * @param {Object} [config] Configuration options
5887 */
5888 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5889 // Mixin constructors
5890 OO.ui.mixin.GroupElement.call( this, config );
5891 };
5892
5893 /* Setup */
5894
5895 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5896
5897 /* Methods */
5898
5899 /**
5900 * Set the disabled state of the widget.
5901 *
5902 * This will also update the disabled state of child widgets.
5903 *
5904 * @param {boolean} disabled Disable widget
5905 * @chainable
5906 */
5907 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5908 var i, len;
5909
5910 // Parent method
5911 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5912 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5913
5914 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5915 if ( this.items ) {
5916 for ( i = 0, len = this.items.length; i < len; i++ ) {
5917 this.items[ i ].updateDisabled();
5918 }
5919 }
5920
5921 return this;
5922 };
5923
5924 /**
5925 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5926 *
5927 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5928 * allows bidirectional communication.
5929 *
5930 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5931 *
5932 * @private
5933 * @abstract
5934 * @class
5935 *
5936 * @constructor
5937 */
5938 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5939 //
5940 };
5941
5942 /* Methods */
5943
5944 /**
5945 * Check if widget is disabled.
5946 *
5947 * Checks parent if present, making disabled state inheritable.
5948 *
5949 * @return {boolean} Widget is disabled
5950 */
5951 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5952 return this.disabled ||
5953 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5954 };
5955
5956 /**
5957 * Set group element is in.
5958 *
5959 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5960 * @chainable
5961 */
5962 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5963 // Parent method
5964 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5965 OO.ui.Element.prototype.setElementGroup.call( this, group );
5966
5967 // Initialize item disabled states
5968 this.updateDisabled();
5969
5970 return this;
5971 };
5972
5973 /**
5974 * OptionWidgets are special elements that can be selected and configured with data. The
5975 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5976 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5977 * and examples, please see the [OOUI documentation on MediaWiki][1].
5978 *
5979 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
5980 *
5981 * @class
5982 * @extends OO.ui.Widget
5983 * @mixins OO.ui.mixin.ItemWidget
5984 * @mixins OO.ui.mixin.LabelElement
5985 * @mixins OO.ui.mixin.FlaggedElement
5986 * @mixins OO.ui.mixin.AccessKeyedElement
5987 *
5988 * @constructor
5989 * @param {Object} [config] Configuration options
5990 */
5991 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5992 // Configuration initialization
5993 config = config || {};
5994
5995 // Parent constructor
5996 OO.ui.OptionWidget.parent.call( this, config );
5997
5998 // Mixin constructors
5999 OO.ui.mixin.ItemWidget.call( this );
6000 OO.ui.mixin.LabelElement.call( this, config );
6001 OO.ui.mixin.FlaggedElement.call( this, config );
6002 OO.ui.mixin.AccessKeyedElement.call( this, config );
6003
6004 // Properties
6005 this.selected = false;
6006 this.highlighted = false;
6007 this.pressed = false;
6008
6009 // Initialization
6010 this.$element
6011 .data( 'oo-ui-optionWidget', this )
6012 // Allow programmatic focussing (and by accesskey), but not tabbing
6013 .attr( 'tabindex', '-1' )
6014 .attr( 'role', 'option' )
6015 .attr( 'aria-selected', 'false' )
6016 .addClass( 'oo-ui-optionWidget' )
6017 .append( this.$label );
6018 };
6019
6020 /* Setup */
6021
6022 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6023 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6024 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6025 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6026 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6027
6028 /* Static Properties */
6029
6030 /**
6031 * Whether this option can be selected. See #setSelected.
6032 *
6033 * @static
6034 * @inheritable
6035 * @property {boolean}
6036 */
6037 OO.ui.OptionWidget.static.selectable = true;
6038
6039 /**
6040 * Whether this option can be highlighted. See #setHighlighted.
6041 *
6042 * @static
6043 * @inheritable
6044 * @property {boolean}
6045 */
6046 OO.ui.OptionWidget.static.highlightable = true;
6047
6048 /**
6049 * Whether this option can be pressed. See #setPressed.
6050 *
6051 * @static
6052 * @inheritable
6053 * @property {boolean}
6054 */
6055 OO.ui.OptionWidget.static.pressable = true;
6056
6057 /**
6058 * Whether this option will be scrolled into view when it is selected.
6059 *
6060 * @static
6061 * @inheritable
6062 * @property {boolean}
6063 */
6064 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6065
6066 /* Methods */
6067
6068 /**
6069 * Check if the option can be selected.
6070 *
6071 * @return {boolean} Item is selectable
6072 */
6073 OO.ui.OptionWidget.prototype.isSelectable = function () {
6074 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6075 };
6076
6077 /**
6078 * Check if the option can be highlighted. A highlight indicates that the option
6079 * may be selected when a user presses enter or clicks. Disabled items cannot
6080 * be highlighted.
6081 *
6082 * @return {boolean} Item is highlightable
6083 */
6084 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6085 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6086 };
6087
6088 /**
6089 * Check if the option can be pressed. The pressed state occurs when a user mouses
6090 * down on an item, but has not yet let go of the mouse.
6091 *
6092 * @return {boolean} Item is pressable
6093 */
6094 OO.ui.OptionWidget.prototype.isPressable = function () {
6095 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6096 };
6097
6098 /**
6099 * Check if the option is selected.
6100 *
6101 * @return {boolean} Item is selected
6102 */
6103 OO.ui.OptionWidget.prototype.isSelected = function () {
6104 return this.selected;
6105 };
6106
6107 /**
6108 * Check if the option is highlighted. A highlight indicates that the
6109 * item may be selected when a user presses enter or clicks.
6110 *
6111 * @return {boolean} Item is highlighted
6112 */
6113 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6114 return this.highlighted;
6115 };
6116
6117 /**
6118 * Check if the option is pressed. The pressed state occurs when a user mouses
6119 * down on an item, but has not yet let go of the mouse. The item may appear
6120 * selected, but it will not be selected until the user releases the mouse.
6121 *
6122 * @return {boolean} Item is pressed
6123 */
6124 OO.ui.OptionWidget.prototype.isPressed = function () {
6125 return this.pressed;
6126 };
6127
6128 /**
6129 * Set the option’s selected state. In general, all modifications to the selection
6130 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6131 * method instead of this method.
6132 *
6133 * @param {boolean} [state=false] Select option
6134 * @chainable
6135 */
6136 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6137 if ( this.constructor.static.selectable ) {
6138 this.selected = !!state;
6139 this.$element
6140 .toggleClass( 'oo-ui-optionWidget-selected', state )
6141 .attr( 'aria-selected', state.toString() );
6142 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6143 this.scrollElementIntoView();
6144 }
6145 this.updateThemeClasses();
6146 }
6147 return this;
6148 };
6149
6150 /**
6151 * Set the option’s highlighted state. In general, all programmatic
6152 * modifications to the highlight should be handled by the
6153 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6154 * method instead of this method.
6155 *
6156 * @param {boolean} [state=false] Highlight option
6157 * @chainable
6158 */
6159 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6160 if ( this.constructor.static.highlightable ) {
6161 this.highlighted = !!state;
6162 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6163 this.updateThemeClasses();
6164 }
6165 return this;
6166 };
6167
6168 /**
6169 * Set the option’s pressed state. In general, all
6170 * programmatic modifications to the pressed state should be handled by the
6171 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6172 * method instead of this method.
6173 *
6174 * @param {boolean} [state=false] Press option
6175 * @chainable
6176 */
6177 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6178 if ( this.constructor.static.pressable ) {
6179 this.pressed = !!state;
6180 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6181 this.updateThemeClasses();
6182 }
6183 return this;
6184 };
6185
6186 /**
6187 * Get text to match search strings against.
6188 *
6189 * The default implementation returns the label text, but subclasses
6190 * can override this to provide more complex behavior.
6191 *
6192 * @return {string|boolean} String to match search string against
6193 */
6194 OO.ui.OptionWidget.prototype.getMatchText = function () {
6195 var label = this.getLabel();
6196 return typeof label === 'string' ? label : this.$label.text();
6197 };
6198
6199 /**
6200 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6201 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6202 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6203 * menu selects}.
6204 *
6205 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6206 * information, please see the [OOUI documentation on MediaWiki][1].
6207 *
6208 * @example
6209 * // Example of a select widget with three options
6210 * var select = new OO.ui.SelectWidget( {
6211 * items: [
6212 * new OO.ui.OptionWidget( {
6213 * data: 'a',
6214 * label: 'Option One',
6215 * } ),
6216 * new OO.ui.OptionWidget( {
6217 * data: 'b',
6218 * label: 'Option Two',
6219 * } ),
6220 * new OO.ui.OptionWidget( {
6221 * data: 'c',
6222 * label: 'Option Three',
6223 * } )
6224 * ]
6225 * } );
6226 * $( 'body' ).append( select.$element );
6227 *
6228 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6229 *
6230 * @abstract
6231 * @class
6232 * @extends OO.ui.Widget
6233 * @mixins OO.ui.mixin.GroupWidget
6234 *
6235 * @constructor
6236 * @param {Object} [config] Configuration options
6237 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6238 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6239 * the [OOUI documentation on MediaWiki] [2] for examples.
6240 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6241 */
6242 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6243 // Configuration initialization
6244 config = config || {};
6245
6246 // Parent constructor
6247 OO.ui.SelectWidget.parent.call( this, config );
6248
6249 // Mixin constructors
6250 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6251
6252 // Properties
6253 this.pressed = false;
6254 this.selecting = null;
6255 this.onMouseUpHandler = this.onMouseUp.bind( this );
6256 this.onMouseMoveHandler = this.onMouseMove.bind( this );
6257 this.onKeyDownHandler = this.onKeyDown.bind( this );
6258 this.onKeyPressHandler = this.onKeyPress.bind( this );
6259 this.keyPressBuffer = '';
6260 this.keyPressBufferTimer = null;
6261 this.blockMouseOverEvents = 0;
6262
6263 // Events
6264 this.connect( this, {
6265 toggle: 'onToggle'
6266 } );
6267 this.$element.on( {
6268 focusin: this.onFocus.bind( this ),
6269 mousedown: this.onMouseDown.bind( this ),
6270 mouseover: this.onMouseOver.bind( this ),
6271 mouseleave: this.onMouseLeave.bind( this )
6272 } );
6273
6274 // Initialization
6275 this.$element
6276 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6277 .attr( 'role', 'listbox' );
6278 this.setFocusOwner( this.$element );
6279 if ( Array.isArray( config.items ) ) {
6280 this.addItems( config.items );
6281 }
6282 };
6283
6284 /* Setup */
6285
6286 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6287 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6288
6289 /* Events */
6290
6291 /**
6292 * @event highlight
6293 *
6294 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6295 *
6296 * @param {OO.ui.OptionWidget|null} item Highlighted item
6297 */
6298
6299 /**
6300 * @event press
6301 *
6302 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6303 * pressed state of an option.
6304 *
6305 * @param {OO.ui.OptionWidget|null} item Pressed item
6306 */
6307
6308 /**
6309 * @event select
6310 *
6311 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6312 *
6313 * @param {OO.ui.OptionWidget|null} item Selected item
6314 */
6315
6316 /**
6317 * @event choose
6318 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6319 * @param {OO.ui.OptionWidget} item Chosen item
6320 */
6321
6322 /**
6323 * @event add
6324 *
6325 * An `add` event is emitted when options are added to the select with the #addItems method.
6326 *
6327 * @param {OO.ui.OptionWidget[]} items Added items
6328 * @param {number} index Index of insertion point
6329 */
6330
6331 /**
6332 * @event remove
6333 *
6334 * A `remove` event is emitted when options are removed from the select with the #clearItems
6335 * or #removeItems methods.
6336 *
6337 * @param {OO.ui.OptionWidget[]} items Removed items
6338 */
6339
6340 /* Methods */
6341
6342 /**
6343 * Handle focus events
6344 *
6345 * @private
6346 * @param {jQuery.Event} event
6347 */
6348 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6349 var item;
6350 if ( event.target === this.$element[ 0 ] ) {
6351 // This widget was focussed, e.g. by the user tabbing to it.
6352 // The styles for focus state depend on one of the items being selected.
6353 if ( !this.findSelectedItem() ) {
6354 item = this.findFirstSelectableItem();
6355 }
6356 } else {
6357 if ( event.target.tabIndex === -1 ) {
6358 // One of the options got focussed (and the event bubbled up here).
6359 // They can't be tabbed to, but they can be activated using accesskeys.
6360 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6361 item = this.findTargetItem( event );
6362 } else {
6363 // There is something actually user-focusable in one of the labels of the options, and the
6364 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6365 return;
6366 }
6367 }
6368
6369 if ( item ) {
6370 if ( item.constructor.static.highlightable ) {
6371 this.highlightItem( item );
6372 } else {
6373 this.selectItem( item );
6374 }
6375 }
6376
6377 if ( event.target !== this.$element[ 0 ] ) {
6378 this.$focusOwner.focus();
6379 }
6380 };
6381
6382 /**
6383 * Handle mouse down events.
6384 *
6385 * @private
6386 * @param {jQuery.Event} e Mouse down event
6387 */
6388 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6389 var item;
6390
6391 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6392 this.togglePressed( true );
6393 item = this.findTargetItem( e );
6394 if ( item && item.isSelectable() ) {
6395 this.pressItem( item );
6396 this.selecting = item;
6397 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6398 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6399 }
6400 }
6401 return false;
6402 };
6403
6404 /**
6405 * Handle mouse up events.
6406 *
6407 * @private
6408 * @param {MouseEvent} e Mouse up event
6409 */
6410 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6411 var item;
6412
6413 this.togglePressed( false );
6414 if ( !this.selecting ) {
6415 item = this.findTargetItem( e );
6416 if ( item && item.isSelectable() ) {
6417 this.selecting = item;
6418 }
6419 }
6420 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6421 this.pressItem( null );
6422 this.chooseItem( this.selecting );
6423 this.selecting = null;
6424 }
6425
6426 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6427 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6428
6429 return false;
6430 };
6431
6432 /**
6433 * Handle mouse move events.
6434 *
6435 * @private
6436 * @param {MouseEvent} e Mouse move event
6437 */
6438 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6439 var item;
6440
6441 if ( !this.isDisabled() && this.pressed ) {
6442 item = this.findTargetItem( e );
6443 if ( item && item !== this.selecting && item.isSelectable() ) {
6444 this.pressItem( item );
6445 this.selecting = item;
6446 }
6447 }
6448 };
6449
6450 /**
6451 * Handle mouse over events.
6452 *
6453 * @private
6454 * @param {jQuery.Event} e Mouse over event
6455 */
6456 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6457 var item;
6458 if ( this.blockMouseOverEvents ) {
6459 return;
6460 }
6461 if ( !this.isDisabled() ) {
6462 item = this.findTargetItem( e );
6463 this.highlightItem( item && item.isHighlightable() ? item : null );
6464 }
6465 return false;
6466 };
6467
6468 /**
6469 * Handle mouse leave events.
6470 *
6471 * @private
6472 * @param {jQuery.Event} e Mouse over event
6473 */
6474 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6475 if ( !this.isDisabled() ) {
6476 this.highlightItem( null );
6477 }
6478 return false;
6479 };
6480
6481 /**
6482 * Handle key down events.
6483 *
6484 * @protected
6485 * @param {KeyboardEvent} e Key down event
6486 */
6487 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6488 var nextItem,
6489 handled = false,
6490 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6491
6492 if ( !this.isDisabled() && this.isVisible() ) {
6493 switch ( e.keyCode ) {
6494 case OO.ui.Keys.ENTER:
6495 if ( currentItem && currentItem.constructor.static.highlightable ) {
6496 // Was only highlighted, now let's select it. No-op if already selected.
6497 this.chooseItem( currentItem );
6498 handled = true;
6499 }
6500 break;
6501 case OO.ui.Keys.UP:
6502 case OO.ui.Keys.LEFT:
6503 this.clearKeyPressBuffer();
6504 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6505 handled = true;
6506 break;
6507 case OO.ui.Keys.DOWN:
6508 case OO.ui.Keys.RIGHT:
6509 this.clearKeyPressBuffer();
6510 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6511 handled = true;
6512 break;
6513 case OO.ui.Keys.ESCAPE:
6514 case OO.ui.Keys.TAB:
6515 if ( currentItem && currentItem.constructor.static.highlightable ) {
6516 currentItem.setHighlighted( false );
6517 }
6518 this.unbindKeyDownListener();
6519 this.unbindKeyPressListener();
6520 // Don't prevent tabbing away / defocusing
6521 handled = false;
6522 break;
6523 }
6524
6525 if ( nextItem ) {
6526 if ( nextItem.constructor.static.highlightable ) {
6527 this.highlightItem( nextItem );
6528 } else {
6529 this.chooseItem( nextItem );
6530 }
6531 this.scrollItemIntoView( nextItem );
6532 }
6533
6534 if ( handled ) {
6535 e.preventDefault();
6536 e.stopPropagation();
6537 }
6538 }
6539 };
6540
6541 /**
6542 * Bind key down listener.
6543 *
6544 * @protected
6545 */
6546 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6547 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6548 };
6549
6550 /**
6551 * Unbind key down listener.
6552 *
6553 * @protected
6554 */
6555 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6556 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6557 };
6558
6559 /**
6560 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6561 *
6562 * @param {OO.ui.OptionWidget} item Item to scroll into view
6563 */
6564 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6565 var widget = this;
6566 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6567 // and around 100-150 ms after it is finished.
6568 this.blockMouseOverEvents++;
6569 item.scrollElementIntoView().done( function () {
6570 setTimeout( function () {
6571 widget.blockMouseOverEvents--;
6572 }, 200 );
6573 } );
6574 };
6575
6576 /**
6577 * Clear the key-press buffer
6578 *
6579 * @protected
6580 */
6581 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6582 if ( this.keyPressBufferTimer ) {
6583 clearTimeout( this.keyPressBufferTimer );
6584 this.keyPressBufferTimer = null;
6585 }
6586 this.keyPressBuffer = '';
6587 };
6588
6589 /**
6590 * Handle key press events.
6591 *
6592 * @protected
6593 * @param {KeyboardEvent} e Key press event
6594 */
6595 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6596 var c, filter, item;
6597
6598 if ( !e.charCode ) {
6599 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6600 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6601 return false;
6602 }
6603 return;
6604 }
6605 if ( String.fromCodePoint ) {
6606 c = String.fromCodePoint( e.charCode );
6607 } else {
6608 c = String.fromCharCode( e.charCode );
6609 }
6610
6611 if ( this.keyPressBufferTimer ) {
6612 clearTimeout( this.keyPressBufferTimer );
6613 }
6614 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6615
6616 item = this.findHighlightedItem() || this.findSelectedItem();
6617
6618 if ( this.keyPressBuffer === c ) {
6619 // Common (if weird) special case: typing "xxxx" will cycle through all
6620 // the items beginning with "x".
6621 if ( item ) {
6622 item = this.findRelativeSelectableItem( item, 1 );
6623 }
6624 } else {
6625 this.keyPressBuffer += c;
6626 }
6627
6628 filter = this.getItemMatcher( this.keyPressBuffer, false );
6629 if ( !item || !filter( item ) ) {
6630 item = this.findRelativeSelectableItem( item, 1, filter );
6631 }
6632 if ( item ) {
6633 if ( this.isVisible() && item.constructor.static.highlightable ) {
6634 this.highlightItem( item );
6635 } else {
6636 this.chooseItem( item );
6637 }
6638 this.scrollItemIntoView( item );
6639 }
6640
6641 e.preventDefault();
6642 e.stopPropagation();
6643 };
6644
6645 /**
6646 * Get a matcher for the specific string
6647 *
6648 * @protected
6649 * @param {string} s String to match against items
6650 * @param {boolean} [exact=false] Only accept exact matches
6651 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6652 */
6653 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6654 var re;
6655
6656 if ( s.normalize ) {
6657 s = s.normalize();
6658 }
6659 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6660 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6661 if ( exact ) {
6662 re += '\\s*$';
6663 }
6664 re = new RegExp( re, 'i' );
6665 return function ( item ) {
6666 var matchText = item.getMatchText();
6667 if ( matchText.normalize ) {
6668 matchText = matchText.normalize();
6669 }
6670 return re.test( matchText );
6671 };
6672 };
6673
6674 /**
6675 * Bind key press listener.
6676 *
6677 * @protected
6678 */
6679 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6680 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6681 };
6682
6683 /**
6684 * Unbind key down listener.
6685 *
6686 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6687 * implementation.
6688 *
6689 * @protected
6690 */
6691 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6692 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6693 this.clearKeyPressBuffer();
6694 };
6695
6696 /**
6697 * Visibility change handler
6698 *
6699 * @protected
6700 * @param {boolean} visible
6701 */
6702 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6703 if ( !visible ) {
6704 this.clearKeyPressBuffer();
6705 }
6706 };
6707
6708 /**
6709 * Get the closest item to a jQuery.Event.
6710 *
6711 * @private
6712 * @param {jQuery.Event} e
6713 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6714 */
6715 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6716 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6717 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6718 return null;
6719 }
6720 return $option.data( 'oo-ui-optionWidget' ) || null;
6721 };
6722
6723 /**
6724 * Find selected item.
6725 *
6726 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6727 */
6728 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6729 var i, len;
6730
6731 for ( i = 0, len = this.items.length; i < len; i++ ) {
6732 if ( this.items[ i ].isSelected() ) {
6733 return this.items[ i ];
6734 }
6735 }
6736 return null;
6737 };
6738
6739 /**
6740 * Find highlighted item.
6741 *
6742 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6743 */
6744 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6745 var i, len;
6746
6747 for ( i = 0, len = this.items.length; i < len; i++ ) {
6748 if ( this.items[ i ].isHighlighted() ) {
6749 return this.items[ i ];
6750 }
6751 }
6752 return null;
6753 };
6754
6755 /**
6756 * Toggle pressed state.
6757 *
6758 * Press is a state that occurs when a user mouses down on an item, but
6759 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6760 * until the user releases the mouse.
6761 *
6762 * @param {boolean} pressed An option is being pressed
6763 */
6764 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6765 if ( pressed === undefined ) {
6766 pressed = !this.pressed;
6767 }
6768 if ( pressed !== this.pressed ) {
6769 this.$element
6770 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6771 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6772 this.pressed = pressed;
6773 }
6774 };
6775
6776 /**
6777 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6778 * and any existing highlight will be removed. The highlight is mutually exclusive.
6779 *
6780 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6781 * @fires highlight
6782 * @chainable
6783 */
6784 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6785 var i, len, highlighted,
6786 changed = false;
6787
6788 for ( i = 0, len = this.items.length; i < len; i++ ) {
6789 highlighted = this.items[ i ] === item;
6790 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6791 this.items[ i ].setHighlighted( highlighted );
6792 changed = true;
6793 }
6794 }
6795 if ( changed ) {
6796 if ( item ) {
6797 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6798 } else {
6799 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6800 }
6801 this.emit( 'highlight', item );
6802 }
6803
6804 return this;
6805 };
6806
6807 /**
6808 * Fetch an item by its label.
6809 *
6810 * @param {string} label Label of the item to select.
6811 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6812 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6813 */
6814 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6815 var i, item, found,
6816 len = this.items.length,
6817 filter = this.getItemMatcher( label, true );
6818
6819 for ( i = 0; i < len; i++ ) {
6820 item = this.items[ i ];
6821 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6822 return item;
6823 }
6824 }
6825
6826 if ( prefix ) {
6827 found = null;
6828 filter = this.getItemMatcher( label, false );
6829 for ( i = 0; i < len; i++ ) {
6830 item = this.items[ i ];
6831 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6832 if ( found ) {
6833 return null;
6834 }
6835 found = item;
6836 }
6837 }
6838 if ( found ) {
6839 return found;
6840 }
6841 }
6842
6843 return null;
6844 };
6845
6846 /**
6847 * Programmatically select an option by its label. If the item does not exist,
6848 * all options will be deselected.
6849 *
6850 * @param {string} [label] Label of the item to select.
6851 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6852 * @fires select
6853 * @chainable
6854 */
6855 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6856 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6857 if ( label === undefined || !itemFromLabel ) {
6858 return this.selectItem();
6859 }
6860 return this.selectItem( itemFromLabel );
6861 };
6862
6863 /**
6864 * Programmatically select an option by its data. If the `data` parameter is omitted,
6865 * or if the item does not exist, all options will be deselected.
6866 *
6867 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6868 * @fires select
6869 * @chainable
6870 */
6871 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6872 var itemFromData = this.findItemFromData( data );
6873 if ( data === undefined || !itemFromData ) {
6874 return this.selectItem();
6875 }
6876 return this.selectItem( itemFromData );
6877 };
6878
6879 /**
6880 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6881 * all options will be deselected.
6882 *
6883 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6884 * @fires select
6885 * @chainable
6886 */
6887 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6888 var i, len, selected,
6889 changed = false;
6890
6891 for ( i = 0, len = this.items.length; i < len; i++ ) {
6892 selected = this.items[ i ] === item;
6893 if ( this.items[ i ].isSelected() !== selected ) {
6894 this.items[ i ].setSelected( selected );
6895 changed = true;
6896 }
6897 }
6898 if ( changed ) {
6899 if ( item && !item.constructor.static.highlightable ) {
6900 if ( item ) {
6901 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6902 } else {
6903 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6904 }
6905 }
6906 this.emit( 'select', item );
6907 }
6908
6909 return this;
6910 };
6911
6912 /**
6913 * Press an item.
6914 *
6915 * Press is a state that occurs when a user mouses down on an item, but has not
6916 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6917 * releases the mouse.
6918 *
6919 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6920 * @fires press
6921 * @chainable
6922 */
6923 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6924 var i, len, pressed,
6925 changed = false;
6926
6927 for ( i = 0, len = this.items.length; i < len; i++ ) {
6928 pressed = this.items[ i ] === item;
6929 if ( this.items[ i ].isPressed() !== pressed ) {
6930 this.items[ i ].setPressed( pressed );
6931 changed = true;
6932 }
6933 }
6934 if ( changed ) {
6935 this.emit( 'press', item );
6936 }
6937
6938 return this;
6939 };
6940
6941 /**
6942 * Choose an item.
6943 *
6944 * Note that ‘choose’ should never be modified programmatically. A user can choose
6945 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6946 * use the #selectItem method.
6947 *
6948 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6949 * when users choose an item with the keyboard or mouse.
6950 *
6951 * @param {OO.ui.OptionWidget} item Item to choose
6952 * @fires choose
6953 * @chainable
6954 */
6955 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6956 if ( item ) {
6957 this.selectItem( item );
6958 this.emit( 'choose', item );
6959 }
6960
6961 return this;
6962 };
6963
6964 /**
6965 * Find an option by its position relative to the specified item (or to the start of the option array,
6966 * if item is `null`). The direction in which to search through the option array is specified with a
6967 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6968 * `null` if there are no options in the array.
6969 *
6970 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6971 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6972 * @param {Function} [filter] Only consider items for which this function returns
6973 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6974 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6975 */
6976 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6977 var currentIndex, nextIndex, i,
6978 increase = direction > 0 ? 1 : -1,
6979 len = this.items.length;
6980
6981 if ( item instanceof OO.ui.OptionWidget ) {
6982 currentIndex = this.items.indexOf( item );
6983 nextIndex = ( currentIndex + increase + len ) % len;
6984 } else {
6985 // If no item is selected and moving forward, start at the beginning.
6986 // If moving backward, start at the end.
6987 nextIndex = direction > 0 ? 0 : len - 1;
6988 }
6989
6990 for ( i = 0; i < len; i++ ) {
6991 item = this.items[ nextIndex ];
6992 if (
6993 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6994 ( !filter || filter( item ) )
6995 ) {
6996 return item;
6997 }
6998 nextIndex = ( nextIndex + increase + len ) % len;
6999 }
7000 return null;
7001 };
7002
7003 /**
7004 * Find the next selectable item or `null` if there are no selectable items.
7005 * Disabled options and menu-section markers and breaks are not selectable.
7006 *
7007 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7008 */
7009 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7010 return this.findRelativeSelectableItem( null, 1 );
7011 };
7012
7013 /**
7014 * Add an array of options to the select. Optionally, an index number can be used to
7015 * specify an insertion point.
7016 *
7017 * @param {OO.ui.OptionWidget[]} items Items to add
7018 * @param {number} [index] Index to insert items after
7019 * @fires add
7020 * @chainable
7021 */
7022 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7023 // Mixin method
7024 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7025
7026 // Always provide an index, even if it was omitted
7027 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7028
7029 return this;
7030 };
7031
7032 /**
7033 * Remove the specified array of options from the select. Options will be detached
7034 * from the DOM, not removed, so they can be reused later. To remove all options from
7035 * the select, you may wish to use the #clearItems method instead.
7036 *
7037 * @param {OO.ui.OptionWidget[]} items Items to remove
7038 * @fires remove
7039 * @chainable
7040 */
7041 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7042 var i, len, item;
7043
7044 // Deselect items being removed
7045 for ( i = 0, len = items.length; i < len; i++ ) {
7046 item = items[ i ];
7047 if ( item.isSelected() ) {
7048 this.selectItem( null );
7049 }
7050 }
7051
7052 // Mixin method
7053 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7054
7055 this.emit( 'remove', items );
7056
7057 return this;
7058 };
7059
7060 /**
7061 * Clear all options from the select. Options will be detached from the DOM, not removed,
7062 * so that they can be reused later. To remove a subset of options from the select, use
7063 * the #removeItems method.
7064 *
7065 * @fires remove
7066 * @chainable
7067 */
7068 OO.ui.SelectWidget.prototype.clearItems = function () {
7069 var items = this.items.slice();
7070
7071 // Mixin method
7072 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7073
7074 // Clear selection
7075 this.selectItem( null );
7076
7077 this.emit( 'remove', items );
7078
7079 return this;
7080 };
7081
7082 /**
7083 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7084 *
7085 * Currently this is just used to set `aria-activedescendant` on it.
7086 *
7087 * @protected
7088 * @param {jQuery} $focusOwner
7089 */
7090 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7091 this.$focusOwner = $focusOwner;
7092 };
7093
7094 /**
7095 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7096 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7097 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7098 * options. For more information about options and selects, please see the
7099 * [OOUI documentation on MediaWiki][1].
7100 *
7101 * @example
7102 * // Decorated options in a select widget
7103 * var select = new OO.ui.SelectWidget( {
7104 * items: [
7105 * new OO.ui.DecoratedOptionWidget( {
7106 * data: 'a',
7107 * label: 'Option with icon',
7108 * icon: 'help'
7109 * } ),
7110 * new OO.ui.DecoratedOptionWidget( {
7111 * data: 'b',
7112 * label: 'Option with indicator',
7113 * indicator: 'next'
7114 * } )
7115 * ]
7116 * } );
7117 * $( 'body' ).append( select.$element );
7118 *
7119 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7120 *
7121 * @class
7122 * @extends OO.ui.OptionWidget
7123 * @mixins OO.ui.mixin.IconElement
7124 * @mixins OO.ui.mixin.IndicatorElement
7125 *
7126 * @constructor
7127 * @param {Object} [config] Configuration options
7128 */
7129 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7130 // Parent constructor
7131 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7132
7133 // Mixin constructors
7134 OO.ui.mixin.IconElement.call( this, config );
7135 OO.ui.mixin.IndicatorElement.call( this, config );
7136
7137 // Initialization
7138 this.$element
7139 .addClass( 'oo-ui-decoratedOptionWidget' )
7140 .prepend( this.$icon )
7141 .append( this.$indicator );
7142 };
7143
7144 /* Setup */
7145
7146 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7147 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7148 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7149
7150 /**
7151 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7152 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7153 * the [OOUI documentation on MediaWiki] [1] for more information.
7154 *
7155 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7156 *
7157 * @class
7158 * @extends OO.ui.DecoratedOptionWidget
7159 *
7160 * @constructor
7161 * @param {Object} [config] Configuration options
7162 */
7163 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7164 // Parent constructor
7165 OO.ui.MenuOptionWidget.parent.call( this, config );
7166
7167 // Properties
7168 this.checkIcon = new OO.ui.IconWidget( {
7169 icon: 'check',
7170 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7171 } );
7172
7173 // Initialization
7174 this.$element
7175 .prepend( this.checkIcon.$element )
7176 .addClass( 'oo-ui-menuOptionWidget' );
7177 };
7178
7179 /* Setup */
7180
7181 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7182
7183 /* Static Properties */
7184
7185 /**
7186 * @static
7187 * @inheritdoc
7188 */
7189 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7190
7191 /**
7192 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7193 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7194 *
7195 * @example
7196 * var myDropdown = new OO.ui.DropdownWidget( {
7197 * menu: {
7198 * items: [
7199 * new OO.ui.MenuSectionOptionWidget( {
7200 * label: 'Dogs'
7201 * } ),
7202 * new OO.ui.MenuOptionWidget( {
7203 * data: 'corgi',
7204 * label: 'Welsh Corgi'
7205 * } ),
7206 * new OO.ui.MenuOptionWidget( {
7207 * data: 'poodle',
7208 * label: 'Standard Poodle'
7209 * } ),
7210 * new OO.ui.MenuSectionOptionWidget( {
7211 * label: 'Cats'
7212 * } ),
7213 * new OO.ui.MenuOptionWidget( {
7214 * data: 'lion',
7215 * label: 'Lion'
7216 * } )
7217 * ]
7218 * }
7219 * } );
7220 * $( 'body' ).append( myDropdown.$element );
7221 *
7222 * @class
7223 * @extends OO.ui.DecoratedOptionWidget
7224 *
7225 * @constructor
7226 * @param {Object} [config] Configuration options
7227 */
7228 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7229 // Parent constructor
7230 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7231
7232 // Initialization
7233 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7234 .removeAttr( 'role aria-selected' );
7235 };
7236
7237 /* Setup */
7238
7239 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7240
7241 /* Static Properties */
7242
7243 /**
7244 * @static
7245 * @inheritdoc
7246 */
7247 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7248
7249 /**
7250 * @static
7251 * @inheritdoc
7252 */
7253 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7254
7255 /**
7256 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7257 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7258 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7259 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7260 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7261 * and customized to be opened, closed, and displayed as needed.
7262 *
7263 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7264 * mouse outside the menu.
7265 *
7266 * Menus also have support for keyboard interaction:
7267 *
7268 * - Enter/Return key: choose and select a menu option
7269 * - Up-arrow key: highlight the previous menu option
7270 * - Down-arrow key: highlight the next menu option
7271 * - Esc key: hide the menu
7272 *
7273 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7274 *
7275 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7276 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7277 *
7278 * @class
7279 * @extends OO.ui.SelectWidget
7280 * @mixins OO.ui.mixin.ClippableElement
7281 * @mixins OO.ui.mixin.FloatableElement
7282 *
7283 * @constructor
7284 * @param {Object} [config] Configuration options
7285 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7286 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7287 * and {@link OO.ui.mixin.LookupElement LookupElement}
7288 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7289 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7290 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7291 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7292 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7293 * that button, unless the button (or its parent widget) is passed in here.
7294 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7295 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7296 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7297 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7298 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7299 * @cfg {number} [width] Width of the menu
7300 */
7301 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7302 // Configuration initialization
7303 config = config || {};
7304
7305 // Parent constructor
7306 OO.ui.MenuSelectWidget.parent.call( this, config );
7307
7308 // Mixin constructors
7309 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7310 OO.ui.mixin.FloatableElement.call( this, config );
7311
7312 // Initial vertical positions other than 'center' will result in
7313 // the menu being flipped if there is not enough space in the container.
7314 // Store the original position so we know what to reset to.
7315 this.originalVerticalPosition = this.verticalPosition;
7316
7317 // Properties
7318 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7319 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7320 this.filterFromInput = !!config.filterFromInput;
7321 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7322 this.$widget = config.widget ? config.widget.$element : null;
7323 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7324 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7325 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7326 this.highlightOnFilter = !!config.highlightOnFilter;
7327 this.width = config.width;
7328
7329 // Initialization
7330 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7331 if ( config.widget ) {
7332 this.setFocusOwner( config.widget.$tabIndexed );
7333 }
7334
7335 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7336 // that reference properties not initialized at that time of parent class construction
7337 // TODO: Find a better way to handle post-constructor setup
7338 this.visible = false;
7339 this.$element.addClass( 'oo-ui-element-hidden' );
7340 };
7341
7342 /* Setup */
7343
7344 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7345 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7346 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7347
7348 /* Events */
7349
7350 /**
7351 * @event ready
7352 *
7353 * The menu is ready: it is visible and has been positioned and clipped.
7354 */
7355
7356 /* Static properties */
7357
7358 /**
7359 * Positions to flip to if there isn't room in the container for the
7360 * menu in a specific direction.
7361 *
7362 * @property {Object.<string,string>}
7363 */
7364 OO.ui.MenuSelectWidget.static.flippedPositions = {
7365 below: 'above',
7366 above: 'below',
7367 top: 'bottom',
7368 bottom: 'top'
7369 };
7370
7371 /* Methods */
7372
7373 /**
7374 * Handles document mouse down events.
7375 *
7376 * @protected
7377 * @param {MouseEvent} e Mouse down event
7378 */
7379 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7380 if (
7381 this.isVisible() &&
7382 !OO.ui.contains(
7383 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7384 e.target,
7385 true
7386 )
7387 ) {
7388 this.toggle( false );
7389 }
7390 };
7391
7392 /**
7393 * @inheritdoc
7394 */
7395 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7396 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7397
7398 if ( !this.isDisabled() && this.isVisible() ) {
7399 switch ( e.keyCode ) {
7400 case OO.ui.Keys.LEFT:
7401 case OO.ui.Keys.RIGHT:
7402 // Do nothing if a text field is associated, arrow keys will be handled natively
7403 if ( !this.$input ) {
7404 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7405 }
7406 break;
7407 case OO.ui.Keys.ESCAPE:
7408 case OO.ui.Keys.TAB:
7409 if ( currentItem ) {
7410 currentItem.setHighlighted( false );
7411 }
7412 this.toggle( false );
7413 // Don't prevent tabbing away, prevent defocusing
7414 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7415 e.preventDefault();
7416 e.stopPropagation();
7417 }
7418 break;
7419 default:
7420 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7421 return;
7422 }
7423 }
7424 };
7425
7426 /**
7427 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7428 * or after items were added/removed (always).
7429 *
7430 * @protected
7431 */
7432 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7433 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7434 anyVisible = false,
7435 len = this.items.length,
7436 showAll = !this.isVisible(),
7437 exactMatch = false;
7438
7439 if ( this.$input && this.filterFromInput ) {
7440 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7441 exactFilter = this.getItemMatcher( this.$input.val(), true );
7442 // Hide non-matching options, and also hide section headers if all options
7443 // in their section are hidden.
7444 for ( i = 0; i < len; i++ ) {
7445 item = this.items[ i ];
7446 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7447 if ( section ) {
7448 // If the previous section was empty, hide its header
7449 section.toggle( showAll || !sectionEmpty );
7450 }
7451 section = item;
7452 sectionEmpty = true;
7453 } else if ( item instanceof OO.ui.OptionWidget ) {
7454 visible = showAll || filter( item );
7455 exactMatch = exactMatch || exactFilter( item );
7456 anyVisible = anyVisible || visible;
7457 sectionEmpty = sectionEmpty && !visible;
7458 item.toggle( visible );
7459 }
7460 }
7461 // Process the final section
7462 if ( section ) {
7463 section.toggle( showAll || !sectionEmpty );
7464 }
7465
7466 if ( anyVisible && this.items.length && !exactMatch ) {
7467 this.scrollItemIntoView( this.items[ 0 ] );
7468 }
7469
7470 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7471
7472 if ( this.highlightOnFilter ) {
7473 // Highlight the first item on the list
7474 item = null;
7475 items = this.getItems();
7476 for ( i = 0; i < items.length; i++ ) {
7477 if ( items[ i ].isVisible() ) {
7478 item = items[ i ];
7479 break;
7480 }
7481 }
7482 this.highlightItem( item );
7483 }
7484
7485 }
7486
7487 // Reevaluate clipping
7488 this.clip();
7489 };
7490
7491 /**
7492 * @inheritdoc
7493 */
7494 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7495 if ( this.$input ) {
7496 this.$input.on( 'keydown', this.onKeyDownHandler );
7497 } else {
7498 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7499 }
7500 };
7501
7502 /**
7503 * @inheritdoc
7504 */
7505 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7506 if ( this.$input ) {
7507 this.$input.off( 'keydown', this.onKeyDownHandler );
7508 } else {
7509 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7510 }
7511 };
7512
7513 /**
7514 * @inheritdoc
7515 */
7516 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7517 if ( this.$input ) {
7518 if ( this.filterFromInput ) {
7519 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7520 this.updateItemVisibility();
7521 }
7522 } else {
7523 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7524 }
7525 };
7526
7527 /**
7528 * @inheritdoc
7529 */
7530 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7531 if ( this.$input ) {
7532 if ( this.filterFromInput ) {
7533 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7534 this.updateItemVisibility();
7535 }
7536 } else {
7537 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7538 }
7539 };
7540
7541 /**
7542 * Choose an item.
7543 *
7544 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7545 *
7546 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7547 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7548 *
7549 * @param {OO.ui.OptionWidget} item Item to choose
7550 * @chainable
7551 */
7552 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7553 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7554 if ( this.hideOnChoose ) {
7555 this.toggle( false );
7556 }
7557 return this;
7558 };
7559
7560 /**
7561 * @inheritdoc
7562 */
7563 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7564 // Parent method
7565 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7566
7567 this.updateItemVisibility();
7568
7569 return this;
7570 };
7571
7572 /**
7573 * @inheritdoc
7574 */
7575 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7576 // Parent method
7577 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7578
7579 this.updateItemVisibility();
7580
7581 return this;
7582 };
7583
7584 /**
7585 * @inheritdoc
7586 */
7587 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7588 // Parent method
7589 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7590
7591 this.updateItemVisibility();
7592
7593 return this;
7594 };
7595
7596 /**
7597 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7598 * `.toggle( true )` after its #$element is attached to the DOM.
7599 *
7600 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7601 * it in the right place and with the right dimensions only work correctly while it is attached.
7602 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7603 * strictly enforced, so currently it only generates a warning in the browser console.
7604 *
7605 * @fires ready
7606 * @inheritdoc
7607 */
7608 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7609 var change, originalHeight, flippedHeight;
7610
7611 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7612 change = visible !== this.isVisible();
7613
7614 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7615 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7616 this.warnedUnattached = true;
7617 }
7618
7619 if ( change && visible ) {
7620 // Reset position before showing the popup again. It's possible we no longer need to flip
7621 // (e.g. if the user scrolled).
7622 this.setVerticalPosition( this.originalVerticalPosition );
7623 }
7624
7625 // Parent method
7626 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7627
7628 if ( change ) {
7629 if ( visible ) {
7630
7631 if ( this.width ) {
7632 this.setIdealSize( this.width );
7633 } else if ( this.$floatableContainer ) {
7634 this.$clippable.css( 'width', 'auto' );
7635 this.setIdealSize(
7636 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7637 // Dropdown is smaller than handle so expand to width
7638 this.$floatableContainer[ 0 ].offsetWidth :
7639 // Dropdown is larger than handle so auto size
7640 'auto'
7641 );
7642 this.$clippable.css( 'width', '' );
7643 }
7644
7645 this.togglePositioning( !!this.$floatableContainer );
7646 this.toggleClipping( true );
7647
7648 this.bindKeyDownListener();
7649 this.bindKeyPressListener();
7650
7651 if (
7652 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7653 this.originalVerticalPosition !== 'center'
7654 ) {
7655 // If opening the menu in one direction causes it to be clipped, flip it
7656 originalHeight = this.$element.height();
7657 this.setVerticalPosition(
7658 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7659 );
7660 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7661 // If flipping also causes it to be clipped, open in whichever direction
7662 // we have more space
7663 flippedHeight = this.$element.height();
7664 if ( originalHeight > flippedHeight ) {
7665 this.setVerticalPosition( this.originalVerticalPosition );
7666 }
7667 }
7668 }
7669 // Note that we do not flip the menu's opening direction if the clipping changes
7670 // later (e.g. after the user scrolls), that seems like it would be annoying
7671
7672 this.$focusOwner.attr( 'aria-expanded', 'true' );
7673
7674 if ( this.findSelectedItem() ) {
7675 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7676 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7677 }
7678
7679 // Auto-hide
7680 if ( this.autoHide ) {
7681 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7682 }
7683
7684 this.emit( 'ready' );
7685 } else {
7686 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7687 this.unbindKeyDownListener();
7688 this.unbindKeyPressListener();
7689 this.$focusOwner.attr( 'aria-expanded', 'false' );
7690 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7691 this.togglePositioning( false );
7692 this.toggleClipping( false );
7693 }
7694 }
7695
7696 return this;
7697 };
7698
7699 /**
7700 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7701 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7702 * users can interact with it.
7703 *
7704 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7705 * OO.ui.DropdownInputWidget instead.
7706 *
7707 * @example
7708 * // Example: A DropdownWidget with a menu that contains three options
7709 * var dropDown = new OO.ui.DropdownWidget( {
7710 * label: 'Dropdown menu: Select a menu option',
7711 * menu: {
7712 * items: [
7713 * new OO.ui.MenuOptionWidget( {
7714 * data: 'a',
7715 * label: 'First'
7716 * } ),
7717 * new OO.ui.MenuOptionWidget( {
7718 * data: 'b',
7719 * label: 'Second'
7720 * } ),
7721 * new OO.ui.MenuOptionWidget( {
7722 * data: 'c',
7723 * label: 'Third'
7724 * } )
7725 * ]
7726 * }
7727 * } );
7728 *
7729 * $( 'body' ).append( dropDown.$element );
7730 *
7731 * dropDown.getMenu().selectItemByData( 'b' );
7732 *
7733 * dropDown.getMenu().findSelectedItem().getData(); // returns 'b'
7734 *
7735 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7736 *
7737 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7738 *
7739 * @class
7740 * @extends OO.ui.Widget
7741 * @mixins OO.ui.mixin.IconElement
7742 * @mixins OO.ui.mixin.IndicatorElement
7743 * @mixins OO.ui.mixin.LabelElement
7744 * @mixins OO.ui.mixin.TitledElement
7745 * @mixins OO.ui.mixin.TabIndexedElement
7746 *
7747 * @constructor
7748 * @param {Object} [config] Configuration options
7749 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7750 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7751 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7752 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7753 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
7754 */
7755 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7756 // Configuration initialization
7757 config = $.extend( { indicator: 'down' }, config );
7758
7759 // Parent constructor
7760 OO.ui.DropdownWidget.parent.call( this, config );
7761
7762 // Properties (must be set before TabIndexedElement constructor call)
7763 this.$handle = $( '<span>' );
7764 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7765
7766 // Mixin constructors
7767 OO.ui.mixin.IconElement.call( this, config );
7768 OO.ui.mixin.IndicatorElement.call( this, config );
7769 OO.ui.mixin.LabelElement.call( this, config );
7770 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7771 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7772
7773 // Properties
7774 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7775 widget: this,
7776 $floatableContainer: this.$element
7777 }, config.menu ) );
7778
7779 // Events
7780 this.$handle.on( {
7781 click: this.onClick.bind( this ),
7782 keydown: this.onKeyDown.bind( this ),
7783 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7784 keypress: this.menu.onKeyPressHandler,
7785 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7786 } );
7787 this.menu.connect( this, {
7788 select: 'onMenuSelect',
7789 toggle: 'onMenuToggle'
7790 } );
7791
7792 // Initialization
7793 this.$handle
7794 .addClass( 'oo-ui-dropdownWidget-handle' )
7795 .attr( {
7796 role: 'combobox',
7797 'aria-owns': this.menu.getElementId(),
7798 'aria-autocomplete': 'list'
7799 } )
7800 .append( this.$icon, this.$label, this.$indicator );
7801 this.$element
7802 .addClass( 'oo-ui-dropdownWidget' )
7803 .append( this.$handle );
7804 this.$overlay.append( this.menu.$element );
7805 };
7806
7807 /* Setup */
7808
7809 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7810 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7811 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7812 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7813 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7814 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7815
7816 /* Methods */
7817
7818 /**
7819 * Get the menu.
7820 *
7821 * @return {OO.ui.MenuSelectWidget} Menu of widget
7822 */
7823 OO.ui.DropdownWidget.prototype.getMenu = function () {
7824 return this.menu;
7825 };
7826
7827 /**
7828 * Handles menu select events.
7829 *
7830 * @private
7831 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7832 */
7833 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7834 var selectedLabel;
7835
7836 if ( !item ) {
7837 this.setLabel( null );
7838 return;
7839 }
7840
7841 selectedLabel = item.getLabel();
7842
7843 // If the label is a DOM element, clone it, because setLabel will append() it
7844 if ( selectedLabel instanceof jQuery ) {
7845 selectedLabel = selectedLabel.clone();
7846 }
7847
7848 this.setLabel( selectedLabel );
7849 };
7850
7851 /**
7852 * Handle menu toggle events.
7853 *
7854 * @private
7855 * @param {boolean} isVisible Open state of the menu
7856 */
7857 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7858 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7859 this.$handle.attr(
7860 'aria-expanded',
7861 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7862 );
7863 };
7864
7865 /**
7866 * Handle mouse click events.
7867 *
7868 * @private
7869 * @param {jQuery.Event} e Mouse click event
7870 */
7871 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7872 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7873 this.menu.toggle();
7874 }
7875 return false;
7876 };
7877
7878 /**
7879 * Handle key down events.
7880 *
7881 * @private
7882 * @param {jQuery.Event} e Key down event
7883 */
7884 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7885 if (
7886 !this.isDisabled() &&
7887 (
7888 e.which === OO.ui.Keys.ENTER ||
7889 (
7890 e.which === OO.ui.Keys.SPACE &&
7891 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7892 // Space only closes the menu is the user is not typing to search.
7893 this.menu.keyPressBuffer === ''
7894 ) ||
7895 (
7896 !this.menu.isVisible() &&
7897 (
7898 e.which === OO.ui.Keys.UP ||
7899 e.which === OO.ui.Keys.DOWN
7900 )
7901 )
7902 )
7903 ) {
7904 this.menu.toggle();
7905 return false;
7906 }
7907 };
7908
7909 /**
7910 * RadioOptionWidget is an option widget that looks like a radio button.
7911 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7912 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
7913 *
7914 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
7915 *
7916 * @class
7917 * @extends OO.ui.OptionWidget
7918 *
7919 * @constructor
7920 * @param {Object} [config] Configuration options
7921 */
7922 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7923 // Configuration initialization
7924 config = config || {};
7925
7926 // Properties (must be done before parent constructor which calls #setDisabled)
7927 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7928
7929 // Parent constructor
7930 OO.ui.RadioOptionWidget.parent.call( this, config );
7931
7932 // Initialization
7933 // Remove implicit role, we're handling it ourselves
7934 this.radio.$input.attr( 'role', 'presentation' );
7935 this.$element
7936 .addClass( 'oo-ui-radioOptionWidget' )
7937 .attr( 'role', 'radio' )
7938 .attr( 'aria-checked', 'false' )
7939 .removeAttr( 'aria-selected' )
7940 .prepend( this.radio.$element );
7941 };
7942
7943 /* Setup */
7944
7945 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7946
7947 /* Static Properties */
7948
7949 /**
7950 * @static
7951 * @inheritdoc
7952 */
7953 OO.ui.RadioOptionWidget.static.highlightable = false;
7954
7955 /**
7956 * @static
7957 * @inheritdoc
7958 */
7959 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7960
7961 /**
7962 * @static
7963 * @inheritdoc
7964 */
7965 OO.ui.RadioOptionWidget.static.pressable = false;
7966
7967 /**
7968 * @static
7969 * @inheritdoc
7970 */
7971 OO.ui.RadioOptionWidget.static.tagName = 'label';
7972
7973 /* Methods */
7974
7975 /**
7976 * @inheritdoc
7977 */
7978 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7979 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7980
7981 this.radio.setSelected( state );
7982 this.$element
7983 .attr( 'aria-checked', state.toString() )
7984 .removeAttr( 'aria-selected' );
7985
7986 return this;
7987 };
7988
7989 /**
7990 * @inheritdoc
7991 */
7992 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7993 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7994
7995 this.radio.setDisabled( this.isDisabled() );
7996
7997 return this;
7998 };
7999
8000 /**
8001 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8002 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8003 * an interface for adding, removing and selecting options.
8004 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8005 *
8006 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8007 * OO.ui.RadioSelectInputWidget instead.
8008 *
8009 * @example
8010 * // A RadioSelectWidget with RadioOptions.
8011 * var option1 = new OO.ui.RadioOptionWidget( {
8012 * data: 'a',
8013 * label: 'Selected radio option'
8014 * } );
8015 *
8016 * var option2 = new OO.ui.RadioOptionWidget( {
8017 * data: 'b',
8018 * label: 'Unselected radio option'
8019 * } );
8020 *
8021 * var radioSelect=new OO.ui.RadioSelectWidget( {
8022 * items: [ option1, option2 ]
8023 * } );
8024 *
8025 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8026 * radioSelect.selectItem( option1 );
8027 *
8028 * $( 'body' ).append( radioSelect.$element );
8029 *
8030 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8031
8032 *
8033 * @class
8034 * @extends OO.ui.SelectWidget
8035 * @mixins OO.ui.mixin.TabIndexedElement
8036 *
8037 * @constructor
8038 * @param {Object} [config] Configuration options
8039 */
8040 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8041 // Parent constructor
8042 OO.ui.RadioSelectWidget.parent.call( this, config );
8043
8044 // Mixin constructors
8045 OO.ui.mixin.TabIndexedElement.call( this, config );
8046
8047 // Events
8048 this.$element.on( {
8049 focus: this.bindKeyDownListener.bind( this ),
8050 blur: this.unbindKeyDownListener.bind( this )
8051 } );
8052
8053 // Initialization
8054 this.$element
8055 .addClass( 'oo-ui-radioSelectWidget' )
8056 .attr( 'role', 'radiogroup' );
8057 };
8058
8059 /* Setup */
8060
8061 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8062 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8063
8064 /**
8065 * MultioptionWidgets are special elements that can be selected and configured with data. The
8066 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8067 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8068 * and examples, please see the [OOUI documentation on MediaWiki][1].
8069 *
8070 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8071 *
8072 * @class
8073 * @extends OO.ui.Widget
8074 * @mixins OO.ui.mixin.ItemWidget
8075 * @mixins OO.ui.mixin.LabelElement
8076 *
8077 * @constructor
8078 * @param {Object} [config] Configuration options
8079 * @cfg {boolean} [selected=false] Whether the option is initially selected
8080 */
8081 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8082 // Configuration initialization
8083 config = config || {};
8084
8085 // Parent constructor
8086 OO.ui.MultioptionWidget.parent.call( this, config );
8087
8088 // Mixin constructors
8089 OO.ui.mixin.ItemWidget.call( this );
8090 OO.ui.mixin.LabelElement.call( this, config );
8091
8092 // Properties
8093 this.selected = null;
8094
8095 // Initialization
8096 this.$element
8097 .addClass( 'oo-ui-multioptionWidget' )
8098 .append( this.$label );
8099 this.setSelected( config.selected );
8100 };
8101
8102 /* Setup */
8103
8104 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8105 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8106 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8107
8108 /* Events */
8109
8110 /**
8111 * @event change
8112 *
8113 * A change event is emitted when the selected state of the option changes.
8114 *
8115 * @param {boolean} selected Whether the option is now selected
8116 */
8117
8118 /* Methods */
8119
8120 /**
8121 * Check if the option is selected.
8122 *
8123 * @return {boolean} Item is selected
8124 */
8125 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8126 return this.selected;
8127 };
8128
8129 /**
8130 * Set the option’s selected state. In general, all modifications to the selection
8131 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8132 * method instead of this method.
8133 *
8134 * @param {boolean} [state=false] Select option
8135 * @chainable
8136 */
8137 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8138 state = !!state;
8139 if ( this.selected !== state ) {
8140 this.selected = state;
8141 this.emit( 'change', state );
8142 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8143 }
8144 return this;
8145 };
8146
8147 /**
8148 * MultiselectWidget allows selecting multiple options from a list.
8149 *
8150 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8151 *
8152 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8153 *
8154 * @class
8155 * @abstract
8156 * @extends OO.ui.Widget
8157 * @mixins OO.ui.mixin.GroupWidget
8158 *
8159 * @constructor
8160 * @param {Object} [config] Configuration options
8161 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8162 */
8163 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8164 // Parent constructor
8165 OO.ui.MultiselectWidget.parent.call( this, config );
8166
8167 // Configuration initialization
8168 config = config || {};
8169
8170 // Mixin constructors
8171 OO.ui.mixin.GroupWidget.call( this, config );
8172
8173 // Events
8174 this.aggregate( { change: 'select' } );
8175 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
8176 // by GroupElement only when items are added/removed
8177 this.connect( this, { select: [ 'emit', 'change' ] } );
8178
8179 // Initialization
8180 if ( config.items ) {
8181 this.addItems( config.items );
8182 }
8183 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8184 this.$element.addClass( 'oo-ui-multiselectWidget' )
8185 .append( this.$group );
8186 };
8187
8188 /* Setup */
8189
8190 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8191 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8192
8193 /* Events */
8194
8195 /**
8196 * @event change
8197 *
8198 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8199 */
8200
8201 /**
8202 * @event select
8203 *
8204 * A select event is emitted when an item is selected or deselected.
8205 */
8206
8207 /* Methods */
8208
8209 /**
8210 * Find options that are selected.
8211 *
8212 * @return {OO.ui.MultioptionWidget[]} Selected options
8213 */
8214 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8215 return this.items.filter( function ( item ) {
8216 return item.isSelected();
8217 } );
8218 };
8219
8220 /**
8221 * Find the data of options that are selected.
8222 *
8223 * @return {Object[]|string[]} Values of selected options
8224 */
8225 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8226 return this.findSelectedItems().map( function ( item ) {
8227 return item.data;
8228 } );
8229 };
8230
8231 /**
8232 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8233 *
8234 * @param {OO.ui.MultioptionWidget[]} items Items to select
8235 * @chainable
8236 */
8237 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8238 this.items.forEach( function ( item ) {
8239 var selected = items.indexOf( item ) !== -1;
8240 item.setSelected( selected );
8241 } );
8242 return this;
8243 };
8244
8245 /**
8246 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8247 *
8248 * @param {Object[]|string[]} datas Values of items to select
8249 * @chainable
8250 */
8251 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8252 var items,
8253 widget = this;
8254 items = datas.map( function ( data ) {
8255 return widget.findItemFromData( data );
8256 } );
8257 this.selectItems( items );
8258 return this;
8259 };
8260
8261 /**
8262 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8263 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8264 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8265 *
8266 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8267 *
8268 * @class
8269 * @extends OO.ui.MultioptionWidget
8270 *
8271 * @constructor
8272 * @param {Object} [config] Configuration options
8273 */
8274 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8275 // Configuration initialization
8276 config = config || {};
8277
8278 // Properties (must be done before parent constructor which calls #setDisabled)
8279 this.checkbox = new OO.ui.CheckboxInputWidget();
8280
8281 // Parent constructor
8282 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8283
8284 // Events
8285 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8286 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8287
8288 // Initialization
8289 this.$element
8290 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8291 .prepend( this.checkbox.$element );
8292 };
8293
8294 /* Setup */
8295
8296 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8297
8298 /* Static Properties */
8299
8300 /**
8301 * @static
8302 * @inheritdoc
8303 */
8304 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8305
8306 /* Methods */
8307
8308 /**
8309 * Handle checkbox selected state change.
8310 *
8311 * @private
8312 */
8313 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8314 this.setSelected( this.checkbox.isSelected() );
8315 };
8316
8317 /**
8318 * @inheritdoc
8319 */
8320 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8321 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8322 this.checkbox.setSelected( state );
8323 return this;
8324 };
8325
8326 /**
8327 * @inheritdoc
8328 */
8329 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8330 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8331 this.checkbox.setDisabled( this.isDisabled() );
8332 return this;
8333 };
8334
8335 /**
8336 * Focus the widget.
8337 */
8338 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8339 this.checkbox.focus();
8340 };
8341
8342 /**
8343 * Handle key down events.
8344 *
8345 * @protected
8346 * @param {jQuery.Event} e
8347 */
8348 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8349 var
8350 element = this.getElementGroup(),
8351 nextItem;
8352
8353 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8354 nextItem = element.getRelativeFocusableItem( this, -1 );
8355 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8356 nextItem = element.getRelativeFocusableItem( this, 1 );
8357 }
8358
8359 if ( nextItem ) {
8360 e.preventDefault();
8361 nextItem.focus();
8362 }
8363 };
8364
8365 /**
8366 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8367 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8368 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8369 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8370 *
8371 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8372 * OO.ui.CheckboxMultiselectInputWidget instead.
8373 *
8374 * @example
8375 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8376 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8377 * data: 'a',
8378 * selected: true,
8379 * label: 'Selected checkbox'
8380 * } );
8381 *
8382 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8383 * data: 'b',
8384 * label: 'Unselected checkbox'
8385 * } );
8386 *
8387 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8388 * items: [ option1, option2 ]
8389 * } );
8390 *
8391 * $( 'body' ).append( multiselect.$element );
8392 *
8393 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8394 *
8395 * @class
8396 * @extends OO.ui.MultiselectWidget
8397 *
8398 * @constructor
8399 * @param {Object} [config] Configuration options
8400 */
8401 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8402 // Parent constructor
8403 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8404
8405 // Properties
8406 this.$lastClicked = null;
8407
8408 // Events
8409 this.$group.on( 'click', this.onClick.bind( this ) );
8410
8411 // Initialization
8412 this.$element
8413 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8414 };
8415
8416 /* Setup */
8417
8418 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8419
8420 /* Methods */
8421
8422 /**
8423 * Get an option by its position relative to the specified item (or to the start of the option array,
8424 * if item is `null`). The direction in which to search through the option array is specified with a
8425 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8426 * `null` if there are no options in the array.
8427 *
8428 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8429 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8430 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8431 */
8432 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8433 var currentIndex, nextIndex, i,
8434 increase = direction > 0 ? 1 : -1,
8435 len = this.items.length;
8436
8437 if ( item ) {
8438 currentIndex = this.items.indexOf( item );
8439 nextIndex = ( currentIndex + increase + len ) % len;
8440 } else {
8441 // If no item is selected and moving forward, start at the beginning.
8442 // If moving backward, start at the end.
8443 nextIndex = direction > 0 ? 0 : len - 1;
8444 }
8445
8446 for ( i = 0; i < len; i++ ) {
8447 item = this.items[ nextIndex ];
8448 if ( item && !item.isDisabled() ) {
8449 return item;
8450 }
8451 nextIndex = ( nextIndex + increase + len ) % len;
8452 }
8453 return null;
8454 };
8455
8456 /**
8457 * Handle click events on checkboxes.
8458 *
8459 * @param {jQuery.Event} e
8460 */
8461 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8462 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8463 $lastClicked = this.$lastClicked,
8464 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8465 .not( '.oo-ui-widget-disabled' );
8466
8467 // Allow selecting multiple options at once by Shift-clicking them
8468 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8469 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8470 lastClickedIndex = $options.index( $lastClicked );
8471 nowClickedIndex = $options.index( $nowClicked );
8472 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8473 // browser. In either case we don't need custom handling.
8474 if ( nowClickedIndex !== lastClickedIndex ) {
8475 items = this.items;
8476 wasSelected = items[ nowClickedIndex ].isSelected();
8477 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8478
8479 // This depends on the DOM order of the items and the order of the .items array being the same.
8480 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8481 if ( !items[ i ].isDisabled() ) {
8482 items[ i ].setSelected( !wasSelected );
8483 }
8484 }
8485 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8486 // handling first, then set our value. The order in which events happen is different for
8487 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8488 // non-click actions that change the checkboxes.
8489 e.preventDefault();
8490 setTimeout( function () {
8491 if ( !items[ nowClickedIndex ].isDisabled() ) {
8492 items[ nowClickedIndex ].setSelected( !wasSelected );
8493 }
8494 } );
8495 }
8496 }
8497
8498 if ( $nowClicked.length ) {
8499 this.$lastClicked = $nowClicked;
8500 }
8501 };
8502
8503 /**
8504 * Focus the widget
8505 *
8506 * @chainable
8507 */
8508 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8509 var item;
8510 if ( !this.isDisabled() ) {
8511 item = this.getRelativeFocusableItem( null, 1 );
8512 if ( item ) {
8513 item.focus();
8514 }
8515 }
8516 return this;
8517 };
8518
8519 /**
8520 * @inheritdoc
8521 */
8522 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8523 this.focus();
8524 };
8525
8526 /**
8527 * Progress bars visually display the status of an operation, such as a download,
8528 * and can be either determinate or indeterminate:
8529 *
8530 * - **determinate** process bars show the percent of an operation that is complete.
8531 *
8532 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8533 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8534 * not use percentages.
8535 *
8536 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8537 *
8538 * @example
8539 * // Examples of determinate and indeterminate progress bars.
8540 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8541 * progress: 33
8542 * } );
8543 * var progressBar2 = new OO.ui.ProgressBarWidget();
8544 *
8545 * // Create a FieldsetLayout to layout progress bars
8546 * var fieldset = new OO.ui.FieldsetLayout;
8547 * fieldset.addItems( [
8548 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8549 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8550 * ] );
8551 * $( 'body' ).append( fieldset.$element );
8552 *
8553 * @class
8554 * @extends OO.ui.Widget
8555 *
8556 * @constructor
8557 * @param {Object} [config] Configuration options
8558 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8559 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8560 * By default, the progress bar is indeterminate.
8561 */
8562 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8563 // Configuration initialization
8564 config = config || {};
8565
8566 // Parent constructor
8567 OO.ui.ProgressBarWidget.parent.call( this, config );
8568
8569 // Properties
8570 this.$bar = $( '<div>' );
8571 this.progress = null;
8572
8573 // Initialization
8574 this.setProgress( config.progress !== undefined ? config.progress : false );
8575 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8576 this.$element
8577 .attr( {
8578 role: 'progressbar',
8579 'aria-valuemin': 0,
8580 'aria-valuemax': 100
8581 } )
8582 .addClass( 'oo-ui-progressBarWidget' )
8583 .append( this.$bar );
8584 };
8585
8586 /* Setup */
8587
8588 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8589
8590 /* Static Properties */
8591
8592 /**
8593 * @static
8594 * @inheritdoc
8595 */
8596 OO.ui.ProgressBarWidget.static.tagName = 'div';
8597
8598 /* Methods */
8599
8600 /**
8601 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8602 *
8603 * @return {number|boolean} Progress percent
8604 */
8605 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8606 return this.progress;
8607 };
8608
8609 /**
8610 * Set the percent of the process completed or `false` for an indeterminate process.
8611 *
8612 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8613 */
8614 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8615 this.progress = progress;
8616
8617 if ( progress !== false ) {
8618 this.$bar.css( 'width', this.progress + '%' );
8619 this.$element.attr( 'aria-valuenow', this.progress );
8620 } else {
8621 this.$bar.css( 'width', '' );
8622 this.$element.removeAttr( 'aria-valuenow' );
8623 }
8624 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8625 };
8626
8627 /**
8628 * InputWidget is the base class for all input widgets, which
8629 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8630 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8631 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8632 *
8633 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8634 *
8635 * @abstract
8636 * @class
8637 * @extends OO.ui.Widget
8638 * @mixins OO.ui.mixin.FlaggedElement
8639 * @mixins OO.ui.mixin.TabIndexedElement
8640 * @mixins OO.ui.mixin.TitledElement
8641 * @mixins OO.ui.mixin.AccessKeyedElement
8642 *
8643 * @constructor
8644 * @param {Object} [config] Configuration options
8645 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8646 * @cfg {string} [value=''] The value of the input.
8647 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8648 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8649 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8650 * before it is accepted.
8651 */
8652 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8653 // Configuration initialization
8654 config = config || {};
8655
8656 // Parent constructor
8657 OO.ui.InputWidget.parent.call( this, config );
8658
8659 // Properties
8660 // See #reusePreInfuseDOM about config.$input
8661 this.$input = config.$input || this.getInputElement( config );
8662 this.value = '';
8663 this.inputFilter = config.inputFilter;
8664
8665 // Mixin constructors
8666 OO.ui.mixin.FlaggedElement.call( this, config );
8667 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8668 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8669 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8670
8671 // Events
8672 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8673
8674 // Initialization
8675 this.$input
8676 .addClass( 'oo-ui-inputWidget-input' )
8677 .attr( 'name', config.name )
8678 .prop( 'disabled', this.isDisabled() );
8679 this.$element
8680 .addClass( 'oo-ui-inputWidget' )
8681 .append( this.$input );
8682 this.setValue( config.value );
8683 if ( config.dir ) {
8684 this.setDir( config.dir );
8685 }
8686 if ( config.inputId !== undefined ) {
8687 this.setInputId( config.inputId );
8688 }
8689 };
8690
8691 /* Setup */
8692
8693 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8694 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8695 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8696 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8697 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8698
8699 /* Static Methods */
8700
8701 /**
8702 * @inheritdoc
8703 */
8704 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8705 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8706 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8707 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8708 return config;
8709 };
8710
8711 /**
8712 * @inheritdoc
8713 */
8714 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8715 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8716 if ( config.$input && config.$input.length ) {
8717 state.value = config.$input.val();
8718 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8719 state.focus = config.$input.is( ':focus' );
8720 }
8721 return state;
8722 };
8723
8724 /* Events */
8725
8726 /**
8727 * @event change
8728 *
8729 * A change event is emitted when the value of the input changes.
8730 *
8731 * @param {string} value
8732 */
8733
8734 /* Methods */
8735
8736 /**
8737 * Get input element.
8738 *
8739 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8740 * different circumstances. The element must have a `value` property (like form elements).
8741 *
8742 * @protected
8743 * @param {Object} config Configuration options
8744 * @return {jQuery} Input element
8745 */
8746 OO.ui.InputWidget.prototype.getInputElement = function () {
8747 return $( '<input>' );
8748 };
8749
8750 /**
8751 * Handle potentially value-changing events.
8752 *
8753 * @private
8754 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8755 */
8756 OO.ui.InputWidget.prototype.onEdit = function () {
8757 var widget = this;
8758 if ( !this.isDisabled() ) {
8759 // Allow the stack to clear so the value will be updated
8760 setTimeout( function () {
8761 widget.setValue( widget.$input.val() );
8762 } );
8763 }
8764 };
8765
8766 /**
8767 * Get the value of the input.
8768 *
8769 * @return {string} Input value
8770 */
8771 OO.ui.InputWidget.prototype.getValue = function () {
8772 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8773 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8774 var value = this.$input.val();
8775 if ( this.value !== value ) {
8776 this.setValue( value );
8777 }
8778 return this.value;
8779 };
8780
8781 /**
8782 * Set the directionality of the input.
8783 *
8784 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8785 * @chainable
8786 */
8787 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8788 this.$input.prop( 'dir', dir );
8789 return this;
8790 };
8791
8792 /**
8793 * Set the value of the input.
8794 *
8795 * @param {string} value New value
8796 * @fires change
8797 * @chainable
8798 */
8799 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8800 value = this.cleanUpValue( value );
8801 // Update the DOM if it has changed. Note that with cleanUpValue, it
8802 // is possible for the DOM value to change without this.value changing.
8803 if ( this.$input.val() !== value ) {
8804 this.$input.val( value );
8805 }
8806 if ( this.value !== value ) {
8807 this.value = value;
8808 this.emit( 'change', this.value );
8809 }
8810 // The first time that the value is set (probably while constructing the widget),
8811 // remember it in defaultValue. This property can be later used to check whether
8812 // the value of the input has been changed since it was created.
8813 if ( this.defaultValue === undefined ) {
8814 this.defaultValue = this.value;
8815 this.$input[ 0 ].defaultValue = this.defaultValue;
8816 }
8817 return this;
8818 };
8819
8820 /**
8821 * Clean up incoming value.
8822 *
8823 * Ensures value is a string, and converts undefined and null to empty string.
8824 *
8825 * @private
8826 * @param {string} value Original value
8827 * @return {string} Cleaned up value
8828 */
8829 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8830 if ( value === undefined || value === null ) {
8831 return '';
8832 } else if ( this.inputFilter ) {
8833 return this.inputFilter( String( value ) );
8834 } else {
8835 return String( value );
8836 }
8837 };
8838
8839 /**
8840 * @inheritdoc
8841 */
8842 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8843 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8844 if ( this.$input ) {
8845 this.$input.prop( 'disabled', this.isDisabled() );
8846 }
8847 return this;
8848 };
8849
8850 /**
8851 * Set the 'id' attribute of the `<input>` element.
8852 *
8853 * @param {string} id
8854 * @chainable
8855 */
8856 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8857 this.$input.attr( 'id', id );
8858 return this;
8859 };
8860
8861 /**
8862 * @inheritdoc
8863 */
8864 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8865 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8866 if ( state.value !== undefined && state.value !== this.getValue() ) {
8867 this.setValue( state.value );
8868 }
8869 if ( state.focus ) {
8870 this.focus();
8871 }
8872 };
8873
8874 /**
8875 * Data widget intended for creating 'hidden'-type inputs.
8876 *
8877 * @class
8878 * @extends OO.ui.Widget
8879 *
8880 * @constructor
8881 * @param {Object} [config] Configuration options
8882 * @cfg {string} [value=''] The value of the input.
8883 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8884 */
8885 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8886 // Configuration initialization
8887 config = $.extend( { value: '', name: '' }, config );
8888
8889 // Parent constructor
8890 OO.ui.HiddenInputWidget.parent.call( this, config );
8891
8892 // Initialization
8893 this.$element.attr( {
8894 type: 'hidden',
8895 value: config.value,
8896 name: config.name
8897 } );
8898 this.$element.removeAttr( 'aria-disabled' );
8899 };
8900
8901 /* Setup */
8902
8903 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8904
8905 /* Static Properties */
8906
8907 /**
8908 * @static
8909 * @inheritdoc
8910 */
8911 OO.ui.HiddenInputWidget.static.tagName = 'input';
8912
8913 /**
8914 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8915 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8916 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8917 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8918 * [OOUI documentation on MediaWiki] [1] for more information.
8919 *
8920 * @example
8921 * // A ButtonInputWidget rendered as an HTML button, the default.
8922 * var button = new OO.ui.ButtonInputWidget( {
8923 * label: 'Input button',
8924 * icon: 'check',
8925 * value: 'check'
8926 * } );
8927 * $( 'body' ).append( button.$element );
8928 *
8929 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
8930 *
8931 * @class
8932 * @extends OO.ui.InputWidget
8933 * @mixins OO.ui.mixin.ButtonElement
8934 * @mixins OO.ui.mixin.IconElement
8935 * @mixins OO.ui.mixin.IndicatorElement
8936 * @mixins OO.ui.mixin.LabelElement
8937 * @mixins OO.ui.mixin.TitledElement
8938 *
8939 * @constructor
8940 * @param {Object} [config] Configuration options
8941 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8942 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8943 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8944 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8945 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8946 */
8947 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8948 // Configuration initialization
8949 config = $.extend( { type: 'button', useInputTag: false }, config );
8950
8951 // See InputWidget#reusePreInfuseDOM about config.$input
8952 if ( config.$input ) {
8953 config.$input.empty();
8954 }
8955
8956 // Properties (must be set before parent constructor, which calls #setValue)
8957 this.useInputTag = config.useInputTag;
8958
8959 // Parent constructor
8960 OO.ui.ButtonInputWidget.parent.call( this, config );
8961
8962 // Mixin constructors
8963 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8964 OO.ui.mixin.IconElement.call( this, config );
8965 OO.ui.mixin.IndicatorElement.call( this, config );
8966 OO.ui.mixin.LabelElement.call( this, config );
8967 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8968
8969 // Initialization
8970 if ( !config.useInputTag ) {
8971 this.$input.append( this.$icon, this.$label, this.$indicator );
8972 }
8973 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8974 };
8975
8976 /* Setup */
8977
8978 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8979 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8980 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8981 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8982 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8983 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8984
8985 /* Static Properties */
8986
8987 /**
8988 * @static
8989 * @inheritdoc
8990 */
8991 OO.ui.ButtonInputWidget.static.tagName = 'span';
8992
8993 /* Methods */
8994
8995 /**
8996 * @inheritdoc
8997 * @protected
8998 */
8999 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9000 var type;
9001 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9002 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9003 };
9004
9005 /**
9006 * Set label value.
9007 *
9008 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9009 *
9010 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9011 * text, or `null` for no label
9012 * @chainable
9013 */
9014 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9015 if ( typeof label === 'function' ) {
9016 label = OO.ui.resolveMsg( label );
9017 }
9018
9019 if ( this.useInputTag ) {
9020 // Discard non-plaintext labels
9021 if ( typeof label !== 'string' ) {
9022 label = '';
9023 }
9024
9025 this.$input.val( label );
9026 }
9027
9028 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9029 };
9030
9031 /**
9032 * Set the value of the input.
9033 *
9034 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9035 * they do not support {@link #value values}.
9036 *
9037 * @param {string} value New value
9038 * @chainable
9039 */
9040 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9041 if ( !this.useInputTag ) {
9042 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9043 }
9044 return this;
9045 };
9046
9047 /**
9048 * @inheritdoc
9049 */
9050 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9051 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9052 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9053 return null;
9054 };
9055
9056 /**
9057 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9058 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9059 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9060 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9061 *
9062 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9063 *
9064 * @example
9065 * // An example of selected, unselected, and disabled checkbox inputs
9066 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9067 * value: 'a',
9068 * selected: true
9069 * } );
9070 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9071 * value: 'b'
9072 * } );
9073 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9074 * value:'c',
9075 * disabled: true
9076 * } );
9077 * // Create a fieldset layout with fields for each checkbox.
9078 * var fieldset = new OO.ui.FieldsetLayout( {
9079 * label: 'Checkboxes'
9080 * } );
9081 * fieldset.addItems( [
9082 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9083 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9084 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9085 * ] );
9086 * $( 'body' ).append( fieldset.$element );
9087 *
9088 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9089 *
9090 * @class
9091 * @extends OO.ui.InputWidget
9092 *
9093 * @constructor
9094 * @param {Object} [config] Configuration options
9095 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9096 */
9097 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9098 // Configuration initialization
9099 config = config || {};
9100
9101 // Parent constructor
9102 OO.ui.CheckboxInputWidget.parent.call( this, config );
9103
9104 // Properties
9105 this.checkIcon = new OO.ui.IconWidget( {
9106 icon: 'check',
9107 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9108 } );
9109
9110 // Initialization
9111 this.$element
9112 .addClass( 'oo-ui-checkboxInputWidget' )
9113 // Required for pretty styling in WikimediaUI theme
9114 .append( this.checkIcon.$element );
9115 this.setSelected( config.selected !== undefined ? config.selected : false );
9116 };
9117
9118 /* Setup */
9119
9120 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9121
9122 /* Static Properties */
9123
9124 /**
9125 * @static
9126 * @inheritdoc
9127 */
9128 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9129
9130 /* Static Methods */
9131
9132 /**
9133 * @inheritdoc
9134 */
9135 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9136 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9137 state.checked = config.$input.prop( 'checked' );
9138 return state;
9139 };
9140
9141 /* Methods */
9142
9143 /**
9144 * @inheritdoc
9145 * @protected
9146 */
9147 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9148 return $( '<input>' ).attr( 'type', 'checkbox' );
9149 };
9150
9151 /**
9152 * @inheritdoc
9153 */
9154 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9155 var widget = this;
9156 if ( !this.isDisabled() ) {
9157 // Allow the stack to clear so the value will be updated
9158 setTimeout( function () {
9159 widget.setSelected( widget.$input.prop( 'checked' ) );
9160 } );
9161 }
9162 };
9163
9164 /**
9165 * Set selection state of this checkbox.
9166 *
9167 * @param {boolean} state `true` for selected
9168 * @chainable
9169 */
9170 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9171 state = !!state;
9172 if ( this.selected !== state ) {
9173 this.selected = state;
9174 this.$input.prop( 'checked', this.selected );
9175 this.emit( 'change', this.selected );
9176 }
9177 // The first time that the selection state is set (probably while constructing the widget),
9178 // remember it in defaultSelected. This property can be later used to check whether
9179 // the selection state of the input has been changed since it was created.
9180 if ( this.defaultSelected === undefined ) {
9181 this.defaultSelected = this.selected;
9182 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9183 }
9184 return this;
9185 };
9186
9187 /**
9188 * Check if this checkbox is selected.
9189 *
9190 * @return {boolean} Checkbox is selected
9191 */
9192 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9193 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9194 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9195 var selected = this.$input.prop( 'checked' );
9196 if ( this.selected !== selected ) {
9197 this.setSelected( selected );
9198 }
9199 return this.selected;
9200 };
9201
9202 /**
9203 * @inheritdoc
9204 */
9205 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9206 if ( !this.isDisabled() ) {
9207 this.$input.click();
9208 }
9209 this.focus();
9210 };
9211
9212 /**
9213 * @inheritdoc
9214 */
9215 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9216 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9217 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9218 this.setSelected( state.checked );
9219 }
9220 };
9221
9222 /**
9223 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9224 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9225 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9226 * more information about input widgets.
9227 *
9228 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9229 * are no options. If no `value` configuration option is provided, the first option is selected.
9230 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9231 *
9232 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9233 *
9234 * @example
9235 * // Example: A DropdownInputWidget with three options
9236 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9237 * options: [
9238 * { data: 'a', label: 'First' },
9239 * { data: 'b', label: 'Second'},
9240 * { data: 'c', label: 'Third' }
9241 * ]
9242 * } );
9243 * $( 'body' ).append( dropdownInput.$element );
9244 *
9245 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9246 *
9247 * @class
9248 * @extends OO.ui.InputWidget
9249 *
9250 * @constructor
9251 * @param {Object} [config] Configuration options
9252 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9253 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9254 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9255 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9256 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9257 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9258 */
9259 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9260 // Configuration initialization
9261 config = config || {};
9262
9263 // Properties (must be done before parent constructor which calls #setDisabled)
9264 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9265 {
9266 $overlay: config.$overlay
9267 },
9268 config.dropdown
9269 ) );
9270 // Set up the options before parent constructor, which uses them to validate config.value.
9271 // Use this instead of setOptions() because this.$input is not set up yet.
9272 this.setOptionsData( config.options || [] );
9273
9274 // Parent constructor
9275 OO.ui.DropdownInputWidget.parent.call( this, config );
9276
9277 // Events
9278 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9279
9280 // Initialization
9281 this.$element
9282 .addClass( 'oo-ui-dropdownInputWidget' )
9283 .append( this.dropdownWidget.$element );
9284 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9285 };
9286
9287 /* Setup */
9288
9289 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9290
9291 /* Methods */
9292
9293 /**
9294 * @inheritdoc
9295 * @protected
9296 */
9297 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9298 return $( '<select>' );
9299 };
9300
9301 /**
9302 * Handles menu select events.
9303 *
9304 * @private
9305 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9306 */
9307 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9308 this.setValue( item ? item.getData() : '' );
9309 };
9310
9311 /**
9312 * @inheritdoc
9313 */
9314 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9315 var selected;
9316 value = this.cleanUpValue( value );
9317 // Only allow setting values that are actually present in the dropdown
9318 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9319 this.dropdownWidget.getMenu().findFirstSelectableItem();
9320 this.dropdownWidget.getMenu().selectItem( selected );
9321 value = selected ? selected.getData() : '';
9322 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9323 if ( this.optionsDirty ) {
9324 // We reached this from the constructor or from #setOptions.
9325 // We have to update the <select> element.
9326 this.updateOptionsInterface();
9327 }
9328 return this;
9329 };
9330
9331 /**
9332 * @inheritdoc
9333 */
9334 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9335 this.dropdownWidget.setDisabled( state );
9336 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9337 return this;
9338 };
9339
9340 /**
9341 * Set the options available for this input.
9342 *
9343 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9344 * @chainable
9345 */
9346 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9347 var value = this.getValue();
9348
9349 this.setOptionsData( options );
9350
9351 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9352 // In case the previous value is no longer an available option, select the first valid one.
9353 this.setValue( value );
9354
9355 return this;
9356 };
9357
9358 /**
9359 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9360 *
9361 * This method may be called before the parent constructor, so various properties may not be
9362 * intialized yet.
9363 *
9364 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9365 * @private
9366 */
9367 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9368 var
9369 optionWidgets,
9370 widget = this;
9371
9372 this.optionsDirty = true;
9373
9374 optionWidgets = options.map( function ( opt ) {
9375 var optValue;
9376
9377 if ( opt.optgroup !== undefined ) {
9378 return widget.createMenuSectionOptionWidget( opt.optgroup );
9379 }
9380
9381 optValue = widget.cleanUpValue( opt.data );
9382 return widget.createMenuOptionWidget(
9383 optValue,
9384 opt.label !== undefined ? opt.label : optValue
9385 );
9386
9387 } );
9388
9389 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9390 };
9391
9392 /**
9393 * Create a menu option widget.
9394 *
9395 * @protected
9396 * @param {string} data Item data
9397 * @param {string} label Item label
9398 * @return {OO.ui.MenuOptionWidget} Option widget
9399 */
9400 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9401 return new OO.ui.MenuOptionWidget( {
9402 data: data,
9403 label: label
9404 } );
9405 };
9406
9407 /**
9408 * Create a menu section option widget.
9409 *
9410 * @protected
9411 * @param {string} label Section item label
9412 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9413 */
9414 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9415 return new OO.ui.MenuSectionOptionWidget( {
9416 label: label
9417 } );
9418 };
9419
9420 /**
9421 * Update the user-visible interface to match the internal list of options and value.
9422 *
9423 * This method must only be called after the parent constructor.
9424 *
9425 * @private
9426 */
9427 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9428 var
9429 $optionsContainer = this.$input,
9430 defaultValue = this.defaultValue,
9431 widget = this;
9432
9433 this.$input.empty();
9434
9435 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9436 var $optionNode;
9437
9438 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9439 $optionNode = $( '<option>' )
9440 .attr( 'value', optionWidget.getData() )
9441 .text( optionWidget.getLabel() );
9442
9443 // Remember original selection state. This property can be later used to check whether
9444 // the selection state of the input has been changed since it was created.
9445 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9446
9447 $optionsContainer.append( $optionNode );
9448 } else {
9449 $optionNode = $( '<optgroup>' )
9450 .attr( 'label', optionWidget.getLabel() );
9451 widget.$input.append( $optionNode );
9452 $optionsContainer = $optionNode;
9453 }
9454 } );
9455
9456 this.optionsDirty = false;
9457 };
9458
9459 /**
9460 * @inheritdoc
9461 */
9462 OO.ui.DropdownInputWidget.prototype.focus = function () {
9463 this.dropdownWidget.focus();
9464 return this;
9465 };
9466
9467 /**
9468 * @inheritdoc
9469 */
9470 OO.ui.DropdownInputWidget.prototype.blur = function () {
9471 this.dropdownWidget.blur();
9472 return this;
9473 };
9474
9475 /**
9476 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9477 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9478 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9479 * please see the [OOUI documentation on MediaWiki][1].
9480 *
9481 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9482 *
9483 * @example
9484 * // An example of selected, unselected, and disabled radio inputs
9485 * var radio1 = new OO.ui.RadioInputWidget( {
9486 * value: 'a',
9487 * selected: true
9488 * } );
9489 * var radio2 = new OO.ui.RadioInputWidget( {
9490 * value: 'b'
9491 * } );
9492 * var radio3 = new OO.ui.RadioInputWidget( {
9493 * value: 'c',
9494 * disabled: true
9495 * } );
9496 * // Create a fieldset layout with fields for each radio button.
9497 * var fieldset = new OO.ui.FieldsetLayout( {
9498 * label: 'Radio inputs'
9499 * } );
9500 * fieldset.addItems( [
9501 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9502 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9503 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9504 * ] );
9505 * $( 'body' ).append( fieldset.$element );
9506 *
9507 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9508 *
9509 * @class
9510 * @extends OO.ui.InputWidget
9511 *
9512 * @constructor
9513 * @param {Object} [config] Configuration options
9514 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9515 */
9516 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9517 // Configuration initialization
9518 config = config || {};
9519
9520 // Parent constructor
9521 OO.ui.RadioInputWidget.parent.call( this, config );
9522
9523 // Initialization
9524 this.$element
9525 .addClass( 'oo-ui-radioInputWidget' )
9526 // Required for pretty styling in WikimediaUI theme
9527 .append( $( '<span>' ) );
9528 this.setSelected( config.selected !== undefined ? config.selected : false );
9529 };
9530
9531 /* Setup */
9532
9533 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9534
9535 /* Static Properties */
9536
9537 /**
9538 * @static
9539 * @inheritdoc
9540 */
9541 OO.ui.RadioInputWidget.static.tagName = 'span';
9542
9543 /* Static Methods */
9544
9545 /**
9546 * @inheritdoc
9547 */
9548 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9549 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9550 state.checked = config.$input.prop( 'checked' );
9551 return state;
9552 };
9553
9554 /* Methods */
9555
9556 /**
9557 * @inheritdoc
9558 * @protected
9559 */
9560 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9561 return $( '<input>' ).attr( 'type', 'radio' );
9562 };
9563
9564 /**
9565 * @inheritdoc
9566 */
9567 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9568 // RadioInputWidget doesn't track its state.
9569 };
9570
9571 /**
9572 * Set selection state of this radio button.
9573 *
9574 * @param {boolean} state `true` for selected
9575 * @chainable
9576 */
9577 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9578 // RadioInputWidget doesn't track its state.
9579 this.$input.prop( 'checked', state );
9580 // The first time that the selection state is set (probably while constructing the widget),
9581 // remember it in defaultSelected. This property can be later used to check whether
9582 // the selection state of the input has been changed since it was created.
9583 if ( this.defaultSelected === undefined ) {
9584 this.defaultSelected = state;
9585 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9586 }
9587 return this;
9588 };
9589
9590 /**
9591 * Check if this radio button is selected.
9592 *
9593 * @return {boolean} Radio is selected
9594 */
9595 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9596 return this.$input.prop( 'checked' );
9597 };
9598
9599 /**
9600 * @inheritdoc
9601 */
9602 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9603 if ( !this.isDisabled() ) {
9604 this.$input.click();
9605 }
9606 this.focus();
9607 };
9608
9609 /**
9610 * @inheritdoc
9611 */
9612 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9613 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9614 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9615 this.setSelected( state.checked );
9616 }
9617 };
9618
9619 /**
9620 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9621 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9622 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9623 * more information about input widgets.
9624 *
9625 * This and OO.ui.DropdownInputWidget support the same configuration options.
9626 *
9627 * @example
9628 * // Example: A RadioSelectInputWidget with three options
9629 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9630 * options: [
9631 * { data: 'a', label: 'First' },
9632 * { data: 'b', label: 'Second'},
9633 * { data: 'c', label: 'Third' }
9634 * ]
9635 * } );
9636 * $( 'body' ).append( radioSelectInput.$element );
9637 *
9638 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9639 *
9640 * @class
9641 * @extends OO.ui.InputWidget
9642 *
9643 * @constructor
9644 * @param {Object} [config] Configuration options
9645 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9646 */
9647 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9648 // Configuration initialization
9649 config = config || {};
9650
9651 // Properties (must be done before parent constructor which calls #setDisabled)
9652 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9653 // Set up the options before parent constructor, which uses them to validate config.value.
9654 // Use this instead of setOptions() because this.$input is not set up yet
9655 this.setOptionsData( config.options || [] );
9656
9657 // Parent constructor
9658 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9659
9660 // Events
9661 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9662
9663 // Initialization
9664 this.$element
9665 .addClass( 'oo-ui-radioSelectInputWidget' )
9666 .append( this.radioSelectWidget.$element );
9667 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9668 };
9669
9670 /* Setup */
9671
9672 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9673
9674 /* Static Methods */
9675
9676 /**
9677 * @inheritdoc
9678 */
9679 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9680 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9681 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9682 return state;
9683 };
9684
9685 /**
9686 * @inheritdoc
9687 */
9688 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9689 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9690 // Cannot reuse the `<input type=radio>` set
9691 delete config.$input;
9692 return config;
9693 };
9694
9695 /* Methods */
9696
9697 /**
9698 * @inheritdoc
9699 * @protected
9700 */
9701 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9702 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9703 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9704 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9705 };
9706
9707 /**
9708 * Handles menu select events.
9709 *
9710 * @private
9711 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9712 */
9713 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9714 this.setValue( item.getData() );
9715 };
9716
9717 /**
9718 * @inheritdoc
9719 */
9720 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9721 var selected;
9722 value = this.cleanUpValue( value );
9723 // Only allow setting values that are actually present in the dropdown
9724 selected = this.radioSelectWidget.findItemFromData( value ) ||
9725 this.radioSelectWidget.findFirstSelectableItem();
9726 this.radioSelectWidget.selectItem( selected );
9727 value = selected ? selected.getData() : '';
9728 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9729 return this;
9730 };
9731
9732 /**
9733 * @inheritdoc
9734 */
9735 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9736 this.radioSelectWidget.setDisabled( state );
9737 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9738 return this;
9739 };
9740
9741 /**
9742 * Set the options available for this input.
9743 *
9744 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9745 * @chainable
9746 */
9747 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9748 var value = this.getValue();
9749
9750 this.setOptionsData( options );
9751
9752 // Re-set the value to update the visible interface (RadioSelectWidget).
9753 // In case the previous value is no longer an available option, select the first valid one.
9754 this.setValue( value );
9755
9756 return this;
9757 };
9758
9759 /**
9760 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9761 *
9762 * This method may be called before the parent constructor, so various properties may not be
9763 * intialized yet.
9764 *
9765 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9766 * @private
9767 */
9768 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
9769 var widget = this;
9770
9771 this.radioSelectWidget
9772 .clearItems()
9773 .addItems( options.map( function ( opt ) {
9774 var optValue = widget.cleanUpValue( opt.data );
9775 return new OO.ui.RadioOptionWidget( {
9776 data: optValue,
9777 label: opt.label !== undefined ? opt.label : optValue
9778 } );
9779 } ) );
9780 };
9781
9782 /**
9783 * @inheritdoc
9784 */
9785 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9786 this.radioSelectWidget.focus();
9787 return this;
9788 };
9789
9790 /**
9791 * @inheritdoc
9792 */
9793 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9794 this.radioSelectWidget.blur();
9795 return this;
9796 };
9797
9798 /**
9799 * CheckboxMultiselectInputWidget is a
9800 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9801 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9802 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
9803 * more information about input widgets.
9804 *
9805 * @example
9806 * // Example: A CheckboxMultiselectInputWidget with three options
9807 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9808 * options: [
9809 * { data: 'a', label: 'First' },
9810 * { data: 'b', label: 'Second'},
9811 * { data: 'c', label: 'Third' }
9812 * ]
9813 * } );
9814 * $( 'body' ).append( multiselectInput.$element );
9815 *
9816 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9817 *
9818 * @class
9819 * @extends OO.ui.InputWidget
9820 *
9821 * @constructor
9822 * @param {Object} [config] Configuration options
9823 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9824 */
9825 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9826 // Configuration initialization
9827 config = config || {};
9828
9829 // Properties (must be done before parent constructor which calls #setDisabled)
9830 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9831 // Must be set before the #setOptionsData call below
9832 this.inputName = config.name;
9833 // Set up the options before parent constructor, which uses them to validate config.value.
9834 // Use this instead of setOptions() because this.$input is not set up yet
9835 this.setOptionsData( config.options || [] );
9836
9837 // Parent constructor
9838 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9839
9840 // Events
9841 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
9842
9843 // Initialization
9844 this.$element
9845 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9846 .append( this.checkboxMultiselectWidget.$element );
9847 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9848 this.$input.detach();
9849 };
9850
9851 /* Setup */
9852
9853 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9854
9855 /* Static Methods */
9856
9857 /**
9858 * @inheritdoc
9859 */
9860 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9861 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9862 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9863 .toArray().map( function ( el ) { return el.value; } );
9864 return state;
9865 };
9866
9867 /**
9868 * @inheritdoc
9869 */
9870 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9871 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9872 // Cannot reuse the `<input type=checkbox>` set
9873 delete config.$input;
9874 return config;
9875 };
9876
9877 /* Methods */
9878
9879 /**
9880 * @inheritdoc
9881 * @protected
9882 */
9883 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9884 // Actually unused
9885 return $( '<unused>' );
9886 };
9887
9888 /**
9889 * Handles CheckboxMultiselectWidget select events.
9890 *
9891 * @private
9892 */
9893 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
9894 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
9895 };
9896
9897 /**
9898 * @inheritdoc
9899 */
9900 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9901 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9902 .toArray().map( function ( el ) { return el.value; } );
9903 if ( this.value !== value ) {
9904 this.setValue( value );
9905 }
9906 return this.value;
9907 };
9908
9909 /**
9910 * @inheritdoc
9911 */
9912 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9913 value = this.cleanUpValue( value );
9914 this.checkboxMultiselectWidget.selectItemsByData( value );
9915 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9916 if ( this.optionsDirty ) {
9917 // We reached this from the constructor or from #setOptions.
9918 // We have to update the <select> element.
9919 this.updateOptionsInterface();
9920 }
9921 return this;
9922 };
9923
9924 /**
9925 * Clean up incoming value.
9926 *
9927 * @param {string[]} value Original value
9928 * @return {string[]} Cleaned up value
9929 */
9930 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9931 var i, singleValue,
9932 cleanValue = [];
9933 if ( !Array.isArray( value ) ) {
9934 return cleanValue;
9935 }
9936 for ( i = 0; i < value.length; i++ ) {
9937 singleValue =
9938 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9939 // Remove options that we don't have here
9940 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
9941 continue;
9942 }
9943 cleanValue.push( singleValue );
9944 }
9945 return cleanValue;
9946 };
9947
9948 /**
9949 * @inheritdoc
9950 */
9951 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9952 this.checkboxMultiselectWidget.setDisabled( state );
9953 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9954 return this;
9955 };
9956
9957 /**
9958 * Set the options available for this input.
9959 *
9960 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9961 * @chainable
9962 */
9963 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9964 var value = this.getValue();
9965
9966 this.setOptionsData( options );
9967
9968 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
9969 // This will also get rid of any stale options that we just removed.
9970 this.setValue( value );
9971
9972 return this;
9973 };
9974
9975 /**
9976 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9977 *
9978 * This method may be called before the parent constructor, so various properties may not be
9979 * intialized yet.
9980 *
9981 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9982 * @private
9983 */
9984 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
9985 var widget = this;
9986
9987 this.optionsDirty = true;
9988
9989 this.checkboxMultiselectWidget
9990 .clearItems()
9991 .addItems( options.map( function ( opt ) {
9992 var optValue, item, optDisabled;
9993 optValue =
9994 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9995 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9996 item = new OO.ui.CheckboxMultioptionWidget( {
9997 data: optValue,
9998 label: opt.label !== undefined ? opt.label : optValue,
9999 disabled: optDisabled
10000 } );
10001 // Set the 'name' and 'value' for form submission
10002 item.checkbox.$input.attr( 'name', widget.inputName );
10003 item.checkbox.setValue( optValue );
10004 return item;
10005 } ) );
10006 };
10007
10008 /**
10009 * Update the user-visible interface to match the internal list of options and value.
10010 *
10011 * This method must only be called after the parent constructor.
10012 *
10013 * @private
10014 */
10015 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10016 var defaultValue = this.defaultValue;
10017
10018 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10019 // Remember original selection state. This property can be later used to check whether
10020 // the selection state of the input has been changed since it was created.
10021 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10022 item.checkbox.defaultSelected = isDefault;
10023 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10024 } );
10025
10026 this.optionsDirty = false;
10027 };
10028
10029 /**
10030 * @inheritdoc
10031 */
10032 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10033 this.checkboxMultiselectWidget.focus();
10034 return this;
10035 };
10036
10037 /**
10038 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10039 * size of the field as well as its presentation. In addition, these widgets can be configured
10040 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10041 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10042 * which modifies incoming values rather than validating them.
10043 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10044 *
10045 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10046 *
10047 * @example
10048 * // Example of a text input widget
10049 * var textInput = new OO.ui.TextInputWidget( {
10050 * value: 'Text input'
10051 * } )
10052 * $( 'body' ).append( textInput.$element );
10053 *
10054 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10055 *
10056 * @class
10057 * @extends OO.ui.InputWidget
10058 * @mixins OO.ui.mixin.IconElement
10059 * @mixins OO.ui.mixin.IndicatorElement
10060 * @mixins OO.ui.mixin.PendingElement
10061 * @mixins OO.ui.mixin.LabelElement
10062 *
10063 * @constructor
10064 * @param {Object} [config] Configuration options
10065 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10066 * 'email', 'url' or 'number'.
10067 * @cfg {string} [placeholder] Placeholder text
10068 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10069 * instruct the browser to focus this widget.
10070 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10071 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10072 *
10073 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10074 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10075 * many emojis) count as 2 characters each.
10076 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10077 * the value or placeholder text: `'before'` or `'after'`
10078 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10079 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10080 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10081 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10082 * leaving it up to the browser).
10083 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10084 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10085 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10086 * value for it to be considered valid; when Function, a function receiving the value as parameter
10087 * that must return true, or promise resolving to true, for it to be considered valid.
10088 */
10089 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10090 // Configuration initialization
10091 config = $.extend( {
10092 type: 'text',
10093 labelPosition: 'after'
10094 }, config );
10095
10096 if ( config.multiline ) {
10097 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
10098 return new OO.ui.MultilineTextInputWidget( config );
10099 }
10100
10101 // Parent constructor
10102 OO.ui.TextInputWidget.parent.call( this, config );
10103
10104 // Mixin constructors
10105 OO.ui.mixin.IconElement.call( this, config );
10106 OO.ui.mixin.IndicatorElement.call( this, config );
10107 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10108 OO.ui.mixin.LabelElement.call( this, config );
10109
10110 // Properties
10111 this.type = this.getSaneType( config );
10112 this.readOnly = false;
10113 this.required = false;
10114 this.validate = null;
10115 this.styleHeight = null;
10116 this.scrollWidth = null;
10117
10118 this.setValidation( config.validate );
10119 this.setLabelPosition( config.labelPosition );
10120
10121 // Events
10122 this.$input.on( {
10123 keypress: this.onKeyPress.bind( this ),
10124 blur: this.onBlur.bind( this ),
10125 focus: this.onFocus.bind( this )
10126 } );
10127 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10128 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10129 this.on( 'labelChange', this.updatePosition.bind( this ) );
10130 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10131
10132 // Initialization
10133 this.$element
10134 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10135 .append( this.$icon, this.$indicator );
10136 this.setReadOnly( !!config.readOnly );
10137 this.setRequired( !!config.required );
10138 if ( config.placeholder !== undefined ) {
10139 this.$input.attr( 'placeholder', config.placeholder );
10140 }
10141 if ( config.maxLength !== undefined ) {
10142 this.$input.attr( 'maxlength', config.maxLength );
10143 }
10144 if ( config.autofocus ) {
10145 this.$input.attr( 'autofocus', 'autofocus' );
10146 }
10147 if ( config.autocomplete === false ) {
10148 this.$input.attr( 'autocomplete', 'off' );
10149 // Turning off autocompletion also disables "form caching" when the user navigates to a
10150 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10151 $( window ).on( {
10152 beforeunload: function () {
10153 this.$input.removeAttr( 'autocomplete' );
10154 }.bind( this ),
10155 pageshow: function () {
10156 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10157 // whole page... it shouldn't hurt, though.
10158 this.$input.attr( 'autocomplete', 'off' );
10159 }.bind( this )
10160 } );
10161 }
10162 if ( config.spellcheck !== undefined ) {
10163 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10164 }
10165 if ( this.label ) {
10166 this.isWaitingToBeAttached = true;
10167 this.installParentChangeDetector();
10168 }
10169 };
10170
10171 /* Setup */
10172
10173 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10174 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10175 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10176 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10177 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10178
10179 /* Static Properties */
10180
10181 OO.ui.TextInputWidget.static.validationPatterns = {
10182 'non-empty': /.+/,
10183 integer: /^\d+$/
10184 };
10185
10186 /* Events */
10187
10188 /**
10189 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10190 *
10191 * @event enter
10192 */
10193
10194 /* Methods */
10195
10196 /**
10197 * Handle icon mouse down events.
10198 *
10199 * @private
10200 * @param {jQuery.Event} e Mouse down event
10201 */
10202 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10203 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10204 this.focus();
10205 return false;
10206 }
10207 };
10208
10209 /**
10210 * Handle indicator mouse down events.
10211 *
10212 * @private
10213 * @param {jQuery.Event} e Mouse down event
10214 */
10215 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10216 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10217 this.focus();
10218 return false;
10219 }
10220 };
10221
10222 /**
10223 * Handle key press events.
10224 *
10225 * @private
10226 * @param {jQuery.Event} e Key press event
10227 * @fires enter If enter key is pressed
10228 */
10229 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10230 if ( e.which === OO.ui.Keys.ENTER ) {
10231 this.emit( 'enter', e );
10232 }
10233 };
10234
10235 /**
10236 * Handle blur events.
10237 *
10238 * @private
10239 * @param {jQuery.Event} e Blur event
10240 */
10241 OO.ui.TextInputWidget.prototype.onBlur = function () {
10242 this.setValidityFlag();
10243 };
10244
10245 /**
10246 * Handle focus events.
10247 *
10248 * @private
10249 * @param {jQuery.Event} e Focus event
10250 */
10251 OO.ui.TextInputWidget.prototype.onFocus = function () {
10252 if ( this.isWaitingToBeAttached ) {
10253 // If we've received focus, then we must be attached to the document, and if
10254 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10255 this.onElementAttach();
10256 }
10257 this.setValidityFlag( true );
10258 };
10259
10260 /**
10261 * Handle element attach events.
10262 *
10263 * @private
10264 * @param {jQuery.Event} e Element attach event
10265 */
10266 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10267 this.isWaitingToBeAttached = false;
10268 // Any previously calculated size is now probably invalid if we reattached elsewhere
10269 this.valCache = null;
10270 this.positionLabel();
10271 };
10272
10273 /**
10274 * Handle debounced change events.
10275 *
10276 * @param {string} value
10277 * @private
10278 */
10279 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10280 this.setValidityFlag();
10281 };
10282
10283 /**
10284 * Check if the input is {@link #readOnly read-only}.
10285 *
10286 * @return {boolean}
10287 */
10288 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10289 return this.readOnly;
10290 };
10291
10292 /**
10293 * Set the {@link #readOnly read-only} state of the input.
10294 *
10295 * @param {boolean} state Make input read-only
10296 * @chainable
10297 */
10298 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10299 this.readOnly = !!state;
10300 this.$input.prop( 'readOnly', this.readOnly );
10301 return this;
10302 };
10303
10304 /**
10305 * Check if the input is {@link #required required}.
10306 *
10307 * @return {boolean}
10308 */
10309 OO.ui.TextInputWidget.prototype.isRequired = function () {
10310 return this.required;
10311 };
10312
10313 /**
10314 * Set the {@link #required required} state of the input.
10315 *
10316 * @param {boolean} state Make input required
10317 * @chainable
10318 */
10319 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10320 this.required = !!state;
10321 if ( this.required ) {
10322 this.$input
10323 .prop( 'required', true )
10324 .attr( 'aria-required', 'true' );
10325 if ( this.getIndicator() === null ) {
10326 this.setIndicator( 'required' );
10327 }
10328 } else {
10329 this.$input
10330 .prop( 'required', false )
10331 .removeAttr( 'aria-required' );
10332 if ( this.getIndicator() === 'required' ) {
10333 this.setIndicator( null );
10334 }
10335 }
10336 return this;
10337 };
10338
10339 /**
10340 * Support function for making #onElementAttach work across browsers.
10341 *
10342 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10343 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10344 *
10345 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10346 * first time that the element gets attached to the documented.
10347 */
10348 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10349 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10350 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10351 widget = this;
10352
10353 if ( MutationObserver ) {
10354 // The new way. If only it wasn't so ugly.
10355
10356 if ( this.isElementAttached() ) {
10357 // Widget is attached already, do nothing. This breaks the functionality of this function when
10358 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10359 // would require observation of the whole document, which would hurt performance of other,
10360 // more important code.
10361 return;
10362 }
10363
10364 // Find topmost node in the tree
10365 topmostNode = this.$element[ 0 ];
10366 while ( topmostNode.parentNode ) {
10367 topmostNode = topmostNode.parentNode;
10368 }
10369
10370 // We have no way to detect the $element being attached somewhere without observing the entire
10371 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10372 // parent node of $element, and instead detect when $element is removed from it (and thus
10373 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10374 // doesn't get attached, we end up back here and create the parent.
10375
10376 mutationObserver = new MutationObserver( function ( mutations ) {
10377 var i, j, removedNodes;
10378 for ( i = 0; i < mutations.length; i++ ) {
10379 removedNodes = mutations[ i ].removedNodes;
10380 for ( j = 0; j < removedNodes.length; j++ ) {
10381 if ( removedNodes[ j ] === topmostNode ) {
10382 setTimeout( onRemove, 0 );
10383 return;
10384 }
10385 }
10386 }
10387 } );
10388
10389 onRemove = function () {
10390 // If the node was attached somewhere else, report it
10391 if ( widget.isElementAttached() ) {
10392 widget.onElementAttach();
10393 }
10394 mutationObserver.disconnect();
10395 widget.installParentChangeDetector();
10396 };
10397
10398 // Create a fake parent and observe it
10399 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10400 mutationObserver.observe( fakeParentNode, { childList: true } );
10401 } else {
10402 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10403 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10404 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10405 }
10406 };
10407
10408 /**
10409 * @inheritdoc
10410 * @protected
10411 */
10412 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10413 if ( this.getSaneType( config ) === 'number' ) {
10414 return $( '<input>' )
10415 .attr( 'step', 'any' )
10416 .attr( 'type', 'number' );
10417 } else {
10418 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10419 }
10420 };
10421
10422 /**
10423 * Get sanitized value for 'type' for given config.
10424 *
10425 * @param {Object} config Configuration options
10426 * @return {string|null}
10427 * @protected
10428 */
10429 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10430 var allowedTypes = [
10431 'text',
10432 'password',
10433 'email',
10434 'url',
10435 'number'
10436 ];
10437 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10438 };
10439
10440 /**
10441 * Focus the input and select a specified range within the text.
10442 *
10443 * @param {number} from Select from offset
10444 * @param {number} [to] Select to offset, defaults to from
10445 * @chainable
10446 */
10447 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10448 var isBackwards, start, end,
10449 input = this.$input[ 0 ];
10450
10451 to = to || from;
10452
10453 isBackwards = to < from;
10454 start = isBackwards ? to : from;
10455 end = isBackwards ? from : to;
10456
10457 this.focus();
10458
10459 try {
10460 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10461 } catch ( e ) {
10462 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10463 // Rather than expensively check if the input is attached every time, just check
10464 // if it was the cause of an error being thrown. If not, rethrow the error.
10465 if ( this.getElementDocument().body.contains( input ) ) {
10466 throw e;
10467 }
10468 }
10469 return this;
10470 };
10471
10472 /**
10473 * Get an object describing the current selection range in a directional manner
10474 *
10475 * @return {Object} Object containing 'from' and 'to' offsets
10476 */
10477 OO.ui.TextInputWidget.prototype.getRange = function () {
10478 var input = this.$input[ 0 ],
10479 start = input.selectionStart,
10480 end = input.selectionEnd,
10481 isBackwards = input.selectionDirection === 'backward';
10482
10483 return {
10484 from: isBackwards ? end : start,
10485 to: isBackwards ? start : end
10486 };
10487 };
10488
10489 /**
10490 * Get the length of the text input value.
10491 *
10492 * This could differ from the length of #getValue if the
10493 * value gets filtered
10494 *
10495 * @return {number} Input length
10496 */
10497 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10498 return this.$input[ 0 ].value.length;
10499 };
10500
10501 /**
10502 * Focus the input and select the entire text.
10503 *
10504 * @chainable
10505 */
10506 OO.ui.TextInputWidget.prototype.select = function () {
10507 return this.selectRange( 0, this.getInputLength() );
10508 };
10509
10510 /**
10511 * Focus the input and move the cursor to the start.
10512 *
10513 * @chainable
10514 */
10515 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10516 return this.selectRange( 0 );
10517 };
10518
10519 /**
10520 * Focus the input and move the cursor to the end.
10521 *
10522 * @chainable
10523 */
10524 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10525 return this.selectRange( this.getInputLength() );
10526 };
10527
10528 /**
10529 * Insert new content into the input.
10530 *
10531 * @param {string} content Content to be inserted
10532 * @chainable
10533 */
10534 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10535 var start, end,
10536 range = this.getRange(),
10537 value = this.getValue();
10538
10539 start = Math.min( range.from, range.to );
10540 end = Math.max( range.from, range.to );
10541
10542 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10543 this.selectRange( start + content.length );
10544 return this;
10545 };
10546
10547 /**
10548 * Insert new content either side of a selection.
10549 *
10550 * @param {string} pre Content to be inserted before the selection
10551 * @param {string} post Content to be inserted after the selection
10552 * @chainable
10553 */
10554 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10555 var start, end,
10556 range = this.getRange(),
10557 offset = pre.length;
10558
10559 start = Math.min( range.from, range.to );
10560 end = Math.max( range.from, range.to );
10561
10562 this.selectRange( start ).insertContent( pre );
10563 this.selectRange( offset + end ).insertContent( post );
10564
10565 this.selectRange( offset + start, offset + end );
10566 return this;
10567 };
10568
10569 /**
10570 * Set the validation pattern.
10571 *
10572 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10573 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10574 * value must contain only numbers).
10575 *
10576 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10577 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10578 */
10579 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10580 if ( validate instanceof RegExp || validate instanceof Function ) {
10581 this.validate = validate;
10582 } else {
10583 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10584 }
10585 };
10586
10587 /**
10588 * Sets the 'invalid' flag appropriately.
10589 *
10590 * @param {boolean} [isValid] Optionally override validation result
10591 */
10592 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10593 var widget = this,
10594 setFlag = function ( valid ) {
10595 if ( !valid ) {
10596 widget.$input.attr( 'aria-invalid', 'true' );
10597 } else {
10598 widget.$input.removeAttr( 'aria-invalid' );
10599 }
10600 widget.setFlags( { invalid: !valid } );
10601 };
10602
10603 if ( isValid !== undefined ) {
10604 setFlag( isValid );
10605 } else {
10606 this.getValidity().then( function () {
10607 setFlag( true );
10608 }, function () {
10609 setFlag( false );
10610 } );
10611 }
10612 };
10613
10614 /**
10615 * Get the validity of current value.
10616 *
10617 * This method returns a promise that resolves if the value is valid and rejects if
10618 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10619 *
10620 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10621 */
10622 OO.ui.TextInputWidget.prototype.getValidity = function () {
10623 var result;
10624
10625 function rejectOrResolve( valid ) {
10626 if ( valid ) {
10627 return $.Deferred().resolve().promise();
10628 } else {
10629 return $.Deferred().reject().promise();
10630 }
10631 }
10632
10633 // Check browser validity and reject if it is invalid
10634 if (
10635 this.$input[ 0 ].checkValidity !== undefined &&
10636 this.$input[ 0 ].checkValidity() === false
10637 ) {
10638 return rejectOrResolve( false );
10639 }
10640
10641 // Run our checks if the browser thinks the field is valid
10642 if ( this.validate instanceof Function ) {
10643 result = this.validate( this.getValue() );
10644 if ( result && $.isFunction( result.promise ) ) {
10645 return result.promise().then( function ( valid ) {
10646 return rejectOrResolve( valid );
10647 } );
10648 } else {
10649 return rejectOrResolve( result );
10650 }
10651 } else {
10652 return rejectOrResolve( this.getValue().match( this.validate ) );
10653 }
10654 };
10655
10656 /**
10657 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10658 *
10659 * @param {string} labelPosition Label position, 'before' or 'after'
10660 * @chainable
10661 */
10662 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10663 this.labelPosition = labelPosition;
10664 if ( this.label ) {
10665 // If there is no label and we only change the position, #updatePosition is a no-op,
10666 // but it takes really a lot of work to do nothing.
10667 this.updatePosition();
10668 }
10669 return this;
10670 };
10671
10672 /**
10673 * Update the position of the inline label.
10674 *
10675 * This method is called by #setLabelPosition, and can also be called on its own if
10676 * something causes the label to be mispositioned.
10677 *
10678 * @chainable
10679 */
10680 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10681 var after = this.labelPosition === 'after';
10682
10683 this.$element
10684 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10685 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10686
10687 this.valCache = null;
10688 this.scrollWidth = null;
10689 this.positionLabel();
10690
10691 return this;
10692 };
10693
10694 /**
10695 * Position the label by setting the correct padding on the input.
10696 *
10697 * @private
10698 * @chainable
10699 */
10700 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10701 var after, rtl, property, newCss;
10702
10703 if ( this.isWaitingToBeAttached ) {
10704 // #onElementAttach will be called soon, which calls this method
10705 return this;
10706 }
10707
10708 newCss = {
10709 'padding-right': '',
10710 'padding-left': ''
10711 };
10712
10713 if ( this.label ) {
10714 this.$element.append( this.$label );
10715 } else {
10716 this.$label.detach();
10717 // Clear old values if present
10718 this.$input.css( newCss );
10719 return;
10720 }
10721
10722 after = this.labelPosition === 'after';
10723 rtl = this.$element.css( 'direction' ) === 'rtl';
10724 property = after === rtl ? 'padding-left' : 'padding-right';
10725
10726 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10727 // We have to clear the padding on the other side, in case the element direction changed
10728 this.$input.css( newCss );
10729
10730 return this;
10731 };
10732
10733 /**
10734 * @class
10735 * @extends OO.ui.TextInputWidget
10736 *
10737 * @constructor
10738 * @param {Object} [config] Configuration options
10739 */
10740 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10741 config = $.extend( {
10742 icon: 'search'
10743 }, config );
10744
10745 // Parent constructor
10746 OO.ui.SearchInputWidget.parent.call( this, config );
10747
10748 // Events
10749 this.connect( this, {
10750 change: 'onChange'
10751 } );
10752
10753 // Initialization
10754 this.updateSearchIndicator();
10755 this.connect( this, {
10756 disable: 'onDisable'
10757 } );
10758 };
10759
10760 /* Setup */
10761
10762 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10763
10764 /* Methods */
10765
10766 /**
10767 * @inheritdoc
10768 * @protected
10769 */
10770 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10771 return 'search';
10772 };
10773
10774 /**
10775 * @inheritdoc
10776 */
10777 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10778 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10779 // Clear the text field
10780 this.setValue( '' );
10781 this.focus();
10782 return false;
10783 }
10784 };
10785
10786 /**
10787 * Update the 'clear' indicator displayed on type: 'search' text
10788 * fields, hiding it when the field is already empty or when it's not
10789 * editable.
10790 */
10791 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10792 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10793 this.setIndicator( null );
10794 } else {
10795 this.setIndicator( 'clear' );
10796 }
10797 };
10798
10799 /**
10800 * Handle change events.
10801 *
10802 * @private
10803 */
10804 OO.ui.SearchInputWidget.prototype.onChange = function () {
10805 this.updateSearchIndicator();
10806 };
10807
10808 /**
10809 * Handle disable events.
10810 *
10811 * @param {boolean} disabled Element is disabled
10812 * @private
10813 */
10814 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10815 this.updateSearchIndicator();
10816 };
10817
10818 /**
10819 * @inheritdoc
10820 */
10821 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10822 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10823 this.updateSearchIndicator();
10824 return this;
10825 };
10826
10827 /**
10828 * @class
10829 * @extends OO.ui.TextInputWidget
10830 *
10831 * @constructor
10832 * @param {Object} [config] Configuration options
10833 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10834 * specifies minimum number of rows to display.
10835 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10836 * Use the #maxRows config to specify a maximum number of displayed rows.
10837 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
10838 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10839 */
10840 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10841 config = $.extend( {
10842 type: 'text'
10843 }, config );
10844 config.multiline = false;
10845 // Parent constructor
10846 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10847
10848 // Properties
10849 this.multiline = true;
10850 this.autosize = !!config.autosize;
10851 this.minRows = config.rows !== undefined ? config.rows : '';
10852 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10853
10854 // Clone for resizing
10855 if ( this.autosize ) {
10856 this.$clone = this.$input
10857 .clone()
10858 .removeAttr( 'id' )
10859 .removeAttr( 'name' )
10860 .insertAfter( this.$input )
10861 .attr( 'aria-hidden', 'true' )
10862 .addClass( 'oo-ui-element-hidden' );
10863 }
10864
10865 // Events
10866 this.connect( this, {
10867 change: 'onChange'
10868 } );
10869
10870 // Initialization
10871 if ( this.multiline && config.rows ) {
10872 this.$input.attr( 'rows', config.rows );
10873 }
10874 if ( this.autosize ) {
10875 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
10876 this.isWaitingToBeAttached = true;
10877 this.installParentChangeDetector();
10878 }
10879 };
10880
10881 /* Setup */
10882
10883 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10884
10885 /* Static Methods */
10886
10887 /**
10888 * @inheritdoc
10889 */
10890 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10891 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10892 state.scrollTop = config.$input.scrollTop();
10893 return state;
10894 };
10895
10896 /* Methods */
10897
10898 /**
10899 * @inheritdoc
10900 */
10901 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10902 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10903 this.adjustSize();
10904 };
10905
10906 /**
10907 * Handle change events.
10908 *
10909 * @private
10910 */
10911 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10912 this.adjustSize();
10913 };
10914
10915 /**
10916 * @inheritdoc
10917 */
10918 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10919 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10920 this.adjustSize();
10921 };
10922
10923 /**
10924 * @inheritdoc
10925 *
10926 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
10927 */
10928 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
10929 if (
10930 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
10931 // Some platforms emit keycode 10 for ctrl+enter in a textarea
10932 e.which === 10
10933 ) {
10934 this.emit( 'enter', e );
10935 }
10936 };
10937
10938 /**
10939 * Automatically adjust the size of the text input.
10940 *
10941 * This only affects multiline inputs that are {@link #autosize autosized}.
10942 *
10943 * @chainable
10944 * @fires resize
10945 */
10946 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10947 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10948 idealHeight, newHeight, scrollWidth, property;
10949
10950 if ( this.$input.val() !== this.valCache ) {
10951 if ( this.autosize ) {
10952 this.$clone
10953 .val( this.$input.val() )
10954 .attr( 'rows', this.minRows )
10955 // Set inline height property to 0 to measure scroll height
10956 .css( 'height', 0 );
10957
10958 this.$clone.removeClass( 'oo-ui-element-hidden' );
10959
10960 this.valCache = this.$input.val();
10961
10962 scrollHeight = this.$clone[ 0 ].scrollHeight;
10963
10964 // Remove inline height property to measure natural heights
10965 this.$clone.css( 'height', '' );
10966 innerHeight = this.$clone.innerHeight();
10967 outerHeight = this.$clone.outerHeight();
10968
10969 // Measure max rows height
10970 this.$clone
10971 .attr( 'rows', this.maxRows )
10972 .css( 'height', 'auto' )
10973 .val( '' );
10974 maxInnerHeight = this.$clone.innerHeight();
10975
10976 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10977 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10978 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10979 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10980
10981 this.$clone.addClass( 'oo-ui-element-hidden' );
10982
10983 // Only apply inline height when expansion beyond natural height is needed
10984 // Use the difference between the inner and outer height as a buffer
10985 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10986 if ( newHeight !== this.styleHeight ) {
10987 this.$input.css( 'height', newHeight );
10988 this.styleHeight = newHeight;
10989 this.emit( 'resize' );
10990 }
10991 }
10992 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10993 if ( scrollWidth !== this.scrollWidth ) {
10994 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10995 // Reset
10996 this.$label.css( { right: '', left: '' } );
10997 this.$indicator.css( { right: '', left: '' } );
10998
10999 if ( scrollWidth ) {
11000 this.$indicator.css( property, scrollWidth );
11001 if ( this.labelPosition === 'after' ) {
11002 this.$label.css( property, scrollWidth );
11003 }
11004 }
11005
11006 this.scrollWidth = scrollWidth;
11007 this.positionLabel();
11008 }
11009 }
11010 return this;
11011 };
11012
11013 /**
11014 * @inheritdoc
11015 * @protected
11016 */
11017 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11018 return $( '<textarea>' );
11019 };
11020
11021 /**
11022 * Check if the input supports multiple lines.
11023 *
11024 * @return {boolean}
11025 */
11026 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
11027 return !!this.multiline;
11028 };
11029
11030 /**
11031 * Check if the input automatically adjusts its size.
11032 *
11033 * @return {boolean}
11034 */
11035 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11036 return !!this.autosize;
11037 };
11038
11039 /**
11040 * @inheritdoc
11041 */
11042 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11043 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11044 if ( state.scrollTop !== undefined ) {
11045 this.$input.scrollTop( state.scrollTop );
11046 }
11047 };
11048
11049 /**
11050 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11051 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11052 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11053 *
11054 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11055 * option, that option will appear to be selected.
11056 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11057 * input field.
11058 *
11059 * After the user chooses an option, its `data` will be used as a new value for the widget.
11060 * A `label` also can be specified for each option: if given, it will be shown instead of the
11061 * `data` in the dropdown menu.
11062 *
11063 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11064 *
11065 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11066 *
11067 * @example
11068 * // Example: A ComboBoxInputWidget.
11069 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11070 * value: 'Option 1',
11071 * options: [
11072 * { data: 'Option 1' },
11073 * { data: 'Option 2' },
11074 * { data: 'Option 3' }
11075 * ]
11076 * } );
11077 * $( 'body' ).append( comboBox.$element );
11078 *
11079 * @example
11080 * // Example: A ComboBoxInputWidget with additional option labels.
11081 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11082 * value: 'Option 1',
11083 * options: [
11084 * {
11085 * data: 'Option 1',
11086 * label: 'Option One'
11087 * },
11088 * {
11089 * data: 'Option 2',
11090 * label: 'Option Two'
11091 * },
11092 * {
11093 * data: 'Option 3',
11094 * label: 'Option Three'
11095 * }
11096 * ]
11097 * } );
11098 * $( 'body' ).append( comboBox.$element );
11099 *
11100 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11101 *
11102 * @class
11103 * @extends OO.ui.TextInputWidget
11104 *
11105 * @constructor
11106 * @param {Object} [config] Configuration options
11107 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11108 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11109 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11110 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11111 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11112 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11113 */
11114 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11115 // Configuration initialization
11116 config = $.extend( {
11117 autocomplete: false
11118 }, config );
11119
11120 // ComboBoxInputWidget shouldn't support `multiline`
11121 config.multiline = false;
11122
11123 // See InputWidget#reusePreInfuseDOM about `config.$input`
11124 if ( config.$input ) {
11125 config.$input.removeAttr( 'list' );
11126 }
11127
11128 // Parent constructor
11129 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11130
11131 // Properties
11132 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11133 this.dropdownButton = new OO.ui.ButtonWidget( {
11134 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11135 indicator: 'down',
11136 disabled: this.disabled
11137 } );
11138 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11139 {
11140 widget: this,
11141 input: this,
11142 $floatableContainer: this.$element,
11143 disabled: this.isDisabled()
11144 },
11145 config.menu
11146 ) );
11147
11148 // Events
11149 this.connect( this, {
11150 change: 'onInputChange',
11151 enter: 'onInputEnter'
11152 } );
11153 this.dropdownButton.connect( this, {
11154 click: 'onDropdownButtonClick'
11155 } );
11156 this.menu.connect( this, {
11157 choose: 'onMenuChoose',
11158 add: 'onMenuItemsChange',
11159 remove: 'onMenuItemsChange',
11160 toggle: 'onMenuToggle'
11161 } );
11162
11163 // Initialization
11164 this.$input.attr( {
11165 role: 'combobox',
11166 'aria-owns': this.menu.getElementId(),
11167 'aria-autocomplete': 'list'
11168 } );
11169 // Do not override options set via config.menu.items
11170 if ( config.options !== undefined ) {
11171 this.setOptions( config.options );
11172 }
11173 this.$field = $( '<div>' )
11174 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11175 .append( this.$input, this.dropdownButton.$element );
11176 this.$element
11177 .addClass( 'oo-ui-comboBoxInputWidget' )
11178 .append( this.$field );
11179 this.$overlay.append( this.menu.$element );
11180 this.onMenuItemsChange();
11181 };
11182
11183 /* Setup */
11184
11185 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11186
11187 /* Methods */
11188
11189 /**
11190 * Get the combobox's menu.
11191 *
11192 * @return {OO.ui.MenuSelectWidget} Menu widget
11193 */
11194 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11195 return this.menu;
11196 };
11197
11198 /**
11199 * Get the combobox's text input widget.
11200 *
11201 * @return {OO.ui.TextInputWidget} Text input widget
11202 */
11203 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11204 return this;
11205 };
11206
11207 /**
11208 * Handle input change events.
11209 *
11210 * @private
11211 * @param {string} value New value
11212 */
11213 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11214 var match = this.menu.findItemFromData( value );
11215
11216 this.menu.selectItem( match );
11217 if ( this.menu.findHighlightedItem() ) {
11218 this.menu.highlightItem( match );
11219 }
11220
11221 if ( !this.isDisabled() ) {
11222 this.menu.toggle( true );
11223 }
11224 };
11225
11226 /**
11227 * Handle input enter events.
11228 *
11229 * @private
11230 */
11231 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11232 if ( !this.isDisabled() ) {
11233 this.menu.toggle( false );
11234 }
11235 };
11236
11237 /**
11238 * Handle button click events.
11239 *
11240 * @private
11241 */
11242 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11243 this.menu.toggle();
11244 this.focus();
11245 };
11246
11247 /**
11248 * Handle menu choose events.
11249 *
11250 * @private
11251 * @param {OO.ui.OptionWidget} item Chosen item
11252 */
11253 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11254 this.setValue( item.getData() );
11255 };
11256
11257 /**
11258 * Handle menu item change events.
11259 *
11260 * @private
11261 */
11262 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11263 var match = this.menu.findItemFromData( this.getValue() );
11264 this.menu.selectItem( match );
11265 if ( this.menu.findHighlightedItem() ) {
11266 this.menu.highlightItem( match );
11267 }
11268 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11269 };
11270
11271 /**
11272 * Handle menu toggle events.
11273 *
11274 * @private
11275 * @param {boolean} isVisible Open state of the menu
11276 */
11277 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11278 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11279 };
11280
11281 /**
11282 * @inheritdoc
11283 */
11284 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11285 // Parent method
11286 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11287
11288 if ( this.dropdownButton ) {
11289 this.dropdownButton.setDisabled( this.isDisabled() );
11290 }
11291 if ( this.menu ) {
11292 this.menu.setDisabled( this.isDisabled() );
11293 }
11294
11295 return this;
11296 };
11297
11298 /**
11299 * Set the options available for this input.
11300 *
11301 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11302 * @chainable
11303 */
11304 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11305 this.getMenu()
11306 .clearItems()
11307 .addItems( options.map( function ( opt ) {
11308 return new OO.ui.MenuOptionWidget( {
11309 data: opt.data,
11310 label: opt.label !== undefined ? opt.label : opt.data
11311 } );
11312 } ) );
11313
11314 return this;
11315 };
11316
11317 /**
11318 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11319 * which is a widget that is specified by reference before any optional configuration settings.
11320 *
11321 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11322 *
11323 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11324 * A left-alignment is used for forms with many fields.
11325 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11326 * A right-alignment is used for long but familiar forms which users tab through,
11327 * verifying the current field with a quick glance at the label.
11328 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11329 * that users fill out from top to bottom.
11330 * - **inline**: The label is placed after the field-widget and aligned to the left.
11331 * An inline-alignment is best used with checkboxes or radio buttons.
11332 *
11333 * Help text can either be:
11334 *
11335 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11336 * - shown as a subtle explanation below the label.
11337 *
11338 * If the help text is brief, or is essential to always espose it, set `helpInline` to `true`. If it
11339 * is long or not essential, leave `helpInline` to its default, `false`.
11340 *
11341 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11342 *
11343 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11344 *
11345 * @class
11346 * @extends OO.ui.Layout
11347 * @mixins OO.ui.mixin.LabelElement
11348 * @mixins OO.ui.mixin.TitledElement
11349 *
11350 * @constructor
11351 * @param {OO.ui.Widget} fieldWidget Field widget
11352 * @param {Object} [config] Configuration options
11353 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11354 * or 'inline'
11355 * @cfg {Array} [errors] Error messages about the widget, which will be
11356 * displayed below the widget.
11357 * The array may contain strings or OO.ui.HtmlSnippet instances.
11358 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11359 * below the widget.
11360 * The array may contain strings or OO.ui.HtmlSnippet instances.
11361 * These are more visible than `help` messages when `helpInline` is set, and so
11362 * might be good for transient messages.
11363 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11364 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11365 * corner of the rendered field; clicking it will display the text in a popup.
11366 * If `helpInline` is `true`, then a subtle description will be shown after the
11367 * label.
11368 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11369 * or shown when the "help" icon is clicked.
11370 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11371 * `help` is given.
11372 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11373 *
11374 * @throws {Error} An error is thrown if no widget is specified
11375 */
11376 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11377 // Allow passing positional parameters inside the config object
11378 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11379 config = fieldWidget;
11380 fieldWidget = config.fieldWidget;
11381 }
11382
11383 // Make sure we have required constructor arguments
11384 if ( fieldWidget === undefined ) {
11385 throw new Error( 'Widget not found' );
11386 }
11387
11388 // Configuration initialization
11389 config = $.extend( { align: 'left', helpInline: false }, config );
11390
11391 // Parent constructor
11392 OO.ui.FieldLayout.parent.call( this, config );
11393
11394 // Mixin constructors
11395 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11396 $label: $( '<label>' )
11397 } ) );
11398 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11399
11400 // Properties
11401 this.fieldWidget = fieldWidget;
11402 this.errors = [];
11403 this.notices = [];
11404 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11405 this.$messages = $( '<ul>' );
11406 this.$header = $( '<span>' );
11407 this.$body = $( '<div>' );
11408 this.align = null;
11409 this.helpInline = config.helpInline;
11410
11411 // Events
11412 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11413
11414 // Initialization
11415 this.$help = config.help ?
11416 this.createHelpElement( config.help, config.$overlay ) :
11417 $( [] );
11418 if ( this.fieldWidget.getInputId() ) {
11419 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11420 if ( this.helpInline ) {
11421 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11422 }
11423 } else {
11424 this.$label.on( 'click', function () {
11425 this.fieldWidget.simulateLabelClick();
11426 }.bind( this ) );
11427 if ( this.helpInline ) {
11428 this.$help.on( 'click', function () {
11429 this.fieldWidget.simulateLabelClick();
11430 }.bind( this ) );
11431 }
11432 }
11433 this.$element
11434 .addClass( 'oo-ui-fieldLayout' )
11435 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11436 .append( this.$body );
11437 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11438 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11439 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11440 this.$field
11441 .addClass( 'oo-ui-fieldLayout-field' )
11442 .append( this.fieldWidget.$element );
11443
11444 this.setErrors( config.errors || [] );
11445 this.setNotices( config.notices || [] );
11446 this.setAlignment( config.align );
11447 // Call this again to take into account the widget's accessKey
11448 this.updateTitle();
11449 };
11450
11451 /* Setup */
11452
11453 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11454 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11455 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11456
11457 /* Methods */
11458
11459 /**
11460 * Handle field disable events.
11461 *
11462 * @private
11463 * @param {boolean} value Field is disabled
11464 */
11465 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11466 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11467 };
11468
11469 /**
11470 * Get the widget contained by the field.
11471 *
11472 * @return {OO.ui.Widget} Field widget
11473 */
11474 OO.ui.FieldLayout.prototype.getField = function () {
11475 return this.fieldWidget;
11476 };
11477
11478 /**
11479 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11480 * #setAlignment). Return `false` if it can't or if this can't be determined.
11481 *
11482 * @return {boolean}
11483 */
11484 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11485 // This is very simplistic, but should be good enough.
11486 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11487 };
11488
11489 /**
11490 * @protected
11491 * @param {string} kind 'error' or 'notice'
11492 * @param {string|OO.ui.HtmlSnippet} text
11493 * @return {jQuery}
11494 */
11495 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11496 var $listItem, $icon, message;
11497 $listItem = $( '<li>' );
11498 if ( kind === 'error' ) {
11499 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11500 $listItem.attr( 'role', 'alert' );
11501 } else if ( kind === 'notice' ) {
11502 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11503 } else {
11504 $icon = '';
11505 }
11506 message = new OO.ui.LabelWidget( { label: text } );
11507 $listItem
11508 .append( $icon, message.$element )
11509 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11510 return $listItem;
11511 };
11512
11513 /**
11514 * Set the field alignment mode.
11515 *
11516 * @private
11517 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11518 * @chainable
11519 */
11520 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11521 if ( value !== this.align ) {
11522 // Default to 'left'
11523 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11524 value = 'left';
11525 }
11526 // Validate
11527 if ( value === 'inline' && !this.isFieldInline() ) {
11528 value = 'top';
11529 }
11530 // Reorder elements
11531
11532 if ( this.helpInline ) {
11533 if ( value === 'top' ) {
11534 this.$header.append( this.$label );
11535 this.$body.append( this.$header, this.$field, this.$help );
11536 } else if ( value === 'inline' ) {
11537 this.$header.append( this.$label, this.$help );
11538 this.$body.append( this.$field, this.$header );
11539 } else {
11540 this.$header.append( this.$label, this.$help );
11541 this.$body.append( this.$header, this.$field );
11542 }
11543 } else {
11544 if ( value === 'top' ) {
11545 this.$header.append( this.$help, this.$label );
11546 this.$body.append( this.$header, this.$field );
11547 } else if ( value === 'inline' ) {
11548 this.$header.append( this.$help, this.$label );
11549 this.$body.append( this.$field, this.$header );
11550 } else {
11551 this.$header.append( this.$label );
11552 this.$body.append( this.$header, this.$help, this.$field );
11553 }
11554 }
11555 // Set classes. The following classes can be used here:
11556 // * oo-ui-fieldLayout-align-left
11557 // * oo-ui-fieldLayout-align-right
11558 // * oo-ui-fieldLayout-align-top
11559 // * oo-ui-fieldLayout-align-inline
11560 if ( this.align ) {
11561 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11562 }
11563 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11564 this.align = value;
11565 }
11566
11567 return this;
11568 };
11569
11570 /**
11571 * Set the list of error messages.
11572 *
11573 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11574 * The array may contain strings or OO.ui.HtmlSnippet instances.
11575 * @chainable
11576 */
11577 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11578 this.errors = errors.slice();
11579 this.updateMessages();
11580 return this;
11581 };
11582
11583 /**
11584 * Set the list of notice messages.
11585 *
11586 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11587 * The array may contain strings or OO.ui.HtmlSnippet instances.
11588 * @chainable
11589 */
11590 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11591 this.notices = notices.slice();
11592 this.updateMessages();
11593 return this;
11594 };
11595
11596 /**
11597 * Update the rendering of error and notice messages.
11598 *
11599 * @private
11600 */
11601 OO.ui.FieldLayout.prototype.updateMessages = function () {
11602 var i;
11603 this.$messages.empty();
11604
11605 if ( this.errors.length || this.notices.length ) {
11606 this.$body.after( this.$messages );
11607 } else {
11608 this.$messages.remove();
11609 return;
11610 }
11611
11612 for ( i = 0; i < this.notices.length; i++ ) {
11613 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11614 }
11615 for ( i = 0; i < this.errors.length; i++ ) {
11616 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11617 }
11618 };
11619
11620 /**
11621 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11622 * (This is a bit of a hack.)
11623 *
11624 * @protected
11625 * @param {string} title Tooltip label for 'title' attribute
11626 * @return {string}
11627 */
11628 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11629 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11630 return this.fieldWidget.formatTitleWithAccessKey( title );
11631 }
11632 return title;
11633 };
11634
11635 /**
11636 * Creates and returns the help element. Also sets the `aria-describedby`
11637 * attribute on the main element of the `fieldWidget`.
11638 *
11639 * @private
11640 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11641 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11642 * @return {jQuery} The element that should become `this.$help`.
11643 */
11644 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11645 var helpId, helpWidget;
11646
11647 if ( this.helpInline ) {
11648 helpWidget = new OO.ui.LabelWidget( {
11649 label: help,
11650 classes: [ 'oo-ui-inline-help' ]
11651 } );
11652
11653 helpId = helpWidget.getElementId();
11654 } else {
11655 helpWidget = new OO.ui.PopupButtonWidget( {
11656 $overlay: $overlay,
11657 popup: {
11658 padded: true
11659 },
11660 classes: [ 'oo-ui-fieldLayout-help' ],
11661 framed: false,
11662 icon: 'info',
11663 label: OO.ui.msg( 'ooui-field-help' )
11664 } );
11665 if ( help instanceof OO.ui.HtmlSnippet ) {
11666 helpWidget.getPopup().$body.html( help.toString() );
11667 } else {
11668 helpWidget.getPopup().$body.text( help );
11669 }
11670
11671 helpId = helpWidget.getPopup().getBodyId();
11672 }
11673
11674 // Set the 'aria-describedby' attribute on the fieldWidget
11675 // Preference given to an input or a button
11676 (
11677 this.fieldWidget.$input ||
11678 this.fieldWidget.$button ||
11679 this.fieldWidget.$element
11680 ).attr( 'aria-describedby', helpId );
11681
11682 return helpWidget.$element;
11683 };
11684
11685 /**
11686 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11687 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11688 * is required and is specified before any optional configuration settings.
11689 *
11690 * Labels can be aligned in one of four ways:
11691 *
11692 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11693 * A left-alignment is used for forms with many fields.
11694 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11695 * A right-alignment is used for long but familiar forms which users tab through,
11696 * verifying the current field with a quick glance at the label.
11697 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11698 * that users fill out from top to bottom.
11699 * - **inline**: The label is placed after the field-widget and aligned to the left.
11700 * An inline-alignment is best used with checkboxes or radio buttons.
11701 *
11702 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11703 * text is specified.
11704 *
11705 * @example
11706 * // Example of an ActionFieldLayout
11707 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11708 * new OO.ui.TextInputWidget( {
11709 * placeholder: 'Field widget'
11710 * } ),
11711 * new OO.ui.ButtonWidget( {
11712 * label: 'Button'
11713 * } ),
11714 * {
11715 * label: 'An ActionFieldLayout. This label is aligned top',
11716 * align: 'top',
11717 * help: 'This is help text'
11718 * }
11719 * );
11720 *
11721 * $( 'body' ).append( actionFieldLayout.$element );
11722 *
11723 * @class
11724 * @extends OO.ui.FieldLayout
11725 *
11726 * @constructor
11727 * @param {OO.ui.Widget} fieldWidget Field widget
11728 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11729 * @param {Object} config
11730 */
11731 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11732 // Allow passing positional parameters inside the config object
11733 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11734 config = fieldWidget;
11735 fieldWidget = config.fieldWidget;
11736 buttonWidget = config.buttonWidget;
11737 }
11738
11739 // Parent constructor
11740 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11741
11742 // Properties
11743 this.buttonWidget = buttonWidget;
11744 this.$button = $( '<span>' );
11745 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11746
11747 // Initialization
11748 this.$element
11749 .addClass( 'oo-ui-actionFieldLayout' );
11750 this.$button
11751 .addClass( 'oo-ui-actionFieldLayout-button' )
11752 .append( this.buttonWidget.$element );
11753 this.$input
11754 .addClass( 'oo-ui-actionFieldLayout-input' )
11755 .append( this.fieldWidget.$element );
11756 this.$field
11757 .append( this.$input, this.$button );
11758 };
11759
11760 /* Setup */
11761
11762 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11763
11764 /**
11765 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11766 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11767 * configured with a label as well. For more information and examples,
11768 * please see the [OOUI documentation on MediaWiki][1].
11769 *
11770 * @example
11771 * // Example of a fieldset layout
11772 * var input1 = new OO.ui.TextInputWidget( {
11773 * placeholder: 'A text input field'
11774 * } );
11775 *
11776 * var input2 = new OO.ui.TextInputWidget( {
11777 * placeholder: 'A text input field'
11778 * } );
11779 *
11780 * var fieldset = new OO.ui.FieldsetLayout( {
11781 * label: 'Example of a fieldset layout'
11782 * } );
11783 *
11784 * fieldset.addItems( [
11785 * new OO.ui.FieldLayout( input1, {
11786 * label: 'Field One'
11787 * } ),
11788 * new OO.ui.FieldLayout( input2, {
11789 * label: 'Field Two'
11790 * } )
11791 * ] );
11792 * $( 'body' ).append( fieldset.$element );
11793 *
11794 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11795 *
11796 * @class
11797 * @extends OO.ui.Layout
11798 * @mixins OO.ui.mixin.IconElement
11799 * @mixins OO.ui.mixin.LabelElement
11800 * @mixins OO.ui.mixin.GroupElement
11801 *
11802 * @constructor
11803 * @param {Object} [config] Configuration options
11804 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11805 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11806 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11807 * For important messages, you are advised to use `notices`, as they are always shown.
11808 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11809 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11810 */
11811 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11812 // Configuration initialization
11813 config = config || {};
11814
11815 // Parent constructor
11816 OO.ui.FieldsetLayout.parent.call( this, config );
11817
11818 // Mixin constructors
11819 OO.ui.mixin.IconElement.call( this, config );
11820 OO.ui.mixin.LabelElement.call( this, config );
11821 OO.ui.mixin.GroupElement.call( this, config );
11822
11823 // Properties
11824 this.$header = $( '<legend>' );
11825 if ( config.help ) {
11826 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11827 $overlay: config.$overlay,
11828 popup: {
11829 padded: true
11830 },
11831 classes: [ 'oo-ui-fieldsetLayout-help' ],
11832 framed: false,
11833 icon: 'info',
11834 label: OO.ui.msg( 'ooui-field-help' )
11835 } );
11836 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11837 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11838 } else {
11839 this.popupButtonWidget.getPopup().$body.text( config.help );
11840 }
11841 this.$help = this.popupButtonWidget.$element;
11842 } else {
11843 this.$help = $( [] );
11844 }
11845
11846 // Initialization
11847 this.$header
11848 .addClass( 'oo-ui-fieldsetLayout-header' )
11849 .append( this.$icon, this.$label, this.$help );
11850 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11851 this.$element
11852 .addClass( 'oo-ui-fieldsetLayout' )
11853 .prepend( this.$header, this.$group );
11854 if ( Array.isArray( config.items ) ) {
11855 this.addItems( config.items );
11856 }
11857 };
11858
11859 /* Setup */
11860
11861 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11862 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11863 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11864 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11865
11866 /* Static Properties */
11867
11868 /**
11869 * @static
11870 * @inheritdoc
11871 */
11872 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11873
11874 /**
11875 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11876 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11877 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11878 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
11879 *
11880 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11881 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11882 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11883 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11884 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11885 * often have simplified APIs to match the capabilities of HTML forms.
11886 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
11887 *
11888 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
11889 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
11890 *
11891 * @example
11892 * // Example of a form layout that wraps a fieldset layout
11893 * var input1 = new OO.ui.TextInputWidget( {
11894 * placeholder: 'Username'
11895 * } );
11896 * var input2 = new OO.ui.TextInputWidget( {
11897 * placeholder: 'Password',
11898 * type: 'password'
11899 * } );
11900 * var submit = new OO.ui.ButtonInputWidget( {
11901 * label: 'Submit'
11902 * } );
11903 *
11904 * var fieldset = new OO.ui.FieldsetLayout( {
11905 * label: 'A form layout'
11906 * } );
11907 * fieldset.addItems( [
11908 * new OO.ui.FieldLayout( input1, {
11909 * label: 'Username',
11910 * align: 'top'
11911 * } ),
11912 * new OO.ui.FieldLayout( input2, {
11913 * label: 'Password',
11914 * align: 'top'
11915 * } ),
11916 * new OO.ui.FieldLayout( submit )
11917 * ] );
11918 * var form = new OO.ui.FormLayout( {
11919 * items: [ fieldset ],
11920 * action: '/api/formhandler',
11921 * method: 'get'
11922 * } )
11923 * $( 'body' ).append( form.$element );
11924 *
11925 * @class
11926 * @extends OO.ui.Layout
11927 * @mixins OO.ui.mixin.GroupElement
11928 *
11929 * @constructor
11930 * @param {Object} [config] Configuration options
11931 * @cfg {string} [method] HTML form `method` attribute
11932 * @cfg {string} [action] HTML form `action` attribute
11933 * @cfg {string} [enctype] HTML form `enctype` attribute
11934 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11935 */
11936 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11937 var action;
11938
11939 // Configuration initialization
11940 config = config || {};
11941
11942 // Parent constructor
11943 OO.ui.FormLayout.parent.call( this, config );
11944
11945 // Mixin constructors
11946 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11947
11948 // Events
11949 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11950
11951 // Make sure the action is safe
11952 action = config.action;
11953 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11954 action = './' + action;
11955 }
11956
11957 // Initialization
11958 this.$element
11959 .addClass( 'oo-ui-formLayout' )
11960 .attr( {
11961 method: config.method,
11962 action: action,
11963 enctype: config.enctype
11964 } );
11965 if ( Array.isArray( config.items ) ) {
11966 this.addItems( config.items );
11967 }
11968 };
11969
11970 /* Setup */
11971
11972 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11973 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11974
11975 /* Events */
11976
11977 /**
11978 * A 'submit' event is emitted when the form is submitted.
11979 *
11980 * @event submit
11981 */
11982
11983 /* Static Properties */
11984
11985 /**
11986 * @static
11987 * @inheritdoc
11988 */
11989 OO.ui.FormLayout.static.tagName = 'form';
11990
11991 /* Methods */
11992
11993 /**
11994 * Handle form submit events.
11995 *
11996 * @private
11997 * @param {jQuery.Event} e Submit event
11998 * @fires submit
11999 */
12000 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12001 if ( this.emit( 'submit' ) ) {
12002 return false;
12003 }
12004 };
12005
12006 /**
12007 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12008 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12009 *
12010 * @example
12011 * // Example of a panel layout
12012 * var panel = new OO.ui.PanelLayout( {
12013 * expanded: false,
12014 * framed: true,
12015 * padded: true,
12016 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12017 * } );
12018 * $( 'body' ).append( panel.$element );
12019 *
12020 * @class
12021 * @extends OO.ui.Layout
12022 *
12023 * @constructor
12024 * @param {Object} [config] Configuration options
12025 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12026 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12027 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12028 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12029 */
12030 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12031 // Configuration initialization
12032 config = $.extend( {
12033 scrollable: false,
12034 padded: false,
12035 expanded: true,
12036 framed: false
12037 }, config );
12038
12039 // Parent constructor
12040 OO.ui.PanelLayout.parent.call( this, config );
12041
12042 // Initialization
12043 this.$element.addClass( 'oo-ui-panelLayout' );
12044 if ( config.scrollable ) {
12045 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12046 }
12047 if ( config.padded ) {
12048 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12049 }
12050 if ( config.expanded ) {
12051 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12052 }
12053 if ( config.framed ) {
12054 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12055 }
12056 };
12057
12058 /* Setup */
12059
12060 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12061
12062 /* Methods */
12063
12064 /**
12065 * Focus the panel layout
12066 *
12067 * The default implementation just focuses the first focusable element in the panel
12068 */
12069 OO.ui.PanelLayout.prototype.focus = function () {
12070 OO.ui.findFocusable( this.$element ).focus();
12071 };
12072
12073 /**
12074 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12075 * items), with small margins between them. Convenient when you need to put a number of block-level
12076 * widgets on a single line next to each other.
12077 *
12078 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12079 *
12080 * @example
12081 * // HorizontalLayout with a text input and a label
12082 * var layout = new OO.ui.HorizontalLayout( {
12083 * items: [
12084 * new OO.ui.LabelWidget( { label: 'Label' } ),
12085 * new OO.ui.TextInputWidget( { value: 'Text' } )
12086 * ]
12087 * } );
12088 * $( 'body' ).append( layout.$element );
12089 *
12090 * @class
12091 * @extends OO.ui.Layout
12092 * @mixins OO.ui.mixin.GroupElement
12093 *
12094 * @constructor
12095 * @param {Object} [config] Configuration options
12096 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12097 */
12098 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12099 // Configuration initialization
12100 config = config || {};
12101
12102 // Parent constructor
12103 OO.ui.HorizontalLayout.parent.call( this, config );
12104
12105 // Mixin constructors
12106 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12107
12108 // Initialization
12109 this.$element.addClass( 'oo-ui-horizontalLayout' );
12110 if ( Array.isArray( config.items ) ) {
12111 this.addItems( config.items );
12112 }
12113 };
12114
12115 /* Setup */
12116
12117 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12118 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12119
12120 /**
12121 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12122 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12123 * (to adjust the value in increments) to allow the user to enter a number.
12124 *
12125 * @example
12126 * // Example: A NumberInputWidget.
12127 * var numberInput = new OO.ui.NumberInputWidget( {
12128 * label: 'NumberInputWidget',
12129 * input: { value: 5 },
12130 * min: 1,
12131 * max: 10
12132 * } );
12133 * $( 'body' ).append( numberInput.$element );
12134 *
12135 * @class
12136 * @extends OO.ui.TextInputWidget
12137 *
12138 * @constructor
12139 * @param {Object} [config] Configuration options
12140 * @cfg {Object} [minusButton] Configuration options to pass to the
12141 * {@link OO.ui.ButtonWidget decrementing button widget}.
12142 * @cfg {Object} [plusButton] Configuration options to pass to the
12143 * {@link OO.ui.ButtonWidget incrementing button widget}.
12144 * @cfg {number} [min=-Infinity] Minimum allowed value
12145 * @cfg {number} [max=Infinity] Maximum allowed value
12146 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12147 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12148 * Defaults to `step` if specified, otherwise `1`.
12149 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12150 * Defaults to 10 times `buttonStep`.
12151 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12152 */
12153 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12154 var $field = $( '<div>' )
12155 .addClass( 'oo-ui-numberInputWidget-field' );
12156
12157 // Configuration initialization
12158 config = $.extend( {
12159 min: -Infinity,
12160 max: Infinity,
12161 showButtons: true
12162 }, config );
12163
12164 // For backward compatibility
12165 $.extend( config, config.input );
12166 this.input = this;
12167
12168 // Parent constructor
12169 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12170 type: 'number'
12171 } ) );
12172
12173 if ( config.showButtons ) {
12174 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12175 {
12176 disabled: this.isDisabled(),
12177 tabIndex: -1,
12178 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12179 icon: 'subtract'
12180 },
12181 config.minusButton
12182 ) );
12183 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12184 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12185 {
12186 disabled: this.isDisabled(),
12187 tabIndex: -1,
12188 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12189 icon: 'add'
12190 },
12191 config.plusButton
12192 ) );
12193 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12194 }
12195
12196 // Events
12197 this.$input.on( {
12198 keydown: this.onKeyDown.bind( this ),
12199 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12200 } );
12201 if ( config.showButtons ) {
12202 this.plusButton.connect( this, {
12203 click: [ 'onButtonClick', +1 ]
12204 } );
12205 this.minusButton.connect( this, {
12206 click: [ 'onButtonClick', -1 ]
12207 } );
12208 }
12209
12210 // Build the field
12211 $field.append( this.$input );
12212 if ( config.showButtons ) {
12213 $field
12214 .prepend( this.minusButton.$element )
12215 .append( this.plusButton.$element );
12216 }
12217
12218 // Initialization
12219 if ( config.allowInteger || config.isInteger ) {
12220 // Backward compatibility
12221 config.step = 1;
12222 }
12223 this.setRange( config.min, config.max );
12224 this.setStep( config.buttonStep, config.pageStep, config.step );
12225 // Set the validation method after we set step and range
12226 // so that it doesn't immediately call setValidityFlag
12227 this.setValidation( this.validateNumber.bind( this ) );
12228
12229 this.$element
12230 .addClass( 'oo-ui-numberInputWidget' )
12231 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12232 .append( $field );
12233 };
12234
12235 /* Setup */
12236
12237 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12238
12239 /* Methods */
12240
12241 // Backward compatibility
12242 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12243 this.setStep( flag ? 1 : null );
12244 };
12245 // Backward compatibility
12246 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12247
12248 // Backward compatibility
12249 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12250 return this.step === 1;
12251 };
12252 // Backward compatibility
12253 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12254
12255 /**
12256 * Set the range of allowed values
12257 *
12258 * @param {number} min Minimum allowed value
12259 * @param {number} max Maximum allowed value
12260 */
12261 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12262 if ( min > max ) {
12263 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12264 }
12265 this.min = min;
12266 this.max = max;
12267 this.$input.attr( 'min', this.min );
12268 this.$input.attr( 'max', this.max );
12269 this.setValidityFlag();
12270 };
12271
12272 /**
12273 * Get the current range
12274 *
12275 * @return {number[]} Minimum and maximum values
12276 */
12277 OO.ui.NumberInputWidget.prototype.getRange = function () {
12278 return [ this.min, this.max ];
12279 };
12280
12281 /**
12282 * Set the stepping deltas
12283 *
12284 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12285 * Defaults to `step` if specified, otherwise `1`.
12286 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12287 * Defaults to 10 times `buttonStep`.
12288 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12289 */
12290 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12291 if ( buttonStep === undefined ) {
12292 buttonStep = step || 1;
12293 }
12294 if ( pageStep === undefined ) {
12295 pageStep = 10 * buttonStep;
12296 }
12297 if ( step !== null && step <= 0 ) {
12298 throw new Error( 'Step value, if given, must be positive' );
12299 }
12300 if ( buttonStep <= 0 ) {
12301 throw new Error( 'Button step value must be positive' );
12302 }
12303 if ( pageStep <= 0 ) {
12304 throw new Error( 'Page step value must be positive' );
12305 }
12306 this.step = step;
12307 this.buttonStep = buttonStep;
12308 this.pageStep = pageStep;
12309 this.$input.attr( 'step', this.step || 'any' );
12310 this.setValidityFlag();
12311 };
12312
12313 /**
12314 * @inheritdoc
12315 */
12316 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12317 if ( value === '' ) {
12318 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12319 // so here we make sure an 'empty' value is actually displayed as such.
12320 this.$input.val( '' );
12321 }
12322 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12323 };
12324
12325 /**
12326 * Get the current stepping values
12327 *
12328 * @return {number[]} Button step, page step, and validity step
12329 */
12330 OO.ui.NumberInputWidget.prototype.getStep = function () {
12331 return [ this.buttonStep, this.pageStep, this.step ];
12332 };
12333
12334 /**
12335 * Get the current value of the widget as a number
12336 *
12337 * @return {number} May be NaN, or an invalid number
12338 */
12339 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12340 return +this.getValue();
12341 };
12342
12343 /**
12344 * Adjust the value of the widget
12345 *
12346 * @param {number} delta Adjustment amount
12347 */
12348 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12349 var n, v = this.getNumericValue();
12350
12351 delta = +delta;
12352 if ( isNaN( delta ) || !isFinite( delta ) ) {
12353 throw new Error( 'Delta must be a finite number' );
12354 }
12355
12356 if ( isNaN( v ) ) {
12357 n = 0;
12358 } else {
12359 n = v + delta;
12360 n = Math.max( Math.min( n, this.max ), this.min );
12361 if ( this.step ) {
12362 n = Math.round( n / this.step ) * this.step;
12363 }
12364 }
12365
12366 if ( n !== v ) {
12367 this.setValue( n );
12368 }
12369 };
12370 /**
12371 * Validate input
12372 *
12373 * @private
12374 * @param {string} value Field value
12375 * @return {boolean}
12376 */
12377 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12378 var n = +value;
12379 if ( value === '' ) {
12380 return !this.isRequired();
12381 }
12382
12383 if ( isNaN( n ) || !isFinite( n ) ) {
12384 return false;
12385 }
12386
12387 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12388 return false;
12389 }
12390
12391 if ( n < this.min || n > this.max ) {
12392 return false;
12393 }
12394
12395 return true;
12396 };
12397
12398 /**
12399 * Handle mouse click events.
12400 *
12401 * @private
12402 * @param {number} dir +1 or -1
12403 */
12404 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12405 this.adjustValue( dir * this.buttonStep );
12406 };
12407
12408 /**
12409 * Handle mouse wheel events.
12410 *
12411 * @private
12412 * @param {jQuery.Event} event
12413 */
12414 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12415 var delta = 0;
12416
12417 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12418 // Standard 'wheel' event
12419 if ( event.originalEvent.deltaMode !== undefined ) {
12420 this.sawWheelEvent = true;
12421 }
12422 if ( event.originalEvent.deltaY ) {
12423 delta = -event.originalEvent.deltaY;
12424 } else if ( event.originalEvent.deltaX ) {
12425 delta = event.originalEvent.deltaX;
12426 }
12427
12428 // Non-standard events
12429 if ( !this.sawWheelEvent ) {
12430 if ( event.originalEvent.wheelDeltaX ) {
12431 delta = -event.originalEvent.wheelDeltaX;
12432 } else if ( event.originalEvent.wheelDeltaY ) {
12433 delta = event.originalEvent.wheelDeltaY;
12434 } else if ( event.originalEvent.wheelDelta ) {
12435 delta = event.originalEvent.wheelDelta;
12436 } else if ( event.originalEvent.detail ) {
12437 delta = -event.originalEvent.detail;
12438 }
12439 }
12440
12441 if ( delta ) {
12442 delta = delta < 0 ? -1 : 1;
12443 this.adjustValue( delta * this.buttonStep );
12444 }
12445
12446 return false;
12447 }
12448 };
12449
12450 /**
12451 * Handle key down events.
12452 *
12453 * @private
12454 * @param {jQuery.Event} e Key down event
12455 */
12456 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12457 if ( !this.isDisabled() ) {
12458 switch ( e.which ) {
12459 case OO.ui.Keys.UP:
12460 this.adjustValue( this.buttonStep );
12461 return false;
12462 case OO.ui.Keys.DOWN:
12463 this.adjustValue( -this.buttonStep );
12464 return false;
12465 case OO.ui.Keys.PAGEUP:
12466 this.adjustValue( this.pageStep );
12467 return false;
12468 case OO.ui.Keys.PAGEDOWN:
12469 this.adjustValue( -this.pageStep );
12470 return false;
12471 }
12472 }
12473 };
12474
12475 /**
12476 * @inheritdoc
12477 */
12478 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12479 // Parent method
12480 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12481
12482 if ( this.minusButton ) {
12483 this.minusButton.setDisabled( this.isDisabled() );
12484 }
12485 if ( this.plusButton ) {
12486 this.plusButton.setDisabled( this.isDisabled() );
12487 }
12488
12489 return this;
12490 };
12491
12492 }( OO ) );
12493
12494 //# sourceMappingURL=oojs-ui-core.js.map.json