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