Merge "Add password policy setting `suggestChangeOnLogin`"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.30.4
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2019 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2019-03-07T09:14:18Z
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,
215 * otherwise only match descendants
216 * @return {boolean} The node is in the list of target nodes
217 */
218 OO.ui.contains = function ( containers, contained, matchContainers ) {
219 var i;
220 if ( !Array.isArray( containers ) ) {
221 containers = [ containers ];
222 }
223 for ( i = containers.length - 1; i >= 0; i-- ) {
224 if (
225 ( matchContainers && contained === containers[ i ] ) ||
226 $.contains( containers[ i ], contained )
227 ) {
228 return true;
229 }
230 }
231 return false;
232 };
233
234 /**
235 * Return a function, that, as long as it continues to be invoked, will not
236 * be triggered. The function will be called after it stops being called for
237 * N milliseconds. If `immediate` is passed, trigger the function on the
238 * leading edge, instead of the trailing.
239 *
240 * Ported from: http://underscorejs.org/underscore.js
241 *
242 * @param {Function} func Function to debounce
243 * @param {number} [wait=0] Wait period in milliseconds
244 * @param {boolean} [immediate] Trigger on leading edge
245 * @return {Function} Debounced function
246 */
247 OO.ui.debounce = function ( func, wait, immediate ) {
248 var timeout;
249 return function () {
250 var context = this,
251 args = arguments,
252 later = function () {
253 timeout = null;
254 if ( !immediate ) {
255 func.apply( context, args );
256 }
257 };
258 if ( immediate && !timeout ) {
259 func.apply( context, args );
260 }
261 if ( !timeout || wait ) {
262 clearTimeout( timeout );
263 timeout = setTimeout( later, wait );
264 }
265 };
266 };
267
268 /**
269 * Puts a console warning with provided message.
270 *
271 * @param {string} message Message
272 */
273 OO.ui.warnDeprecation = function ( message ) {
274 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
275 // eslint-disable-next-line no-console
276 console.warn( message );
277 }
278 };
279
280 /**
281 * Returns a function, that, when invoked, will only be triggered at most once
282 * during a given window of time. If called again during that window, it will
283 * wait until the window ends and then trigger itself again.
284 *
285 * As it's not knowable to the caller whether the function will actually run
286 * when the wrapper is called, return values from the function are entirely
287 * discarded.
288 *
289 * @param {Function} func Function to throttle
290 * @param {number} wait Throttle window length, in milliseconds
291 * @return {Function} Throttled function
292 */
293 OO.ui.throttle = function ( func, wait ) {
294 var context, args, timeout,
295 previous = 0,
296 run = function () {
297 timeout = null;
298 previous = OO.ui.now();
299 func.apply( context, args );
300 };
301 return function () {
302 // Check how long it's been since the last time the function was
303 // called, and whether it's more or less than the requested throttle
304 // period. If it's less, run the function immediately. If it's more,
305 // set a timeout for the remaining time -- but don't replace an
306 // existing timeout, since that'd indefinitely prolong the wait.
307 var remaining = wait - ( OO.ui.now() - previous );
308 context = this;
309 args = arguments;
310 if ( remaining <= 0 ) {
311 // Note: unless wait was ridiculously large, this means we'll
312 // automatically run the first time the function was called in a
313 // given period. (If you provide a wait period larger than the
314 // current Unix timestamp, you *deserve* unexpected behavior.)
315 clearTimeout( timeout );
316 run();
317 } else if ( !timeout ) {
318 timeout = setTimeout( run, remaining );
319 }
320 };
321 };
322
323 /**
324 * A (possibly faster) way to get the current timestamp as an integer.
325 *
326 * @return {number} Current timestamp, in milliseconds since the Unix epoch
327 */
328 OO.ui.now = Date.now || function () {
329 return new Date().getTime();
330 };
331
332 /**
333 * Reconstitute a JavaScript object corresponding to a widget created by
334 * the PHP implementation.
335 *
336 * This is an alias for `OO.ui.Element.static.infuse()`.
337 *
338 * @param {string|HTMLElement|jQuery} idOrNode
339 * A DOM id (if a string) or node for the widget to infuse.
340 * @param {Object} [config] Configuration options
341 * @return {OO.ui.Element}
342 * The `OO.ui.Element` corresponding to this (infusable) document node.
343 */
344 OO.ui.infuse = function ( idOrNode, config ) {
345 return OO.ui.Element.static.infuse( idOrNode, config );
346 };
347
348 ( function () {
349 /**
350 * Message store for the default implementation of OO.ui.msg.
351 *
352 * Environments that provide a localization system should not use this, but should override
353 * OO.ui.msg altogether.
354 *
355 * @private
356 */
357 var messages = {
358 // Tool tip for a button that moves items in a list down one place
359 'ooui-outline-control-move-down': 'Move item down',
360 // Tool tip for a button that moves items in a list up one place
361 'ooui-outline-control-move-up': 'Move item up',
362 // Tool tip for a button that removes items from a list
363 'ooui-outline-control-remove': 'Remove item',
364 // Label for the toolbar group that contains a list of all other available tools
365 'ooui-toolbar-more': 'More',
366 // Label for the fake tool that expands the full list of tools in a toolbar group
367 'ooui-toolgroup-expand': 'More',
368 // Label for the fake tool that collapses the full list of tools in a toolbar group
369 'ooui-toolgroup-collapse': 'Fewer',
370 // Default label for the tooltip for the button that removes a tag item
371 'ooui-item-remove': 'Remove',
372 // Default label for the accept button of a confirmation dialog
373 'ooui-dialog-message-accept': 'OK',
374 // Default label for the reject button of a confirmation dialog
375 'ooui-dialog-message-reject': 'Cancel',
376 // Title for process dialog error description
377 'ooui-dialog-process-error': 'Something went wrong',
378 // Label for process dialog dismiss error button, visible when describing errors
379 'ooui-dialog-process-dismiss': 'Dismiss',
380 // Label for process dialog retry action button, visible when describing only recoverable
381 // errors
382 'ooui-dialog-process-retry': 'Try again',
383 // Label for process dialog retry action button, visible when describing only warnings
384 'ooui-dialog-process-continue': 'Continue',
385 // Label for button in combobox input that triggers its dropdown
386 'ooui-combobox-button-label': 'Dropdown for combobox',
387 // Label for the file selection widget's select file button
388 'ooui-selectfile-button-select': 'Select a file',
389 // Label for the file selection widget if file selection is not supported
390 'ooui-selectfile-not-supported': 'File selection is not supported',
391 // Label for the file selection widget when no file is currently selected
392 'ooui-selectfile-placeholder': 'No file is selected',
393 // Label for the file selection widget's drop target
394 'ooui-selectfile-dragdrop-placeholder': 'Drop file here',
395 // Label for the help icon attached to a form field
396 'ooui-field-help': 'Help'
397 };
398
399 /**
400 * Get a localized message.
401 *
402 * After the message key, message parameters may optionally be passed. In the default
403 * implementation, any occurrences of $1 are replaced with the first parameter, $2 with the
404 * second parameter, etc.
405 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long
406 * as they support unnamed, ordered message parameters.
407 *
408 * In environments that provide a localization system, this function should be overridden to
409 * return the message translated in the user's language. The default implementation always
410 * returns English messages. An example of doing this with
411 * [jQuery.i18n](https://github.com/wikimedia/jquery.i18n) follows.
412 *
413 * @example
414 * var i, iLen, button,
415 * messagePath = 'oojs-ui/dist/i18n/',
416 * languages = [ $.i18n().locale, 'ur', 'en' ],
417 * languageMap = {};
418 *
419 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
420 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
421 * }
422 *
423 * $.i18n().load( languageMap ).done( function() {
424 * // Replace the built-in `msg` only once we've loaded the internationalization.
425 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
426 * // you put off creating any widgets until this promise is complete, no English
427 * // will be displayed.
428 * OO.ui.msg = $.i18n;
429 *
430 * // A button displaying "OK" in the default locale
431 * button = new OO.ui.ButtonWidget( {
432 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
433 * icon: 'check'
434 * } );
435 * $( document.body ).append( button.$element );
436 *
437 * // A button displaying "OK" in Urdu
438 * $.i18n().locale = 'ur';
439 * button = new OO.ui.ButtonWidget( {
440 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
441 * icon: 'check'
442 * } );
443 * $( document.body ).append( button.$element );
444 * } );
445 *
446 * @param {string} key Message key
447 * @param {...Mixed} [params] Message parameters
448 * @return {string} Translated message with parameters substituted
449 */
450 OO.ui.msg = function ( key ) {
451 var message = messages[ key ],
452 params = Array.prototype.slice.call( arguments, 1 );
453 if ( typeof message === 'string' ) {
454 // Perform $1 substitution
455 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
456 var i = parseInt( n, 10 );
457 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
458 } );
459 } else {
460 // Return placeholder if message not found
461 message = '[' + key + ']';
462 }
463 return message;
464 };
465 }() );
466
467 /**
468 * Package a message and arguments for deferred resolution.
469 *
470 * Use this when you are statically specifying a message and the message may not yet be present.
471 *
472 * @param {string} key Message key
473 * @param {...Mixed} [params] Message parameters
474 * @return {Function} Function that returns the resolved message when executed
475 */
476 OO.ui.deferMsg = function () {
477 var args = arguments;
478 return function () {
479 return OO.ui.msg.apply( OO.ui, args );
480 };
481 };
482
483 /**
484 * Resolve a message.
485 *
486 * If the message is a function it will be executed, otherwise it will pass through directly.
487 *
488 * @param {Function|string} msg Deferred message, or message text
489 * @return {string} Resolved message
490 */
491 OO.ui.resolveMsg = function ( msg ) {
492 if ( typeof msg === 'function' ) {
493 return msg();
494 }
495 return msg;
496 };
497
498 /**
499 * @param {string} url
500 * @return {boolean}
501 */
502 OO.ui.isSafeUrl = function ( url ) {
503 // Keep this function in sync with php/Tag.php
504 var i, protocolWhitelist;
505
506 function stringStartsWith( haystack, needle ) {
507 return haystack.substr( 0, needle.length ) === needle;
508 }
509
510 protocolWhitelist = [
511 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
512 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
513 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
514 ];
515
516 if ( url === '' ) {
517 return true;
518 }
519
520 for ( i = 0; i < protocolWhitelist.length; i++ ) {
521 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
522 return true;
523 }
524 }
525
526 // This matches '//' too
527 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
528 return true;
529 }
530 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
531 return true;
532 }
533
534 return false;
535 };
536
537 /**
538 * Check if the user has a 'mobile' device.
539 *
540 * For our purposes this means the user is primarily using an
541 * on-screen keyboard, touch input instead of a mouse and may
542 * have a physically small display.
543 *
544 * It is left up to implementors to decide how to compute this
545 * so the default implementation always returns false.
546 *
547 * @return {boolean} User is on a mobile device
548 */
549 OO.ui.isMobile = function () {
550 return false;
551 };
552
553 /**
554 * Get the additional spacing that should be taken into account when displaying elements that are
555 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
556 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
557 *
558 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
559 * the extra spacing from that edge of viewport (in pixels)
560 */
561 OO.ui.getViewportSpacing = function () {
562 return {
563 top: 0,
564 right: 0,
565 bottom: 0,
566 left: 0
567 };
568 };
569
570 /**
571 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
572 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
573 *
574 * @return {jQuery} Default overlay node
575 */
576 OO.ui.getDefaultOverlay = function () {
577 if ( !OO.ui.$defaultOverlay ) {
578 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
579 $( document.body ).append( OO.ui.$defaultOverlay );
580 }
581 return OO.ui.$defaultOverlay;
582 };
583
584 /*!
585 * Mixin namespace.
586 */
587
588 /**
589 * Namespace for OOUI mixins.
590 *
591 * Mixins are named according to the type of object they are intended to
592 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
593 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
594 * is intended to be mixed in to an instance of OO.ui.Widget.
595 *
596 * @class
597 * @singleton
598 */
599 OO.ui.mixin = {};
600
601 /**
602 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
603 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not
604 * have events connected to them and can't be interacted with.
605 *
606 * @abstract
607 * @class
608 *
609 * @constructor
610 * @param {Object} [config] Configuration options
611 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are
612 * added to the top level (e.g., the outermost div) of the element. See the
613 * [OOUI documentation on MediaWiki][2] for an example.
614 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
615 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
616 * @cfg {string} [text] Text to insert
617 * @cfg {Array} [content] An array of content elements to append (after #text).
618 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
619 * Instances of OO.ui.Element will have their $element appended.
620 * @cfg {jQuery} [$content] Content elements to append (after #text).
621 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
622 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number,
623 * array, object).
624 * Data can also be specified with the #setData method.
625 */
626 OO.ui.Element = function OoUiElement( config ) {
627 if ( OO.ui.isDemo ) {
628 this.initialConfig = config;
629 }
630 // Configuration initialization
631 config = config || {};
632
633 // Properties
634 this.$ = function () {
635 OO.ui.warnDeprecation( 'this.$ is deprecated, use global $ instead' );
636 return $.apply( this, arguments );
637 };
638 this.elementId = null;
639 this.visible = true;
640 this.data = config.data;
641 this.$element = config.$element ||
642 $( document.createElement( this.getTagName() ) );
643 this.elementGroup = null;
644
645 // Initialization
646 if ( Array.isArray( config.classes ) ) {
647 this.$element.addClass( config.classes );
648 }
649 if ( config.id ) {
650 this.setElementId( config.id );
651 }
652 if ( config.text ) {
653 this.$element.text( config.text );
654 }
655 if ( config.content ) {
656 // The `content` property treats plain strings as text; use an
657 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
658 // appropriate $element appended.
659 this.$element.append( config.content.map( function ( v ) {
660 if ( typeof v === 'string' ) {
661 // Escape string so it is properly represented in HTML.
662 return document.createTextNode( v );
663 } else if ( v instanceof OO.ui.HtmlSnippet ) {
664 // Bypass escaping.
665 return v.toString();
666 } else if ( v instanceof OO.ui.Element ) {
667 return v.$element;
668 }
669 return v;
670 } ) );
671 }
672 if ( config.$content ) {
673 // The `$content` property treats plain strings as HTML.
674 this.$element.append( config.$content );
675 }
676 };
677
678 /* Setup */
679
680 OO.initClass( OO.ui.Element );
681
682 /* Static Properties */
683
684 /**
685 * The name of the HTML tag used by the element.
686 *
687 * The static value may be ignored if the #getTagName method is overridden.
688 *
689 * @static
690 * @inheritable
691 * @property {string}
692 */
693 OO.ui.Element.static.tagName = 'div';
694
695 /* Static Methods */
696
697 /**
698 * Reconstitute a JavaScript object corresponding to a widget created
699 * by the PHP implementation.
700 *
701 * @param {string|HTMLElement|jQuery} idOrNode
702 * A DOM id (if a string) or node for the widget to infuse.
703 * @param {Object} [config] Configuration options
704 * @return {OO.ui.Element}
705 * The `OO.ui.Element` corresponding to this (infusable) document node.
706 * For `Tag` objects emitted on the HTML side (used occasionally for content)
707 * the value returned is a newly-created Element wrapping around the existing
708 * DOM node.
709 */
710 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
711 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
712
713 if ( typeof idOrNode === 'string' ) {
714 // IDs deprecated since 0.29.7
715 OO.ui.warnDeprecation(
716 'Passing a string ID to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
717 );
718 }
719 // Verify that the type matches up.
720 // FIXME: uncomment after T89721 is fixed, see T90929.
721 /*
722 if ( !( obj instanceof this['class'] ) ) {
723 throw new Error( 'Infusion type mismatch!' );
724 }
725 */
726 return obj;
727 };
728
729 /**
730 * Implementation helper for `infuse`; skips the type check and has an
731 * extra property so that only the top-level invocation touches the DOM.
732 *
733 * @private
734 * @param {string|HTMLElement|jQuery} idOrNode
735 * @param {Object} [config] Configuration options
736 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
737 * when the top-level widget of this infusion is inserted into DOM,
738 * replacing the original node; only used internally.
739 * @return {OO.ui.Element}
740 */
741 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
742 // look for a cached result of a previous infusion.
743 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
744 if ( typeof idOrNode === 'string' ) {
745 id = idOrNode;
746 $elem = $( document.getElementById( id ) );
747 } else {
748 $elem = $( idOrNode );
749 id = $elem.attr( 'id' );
750 }
751 if ( !$elem.length ) {
752 if ( typeof idOrNode === 'string' ) {
753 error = 'Widget not found: ' + idOrNode;
754 } else if ( idOrNode && idOrNode.selector ) {
755 error = 'Widget not found: ' + idOrNode.selector;
756 } else {
757 error = 'Widget not found';
758 }
759 throw new Error( error );
760 }
761 if ( $elem[ 0 ].oouiInfused ) {
762 $elem = $elem[ 0 ].oouiInfused;
763 }
764 data = $elem.data( 'ooui-infused' );
765 if ( data ) {
766 // cached!
767 if ( data === true ) {
768 throw new Error( 'Circular dependency! ' + id );
769 }
770 if ( domPromise ) {
771 // Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
772 state = data.constructor.static.gatherPreInfuseState( $elem, data );
773 // Restore dynamic state after the new element is re-inserted into DOM under
774 // infused parent.
775 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
776 infusedChildren = $elem.data( 'ooui-infused-children' );
777 if ( infusedChildren && infusedChildren.length ) {
778 infusedChildren.forEach( function ( data ) {
779 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
780 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
781 } );
782 }
783 }
784 return data;
785 }
786 data = $elem.attr( 'data-ooui' );
787 if ( !data ) {
788 throw new Error( 'No infusion data found: ' + id );
789 }
790 try {
791 data = JSON.parse( data );
792 } catch ( _ ) {
793 data = null;
794 }
795 if ( !( data && data._ ) ) {
796 throw new Error( 'No valid infusion data found: ' + id );
797 }
798 if ( data._ === 'Tag' ) {
799 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
800 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
801 }
802 parts = data._.split( '.' );
803 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
804 if ( cls === undefined ) {
805 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
806 }
807
808 // Verify that we're creating an OO.ui.Element instance
809 parent = cls.parent;
810
811 while ( parent !== undefined ) {
812 if ( parent === OO.ui.Element ) {
813 // Safe
814 break;
815 }
816
817 parent = parent.parent;
818 }
819
820 if ( parent !== OO.ui.Element ) {
821 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
822 }
823
824 if ( !domPromise ) {
825 top = $.Deferred();
826 domPromise = top.promise();
827 }
828 $elem.data( 'ooui-infused', true ); // prevent loops
829 data.id = id; // implicit
830 infusedChildren = [];
831 data = OO.copy( data, null, function deserialize( value ) {
832 var infused;
833 if ( OO.isPlainObject( value ) ) {
834 if ( value.tag ) {
835 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
836 infusedChildren.push( infused );
837 // Flatten the structure
838 infusedChildren.push.apply(
839 infusedChildren,
840 infused.$element.data( 'ooui-infused-children' ) || []
841 );
842 infused.$element.removeData( 'ooui-infused-children' );
843 return infused;
844 }
845 if ( value.html !== undefined ) {
846 return new OO.ui.HtmlSnippet( value.html );
847 }
848 }
849 } );
850 // allow widgets to reuse parts of the DOM
851 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
852 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
853 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
854 // rebuild widget
855 // eslint-disable-next-line new-cap
856 obj = new cls( $.extend( {}, config, data ) );
857 // If anyone is holding a reference to the old DOM element,
858 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
859 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
860 $elem[ 0 ].oouiInfused = obj.$element;
861 // now replace old DOM with this new DOM.
862 if ( top ) {
863 // An efficient constructor might be able to reuse the entire DOM tree of the original
864 // element, so only mutate the DOM if we need to.
865 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
866 $elem.replaceWith( obj.$element );
867 }
868 top.resolve();
869 }
870 obj.$element.data( 'ooui-infused', obj );
871 obj.$element.data( 'ooui-infused-children', infusedChildren );
872 // set the 'data-ooui' attribute so we can identify infused widgets
873 obj.$element.attr( 'data-ooui', '' );
874 // restore dynamic state after the new element is inserted into DOM
875 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
876 return obj;
877 };
878
879 /**
880 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
881 *
882 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
883 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
884 * constructor, which will be given the enhanced config.
885 *
886 * @protected
887 * @param {HTMLElement} node
888 * @param {Object} config
889 * @return {Object}
890 */
891 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
892 return config;
893 };
894
895 /**
896 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
897 * node (and its children) that represent an Element of the same class and the given configuration,
898 * generated by the PHP implementation.
899 *
900 * This method is called just before `node` is detached from the DOM. The return value of this
901 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
902 * is inserted into DOM to replace `node`.
903 *
904 * @protected
905 * @param {HTMLElement} node
906 * @param {Object} config
907 * @return {Object}
908 */
909 OO.ui.Element.static.gatherPreInfuseState = function () {
910 return {};
911 };
912
913 /**
914 * Get a jQuery function within a specific document.
915 *
916 * @static
917 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
918 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
919 * not in an iframe
920 * @return {Function} Bound jQuery function
921 */
922 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
923 function wrapper( selector ) {
924 return $( selector, wrapper.context );
925 }
926
927 wrapper.context = this.getDocument( context );
928
929 if ( $iframe ) {
930 wrapper.$iframe = $iframe;
931 }
932
933 return wrapper;
934 };
935
936 /**
937 * Get the document of an element.
938 *
939 * @static
940 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
941 * @return {HTMLDocument|null} Document object
942 */
943 OO.ui.Element.static.getDocument = function ( obj ) {
944 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
945 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
946 // Empty jQuery selections might have a context
947 obj.context ||
948 // HTMLElement
949 obj.ownerDocument ||
950 // Window
951 obj.document ||
952 // HTMLDocument
953 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
954 null;
955 };
956
957 /**
958 * Get the window of an element or document.
959 *
960 * @static
961 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
962 * @return {Window} Window object
963 */
964 OO.ui.Element.static.getWindow = function ( obj ) {
965 var doc = this.getDocument( obj );
966 return doc.defaultView;
967 };
968
969 /**
970 * Get the direction of an element or document.
971 *
972 * @static
973 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
974 * @return {string} Text direction, either 'ltr' or 'rtl'
975 */
976 OO.ui.Element.static.getDir = function ( obj ) {
977 var isDoc, isWin;
978
979 if ( obj instanceof $ ) {
980 obj = obj[ 0 ];
981 }
982 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
983 isWin = obj.document !== undefined;
984 if ( isDoc || isWin ) {
985 if ( isWin ) {
986 obj = obj.document;
987 }
988 obj = obj.body;
989 }
990 return $( obj ).css( 'direction' );
991 };
992
993 /**
994 * Get the offset between two frames.
995 *
996 * TODO: Make this function not use recursion.
997 *
998 * @static
999 * @param {Window} from Window of the child frame
1000 * @param {Window} [to=window] Window of the parent frame
1001 * @param {Object} [offset] Offset to start with, used internally
1002 * @return {Object} Offset object, containing left and top properties
1003 */
1004 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
1005 var i, len, frames, frame, rect;
1006
1007 if ( !to ) {
1008 to = window;
1009 }
1010 if ( !offset ) {
1011 offset = { top: 0, left: 0 };
1012 }
1013 if ( from.parent === from ) {
1014 return offset;
1015 }
1016
1017 // Get iframe element
1018 frames = from.parent.document.getElementsByTagName( 'iframe' );
1019 for ( i = 0, len = frames.length; i < len; i++ ) {
1020 if ( frames[ i ].contentWindow === from ) {
1021 frame = frames[ i ];
1022 break;
1023 }
1024 }
1025
1026 // Recursively accumulate offset values
1027 if ( frame ) {
1028 rect = frame.getBoundingClientRect();
1029 offset.left += rect.left;
1030 offset.top += rect.top;
1031 if ( from !== to ) {
1032 this.getFrameOffset( from.parent, offset );
1033 }
1034 }
1035 return offset;
1036 };
1037
1038 /**
1039 * Get the offset between two elements.
1040 *
1041 * The two elements may be in a different frame, but in that case the frame $element is in must
1042 * be contained in the frame $anchor is in.
1043 *
1044 * @static
1045 * @param {jQuery} $element Element whose position to get
1046 * @param {jQuery} $anchor Element to get $element's position relative to
1047 * @return {Object} Translated position coordinates, containing top and left properties
1048 */
1049 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1050 var iframe, iframePos,
1051 pos = $element.offset(),
1052 anchorPos = $anchor.offset(),
1053 elementDocument = this.getDocument( $element ),
1054 anchorDocument = this.getDocument( $anchor );
1055
1056 // If $element isn't in the same document as $anchor, traverse up
1057 while ( elementDocument !== anchorDocument ) {
1058 iframe = elementDocument.defaultView.frameElement;
1059 if ( !iframe ) {
1060 throw new Error( '$element frame is not contained in $anchor frame' );
1061 }
1062 iframePos = $( iframe ).offset();
1063 pos.left += iframePos.left;
1064 pos.top += iframePos.top;
1065 elementDocument = iframe.ownerDocument;
1066 }
1067 pos.left -= anchorPos.left;
1068 pos.top -= anchorPos.top;
1069 return pos;
1070 };
1071
1072 /**
1073 * Get element border sizes.
1074 *
1075 * @static
1076 * @param {HTMLElement} el Element to measure
1077 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1078 */
1079 OO.ui.Element.static.getBorders = function ( el ) {
1080 var doc = el.ownerDocument,
1081 win = doc.defaultView,
1082 style = win.getComputedStyle( el, null ),
1083 $el = $( el ),
1084 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1085 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1086 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1087 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1088
1089 return {
1090 top: top,
1091 left: left,
1092 bottom: bottom,
1093 right: right
1094 };
1095 };
1096
1097 /**
1098 * Get dimensions of an element or window.
1099 *
1100 * @static
1101 * @param {HTMLElement|Window} el Element to measure
1102 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1103 */
1104 OO.ui.Element.static.getDimensions = function ( el ) {
1105 var $el, $win,
1106 doc = el.ownerDocument || el.document,
1107 win = doc.defaultView;
1108
1109 if ( win === el || el === doc.documentElement ) {
1110 $win = $( win );
1111 return {
1112 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1113 scroll: {
1114 top: $win.scrollTop(),
1115 left: $win.scrollLeft()
1116 },
1117 scrollbar: { right: 0, bottom: 0 },
1118 rect: {
1119 top: 0,
1120 left: 0,
1121 bottom: $win.innerHeight(),
1122 right: $win.innerWidth()
1123 }
1124 };
1125 } else {
1126 $el = $( el );
1127 return {
1128 borders: this.getBorders( el ),
1129 scroll: {
1130 top: $el.scrollTop(),
1131 left: $el.scrollLeft()
1132 },
1133 scrollbar: {
1134 right: $el.innerWidth() - el.clientWidth,
1135 bottom: $el.innerHeight() - el.clientHeight
1136 },
1137 rect: el.getBoundingClientRect()
1138 };
1139 }
1140 };
1141
1142 /**
1143 * Get the number of pixels that an element's content is scrolled to the left.
1144 *
1145 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1146 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1147 *
1148 * This function smooths out browser inconsistencies (nicely described in the README at
1149 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1150 * with Firefox's 'scrollLeft', which seems the sanest.
1151 *
1152 * @static
1153 * @method
1154 * @param {HTMLElement|Window} el Element to measure
1155 * @return {number} Scroll position from the left.
1156 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1157 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1158 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1159 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1160 */
1161 OO.ui.Element.static.getScrollLeft = ( function () {
1162 var rtlScrollType = null;
1163
1164 function test() {
1165 var $definer = $( '<div>' ).attr( {
1166 dir: 'rtl',
1167 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1168 } ).text( 'ABCD' ),
1169 definer = $definer[ 0 ];
1170
1171 $definer.appendTo( 'body' );
1172 if ( definer.scrollLeft > 0 ) {
1173 // Safari, Chrome
1174 rtlScrollType = 'default';
1175 } else {
1176 definer.scrollLeft = 1;
1177 if ( definer.scrollLeft === 0 ) {
1178 // Firefox, old Opera
1179 rtlScrollType = 'negative';
1180 } else {
1181 // Internet Explorer, Edge
1182 rtlScrollType = 'reverse';
1183 }
1184 }
1185 $definer.remove();
1186 }
1187
1188 return function getScrollLeft( el ) {
1189 var isRoot = el.window === el ||
1190 el === el.ownerDocument.body ||
1191 el === el.ownerDocument.documentElement,
1192 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1193 // All browsers use the correct scroll type ('negative') on the root, so don't
1194 // do any fixups when looking at the root element
1195 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1196
1197 if ( direction === 'rtl' ) {
1198 if ( rtlScrollType === null ) {
1199 test();
1200 }
1201 if ( rtlScrollType === 'reverse' ) {
1202 scrollLeft = -scrollLeft;
1203 } else if ( rtlScrollType === 'default' ) {
1204 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1205 }
1206 }
1207
1208 return scrollLeft;
1209 };
1210 }() );
1211
1212 /**
1213 * Get the root scrollable element of given element's document.
1214 *
1215 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1216 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1217 * lets us use 'body' or 'documentElement' based on what is working.
1218 *
1219 * https://code.google.com/p/chromium/issues/detail?id=303131
1220 *
1221 * @static
1222 * @param {HTMLElement} el Element to find root scrollable parent for
1223 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1224 * depending on browser
1225 */
1226 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1227 var scrollTop, body;
1228
1229 if ( OO.ui.scrollableElement === undefined ) {
1230 body = el.ownerDocument.body;
1231 scrollTop = body.scrollTop;
1232 body.scrollTop = 1;
1233
1234 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1235 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1236 if ( Math.round( body.scrollTop ) === 1 ) {
1237 body.scrollTop = scrollTop;
1238 OO.ui.scrollableElement = 'body';
1239 } else {
1240 OO.ui.scrollableElement = 'documentElement';
1241 }
1242 }
1243
1244 return el.ownerDocument[ OO.ui.scrollableElement ];
1245 };
1246
1247 /**
1248 * Get closest scrollable container.
1249 *
1250 * Traverses up until either a scrollable element or the root is reached, in which case the root
1251 * scrollable element will be returned (see #getRootScrollableElement).
1252 *
1253 * @static
1254 * @param {HTMLElement} el Element to find scrollable container for
1255 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1256 * @return {HTMLElement} Closest scrollable container
1257 */
1258 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1259 var i, val,
1260 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1261 // 'overflow-y' have different values, so we need to check the separate properties.
1262 props = [ 'overflow-x', 'overflow-y' ],
1263 $parent = $( el ).parent();
1264
1265 if ( dimension === 'x' || dimension === 'y' ) {
1266 props = [ 'overflow-' + dimension ];
1267 }
1268
1269 // Special case for the document root (which doesn't really have any scrollable container,
1270 // since it is the ultimate scrollable container, but this is probably saner than null or
1271 // exception).
1272 if ( $( el ).is( 'html, body' ) ) {
1273 return this.getRootScrollableElement( el );
1274 }
1275
1276 while ( $parent.length ) {
1277 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1278 return $parent[ 0 ];
1279 }
1280 i = props.length;
1281 while ( i-- ) {
1282 val = $parent.css( props[ i ] );
1283 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1284 // never be scrolled in that direction, but they can actually be scrolled
1285 // programatically. The user can unintentionally perform a scroll in such case even if
1286 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1287 // when using built-in find functionality.
1288 // This could cause funny issues...
1289 if ( val === 'auto' || val === 'scroll' ) {
1290 return $parent[ 0 ];
1291 }
1292 }
1293 $parent = $parent.parent();
1294 }
1295 // The element is unattached... return something mostly sane
1296 return this.getRootScrollableElement( el );
1297 };
1298
1299 /**
1300 * Scroll element into view.
1301 *
1302 * @static
1303 * @param {HTMLElement} el Element to scroll into view
1304 * @param {Object} [config] Configuration options
1305 * @param {string} [config.duration='fast'] jQuery animation duration value
1306 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1307 * to scroll in both directions
1308 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1309 */
1310 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1311 var position, animations, container, $container, elementDimensions, containerDimensions,
1312 $window,
1313 deferred = $.Deferred();
1314
1315 // Configuration initialization
1316 config = config || {};
1317
1318 animations = {};
1319 container = this.getClosestScrollableContainer( el, config.direction );
1320 $container = $( container );
1321 elementDimensions = this.getDimensions( el );
1322 containerDimensions = this.getDimensions( container );
1323 $window = $( this.getWindow( el ) );
1324
1325 // Compute the element's position relative to the container
1326 if ( $container.is( 'html, body' ) ) {
1327 // If the scrollable container is the root, this is easy
1328 position = {
1329 top: elementDimensions.rect.top,
1330 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1331 left: elementDimensions.rect.left,
1332 right: $window.innerWidth() - elementDimensions.rect.right
1333 };
1334 } else {
1335 // Otherwise, we have to subtract el's coordinates from container's coordinates
1336 position = {
1337 top: elementDimensions.rect.top -
1338 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1339 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1340 containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1341 left: elementDimensions.rect.left -
1342 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1343 right: containerDimensions.rect.right - containerDimensions.borders.right -
1344 containerDimensions.scrollbar.right - elementDimensions.rect.right
1345 };
1346 }
1347
1348 if ( !config.direction || config.direction === 'y' ) {
1349 if ( position.top < 0 ) {
1350 animations.scrollTop = containerDimensions.scroll.top + position.top;
1351 } else if ( position.top > 0 && position.bottom < 0 ) {
1352 animations.scrollTop = containerDimensions.scroll.top +
1353 Math.min( position.top, -position.bottom );
1354 }
1355 }
1356 if ( !config.direction || config.direction === 'x' ) {
1357 if ( position.left < 0 ) {
1358 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1359 } else if ( position.left > 0 && position.right < 0 ) {
1360 animations.scrollLeft = containerDimensions.scroll.left +
1361 Math.min( position.left, -position.right );
1362 }
1363 }
1364 if ( !$.isEmptyObject( animations ) ) {
1365 // eslint-disable-next-line no-jquery/no-animate
1366 $container.stop( true ).animate( animations, config.duration === undefined ?
1367 'fast' : config.duration );
1368 $container.queue( function ( next ) {
1369 deferred.resolve();
1370 next();
1371 } );
1372 } else {
1373 deferred.resolve();
1374 }
1375 return deferred.promise();
1376 };
1377
1378 /**
1379 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1380 * and reserve space for them, because it probably doesn't.
1381 *
1382 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1383 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1384 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1385 * reflow, and then reattach (or show) them back.
1386 *
1387 * @static
1388 * @param {HTMLElement} el Element to reconsider the scrollbars on
1389 */
1390 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1391 var i, len, scrollLeft, scrollTop, nodes = [];
1392 // Save scroll position
1393 scrollLeft = el.scrollLeft;
1394 scrollTop = el.scrollTop;
1395 // Detach all children
1396 while ( el.firstChild ) {
1397 nodes.push( el.firstChild );
1398 el.removeChild( el.firstChild );
1399 }
1400 // Force reflow
1401 // eslint-disable-next-line no-void
1402 void el.offsetHeight;
1403 // Reattach all children
1404 for ( i = 0, len = nodes.length; i < len; i++ ) {
1405 el.appendChild( nodes[ i ] );
1406 }
1407 // Restore scroll position (no-op if scrollbars disappeared)
1408 el.scrollLeft = scrollLeft;
1409 el.scrollTop = scrollTop;
1410 };
1411
1412 /* Methods */
1413
1414 /**
1415 * Toggle visibility of an element.
1416 *
1417 * @param {boolean} [show] Make element visible, omit to toggle visibility
1418 * @fires visible
1419 * @chainable
1420 * @return {OO.ui.Element} The element, for chaining
1421 */
1422 OO.ui.Element.prototype.toggle = function ( show ) {
1423 show = show === undefined ? !this.visible : !!show;
1424
1425 if ( show !== this.isVisible() ) {
1426 this.visible = show;
1427 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1428 this.emit( 'toggle', show );
1429 }
1430
1431 return this;
1432 };
1433
1434 /**
1435 * Check if element is visible.
1436 *
1437 * @return {boolean} element is visible
1438 */
1439 OO.ui.Element.prototype.isVisible = function () {
1440 return this.visible;
1441 };
1442
1443 /**
1444 * Get element data.
1445 *
1446 * @return {Mixed} Element data
1447 */
1448 OO.ui.Element.prototype.getData = function () {
1449 return this.data;
1450 };
1451
1452 /**
1453 * Set element data.
1454 *
1455 * @param {Mixed} data Element data
1456 * @chainable
1457 * @return {OO.ui.Element} The element, for chaining
1458 */
1459 OO.ui.Element.prototype.setData = function ( data ) {
1460 this.data = data;
1461 return this;
1462 };
1463
1464 /**
1465 * Set the element has an 'id' attribute.
1466 *
1467 * @param {string} id
1468 * @chainable
1469 * @return {OO.ui.Element} The element, for chaining
1470 */
1471 OO.ui.Element.prototype.setElementId = function ( id ) {
1472 this.elementId = id;
1473 this.$element.attr( 'id', id );
1474 return this;
1475 };
1476
1477 /**
1478 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1479 * and return its value.
1480 *
1481 * @return {string}
1482 */
1483 OO.ui.Element.prototype.getElementId = function () {
1484 if ( this.elementId === null ) {
1485 this.setElementId( OO.ui.generateElementId() );
1486 }
1487 return this.elementId;
1488 };
1489
1490 /**
1491 * Check if element supports one or more methods.
1492 *
1493 * @param {string|string[]} methods Method or list of methods to check
1494 * @return {boolean} All methods are supported
1495 */
1496 OO.ui.Element.prototype.supports = function ( methods ) {
1497 var i, len,
1498 support = 0;
1499
1500 methods = Array.isArray( methods ) ? methods : [ methods ];
1501 for ( i = 0, len = methods.length; i < len; i++ ) {
1502 if ( typeof this[ methods[ i ] ] === 'function' ) {
1503 support++;
1504 }
1505 }
1506
1507 return methods.length === support;
1508 };
1509
1510 /**
1511 * Update the theme-provided classes.
1512 *
1513 * @localdoc This is called in element mixins and widget classes any time state changes.
1514 * Updating is debounced, minimizing overhead of changing multiple attributes and
1515 * guaranteeing that theme updates do not occur within an element's constructor
1516 */
1517 OO.ui.Element.prototype.updateThemeClasses = function () {
1518 OO.ui.theme.queueUpdateElementClasses( this );
1519 };
1520
1521 /**
1522 * Get the HTML tag name.
1523 *
1524 * Override this method to base the result on instance information.
1525 *
1526 * @return {string} HTML tag name
1527 */
1528 OO.ui.Element.prototype.getTagName = function () {
1529 return this.constructor.static.tagName;
1530 };
1531
1532 /**
1533 * Check if the element is attached to the DOM
1534 *
1535 * @return {boolean} The element is attached to the DOM
1536 */
1537 OO.ui.Element.prototype.isElementAttached = function () {
1538 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1539 };
1540
1541 /**
1542 * Get the DOM document.
1543 *
1544 * @return {HTMLDocument} Document object
1545 */
1546 OO.ui.Element.prototype.getElementDocument = function () {
1547 // Don't cache this in other ways either because subclasses could can change this.$element
1548 return OO.ui.Element.static.getDocument( this.$element );
1549 };
1550
1551 /**
1552 * Get the DOM window.
1553 *
1554 * @return {Window} Window object
1555 */
1556 OO.ui.Element.prototype.getElementWindow = function () {
1557 return OO.ui.Element.static.getWindow( this.$element );
1558 };
1559
1560 /**
1561 * Get closest scrollable container.
1562 *
1563 * @return {HTMLElement} Closest scrollable container
1564 */
1565 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1566 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1567 };
1568
1569 /**
1570 * Get group element is in.
1571 *
1572 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1573 */
1574 OO.ui.Element.prototype.getElementGroup = function () {
1575 return this.elementGroup;
1576 };
1577
1578 /**
1579 * Set group element is in.
1580 *
1581 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1582 * @chainable
1583 * @return {OO.ui.Element} The element, for chaining
1584 */
1585 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1586 this.elementGroup = group;
1587 return this;
1588 };
1589
1590 /**
1591 * Scroll element into view.
1592 *
1593 * @param {Object} [config] Configuration options
1594 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1595 */
1596 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1597 if (
1598 !this.isElementAttached() ||
1599 !this.isVisible() ||
1600 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1601 ) {
1602 return $.Deferred().resolve();
1603 }
1604 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1605 };
1606
1607 /**
1608 * Restore the pre-infusion dynamic state for this widget.
1609 *
1610 * This method is called after #$element has been inserted into DOM. The parameter is the return
1611 * value of #gatherPreInfuseState.
1612 *
1613 * @protected
1614 * @param {Object} state
1615 */
1616 OO.ui.Element.prototype.restorePreInfuseState = function () {
1617 };
1618
1619 /**
1620 * Wraps an HTML snippet for use with configuration values which default
1621 * to strings. This bypasses the default html-escaping done to string
1622 * values.
1623 *
1624 * @class
1625 *
1626 * @constructor
1627 * @param {string} [content] HTML content
1628 */
1629 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1630 // Properties
1631 this.content = content;
1632 };
1633
1634 /* Setup */
1635
1636 OO.initClass( OO.ui.HtmlSnippet );
1637
1638 /* Methods */
1639
1640 /**
1641 * Render into HTML.
1642 *
1643 * @return {string} Unchanged HTML snippet.
1644 */
1645 OO.ui.HtmlSnippet.prototype.toString = function () {
1646 return this.content;
1647 };
1648
1649 /**
1650 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1651 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1652 * are, combined.
1653 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1654 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1655 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1656 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1657 * for more information and examples.
1658 *
1659 * @abstract
1660 * @class
1661 * @extends OO.ui.Element
1662 * @mixins OO.EventEmitter
1663 *
1664 * @constructor
1665 * @param {Object} [config] Configuration options
1666 */
1667 OO.ui.Layout = function OoUiLayout( config ) {
1668 // Configuration initialization
1669 config = config || {};
1670
1671 // Parent constructor
1672 OO.ui.Layout.parent.call( this, config );
1673
1674 // Mixin constructors
1675 OO.EventEmitter.call( this );
1676
1677 // Initialization
1678 this.$element.addClass( 'oo-ui-layout' );
1679 };
1680
1681 /* Setup */
1682
1683 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1684 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1685
1686 /* Methods */
1687
1688 /**
1689 * Reset scroll offsets
1690 *
1691 * @chainable
1692 * @return {OO.ui.Layout} The layout, for chaining
1693 */
1694 OO.ui.Layout.prototype.resetScroll = function () {
1695 this.$element[ 0 ].scrollTop = 0;
1696 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1697
1698 return this;
1699 };
1700
1701 /**
1702 * Widgets are compositions of one or more OOUI elements that users can both view
1703 * and interact with. All widgets can be configured and modified via a standard API,
1704 * and their state can change dynamically according to a model.
1705 *
1706 * @abstract
1707 * @class
1708 * @extends OO.ui.Element
1709 * @mixins OO.EventEmitter
1710 *
1711 * @constructor
1712 * @param {Object} [config] Configuration options
1713 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1714 * appearance reflects this state.
1715 */
1716 OO.ui.Widget = function OoUiWidget( config ) {
1717 // Initialize config
1718 config = $.extend( { disabled: false }, config );
1719
1720 // Parent constructor
1721 OO.ui.Widget.parent.call( this, config );
1722
1723 // Mixin constructors
1724 OO.EventEmitter.call( this );
1725
1726 // Properties
1727 this.disabled = null;
1728 this.wasDisabled = null;
1729
1730 // Initialization
1731 this.$element.addClass( 'oo-ui-widget' );
1732 this.setDisabled( !!config.disabled );
1733 };
1734
1735 /* Setup */
1736
1737 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1738 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1739
1740 /* Events */
1741
1742 /**
1743 * @event disable
1744 *
1745 * A 'disable' event is emitted when the disabled state of the widget changes
1746 * (i.e. on disable **and** enable).
1747 *
1748 * @param {boolean} disabled Widget is disabled
1749 */
1750
1751 /**
1752 * @event toggle
1753 *
1754 * A 'toggle' event is emitted when the visibility of the widget changes.
1755 *
1756 * @param {boolean} visible Widget is visible
1757 */
1758
1759 /* Methods */
1760
1761 /**
1762 * Check if the widget is disabled.
1763 *
1764 * @return {boolean} Widget is disabled
1765 */
1766 OO.ui.Widget.prototype.isDisabled = function () {
1767 return this.disabled;
1768 };
1769
1770 /**
1771 * Set the 'disabled' state of the widget.
1772 *
1773 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1774 *
1775 * @param {boolean} disabled Disable widget
1776 * @chainable
1777 * @return {OO.ui.Widget} The widget, for chaining
1778 */
1779 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1780 var isDisabled;
1781
1782 this.disabled = !!disabled;
1783 isDisabled = this.isDisabled();
1784 if ( isDisabled !== this.wasDisabled ) {
1785 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1786 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1787 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1788 this.emit( 'disable', isDisabled );
1789 this.updateThemeClasses();
1790 }
1791 this.wasDisabled = isDisabled;
1792
1793 return this;
1794 };
1795
1796 /**
1797 * Update the disabled state, in case of changes in parent widget.
1798 *
1799 * @chainable
1800 * @return {OO.ui.Widget} The widget, for chaining
1801 */
1802 OO.ui.Widget.prototype.updateDisabled = function () {
1803 this.setDisabled( this.disabled );
1804 return this;
1805 };
1806
1807 /**
1808 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1809 * value.
1810 *
1811 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1812 * instead.
1813 *
1814 * @return {string|null} The ID of the labelable element
1815 */
1816 OO.ui.Widget.prototype.getInputId = function () {
1817 return null;
1818 };
1819
1820 /**
1821 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1822 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1823 * override this method to provide intuitive, accessible behavior.
1824 *
1825 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1826 * Individual widgets may override it too.
1827 *
1828 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1829 * directly.
1830 */
1831 OO.ui.Widget.prototype.simulateLabelClick = function () {
1832 };
1833
1834 /**
1835 * Theme logic.
1836 *
1837 * @abstract
1838 * @class
1839 *
1840 * @constructor
1841 */
1842 OO.ui.Theme = function OoUiTheme() {
1843 this.elementClassesQueue = [];
1844 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1845 };
1846
1847 /* Setup */
1848
1849 OO.initClass( OO.ui.Theme );
1850
1851 /* Methods */
1852
1853 /**
1854 * Get a list of classes to be applied to a widget.
1855 *
1856 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1857 * otherwise state transitions will not work properly.
1858 *
1859 * @param {OO.ui.Element} element Element for which to get classes
1860 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1861 */
1862 OO.ui.Theme.prototype.getElementClasses = function () {
1863 return { on: [], off: [] };
1864 };
1865
1866 /**
1867 * Update CSS classes provided by the theme.
1868 *
1869 * For elements with theme logic hooks, this should be called any time there's a state change.
1870 *
1871 * @param {OO.ui.Element} element Element for which to update classes
1872 */
1873 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1874 var $elements = $( [] ),
1875 classes = this.getElementClasses( element );
1876
1877 if ( element.$icon ) {
1878 $elements = $elements.add( element.$icon );
1879 }
1880 if ( element.$indicator ) {
1881 $elements = $elements.add( element.$indicator );
1882 }
1883
1884 $elements
1885 .removeClass( classes.off )
1886 .addClass( classes.on );
1887 };
1888
1889 /**
1890 * @private
1891 */
1892 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1893 var i;
1894 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1895 this.updateElementClasses( this.elementClassesQueue[ i ] );
1896 }
1897 // Clear the queue
1898 this.elementClassesQueue = [];
1899 };
1900
1901 /**
1902 * Queue #updateElementClasses to be called for this element.
1903 *
1904 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1905 * to make them synchronous.
1906 *
1907 * @param {OO.ui.Element} element Element for which to update classes
1908 */
1909 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1910 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1911 // the most common case (this method is often called repeatedly for the same element).
1912 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1913 return;
1914 }
1915 this.elementClassesQueue.push( element );
1916 this.debouncedUpdateQueuedElementClasses();
1917 };
1918
1919 /**
1920 * Get the transition duration in milliseconds for dialogs opening/closing
1921 *
1922 * The dialog should be fully rendered this many milliseconds after the
1923 * ready process has executed.
1924 *
1925 * @return {number} Transition duration in milliseconds
1926 */
1927 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1928 return 0;
1929 };
1930
1931 /**
1932 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1933 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1934 * order in which users will navigate through the focusable elements via the Tab key.
1935 *
1936 * @example
1937 * // TabIndexedElement is mixed into the ButtonWidget class
1938 * // to provide a tabIndex property.
1939 * var button1 = new OO.ui.ButtonWidget( {
1940 * label: 'fourth',
1941 * tabIndex: 4
1942 * } ),
1943 * button2 = new OO.ui.ButtonWidget( {
1944 * label: 'second',
1945 * tabIndex: 2
1946 * } ),
1947 * button3 = new OO.ui.ButtonWidget( {
1948 * label: 'third',
1949 * tabIndex: 3
1950 * } ),
1951 * button4 = new OO.ui.ButtonWidget( {
1952 * label: 'first',
1953 * tabIndex: 1
1954 * } );
1955 * $( document.body ).append(
1956 * button1.$element,
1957 * button2.$element,
1958 * button3.$element,
1959 * button4.$element
1960 * );
1961 *
1962 * @abstract
1963 * @class
1964 *
1965 * @constructor
1966 * @param {Object} [config] Configuration options
1967 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1968 * the functionality is applied to the element created by the class ($element). If a different
1969 * element is specified, the tabindex functionality will be applied to it instead.
1970 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the
1971 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
1972 * navigation order; use -1 to remove the element from the tab-navigation flow.
1973 */
1974 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1975 // Configuration initialization
1976 config = $.extend( { tabIndex: 0 }, config );
1977
1978 // Properties
1979 this.$tabIndexed = null;
1980 this.tabIndex = null;
1981
1982 // Events
1983 this.connect( this, {
1984 disable: 'onTabIndexedElementDisable'
1985 } );
1986
1987 // Initialization
1988 this.setTabIndex( config.tabIndex );
1989 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1990 };
1991
1992 /* Setup */
1993
1994 OO.initClass( OO.ui.mixin.TabIndexedElement );
1995
1996 /* Methods */
1997
1998 /**
1999 * Set the element that should use the tabindex functionality.
2000 *
2001 * This method is used to retarget a tabindex mixin so that its functionality applies
2002 * to the specified element. If an element is currently using the functionality, the mixin’s
2003 * effect on that element is removed before the new element is set up.
2004 *
2005 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
2006 * @chainable
2007 * @return {OO.ui.Element} The element, for chaining
2008 */
2009 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
2010 var tabIndex = this.tabIndex;
2011 // Remove attributes from old $tabIndexed
2012 this.setTabIndex( null );
2013 // Force update of new $tabIndexed
2014 this.$tabIndexed = $tabIndexed;
2015 this.tabIndex = tabIndex;
2016 return this.updateTabIndex();
2017 };
2018
2019 /**
2020 * Set the value of the tabindex.
2021 *
2022 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
2023 * @chainable
2024 * @return {OO.ui.Element} The element, for chaining
2025 */
2026 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
2027 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2028
2029 if ( this.tabIndex !== tabIndex ) {
2030 this.tabIndex = tabIndex;
2031 this.updateTabIndex();
2032 }
2033
2034 return this;
2035 };
2036
2037 /**
2038 * Update the `tabindex` attribute, in case of changes to tab index or
2039 * disabled state.
2040 *
2041 * @private
2042 * @chainable
2043 * @return {OO.ui.Element} The element, for chaining
2044 */
2045 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2046 if ( this.$tabIndexed ) {
2047 if ( this.tabIndex !== null ) {
2048 // Do not index over disabled elements
2049 this.$tabIndexed.attr( {
2050 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2051 // Support: ChromeVox and NVDA
2052 // These do not seem to inherit aria-disabled from parent elements
2053 'aria-disabled': this.isDisabled().toString()
2054 } );
2055 } else {
2056 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2057 }
2058 }
2059 return this;
2060 };
2061
2062 /**
2063 * Handle disable events.
2064 *
2065 * @private
2066 * @param {boolean} disabled Element is disabled
2067 */
2068 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2069 this.updateTabIndex();
2070 };
2071
2072 /**
2073 * Get the value of the tabindex.
2074 *
2075 * @return {number|null} Tabindex value
2076 */
2077 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2078 return this.tabIndex;
2079 };
2080
2081 /**
2082 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2083 *
2084 * If the element already has an ID then that is returned, otherwise unique ID is
2085 * generated, set on the element, and returned.
2086 *
2087 * @return {string|null} The ID of the focusable element
2088 */
2089 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2090 var id;
2091
2092 if ( !this.$tabIndexed ) {
2093 return null;
2094 }
2095 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2096 return null;
2097 }
2098
2099 id = this.$tabIndexed.attr( 'id' );
2100 if ( id === undefined ) {
2101 id = OO.ui.generateElementId();
2102 this.$tabIndexed.attr( 'id', id );
2103 }
2104
2105 return id;
2106 };
2107
2108 /**
2109 * Whether the node is 'labelable' according to the HTML spec
2110 * (i.e., whether it can be interacted with through a `<label for="…">`).
2111 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2112 *
2113 * @private
2114 * @param {jQuery} $node
2115 * @return {boolean}
2116 */
2117 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2118 var
2119 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2120 tagName = $node.prop( 'tagName' ).toLowerCase();
2121
2122 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2123 return true;
2124 }
2125 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2126 return true;
2127 }
2128 return false;
2129 };
2130
2131 /**
2132 * Focus this element.
2133 *
2134 * @chainable
2135 * @return {OO.ui.Element} The element, for chaining
2136 */
2137 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2138 if ( !this.isDisabled() ) {
2139 this.$tabIndexed.trigger( 'focus' );
2140 }
2141 return this;
2142 };
2143
2144 /**
2145 * Blur this element.
2146 *
2147 * @chainable
2148 * @return {OO.ui.Element} The element, for chaining
2149 */
2150 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2151 this.$tabIndexed.trigger( 'blur' );
2152 return this;
2153 };
2154
2155 /**
2156 * @inheritdoc OO.ui.Widget
2157 */
2158 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2159 this.focus();
2160 };
2161
2162 /**
2163 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2164 * interface element that can be configured with access keys for keyboard interaction.
2165 * See the [OOUI documentation on MediaWiki] [1] for examples.
2166 *
2167 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2168 *
2169 * @abstract
2170 * @class
2171 *
2172 * @constructor
2173 * @param {Object} [config] Configuration options
2174 * @cfg {jQuery} [$button] The button element created by the class.
2175 * If this configuration is omitted, the button element will use a generated `<a>`.
2176 * @cfg {boolean} [framed=true] Render the button with a frame
2177 */
2178 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2179 // Configuration initialization
2180 config = config || {};
2181
2182 // Properties
2183 this.$button = null;
2184 this.framed = null;
2185 this.active = config.active !== undefined && config.active;
2186 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2187 this.onMouseDownHandler = this.onMouseDown.bind( this );
2188 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2189 this.onKeyDownHandler = this.onKeyDown.bind( this );
2190 this.onClickHandler = this.onClick.bind( this );
2191 this.onKeyPressHandler = this.onKeyPress.bind( this );
2192
2193 // Initialization
2194 this.$element.addClass( 'oo-ui-buttonElement' );
2195 this.toggleFramed( config.framed === undefined || config.framed );
2196 this.setButtonElement( config.$button || $( '<a>' ) );
2197 };
2198
2199 /* Setup */
2200
2201 OO.initClass( OO.ui.mixin.ButtonElement );
2202
2203 /* Static Properties */
2204
2205 /**
2206 * Cancel mouse down events.
2207 *
2208 * This property is usually set to `true` to prevent the focus from changing when the button is
2209 * clicked.
2210 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2211 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2212 * behavior is possible and mousedown events can be handled by a parent widget.
2213 *
2214 * @static
2215 * @inheritable
2216 * @property {boolean}
2217 */
2218 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2219
2220 /* Events */
2221
2222 /**
2223 * A 'click' event is emitted when the button element is clicked.
2224 *
2225 * @event click
2226 */
2227
2228 /* Methods */
2229
2230 /**
2231 * Set the button element.
2232 *
2233 * This method is used to retarget a button mixin so that its functionality applies to
2234 * the specified button element instead of the one created by the class. If a button element
2235 * is already set, the method will remove the mixin’s effect on that element.
2236 *
2237 * @param {jQuery} $button Element to use as button
2238 */
2239 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2240 if ( this.$button ) {
2241 this.$button
2242 .removeClass( 'oo-ui-buttonElement-button' )
2243 .removeAttr( 'role accesskey' )
2244 .off( {
2245 mousedown: this.onMouseDownHandler,
2246 keydown: this.onKeyDownHandler,
2247 click: this.onClickHandler,
2248 keypress: this.onKeyPressHandler
2249 } );
2250 }
2251
2252 this.$button = $button
2253 .addClass( 'oo-ui-buttonElement-button' )
2254 .on( {
2255 mousedown: this.onMouseDownHandler,
2256 keydown: this.onKeyDownHandler,
2257 click: this.onClickHandler,
2258 keypress: this.onKeyPressHandler
2259 } );
2260
2261 // Add `role="button"` on `<a>` elements, where it's needed
2262 // `toUpperCase()` is added for XHTML documents
2263 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2264 this.$button.attr( 'role', 'button' );
2265 }
2266 };
2267
2268 /**
2269 * Handles mouse down events.
2270 *
2271 * @protected
2272 * @param {jQuery.Event} e Mouse down event
2273 * @return {undefined/boolean} False to prevent default if event is handled
2274 */
2275 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2276 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2277 return;
2278 }
2279 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2280 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2281 // reliably remove the pressed class
2282 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2283 // Prevent change of focus unless specifically configured otherwise
2284 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2285 return false;
2286 }
2287 };
2288
2289 /**
2290 * Handles document mouse up events.
2291 *
2292 * @protected
2293 * @param {MouseEvent} e Mouse up event
2294 */
2295 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2296 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2297 return;
2298 }
2299 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2300 // Stop listening for mouseup, since we only needed this once
2301 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2302 };
2303
2304 // Deprecated alias since 0.28.3
2305 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function () {
2306 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
2307 this.onDocumentMouseUp.apply( this, arguments );
2308 };
2309
2310 /**
2311 * Handles mouse click events.
2312 *
2313 * @protected
2314 * @param {jQuery.Event} e Mouse click event
2315 * @fires click
2316 * @return {undefined/boolean} False to prevent default if event is handled
2317 */
2318 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2319 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2320 if ( this.emit( 'click' ) ) {
2321 return false;
2322 }
2323 }
2324 };
2325
2326 /**
2327 * Handles key down events.
2328 *
2329 * @protected
2330 * @param {jQuery.Event} e Key down event
2331 */
2332 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2333 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2334 return;
2335 }
2336 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2337 // Run the keyup handler no matter where the key is when the button is let go, so we can
2338 // reliably remove the pressed class
2339 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2340 };
2341
2342 /**
2343 * Handles document key up events.
2344 *
2345 * @protected
2346 * @param {KeyboardEvent} e Key up event
2347 */
2348 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2349 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2350 return;
2351 }
2352 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2353 // Stop listening for keyup, since we only needed this once
2354 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2355 };
2356
2357 // Deprecated alias since 0.28.3
2358 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function () {
2359 OO.ui.warnDeprecation( 'onKeyUp is deprecated, use onDocumentKeyUp instead' );
2360 this.onDocumentKeyUp.apply( this, arguments );
2361 };
2362
2363 /**
2364 * Handles key press events.
2365 *
2366 * @protected
2367 * @param {jQuery.Event} e Key press event
2368 * @fires click
2369 * @return {undefined/boolean} False to prevent default if event is handled
2370 */
2371 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2372 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2373 if ( this.emit( 'click' ) ) {
2374 return false;
2375 }
2376 }
2377 };
2378
2379 /**
2380 * Check if button has a frame.
2381 *
2382 * @return {boolean} Button is framed
2383 */
2384 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2385 return this.framed;
2386 };
2387
2388 /**
2389 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2390 * on and off.
2391 *
2392 * @param {boolean} [framed] Make button framed, omit to toggle
2393 * @chainable
2394 * @return {OO.ui.Element} The element, for chaining
2395 */
2396 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2397 framed = framed === undefined ? !this.framed : !!framed;
2398 if ( framed !== this.framed ) {
2399 this.framed = framed;
2400 this.$element
2401 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2402 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2403 this.updateThemeClasses();
2404 }
2405
2406 return this;
2407 };
2408
2409 /**
2410 * Set the button's active state.
2411 *
2412 * The active state can be set on:
2413 *
2414 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2415 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2416 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2417 *
2418 * @protected
2419 * @param {boolean} value Make button active
2420 * @chainable
2421 * @return {OO.ui.Element} The element, for chaining
2422 */
2423 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2424 this.active = !!value;
2425 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2426 this.updateThemeClasses();
2427 return this;
2428 };
2429
2430 /**
2431 * Check if the button is active
2432 *
2433 * @protected
2434 * @return {boolean} The button is active
2435 */
2436 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2437 return this.active;
2438 };
2439
2440 /**
2441 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2442 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2443 * items from the group is done through the interface the class provides.
2444 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2445 *
2446 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2447 *
2448 * @abstract
2449 * @mixins OO.EmitterList
2450 * @class
2451 *
2452 * @constructor
2453 * @param {Object} [config] Configuration options
2454 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2455 * is omitted, the group element will use a generated `<div>`.
2456 */
2457 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2458 // Configuration initialization
2459 config = config || {};
2460
2461 // Mixin constructors
2462 OO.EmitterList.call( this, config );
2463
2464 // Properties
2465 this.$group = null;
2466
2467 // Initialization
2468 this.setGroupElement( config.$group || $( '<div>' ) );
2469 };
2470
2471 /* Setup */
2472
2473 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2474
2475 /* Events */
2476
2477 /**
2478 * @event change
2479 *
2480 * A change event is emitted when the set of selected items changes.
2481 *
2482 * @param {OO.ui.Element[]} items Items currently in the group
2483 */
2484
2485 /* Methods */
2486
2487 /**
2488 * Set the group element.
2489 *
2490 * If an element is already set, items will be moved to the new element.
2491 *
2492 * @param {jQuery} $group Element to use as group
2493 */
2494 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2495 var i, len;
2496
2497 this.$group = $group;
2498 for ( i = 0, len = this.items.length; i < len; i++ ) {
2499 this.$group.append( this.items[ i ].$element );
2500 }
2501 };
2502
2503 /**
2504 * Find an item by its data.
2505 *
2506 * Only the first item with matching data will be returned. To return all matching items,
2507 * use the #findItemsFromData method.
2508 *
2509 * @param {Object} data Item data to search for
2510 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2511 */
2512 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2513 var i, len, item,
2514 hash = OO.getHash( data );
2515
2516 for ( i = 0, len = this.items.length; i < len; i++ ) {
2517 item = this.items[ i ];
2518 if ( hash === OO.getHash( item.getData() ) ) {
2519 return item;
2520 }
2521 }
2522
2523 return null;
2524 };
2525
2526 /**
2527 * Find items by their data.
2528 *
2529 * All items with matching data will be returned. To return only the first match, use the
2530 * #findItemFromData method instead.
2531 *
2532 * @param {Object} data Item data to search for
2533 * @return {OO.ui.Element[]} Items with equivalent data
2534 */
2535 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2536 var i, len, item,
2537 hash = OO.getHash( data ),
2538 items = [];
2539
2540 for ( i = 0, len = this.items.length; i < len; i++ ) {
2541 item = this.items[ i ];
2542 if ( hash === OO.getHash( item.getData() ) ) {
2543 items.push( item );
2544 }
2545 }
2546
2547 return items;
2548 };
2549
2550 /**
2551 * Add items to the group.
2552 *
2553 * Items will be added to the end of the group array unless the optional `index` parameter
2554 * specifies a different insertion point. Adding an existing item will move it to the end of the
2555 * array or the point specified by the `index`.
2556 *
2557 * @param {OO.ui.Element[]} items An array of items to add to the group
2558 * @param {number} [index] Index of the insertion point
2559 * @chainable
2560 * @return {OO.ui.Element} The element, for chaining
2561 */
2562 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2563
2564 if ( items.length === 0 ) {
2565 return this;
2566 }
2567
2568 // Mixin method
2569 OO.EmitterList.prototype.addItems.call( this, items, index );
2570
2571 this.emit( 'change', this.getItems() );
2572 return this;
2573 };
2574
2575 /**
2576 * @inheritdoc
2577 */
2578 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2579 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2580 this.insertItemElements( items, newIndex );
2581
2582 // Mixin method
2583 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2584
2585 return newIndex;
2586 };
2587
2588 /**
2589 * @inheritdoc
2590 */
2591 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2592 item.setElementGroup( this );
2593 this.insertItemElements( item, index );
2594
2595 // Mixin method
2596 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2597
2598 return index;
2599 };
2600
2601 /**
2602 * Insert elements into the group
2603 *
2604 * @private
2605 * @param {OO.ui.Element} itemWidget Item to insert
2606 * @param {number} index Insertion index
2607 */
2608 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2609 if ( index === undefined || index < 0 || index >= this.items.length ) {
2610 this.$group.append( itemWidget.$element );
2611 } else if ( index === 0 ) {
2612 this.$group.prepend( itemWidget.$element );
2613 } else {
2614 this.items[ index ].$element.before( itemWidget.$element );
2615 }
2616 };
2617
2618 /**
2619 * Remove the specified items from a group.
2620 *
2621 * Removed items are detached (not removed) from the DOM so that they may be reused.
2622 * To remove all items from a group, you may wish to use the #clearItems method instead.
2623 *
2624 * @param {OO.ui.Element[]} items An array of items to remove
2625 * @chainable
2626 * @return {OO.ui.Element} The element, for chaining
2627 */
2628 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2629 var i, len, item, index;
2630
2631 if ( items.length === 0 ) {
2632 return this;
2633 }
2634
2635 // Remove specific items elements
2636 for ( i = 0, len = items.length; i < len; i++ ) {
2637 item = items[ i ];
2638 index = this.items.indexOf( item );
2639 if ( index !== -1 ) {
2640 item.setElementGroup( null );
2641 item.$element.detach();
2642 }
2643 }
2644
2645 // Mixin method
2646 OO.EmitterList.prototype.removeItems.call( this, items );
2647
2648 this.emit( 'change', this.getItems() );
2649 return this;
2650 };
2651
2652 /**
2653 * Clear all items from the group.
2654 *
2655 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2656 * To remove only a subset of items from a group, use the #removeItems method.
2657 *
2658 * @chainable
2659 * @return {OO.ui.Element} The element, for chaining
2660 */
2661 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2662 var i, len;
2663
2664 // Remove all item elements
2665 for ( i = 0, len = this.items.length; i < len; i++ ) {
2666 this.items[ i ].setElementGroup( null );
2667 this.items[ i ].$element.detach();
2668 }
2669
2670 // Mixin method
2671 OO.EmitterList.prototype.clearItems.call( this );
2672
2673 this.emit( 'change', this.getItems() );
2674 return this;
2675 };
2676
2677 /**
2678 * LabelElement is often mixed into other classes to generate a label, which
2679 * helps identify the function of an interface element.
2680 * See the [OOUI documentation on MediaWiki] [1] for more information.
2681 *
2682 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2683 *
2684 * @abstract
2685 * @class
2686 *
2687 * @constructor
2688 * @param {Object} [config] Configuration options
2689 * @cfg {jQuery} [$label] The label element created by the class. If this
2690 * configuration is omitted, the label element will use a generated `<span>`.
2691 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be
2692 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2693 * produce a string in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2694 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2695 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still
2696 * accessible to screen-readers).
2697 */
2698 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2699 // Configuration initialization
2700 config = config || {};
2701
2702 // Properties
2703 this.$label = null;
2704 this.label = null;
2705 this.invisibleLabel = null;
2706
2707 // Initialization
2708 this.setLabel( config.label || this.constructor.static.label );
2709 this.setLabelElement( config.$label || $( '<span>' ) );
2710 this.setInvisibleLabel( config.invisibleLabel );
2711 };
2712
2713 /* Setup */
2714
2715 OO.initClass( OO.ui.mixin.LabelElement );
2716
2717 /* Events */
2718
2719 /**
2720 * @event labelChange
2721 * @param {string} value
2722 */
2723
2724 /* Static Properties */
2725
2726 /**
2727 * The label text. The label can be specified as a plaintext string, a function that will
2728 * produce a string in the future, or `null` for no label. The static value will
2729 * be overridden if a label is specified with the #label config option.
2730 *
2731 * @static
2732 * @inheritable
2733 * @property {string|Function|null}
2734 */
2735 OO.ui.mixin.LabelElement.static.label = null;
2736
2737 /* Static methods */
2738
2739 /**
2740 * Highlight the first occurrence of the query in the given text
2741 *
2742 * @param {string} text Text
2743 * @param {string} query Query to find
2744 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2745 * @return {jQuery} Text with the first match of the query
2746 * sub-string wrapped in highlighted span
2747 */
2748 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2749 var i, tLen, qLen,
2750 offset = -1,
2751 $result = $( '<span>' );
2752
2753 if ( compare ) {
2754 tLen = text.length;
2755 qLen = query.length;
2756 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2757 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2758 offset = i;
2759 }
2760 }
2761 } else {
2762 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2763 }
2764
2765 if ( !query.length || offset === -1 ) {
2766 $result.text( text );
2767 } else {
2768 $result.append(
2769 document.createTextNode( text.slice( 0, offset ) ),
2770 $( '<span>' )
2771 .addClass( 'oo-ui-labelElement-label-highlight' )
2772 .text( text.slice( offset, offset + query.length ) ),
2773 document.createTextNode( text.slice( offset + query.length ) )
2774 );
2775 }
2776 return $result.contents();
2777 };
2778
2779 /* Methods */
2780
2781 /**
2782 * Set the label element.
2783 *
2784 * If an element is already set, it will be cleaned up before setting up the new element.
2785 *
2786 * @param {jQuery} $label Element to use as label
2787 */
2788 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2789 if ( this.$label ) {
2790 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2791 }
2792
2793 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2794 this.setLabelContent( this.label );
2795 };
2796
2797 /**
2798 * Set the label.
2799 *
2800 * An empty string will result in the label being hidden. A string containing only whitespace will
2801 * be converted to a single `&nbsp;`.
2802 *
2803 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2804 * returns nodes or text; or null for no label
2805 * @chainable
2806 * @return {OO.ui.Element} The element, for chaining
2807 */
2808 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2809 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2810 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2811
2812 if ( this.label !== label ) {
2813 if ( this.$label ) {
2814 this.setLabelContent( label );
2815 }
2816 this.label = label;
2817 this.emit( 'labelChange' );
2818 }
2819
2820 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2821
2822 return this;
2823 };
2824
2825 /**
2826 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2827 *
2828 * @param {boolean} invisibleLabel
2829 * @chainable
2830 * @return {OO.ui.Element} The element, for chaining
2831 */
2832 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2833 invisibleLabel = !!invisibleLabel;
2834
2835 if ( this.invisibleLabel !== invisibleLabel ) {
2836 this.invisibleLabel = invisibleLabel;
2837 this.emit( 'labelChange' );
2838 }
2839
2840 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2841 // Pretend that there is no label, a lot of CSS has been written with this assumption
2842 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2843
2844 return this;
2845 };
2846
2847 /**
2848 * Set the label as plain text with a highlighted query
2849 *
2850 * @param {string} text Text label to set
2851 * @param {string} query Substring of text to highlight
2852 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2853 * @chainable
2854 * @return {OO.ui.Element} The element, for chaining
2855 */
2856 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2857 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2858 };
2859
2860 /**
2861 * Get the label.
2862 *
2863 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2864 * text; or null for no label
2865 */
2866 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2867 return this.label;
2868 };
2869
2870 /**
2871 * Set the content of the label.
2872 *
2873 * Do not call this method until after the label element has been set by #setLabelElement.
2874 *
2875 * @private
2876 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2877 * text; or null for no label
2878 */
2879 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2880 if ( typeof label === 'string' ) {
2881 if ( label.match( /^\s*$/ ) ) {
2882 // Convert whitespace only string to a single non-breaking space
2883 this.$label.html( '&nbsp;' );
2884 } else {
2885 this.$label.text( label );
2886 }
2887 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2888 this.$label.html( label.toString() );
2889 } else if ( label instanceof $ ) {
2890 this.$label.empty().append( label );
2891 } else {
2892 this.$label.empty();
2893 }
2894 };
2895
2896 /**
2897 * IconElement is often mixed into other classes to generate an icon.
2898 * Icons are graphics, about the size of normal text. They are used to aid the user
2899 * in locating a control or to convey information in a space-efficient way. See the
2900 * [OOUI documentation on MediaWiki] [1] for a list of icons
2901 * included in the library.
2902 *
2903 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2904 *
2905 * @abstract
2906 * @class
2907 *
2908 * @constructor
2909 * @param {Object} [config] Configuration options
2910 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2911 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2912 * the icon element be set to an existing icon instead of the one generated by this class, set a
2913 * value using a jQuery selection. For example:
2914 *
2915 * // Use a <div> tag instead of a <span>
2916 * $icon: $( '<div>' )
2917 * // Use an existing icon element instead of the one generated by the class
2918 * $icon: this.$element
2919 * // Use an icon element from a child widget
2920 * $icon: this.childwidget.$element
2921 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
2922 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
2923 * name and additional names keyed by language code. The `default` name is used when no icon is
2924 * keyed by the user's language.
2925 *
2926 * Example of an i18n map:
2927 *
2928 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2929 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2930 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2931 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that
2932 * returns title text. The icon title is displayed when users move the mouse over the icon.
2933 */
2934 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2935 // Configuration initialization
2936 config = config || {};
2937
2938 // Properties
2939 this.$icon = null;
2940 this.icon = null;
2941 this.iconTitle = null;
2942
2943 // `iconTitle`s are deprecated since 0.30.0
2944 if ( config.iconTitle !== undefined ) {
2945 OO.ui.warnDeprecation( 'IconElement: Widgets with iconTitle set are deprecated, use title instead. See T76638 for details.' );
2946 }
2947
2948 // Initialization
2949 this.setIcon( config.icon || this.constructor.static.icon );
2950 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2951 this.setIconElement( config.$icon || $( '<span>' ) );
2952 };
2953
2954 /* Setup */
2955
2956 OO.initClass( OO.ui.mixin.IconElement );
2957
2958 /* Static Properties */
2959
2960 /**
2961 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
2962 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
2963 * language code. The `default` name is used when no icon is keyed by the user's language.
2964 *
2965 * Example of an i18n map:
2966 *
2967 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2968 *
2969 * Note: the static property will be overridden if the #icon configuration is used.
2970 *
2971 * @static
2972 * @inheritable
2973 * @property {Object|string}
2974 */
2975 OO.ui.mixin.IconElement.static.icon = null;
2976
2977 /**
2978 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2979 * function that returns title text, or `null` for no title.
2980 *
2981 * The static property will be overridden if the #iconTitle configuration is used.
2982 *
2983 * @static
2984 * @inheritable
2985 * @property {string|Function|null}
2986 */
2987 OO.ui.mixin.IconElement.static.iconTitle = null;
2988
2989 /* Methods */
2990
2991 /**
2992 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2993 * applies to the specified icon element instead of the one created by the class. If an icon
2994 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2995 * and mixin methods will no longer affect the element.
2996 *
2997 * @param {jQuery} $icon Element to use as icon
2998 */
2999 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
3000 if ( this.$icon ) {
3001 this.$icon
3002 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
3003 .removeAttr( 'title' );
3004 }
3005
3006 this.$icon = $icon
3007 .addClass( 'oo-ui-iconElement-icon' )
3008 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
3009 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
3010 if ( this.iconTitle !== null ) {
3011 this.$icon.attr( 'title', this.iconTitle );
3012 }
3013
3014 this.updateThemeClasses();
3015 };
3016
3017 /**
3018 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
3019 * The icon parameter can also be set to a map of icon names. See the #icon config setting
3020 * for an example.
3021 *
3022 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
3023 * by language code, or `null` to remove the icon.
3024 * @chainable
3025 * @return {OO.ui.Element} The element, for chaining
3026 */
3027 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
3028 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
3029 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
3030
3031 if ( this.icon !== icon ) {
3032 if ( this.$icon ) {
3033 if ( this.icon !== null ) {
3034 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
3035 }
3036 if ( icon !== null ) {
3037 this.$icon.addClass( 'oo-ui-icon-' + icon );
3038 }
3039 }
3040 this.icon = icon;
3041 }
3042
3043 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
3044 if ( this.$icon ) {
3045 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
3046 }
3047 this.updateThemeClasses();
3048
3049 return this;
3050 };
3051
3052 /**
3053 * Set the icon title. Use `null` to remove the title.
3054 *
3055 * @param {string|Function|null} iconTitle A text string used as the icon title,
3056 * a function that returns title text, or `null` for no title.
3057 * @chainable
3058 * @return {OO.ui.Element} The element, for chaining
3059 * @deprecated
3060 */
3061 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
3062 iconTitle =
3063 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
3064 OO.ui.resolveMsg( iconTitle ) : null;
3065
3066 if ( this.iconTitle !== iconTitle ) {
3067 this.iconTitle = iconTitle;
3068 if ( this.$icon ) {
3069 if ( this.iconTitle !== null ) {
3070 this.$icon.attr( 'title', iconTitle );
3071 } else {
3072 this.$icon.removeAttr( 'title' );
3073 }
3074 }
3075 }
3076
3077 // `setIconTitle` is deprecated since 0.30.0
3078 if ( iconTitle !== null ) {
3079 // Avoid a warning when this is called from the constructor with no iconTitle set
3080 OO.ui.warnDeprecation( 'IconElement: setIconTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3081 }
3082
3083 return this;
3084 };
3085
3086 /**
3087 * Get the symbolic name of the icon.
3088 *
3089 * @return {string} Icon name
3090 */
3091 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3092 return this.icon;
3093 };
3094
3095 /**
3096 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3097 *
3098 * @return {string} Icon title text
3099 * @deprecated
3100 */
3101 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3102 return this.iconTitle;
3103 };
3104
3105 /**
3106 * IndicatorElement is often mixed into other classes to generate an indicator.
3107 * Indicators are small graphics that are generally used in two ways:
3108 *
3109 * - To draw attention to the status of an item. For example, an indicator might be
3110 * used to show that an item in a list has errors that need to be resolved.
3111 * - To clarify the function of a control that acts in an exceptional way (a button
3112 * that opens a menu instead of performing an action directly, for example).
3113 *
3114 * For a list of indicators included in the library, please see the
3115 * [OOUI documentation on MediaWiki] [1].
3116 *
3117 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3118 *
3119 * @abstract
3120 * @class
3121 *
3122 * @constructor
3123 * @param {Object} [config] Configuration options
3124 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3125 * configuration is omitted, the indicator element will use a generated `<span>`.
3126 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3127 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3128 * in the library.
3129 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3130 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
3131 * or a function that returns title text. The indicator title is displayed when users move
3132 * the mouse over the indicator.
3133 */
3134 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3135 // Configuration initialization
3136 config = config || {};
3137
3138 // Properties
3139 this.$indicator = null;
3140 this.indicator = null;
3141 this.indicatorTitle = null;
3142
3143 // `indicatorTitle`s are deprecated since 0.30.0
3144 if ( config.indicatorTitle !== undefined ) {
3145 OO.ui.warnDeprecation( 'IndicatorElement: Widgets with indicatorTitle set are deprecated, use title instead. See T76638 for details.' );
3146 }
3147
3148 // Initialization
3149 this.setIndicator( config.indicator || this.constructor.static.indicator );
3150 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
3151 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3152 };
3153
3154 /* Setup */
3155
3156 OO.initClass( OO.ui.mixin.IndicatorElement );
3157
3158 /* Static Properties */
3159
3160 /**
3161 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3162 * The static property will be overridden if the #indicator configuration is used.
3163 *
3164 * @static
3165 * @inheritable
3166 * @property {string|null}
3167 */
3168 OO.ui.mixin.IndicatorElement.static.indicator = null;
3169
3170 /**
3171 * A text string used as the indicator title, a function that returns title text, or `null`
3172 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3173 * used.
3174 *
3175 * @static
3176 * @inheritable
3177 * @property {string|Function|null}
3178 */
3179 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3180
3181 /* Methods */
3182
3183 /**
3184 * Set the indicator element.
3185 *
3186 * If an element is already set, it will be cleaned up before setting up the new element.
3187 *
3188 * @param {jQuery} $indicator Element to use as indicator
3189 */
3190 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3191 if ( this.$indicator ) {
3192 this.$indicator
3193 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3194 .removeAttr( 'title' );
3195 }
3196
3197 this.$indicator = $indicator
3198 .addClass( 'oo-ui-indicatorElement-indicator' )
3199 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3200 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3201 if ( this.indicatorTitle !== null ) {
3202 this.$indicator.attr( 'title', this.indicatorTitle );
3203 }
3204
3205 this.updateThemeClasses();
3206 };
3207
3208 /**
3209 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null`
3210 * to remove the indicator.
3211 *
3212 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3213 * @chainable
3214 * @return {OO.ui.Element} The element, for chaining
3215 */
3216 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3217 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3218
3219 if ( this.indicator !== indicator ) {
3220 if ( this.$indicator ) {
3221 if ( this.indicator !== null ) {
3222 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3223 }
3224 if ( indicator !== null ) {
3225 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3226 }
3227 }
3228 this.indicator = indicator;
3229 }
3230
3231 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3232 if ( this.$indicator ) {
3233 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3234 }
3235 this.updateThemeClasses();
3236
3237 return this;
3238 };
3239
3240 /**
3241 * Set the indicator title.
3242 *
3243 * The title is displayed when a user moves the mouse over the indicator.
3244 *
3245 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text,
3246 * or `null` for no indicator title
3247 * @chainable
3248 * @return {OO.ui.Element} The element, for chaining
3249 * @deprecated
3250 */
3251 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
3252 indicatorTitle =
3253 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
3254 OO.ui.resolveMsg( indicatorTitle ) : null;
3255
3256 if ( this.indicatorTitle !== indicatorTitle ) {
3257 this.indicatorTitle = indicatorTitle;
3258 if ( this.$indicator ) {
3259 if ( this.indicatorTitle !== null ) {
3260 this.$indicator.attr( 'title', indicatorTitle );
3261 } else {
3262 this.$indicator.removeAttr( 'title' );
3263 }
3264 }
3265 }
3266
3267 // `setIndicatorTitle` is deprecated since 0.30.0
3268 if ( indicatorTitle !== null ) {
3269 // Avoid a warning when this is called from the constructor with no indicatorTitle set
3270 OO.ui.warnDeprecation( 'IndicatorElement: setIndicatorTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3271 }
3272
3273 return this;
3274 };
3275
3276 /**
3277 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3278 *
3279 * @return {string} Symbolic name of indicator
3280 */
3281 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3282 return this.indicator;
3283 };
3284
3285 /**
3286 * Get the indicator title.
3287 *
3288 * The title is displayed when a user moves the mouse over the indicator.
3289 *
3290 * @return {string} Indicator title text
3291 * @deprecated
3292 */
3293 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3294 return this.indicatorTitle;
3295 };
3296
3297 /**
3298 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3299 * additional functionality to an element created by another class. The class provides
3300 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3301 * which are used to customize the look and feel of a widget to better describe its
3302 * importance and functionality.
3303 *
3304 * The library currently contains the following styling flags for general use:
3305 *
3306 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3307 * forward in a process.
3308 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3309 * something.
3310 *
3311 * The flags affect the appearance of the buttons:
3312 *
3313 * @example
3314 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3315 * var button1 = new OO.ui.ButtonWidget( {
3316 * label: 'Progressive',
3317 * flags: 'progressive'
3318 * } ),
3319 * button2 = new OO.ui.ButtonWidget( {
3320 * label: 'Destructive',
3321 * flags: 'destructive'
3322 * } );
3323 * $( document.body ).append( button1.$element, button2.$element );
3324 *
3325 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3326 * action, use these flags: **primary** and **safe**.
3327 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3328 *
3329 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3330 *
3331 * @abstract
3332 * @class
3333 *
3334 * @constructor
3335 * @param {Object} [config] Configuration options
3336 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3337 * to apply.
3338 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3339 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3340 * @cfg {jQuery} [$flagged] The flagged element. By default,
3341 * the flagged functionality is applied to the element created by the class ($element).
3342 * If a different element is specified, the flagged functionality will be applied to it instead.
3343 */
3344 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3345 // Configuration initialization
3346 config = config || {};
3347
3348 // Properties
3349 this.flags = {};
3350 this.$flagged = null;
3351
3352 // Initialization
3353 this.setFlags( config.flags );
3354 this.setFlaggedElement( config.$flagged || this.$element );
3355 };
3356
3357 /* Events */
3358
3359 /**
3360 * @event flag
3361 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3362 * parameter contains the name of each modified flag and indicates whether it was
3363 * added or removed.
3364 *
3365 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3366 * that the flag was added, `false` that the flag was removed.
3367 */
3368
3369 /* Methods */
3370
3371 /**
3372 * Set the flagged element.
3373 *
3374 * This method is used to retarget a flagged mixin so that its functionality applies to the
3375 * specified element.
3376 * If an element is already set, the method will remove the mixin’s effect on that element.
3377 *
3378 * @param {jQuery} $flagged Element that should be flagged
3379 */
3380 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3381 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3382 return 'oo-ui-flaggedElement-' + flag;
3383 } );
3384
3385 if ( this.$flagged ) {
3386 this.$flagged.removeClass( classNames );
3387 }
3388
3389 this.$flagged = $flagged.addClass( classNames );
3390 };
3391
3392 /**
3393 * Check if the specified flag is set.
3394 *
3395 * @param {string} flag Name of flag
3396 * @return {boolean} The flag is set
3397 */
3398 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3399 // This may be called before the constructor, thus before this.flags is set
3400 return this.flags && ( flag in this.flags );
3401 };
3402
3403 /**
3404 * Get the names of all flags set.
3405 *
3406 * @return {string[]} Flag names
3407 */
3408 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3409 // This may be called before the constructor, thus before this.flags is set
3410 return Object.keys( this.flags || {} );
3411 };
3412
3413 /**
3414 * Clear all flags.
3415 *
3416 * @chainable
3417 * @return {OO.ui.Element} The element, for chaining
3418 * @fires flag
3419 */
3420 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3421 var flag, className,
3422 changes = {},
3423 remove = [],
3424 classPrefix = 'oo-ui-flaggedElement-';
3425
3426 for ( flag in this.flags ) {
3427 className = classPrefix + flag;
3428 changes[ flag ] = false;
3429 delete this.flags[ flag ];
3430 remove.push( className );
3431 }
3432
3433 if ( this.$flagged ) {
3434 this.$flagged.removeClass( remove );
3435 }
3436
3437 this.updateThemeClasses();
3438 this.emit( 'flag', changes );
3439
3440 return this;
3441 };
3442
3443 /**
3444 * Add one or more flags.
3445 *
3446 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3447 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3448 * be added (`true`) or removed (`false`).
3449 * @chainable
3450 * @return {OO.ui.Element} The element, for chaining
3451 * @fires flag
3452 */
3453 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3454 var i, len, flag, className,
3455 changes = {},
3456 add = [],
3457 remove = [],
3458 classPrefix = 'oo-ui-flaggedElement-';
3459
3460 if ( typeof flags === 'string' ) {
3461 className = classPrefix + flags;
3462 // Set
3463 if ( !this.flags[ flags ] ) {
3464 this.flags[ flags ] = true;
3465 add.push( className );
3466 }
3467 } else if ( Array.isArray( flags ) ) {
3468 for ( i = 0, len = flags.length; i < len; i++ ) {
3469 flag = flags[ i ];
3470 className = classPrefix + flag;
3471 // Set
3472 if ( !this.flags[ flag ] ) {
3473 changes[ flag ] = true;
3474 this.flags[ flag ] = true;
3475 add.push( className );
3476 }
3477 }
3478 } else if ( OO.isPlainObject( flags ) ) {
3479 for ( flag in flags ) {
3480 className = classPrefix + flag;
3481 if ( flags[ flag ] ) {
3482 // Set
3483 if ( !this.flags[ flag ] ) {
3484 changes[ flag ] = true;
3485 this.flags[ flag ] = true;
3486 add.push( className );
3487 }
3488 } else {
3489 // Remove
3490 if ( this.flags[ flag ] ) {
3491 changes[ flag ] = false;
3492 delete this.flags[ flag ];
3493 remove.push( className );
3494 }
3495 }
3496 }
3497 }
3498
3499 if ( this.$flagged ) {
3500 this.$flagged
3501 .addClass( add )
3502 .removeClass( remove );
3503 }
3504
3505 this.updateThemeClasses();
3506 this.emit( 'flag', changes );
3507
3508 return this;
3509 };
3510
3511 /**
3512 * TitledElement is mixed into other classes to provide a `title` attribute.
3513 * Titles are rendered by the browser and are made visible when the user moves
3514 * the mouse over the element. Titles are not visible on touch devices.
3515 *
3516 * @example
3517 * // TitledElement provides a `title` attribute to the
3518 * // ButtonWidget class.
3519 * var button = new OO.ui.ButtonWidget( {
3520 * label: 'Button with Title',
3521 * title: 'I am a button'
3522 * } );
3523 * $( document.body ).append( button.$element );
3524 *
3525 * @abstract
3526 * @class
3527 *
3528 * @constructor
3529 * @param {Object} [config] Configuration options
3530 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3531 * If this config is omitted, the title functionality is applied to $element, the
3532 * element created by the class.
3533 * @cfg {string|Function} [title] The title text or a function that returns text. If
3534 * this config is omitted, the value of the {@link #static-title static title} property is used.
3535 */
3536 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3537 // Configuration initialization
3538 config = config || {};
3539
3540 // Properties
3541 this.$titled = null;
3542 this.title = null;
3543
3544 // Initialization
3545 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3546 this.setTitledElement( config.$titled || this.$element );
3547 };
3548
3549 /* Setup */
3550
3551 OO.initClass( OO.ui.mixin.TitledElement );
3552
3553 /* Static Properties */
3554
3555 /**
3556 * The title text, a function that returns text, or `null` for no title. The value of the static
3557 * property is overridden if the #title config option is used.
3558 *
3559 * @static
3560 * @inheritable
3561 * @property {string|Function|null}
3562 */
3563 OO.ui.mixin.TitledElement.static.title = null;
3564
3565 /* Methods */
3566
3567 /**
3568 * Set the titled element.
3569 *
3570 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3571 * specified element.
3572 * If an element is already set, the mixin’s effect on that element is removed before the new
3573 * element is set up.
3574 *
3575 * @param {jQuery} $titled Element that should use the 'titled' functionality
3576 */
3577 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3578 if ( this.$titled ) {
3579 this.$titled.removeAttr( 'title' );
3580 }
3581
3582 this.$titled = $titled;
3583 if ( this.title ) {
3584 this.updateTitle();
3585 }
3586 };
3587
3588 /**
3589 * Set title.
3590 *
3591 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3592 * for no title
3593 * @chainable
3594 * @return {OO.ui.Element} The element, for chaining
3595 */
3596 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3597 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3598 title = ( typeof title === 'string' && title.length ) ? title : null;
3599
3600 if ( this.title !== title ) {
3601 this.title = title;
3602 this.updateTitle();
3603 }
3604
3605 return this;
3606 };
3607
3608 /**
3609 * Update the title attribute, in case of changes to title or accessKey.
3610 *
3611 * @protected
3612 * @chainable
3613 * @return {OO.ui.Element} The element, for chaining
3614 */
3615 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3616 var title = this.getTitle();
3617 if ( this.$titled ) {
3618 if ( title !== null ) {
3619 // Only if this is an AccessKeyedElement
3620 if ( this.formatTitleWithAccessKey ) {
3621 title = this.formatTitleWithAccessKey( title );
3622 }
3623 this.$titled.attr( 'title', title );
3624 } else {
3625 this.$titled.removeAttr( 'title' );
3626 }
3627 }
3628 return this;
3629 };
3630
3631 /**
3632 * Get title.
3633 *
3634 * @return {string} Title string
3635 */
3636 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3637 return this.title;
3638 };
3639
3640 /**
3641 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3642 * Access keys allow an user to go to a specific element by using
3643 * a shortcut combination of a browser specific keys + the key
3644 * set to the field.
3645 *
3646 * @example
3647 * // AccessKeyedElement provides an `accesskey` attribute to the
3648 * // ButtonWidget class.
3649 * var button = new OO.ui.ButtonWidget( {
3650 * label: 'Button with access key',
3651 * accessKey: 'k'
3652 * } );
3653 * $( document.body ).append( button.$element );
3654 *
3655 * @abstract
3656 * @class
3657 *
3658 * @constructor
3659 * @param {Object} [config] Configuration options
3660 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3661 * If this config is omitted, the access key functionality is applied to $element, the
3662 * element created by the class.
3663 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3664 * this config is omitted, no access key will be added.
3665 */
3666 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3667 // Configuration initialization
3668 config = config || {};
3669
3670 // Properties
3671 this.$accessKeyed = null;
3672 this.accessKey = null;
3673
3674 // Initialization
3675 this.setAccessKey( config.accessKey || null );
3676 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3677
3678 // If this is also a TitledElement and it initialized before we did, we may have
3679 // to update the title with the access key
3680 if ( this.updateTitle ) {
3681 this.updateTitle();
3682 }
3683 };
3684
3685 /* Setup */
3686
3687 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3688
3689 /* Static Properties */
3690
3691 /**
3692 * The access key, a function that returns a key, or `null` for no access key.
3693 *
3694 * @static
3695 * @inheritable
3696 * @property {string|Function|null}
3697 */
3698 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3699
3700 /* Methods */
3701
3702 /**
3703 * Set the access keyed element.
3704 *
3705 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3706 * the specified element.
3707 * If an element is already set, the mixin's effect on that element is removed before the new
3708 * element is set up.
3709 *
3710 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3711 */
3712 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3713 if ( this.$accessKeyed ) {
3714 this.$accessKeyed.removeAttr( 'accesskey' );
3715 }
3716
3717 this.$accessKeyed = $accessKeyed;
3718 if ( this.accessKey ) {
3719 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3720 }
3721 };
3722
3723 /**
3724 * Set access key.
3725 *
3726 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3727 * access key
3728 * @chainable
3729 * @return {OO.ui.Element} The element, for chaining
3730 */
3731 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3732 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3733
3734 if ( this.accessKey !== accessKey ) {
3735 if ( this.$accessKeyed ) {
3736 if ( accessKey !== null ) {
3737 this.$accessKeyed.attr( 'accesskey', accessKey );
3738 } else {
3739 this.$accessKeyed.removeAttr( 'accesskey' );
3740 }
3741 }
3742 this.accessKey = accessKey;
3743
3744 // Only if this is a TitledElement
3745 if ( this.updateTitle ) {
3746 this.updateTitle();
3747 }
3748 }
3749
3750 return this;
3751 };
3752
3753 /**
3754 * Get access key.
3755 *
3756 * @return {string} accessKey string
3757 */
3758 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3759 return this.accessKey;
3760 };
3761
3762 /**
3763 * Add information about the access key to the element's tooltip label.
3764 * (This is only public for hacky usage in FieldLayout.)
3765 *
3766 * @param {string} title Tooltip label for `title` attribute
3767 * @return {string}
3768 */
3769 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3770 var accessKey;
3771
3772 if ( !this.$accessKeyed ) {
3773 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3774 // function.
3775 return title;
3776 }
3777 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3778 // single key.
3779 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3780 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3781 } else {
3782 accessKey = this.getAccessKey();
3783 }
3784 if ( accessKey ) {
3785 title += ' [' + accessKey + ']';
3786 }
3787 return title;
3788 };
3789
3790 /**
3791 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3792 * feels, and functionality can be customized via the class’s configuration options
3793 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3794 * and examples.
3795 *
3796 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3797 *
3798 * @example
3799 * // A button widget.
3800 * var button = new OO.ui.ButtonWidget( {
3801 * label: 'Button with Icon',
3802 * icon: 'trash',
3803 * title: 'Remove'
3804 * } );
3805 * $( document.body ).append( button.$element );
3806 *
3807 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3808 *
3809 * @class
3810 * @extends OO.ui.Widget
3811 * @mixins OO.ui.mixin.ButtonElement
3812 * @mixins OO.ui.mixin.IconElement
3813 * @mixins OO.ui.mixin.IndicatorElement
3814 * @mixins OO.ui.mixin.LabelElement
3815 * @mixins OO.ui.mixin.TitledElement
3816 * @mixins OO.ui.mixin.FlaggedElement
3817 * @mixins OO.ui.mixin.TabIndexedElement
3818 * @mixins OO.ui.mixin.AccessKeyedElement
3819 *
3820 * @constructor
3821 * @param {Object} [config] Configuration options
3822 * @cfg {boolean} [active=false] Whether button should be shown as active
3823 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3824 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3825 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3826 */
3827 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3828 // Configuration initialization
3829 config = config || {};
3830
3831 // Parent constructor
3832 OO.ui.ButtonWidget.parent.call( this, config );
3833
3834 // Mixin constructors
3835 OO.ui.mixin.ButtonElement.call( this, config );
3836 OO.ui.mixin.IconElement.call( this, config );
3837 OO.ui.mixin.IndicatorElement.call( this, config );
3838 OO.ui.mixin.LabelElement.call( this, config );
3839 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, {
3840 $titled: this.$button
3841 } ) );
3842 OO.ui.mixin.FlaggedElement.call( this, config );
3843 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
3844 $tabIndexed: this.$button
3845 } ) );
3846 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, {
3847 $accessKeyed: this.$button
3848 } ) );
3849
3850 // Properties
3851 this.href = null;
3852 this.target = null;
3853 this.noFollow = false;
3854
3855 // Events
3856 this.connect( this, {
3857 disable: 'onDisable'
3858 } );
3859
3860 // Initialization
3861 this.$button.append( this.$icon, this.$label, this.$indicator );
3862 this.$element
3863 .addClass( 'oo-ui-buttonWidget' )
3864 .append( this.$button );
3865 this.setActive( config.active );
3866 this.setHref( config.href );
3867 this.setTarget( config.target );
3868 this.setNoFollow( config.noFollow );
3869 };
3870
3871 /* Setup */
3872
3873 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3874 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3875 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3876 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3877 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3878 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3879 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3880 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3881 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3882
3883 /* Static Properties */
3884
3885 /**
3886 * @static
3887 * @inheritdoc
3888 */
3889 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3890
3891 /**
3892 * @static
3893 * @inheritdoc
3894 */
3895 OO.ui.ButtonWidget.static.tagName = 'span';
3896
3897 /* Methods */
3898
3899 /**
3900 * Get hyperlink location.
3901 *
3902 * @return {string} Hyperlink location
3903 */
3904 OO.ui.ButtonWidget.prototype.getHref = function () {
3905 return this.href;
3906 };
3907
3908 /**
3909 * Get hyperlink target.
3910 *
3911 * @return {string} Hyperlink target
3912 */
3913 OO.ui.ButtonWidget.prototype.getTarget = function () {
3914 return this.target;
3915 };
3916
3917 /**
3918 * Get search engine traversal hint.
3919 *
3920 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3921 */
3922 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3923 return this.noFollow;
3924 };
3925
3926 /**
3927 * Set hyperlink location.
3928 *
3929 * @param {string|null} href Hyperlink location, null to remove
3930 * @chainable
3931 * @return {OO.ui.Widget} The widget, for chaining
3932 */
3933 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3934 href = typeof href === 'string' ? href : null;
3935 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3936 href = './' + href;
3937 }
3938
3939 if ( href !== this.href ) {
3940 this.href = href;
3941 this.updateHref();
3942 }
3943
3944 return this;
3945 };
3946
3947 /**
3948 * Update the `href` attribute, in case of changes to href or
3949 * disabled state.
3950 *
3951 * @private
3952 * @chainable
3953 * @return {OO.ui.Widget} The widget, for chaining
3954 */
3955 OO.ui.ButtonWidget.prototype.updateHref = function () {
3956 if ( this.href !== null && !this.isDisabled() ) {
3957 this.$button.attr( 'href', this.href );
3958 } else {
3959 this.$button.removeAttr( 'href' );
3960 }
3961
3962 return this;
3963 };
3964
3965 /**
3966 * Handle disable events.
3967 *
3968 * @private
3969 * @param {boolean} disabled Element is disabled
3970 */
3971 OO.ui.ButtonWidget.prototype.onDisable = function () {
3972 this.updateHref();
3973 };
3974
3975 /**
3976 * Set hyperlink target.
3977 *
3978 * @param {string|null} target Hyperlink target, null to remove
3979 * @return {OO.ui.Widget} The widget, for chaining
3980 */
3981 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3982 target = typeof target === 'string' ? target : null;
3983
3984 if ( target !== this.target ) {
3985 this.target = target;
3986 if ( target !== null ) {
3987 this.$button.attr( 'target', target );
3988 } else {
3989 this.$button.removeAttr( 'target' );
3990 }
3991 }
3992
3993 return this;
3994 };
3995
3996 /**
3997 * Set search engine traversal hint.
3998 *
3999 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
4000 * @return {OO.ui.Widget} The widget, for chaining
4001 */
4002 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
4003 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
4004
4005 if ( noFollow !== this.noFollow ) {
4006 this.noFollow = noFollow;
4007 if ( noFollow ) {
4008 this.$button.attr( 'rel', 'nofollow' );
4009 } else {
4010 this.$button.removeAttr( 'rel' );
4011 }
4012 }
4013
4014 return this;
4015 };
4016
4017 // Override method visibility hints from ButtonElement
4018 /**
4019 * @method setActive
4020 * @inheritdoc
4021 */
4022 /**
4023 * @method isActive
4024 * @inheritdoc
4025 */
4026
4027 /**
4028 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
4029 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
4030 * removed, and cleared from the group.
4031 *
4032 * @example
4033 * // A ButtonGroupWidget with two buttons.
4034 * var button1 = new OO.ui.PopupButtonWidget( {
4035 * label: 'Select a category',
4036 * icon: 'menu',
4037 * popup: {
4038 * $content: $( '<p>List of categories…</p>' ),
4039 * padded: true,
4040 * align: 'left'
4041 * }
4042 * } ),
4043 * button2 = new OO.ui.ButtonWidget( {
4044 * label: 'Add item'
4045 * } ),
4046 * buttonGroup = new OO.ui.ButtonGroupWidget( {
4047 * items: [ button1, button2 ]
4048 * } );
4049 * $( document.body ).append( buttonGroup.$element );
4050 *
4051 * @class
4052 * @extends OO.ui.Widget
4053 * @mixins OO.ui.mixin.GroupElement
4054 * @mixins OO.ui.mixin.TitledElement
4055 *
4056 * @constructor
4057 * @param {Object} [config] Configuration options
4058 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
4059 */
4060 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
4061 // Configuration initialization
4062 config = config || {};
4063
4064 // Parent constructor
4065 OO.ui.ButtonGroupWidget.parent.call( this, config );
4066
4067 // Mixin constructors
4068 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, {
4069 $group: this.$element
4070 } ) );
4071 OO.ui.mixin.TitledElement.call( this, config );
4072
4073 // Initialization
4074 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
4075 if ( Array.isArray( config.items ) ) {
4076 this.addItems( config.items );
4077 }
4078 };
4079
4080 /* Setup */
4081
4082 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
4083 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
4084 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
4085
4086 /* Static Properties */
4087
4088 /**
4089 * @static
4090 * @inheritdoc
4091 */
4092 OO.ui.ButtonGroupWidget.static.tagName = 'span';
4093
4094 /* Methods */
4095
4096 /**
4097 * Focus the widget
4098 *
4099 * @chainable
4100 * @return {OO.ui.Widget} The widget, for chaining
4101 */
4102 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4103 if ( !this.isDisabled() ) {
4104 if ( this.items[ 0 ] ) {
4105 this.items[ 0 ].focus();
4106 }
4107 }
4108 return this;
4109 };
4110
4111 /**
4112 * @inheritdoc
4113 */
4114 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4115 this.focus();
4116 };
4117
4118 /**
4119 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
4120 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
4121 * identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4122 * for a list of icons included in the library.
4123 *
4124 * @example
4125 * // An IconWidget with a label via LabelWidget.
4126 * var myIcon = new OO.ui.IconWidget( {
4127 * icon: 'help',
4128 * title: 'Help'
4129 * } ),
4130 * // Create a label.
4131 * iconLabel = new OO.ui.LabelWidget( {
4132 * label: 'Help'
4133 * } );
4134 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4135 *
4136 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4137 *
4138 * @class
4139 * @extends OO.ui.Widget
4140 * @mixins OO.ui.mixin.IconElement
4141 * @mixins OO.ui.mixin.TitledElement
4142 * @mixins OO.ui.mixin.LabelElement
4143 * @mixins OO.ui.mixin.FlaggedElement
4144 *
4145 * @constructor
4146 * @param {Object} [config] Configuration options
4147 */
4148 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4149 // Configuration initialization
4150 config = config || {};
4151
4152 // Parent constructor
4153 OO.ui.IconWidget.parent.call( this, config );
4154
4155 // Mixin constructors
4156 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, {
4157 $icon: this.$element
4158 } ) );
4159 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, {
4160 $titled: this.$element
4161 } ) );
4162 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
4163 $label: this.$element,
4164 invisibleLabel: true
4165 } ) );
4166 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, {
4167 $flagged: this.$element
4168 } ) );
4169
4170 // Initialization
4171 this.$element.addClass( 'oo-ui-iconWidget' );
4172 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4173 // nested in other widgets, because this widget used to not mix in LabelElement.
4174 this.$element.removeClass( 'oo-ui-labelElement-label' );
4175 };
4176
4177 /* Setup */
4178
4179 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4180 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4181 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4182 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4183 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4184
4185 /* Static Properties */
4186
4187 /**
4188 * @static
4189 * @inheritdoc
4190 */
4191 OO.ui.IconWidget.static.tagName = 'span';
4192
4193 /**
4194 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4195 * attention to the status of an item or to clarify the function within a control. For a list of
4196 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4197 *
4198 * @example
4199 * // An indicator widget.
4200 * var indicator1 = new OO.ui.IndicatorWidget( {
4201 * indicator: 'required'
4202 * } ),
4203 * // Create a fieldset layout to add a label.
4204 * fieldset = new OO.ui.FieldsetLayout();
4205 * fieldset.addItems( [
4206 * new OO.ui.FieldLayout( indicator1, {
4207 * label: 'A required indicator:'
4208 * } )
4209 * ] );
4210 * $( document.body ).append( fieldset.$element );
4211 *
4212 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4213 *
4214 * @class
4215 * @extends OO.ui.Widget
4216 * @mixins OO.ui.mixin.IndicatorElement
4217 * @mixins OO.ui.mixin.TitledElement
4218 * @mixins OO.ui.mixin.LabelElement
4219 *
4220 * @constructor
4221 * @param {Object} [config] Configuration options
4222 */
4223 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4224 // Configuration initialization
4225 config = config || {};
4226
4227 // Parent constructor
4228 OO.ui.IndicatorWidget.parent.call( this, config );
4229
4230 // Mixin constructors
4231 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, {
4232 $indicator: this.$element
4233 } ) );
4234 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, {
4235 $titled: this.$element
4236 } ) );
4237 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
4238 $label: this.$element,
4239 invisibleLabel: true
4240 } ) );
4241
4242 // Initialization
4243 this.$element.addClass( 'oo-ui-indicatorWidget' );
4244 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4245 // nested in other widgets, because this widget used to not mix in LabelElement.
4246 this.$element.removeClass( 'oo-ui-labelElement-label' );
4247 };
4248
4249 /* Setup */
4250
4251 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4252 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4253 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4254 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4255
4256 /* Static Properties */
4257
4258 /**
4259 * @static
4260 * @inheritdoc
4261 */
4262 OO.ui.IndicatorWidget.static.tagName = 'span';
4263
4264 /**
4265 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4266 * be configured with a `label` option that is set to a string, a label node, or a function:
4267 *
4268 * - String: a plaintext string
4269 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4270 * label that includes a link or special styling, such as a gray color or additional
4271 * graphical elements.
4272 * - Function: a function that will produce a string in the future. Functions are used
4273 * in cases where the value of the label is not currently defined.
4274 *
4275 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4276 * which will come into focus when the label is clicked.
4277 *
4278 * @example
4279 * // Two LabelWidgets.
4280 * var label1 = new OO.ui.LabelWidget( {
4281 * label: 'plaintext label'
4282 * } ),
4283 * label2 = new OO.ui.LabelWidget( {
4284 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4285 * } ),
4286 * // Create a fieldset layout with fields for each example.
4287 * fieldset = new OO.ui.FieldsetLayout();
4288 * fieldset.addItems( [
4289 * new OO.ui.FieldLayout( label1 ),
4290 * new OO.ui.FieldLayout( label2 )
4291 * ] );
4292 * $( document.body ).append( fieldset.$element );
4293 *
4294 * @class
4295 * @extends OO.ui.Widget
4296 * @mixins OO.ui.mixin.LabelElement
4297 * @mixins OO.ui.mixin.TitledElement
4298 *
4299 * @constructor
4300 * @param {Object} [config] Configuration options
4301 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4302 * Clicking the label will focus the specified input field.
4303 */
4304 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4305 // Configuration initialization
4306 config = config || {};
4307
4308 // Parent constructor
4309 OO.ui.LabelWidget.parent.call( this, config );
4310
4311 // Mixin constructors
4312 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
4313 $label: this.$element
4314 } ) );
4315 OO.ui.mixin.TitledElement.call( this, config );
4316
4317 // Properties
4318 this.input = config.input;
4319
4320 // Initialization
4321 if ( this.input ) {
4322 if ( this.input.getInputId() ) {
4323 this.$element.attr( 'for', this.input.getInputId() );
4324 } else {
4325 this.$label.on( 'click', function () {
4326 this.input.simulateLabelClick();
4327 }.bind( this ) );
4328 }
4329 }
4330 this.$element.addClass( 'oo-ui-labelWidget' );
4331 };
4332
4333 /* Setup */
4334
4335 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4336 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4337 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4338
4339 /* Static Properties */
4340
4341 /**
4342 * @static
4343 * @inheritdoc
4344 */
4345 OO.ui.LabelWidget.static.tagName = 'label';
4346
4347 /**
4348 * PendingElement is a mixin that is used to create elements that notify users that something is
4349 * happening and that they should wait before proceeding. The pending state is visually represented
4350 * with a pending texture that appears in the head of a pending
4351 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4352 * {@link OO.ui.TextInputWidget text input widget}.
4353 *
4354 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4355 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4356 * not currently supported for action widgets used in process dialogs.
4357 *
4358 * @example
4359 * function MessageDialog( config ) {
4360 * MessageDialog.parent.call( this, config );
4361 * }
4362 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4363 *
4364 * MessageDialog.static.name = 'myMessageDialog';
4365 * MessageDialog.static.actions = [
4366 * { action: 'save', label: 'Done', flags: 'primary' },
4367 * { label: 'Cancel', flags: 'safe' }
4368 * ];
4369 *
4370 * MessageDialog.prototype.initialize = function () {
4371 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4372 * this.content = new OO.ui.PanelLayout( { padded: true } );
4373 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4374 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4375 * 'process dialogs.</p>' );
4376 * this.$body.append( this.content.$element );
4377 * };
4378 * MessageDialog.prototype.getBodyHeight = function () {
4379 * return 100;
4380 * }
4381 * MessageDialog.prototype.getActionProcess = function ( action ) {
4382 * var dialog = this;
4383 * if ( action === 'save' ) {
4384 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4385 * return new OO.ui.Process()
4386 * .next( 1000 )
4387 * .next( function () {
4388 * dialog.getActions().get({actions: 'save'})[0].popPending();
4389 * } );
4390 * }
4391 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4392 * };
4393 *
4394 * var windowManager = new OO.ui.WindowManager();
4395 * $( document.body ).append( windowManager.$element );
4396 *
4397 * var dialog = new MessageDialog();
4398 * windowManager.addWindows( [ dialog ] );
4399 * windowManager.openWindow( dialog );
4400 *
4401 * @abstract
4402 * @class
4403 *
4404 * @constructor
4405 * @param {Object} [config] Configuration options
4406 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4407 */
4408 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4409 // Configuration initialization
4410 config = config || {};
4411
4412 // Properties
4413 this.pending = 0;
4414 this.$pending = null;
4415
4416 // Initialisation
4417 this.setPendingElement( config.$pending || this.$element );
4418 };
4419
4420 /* Setup */
4421
4422 OO.initClass( OO.ui.mixin.PendingElement );
4423
4424 /* Methods */
4425
4426 /**
4427 * Set the pending element (and clean up any existing one).
4428 *
4429 * @param {jQuery} $pending The element to set to pending.
4430 */
4431 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4432 if ( this.$pending ) {
4433 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4434 }
4435
4436 this.$pending = $pending;
4437 if ( this.pending > 0 ) {
4438 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4439 }
4440 };
4441
4442 /**
4443 * Check if an element is pending.
4444 *
4445 * @return {boolean} Element is pending
4446 */
4447 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4448 return !!this.pending;
4449 };
4450
4451 /**
4452 * Increase the pending counter. The pending state will remain active until the counter is zero
4453 * (i.e., the number of calls to #pushPending and #popPending is the same).
4454 *
4455 * @chainable
4456 * @return {OO.ui.Element} The element, for chaining
4457 */
4458 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4459 if ( this.pending === 0 ) {
4460 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4461 this.updateThemeClasses();
4462 }
4463 this.pending++;
4464
4465 return this;
4466 };
4467
4468 /**
4469 * Decrease the pending counter. The pending state will remain active until the counter is zero
4470 * (i.e., the number of calls to #pushPending and #popPending is the same).
4471 *
4472 * @chainable
4473 * @return {OO.ui.Element} The element, for chaining
4474 */
4475 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4476 if ( this.pending === 1 ) {
4477 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4478 this.updateThemeClasses();
4479 }
4480 this.pending = Math.max( 0, this.pending - 1 );
4481
4482 return this;
4483 };
4484
4485 /**
4486 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4487 * in the document (for example, in an OO.ui.Window's $overlay).
4488 *
4489 * The elements's position is automatically calculated and maintained when window is resized or the
4490 * page is scrolled. If you reposition the container manually, you have to call #position to make
4491 * sure the element is still placed correctly.
4492 *
4493 * As positioning is only possible when both the element and the container are attached to the DOM
4494 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4495 * the #toggle method to display a floating popup, for example.
4496 *
4497 * @abstract
4498 * @class
4499 *
4500 * @constructor
4501 * @param {Object} [config] Configuration options
4502 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4503 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4504 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4505 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4506 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4507 * 'top': Align the top edge with $floatableContainer's top edge
4508 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4509 * 'center': Vertically align the center with $floatableContainer's center
4510 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4511 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4512 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4513 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4514 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4515 * 'center': Horizontally align the center with $floatableContainer's center
4516 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4517 * is out of view
4518 */
4519 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4520 // Configuration initialization
4521 config = config || {};
4522
4523 // Properties
4524 this.$floatable = null;
4525 this.$floatableContainer = null;
4526 this.$floatableWindow = null;
4527 this.$floatableClosestScrollable = null;
4528 this.floatableOutOfView = false;
4529 this.onFloatableScrollHandler = this.position.bind( this );
4530 this.onFloatableWindowResizeHandler = this.position.bind( this );
4531
4532 // Initialization
4533 this.setFloatableContainer( config.$floatableContainer );
4534 this.setFloatableElement( config.$floatable || this.$element );
4535 this.setVerticalPosition( config.verticalPosition || 'below' );
4536 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4537 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
4538 true : !!config.hideWhenOutOfView;
4539 };
4540
4541 /* Methods */
4542
4543 /**
4544 * Set floatable element.
4545 *
4546 * If an element is already set, it will be cleaned up before setting up the new element.
4547 *
4548 * @param {jQuery} $floatable Element to make floatable
4549 */
4550 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4551 if ( this.$floatable ) {
4552 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4553 this.$floatable.css( { left: '', top: '' } );
4554 }
4555
4556 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4557 this.position();
4558 };
4559
4560 /**
4561 * Set floatable container.
4562 *
4563 * The element will be positioned relative to the specified container.
4564 *
4565 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4566 */
4567 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4568 this.$floatableContainer = $floatableContainer;
4569 if ( this.$floatable ) {
4570 this.position();
4571 }
4572 };
4573
4574 /**
4575 * Change how the element is positioned vertically.
4576 *
4577 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4578 */
4579 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4580 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4581 throw new Error( 'Invalid value for vertical position: ' + position );
4582 }
4583 if ( this.verticalPosition !== position ) {
4584 this.verticalPosition = position;
4585 if ( this.$floatable ) {
4586 this.position();
4587 }
4588 }
4589 };
4590
4591 /**
4592 * Change how the element is positioned horizontally.
4593 *
4594 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4595 */
4596 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4597 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4598 throw new Error( 'Invalid value for horizontal position: ' + position );
4599 }
4600 if ( this.horizontalPosition !== position ) {
4601 this.horizontalPosition = position;
4602 if ( this.$floatable ) {
4603 this.position();
4604 }
4605 }
4606 };
4607
4608 /**
4609 * Toggle positioning.
4610 *
4611 * Do not turn positioning on until after the element is attached to the DOM and visible.
4612 *
4613 * @param {boolean} [positioning] Enable positioning, omit to toggle
4614 * @chainable
4615 * @return {OO.ui.Element} The element, for chaining
4616 */
4617 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4618 var closestScrollableOfContainer;
4619
4620 if ( !this.$floatable || !this.$floatableContainer ) {
4621 return this;
4622 }
4623
4624 positioning = positioning === undefined ? !this.positioning : !!positioning;
4625
4626 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4627 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4628 this.warnedUnattached = true;
4629 }
4630
4631 if ( this.positioning !== positioning ) {
4632 this.positioning = positioning;
4633
4634 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
4635 this.$floatableContainer[ 0 ]
4636 );
4637 // If the scrollable is the root, we have to listen to scroll events
4638 // on the window because of browser inconsistencies.
4639 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4640 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
4641 closestScrollableOfContainer
4642 );
4643 }
4644
4645 if ( positioning ) {
4646 this.$floatableWindow = $( this.getElementWindow() );
4647 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4648
4649 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4650 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4651
4652 // Initial position after visible
4653 this.position();
4654 } else {
4655 if ( this.$floatableWindow ) {
4656 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4657 this.$floatableWindow = null;
4658 }
4659
4660 if ( this.$floatableClosestScrollable ) {
4661 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4662 this.$floatableClosestScrollable = null;
4663 }
4664
4665 this.$floatable.css( { left: '', right: '', top: '' } );
4666 }
4667 }
4668
4669 return this;
4670 };
4671
4672 /**
4673 * Check whether the bottom edge of the given element is within the viewport of the given
4674 * container.
4675 *
4676 * @private
4677 * @param {jQuery} $element
4678 * @param {jQuery} $container
4679 * @return {boolean}
4680 */
4681 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4682 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds,
4683 rightEdgeInBounds, startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4684 direction = $element.css( 'direction' );
4685
4686 elemRect = $element[ 0 ].getBoundingClientRect();
4687 if ( $container[ 0 ] === window ) {
4688 viewportSpacing = OO.ui.getViewportSpacing();
4689 contRect = {
4690 top: 0,
4691 left: 0,
4692 right: document.documentElement.clientWidth,
4693 bottom: document.documentElement.clientHeight
4694 };
4695 contRect.top += viewportSpacing.top;
4696 contRect.left += viewportSpacing.left;
4697 contRect.right -= viewportSpacing.right;
4698 contRect.bottom -= viewportSpacing.bottom;
4699 } else {
4700 contRect = $container[ 0 ].getBoundingClientRect();
4701 }
4702
4703 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4704 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4705 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4706 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4707 if ( direction === 'rtl' ) {
4708 startEdgeInBounds = rightEdgeInBounds;
4709 endEdgeInBounds = leftEdgeInBounds;
4710 } else {
4711 startEdgeInBounds = leftEdgeInBounds;
4712 endEdgeInBounds = rightEdgeInBounds;
4713 }
4714
4715 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4716 return false;
4717 }
4718 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4719 return false;
4720 }
4721 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4722 return false;
4723 }
4724 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4725 return false;
4726 }
4727
4728 // The other positioning values are all about being inside the container,
4729 // so in those cases all we care about is that any part of the container is visible.
4730 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4731 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4732 };
4733
4734 /**
4735 * Check if the floatable is hidden to the user because it was offscreen.
4736 *
4737 * @return {boolean} Floatable is out of view
4738 */
4739 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4740 return this.floatableOutOfView;
4741 };
4742
4743 /**
4744 * Position the floatable below its container.
4745 *
4746 * This should only be done when both of them are attached to the DOM and visible.
4747 *
4748 * @chainable
4749 * @return {OO.ui.Element} The element, for chaining
4750 */
4751 OO.ui.mixin.FloatableElement.prototype.position = function () {
4752 if ( !this.positioning ) {
4753 return this;
4754 }
4755
4756 if ( !(
4757 // To continue, some things need to be true:
4758 // The element must actually be in the DOM
4759 this.isElementAttached() && (
4760 // The closest scrollable is the current window
4761 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4762 // OR is an element in the element's DOM
4763 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4764 )
4765 ) ) {
4766 // Abort early if important parts of the widget are no longer attached to the DOM
4767 return this;
4768 }
4769
4770 this.floatableOutOfView = this.hideWhenOutOfView &&
4771 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4772 if ( this.floatableOutOfView ) {
4773 this.$floatable.addClass( 'oo-ui-element-hidden' );
4774 return this;
4775 } else {
4776 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4777 }
4778
4779 this.$floatable.css( this.computePosition() );
4780
4781 // We updated the position, so re-evaluate the clipping state.
4782 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4783 // will not notice the need to update itself.)
4784 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
4785 // Why does it not listen to the right events in the right places?
4786 if ( this.clip ) {
4787 this.clip();
4788 }
4789
4790 return this;
4791 };
4792
4793 /**
4794 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4795 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4796 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4797 *
4798 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4799 */
4800 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4801 var isBody, scrollableX, scrollableY, containerPos,
4802 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4803 newPos = { top: '', left: '', bottom: '', right: '' },
4804 direction = this.$floatableContainer.css( 'direction' ),
4805 $offsetParent = this.$floatable.offsetParent();
4806
4807 if ( $offsetParent.is( 'html' ) ) {
4808 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4809 // <html> element, but they do work on the <body>
4810 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4811 }
4812 isBody = $offsetParent.is( 'body' );
4813 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
4814 $offsetParent.css( 'overflow-x' ) === 'auto';
4815 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
4816 $offsetParent.css( 'overflow-y' ) === 'auto';
4817
4818 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4819 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4820 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
4821 // is the body, or if it isn't scrollable
4822 scrollTop = scrollableY && !isBody ?
4823 $offsetParent.scrollTop() : 0;
4824 scrollLeft = scrollableX && !isBody ?
4825 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4826
4827 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4828 // if the <body> has a margin
4829 containerPos = isBody ?
4830 this.$floatableContainer.offset() :
4831 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4832 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4833 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4834 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4835 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4836
4837 if ( this.verticalPosition === 'below' ) {
4838 newPos.top = containerPos.bottom;
4839 } else if ( this.verticalPosition === 'above' ) {
4840 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4841 } else if ( this.verticalPosition === 'top' ) {
4842 newPos.top = containerPos.top;
4843 } else if ( this.verticalPosition === 'bottom' ) {
4844 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4845 } else if ( this.verticalPosition === 'center' ) {
4846 newPos.top = containerPos.top +
4847 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4848 }
4849
4850 if ( this.horizontalPosition === 'before' ) {
4851 newPos.end = containerPos.start;
4852 } else if ( this.horizontalPosition === 'after' ) {
4853 newPos.start = containerPos.end;
4854 } else if ( this.horizontalPosition === 'start' ) {
4855 newPos.start = containerPos.start;
4856 } else if ( this.horizontalPosition === 'end' ) {
4857 newPos.end = containerPos.end;
4858 } else if ( this.horizontalPosition === 'center' ) {
4859 newPos.left = containerPos.left +
4860 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4861 }
4862
4863 if ( newPos.start !== undefined ) {
4864 if ( direction === 'rtl' ) {
4865 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4866 $offsetParent ).outerWidth() - newPos.start;
4867 } else {
4868 newPos.left = newPos.start;
4869 }
4870 delete newPos.start;
4871 }
4872 if ( newPos.end !== undefined ) {
4873 if ( direction === 'rtl' ) {
4874 newPos.left = newPos.end;
4875 } else {
4876 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4877 $offsetParent ).outerWidth() - newPos.end;
4878 }
4879 delete newPos.end;
4880 }
4881
4882 // Account for scroll position
4883 if ( newPos.top !== '' ) {
4884 newPos.top += scrollTop;
4885 }
4886 if ( newPos.bottom !== '' ) {
4887 newPos.bottom -= scrollTop;
4888 }
4889 if ( newPos.left !== '' ) {
4890 newPos.left += scrollLeft;
4891 }
4892 if ( newPos.right !== '' ) {
4893 newPos.right -= scrollLeft;
4894 }
4895
4896 // Account for scrollbar gutter
4897 if ( newPos.bottom !== '' ) {
4898 newPos.bottom -= horizScrollbarHeight;
4899 }
4900 if ( direction === 'rtl' ) {
4901 if ( newPos.left !== '' ) {
4902 newPos.left -= vertScrollbarWidth;
4903 }
4904 } else {
4905 if ( newPos.right !== '' ) {
4906 newPos.right -= vertScrollbarWidth;
4907 }
4908 }
4909
4910 return newPos;
4911 };
4912
4913 /**
4914 * Element that can be automatically clipped to visible boundaries.
4915 *
4916 * Whenever the element's natural height changes, you have to call
4917 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4918 * clipping correctly.
4919 *
4920 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4921 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4922 * then #$clippable will be given a fixed reduced height and/or width and will be made
4923 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4924 * but you can build a static footer by setting #$clippableContainer to an element that contains
4925 * #$clippable and the footer.
4926 *
4927 * @abstract
4928 * @class
4929 *
4930 * @constructor
4931 * @param {Object} [config] Configuration options
4932 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4933 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4934 * omit to use #$clippable
4935 */
4936 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4937 // Configuration initialization
4938 config = config || {};
4939
4940 // Properties
4941 this.$clippable = null;
4942 this.$clippableContainer = null;
4943 this.clipping = false;
4944 this.clippedHorizontally = false;
4945 this.clippedVertically = false;
4946 this.$clippableScrollableContainer = null;
4947 this.$clippableScroller = null;
4948 this.$clippableWindow = null;
4949 this.idealWidth = null;
4950 this.idealHeight = null;
4951 this.onClippableScrollHandler = this.clip.bind( this );
4952 this.onClippableWindowResizeHandler = this.clip.bind( this );
4953
4954 // Initialization
4955 if ( config.$clippableContainer ) {
4956 this.setClippableContainer( config.$clippableContainer );
4957 }
4958 this.setClippableElement( config.$clippable || this.$element );
4959 };
4960
4961 /* Methods */
4962
4963 /**
4964 * Set clippable element.
4965 *
4966 * If an element is already set, it will be cleaned up before setting up the new element.
4967 *
4968 * @param {jQuery} $clippable Element to make clippable
4969 */
4970 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4971 if ( this.$clippable ) {
4972 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4973 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4974 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4975 }
4976
4977 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4978 this.clip();
4979 };
4980
4981 /**
4982 * Set clippable container.
4983 *
4984 * This is the container that will be measured when deciding whether to clip. When clipping,
4985 * #$clippable will be resized in order to keep the clippable container fully visible.
4986 *
4987 * If the clippable container is unset, #$clippable will be used.
4988 *
4989 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4990 */
4991 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4992 this.$clippableContainer = $clippableContainer;
4993 if ( this.$clippable ) {
4994 this.clip();
4995 }
4996 };
4997
4998 /**
4999 * Toggle clipping.
5000 *
5001 * Do not turn clipping on until after the element is attached to the DOM and visible.
5002 *
5003 * @param {boolean} [clipping] Enable clipping, omit to toggle
5004 * @chainable
5005 * @return {OO.ui.Element} The element, for chaining
5006 */
5007 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
5008 clipping = clipping === undefined ? !this.clipping : !!clipping;
5009
5010 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
5011 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
5012 this.warnedUnattached = true;
5013 }
5014
5015 if ( this.clipping !== clipping ) {
5016 this.clipping = clipping;
5017 if ( clipping ) {
5018 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
5019 // If the clippable container is the root, we have to listen to scroll events and check
5020 // jQuery.scrollTop on the window because of browser inconsistencies
5021 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
5022 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
5023 this.$clippableScrollableContainer;
5024 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
5025 this.$clippableWindow = $( this.getElementWindow() )
5026 .on( 'resize', this.onClippableWindowResizeHandler );
5027 // Initial clip after visible
5028 this.clip();
5029 } else {
5030 this.$clippable.css( {
5031 width: '',
5032 height: '',
5033 maxWidth: '',
5034 maxHeight: '',
5035 overflowX: '',
5036 overflowY: ''
5037 } );
5038 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5039
5040 this.$clippableScrollableContainer = null;
5041 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
5042 this.$clippableScroller = null;
5043 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
5044 this.$clippableWindow = null;
5045 }
5046 }
5047
5048 return this;
5049 };
5050
5051 /**
5052 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
5053 *
5054 * @return {boolean} Element will be clipped to the visible area
5055 */
5056 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
5057 return this.clipping;
5058 };
5059
5060 /**
5061 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
5062 *
5063 * @return {boolean} Part of the element is being clipped
5064 */
5065 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
5066 return this.clippedHorizontally || this.clippedVertically;
5067 };
5068
5069 /**
5070 * Check if the right of the element is being clipped by the nearest scrollable container.
5071 *
5072 * @return {boolean} Part of the element is being clipped
5073 */
5074 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
5075 return this.clippedHorizontally;
5076 };
5077
5078 /**
5079 * Check if the bottom of the element is being clipped by the nearest scrollable container.
5080 *
5081 * @return {boolean} Part of the element is being clipped
5082 */
5083 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
5084 return this.clippedVertically;
5085 };
5086
5087 /**
5088 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
5089 *
5090 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
5091 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
5092 */
5093 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
5094 this.idealWidth = width;
5095 this.idealHeight = height;
5096
5097 if ( !this.clipping ) {
5098 // Update dimensions
5099 this.$clippable.css( { width: width, height: height } );
5100 }
5101 // While clipping, idealWidth and idealHeight are not considered
5102 };
5103
5104 /**
5105 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5106 * ClippableElement will clip the opposite side when reducing element's width.
5107 *
5108 * Classes that mix in ClippableElement should override this to return 'right' if their
5109 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5110 * If your class also mixes in FloatableElement, this is handled automatically.
5111 *
5112 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5113 * always in pixels, even if they were unset or set to 'auto'.)
5114 *
5115 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5116 *
5117 * @return {string} 'left' or 'right'
5118 */
5119 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5120 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5121 return 'right';
5122 }
5123 return 'left';
5124 };
5125
5126 /**
5127 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5128 * ClippableElement will clip the opposite side when reducing element's width.
5129 *
5130 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5131 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5132 * If your class also mixes in FloatableElement, this is handled automatically.
5133 *
5134 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5135 * always in pixels, even if they were unset or set to 'auto'.)
5136 *
5137 * When in doubt, 'top' is a sane fallback.
5138 *
5139 * @return {string} 'top' or 'bottom'
5140 */
5141 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5142 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5143 return 'bottom';
5144 }
5145 return 'top';
5146 };
5147
5148 /**
5149 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5150 * when the element's natural height changes.
5151 *
5152 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5153 * overlapped by, the visible area of the nearest scrollable container.
5154 *
5155 * Because calling clip() when the natural height changes isn't always possible, we also set
5156 * max-height when the element isn't being clipped. This means that if the element tries to grow
5157 * beyond the edge, something reasonable will happen before clip() is called.
5158 *
5159 * @chainable
5160 * @return {OO.ui.Element} The element, for chaining
5161 */
5162 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5163 var extraHeight, extraWidth, viewportSpacing,
5164 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5165 naturalWidth, naturalHeight, clipWidth, clipHeight,
5166 $item, itemRect, $viewport, viewportRect, availableRect,
5167 direction, vertScrollbarWidth, horizScrollbarHeight,
5168 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5169 // by one or two pixels. (And also so that we have space to display drop shadows.)
5170 // Chosen by fair dice roll.
5171 buffer = 7;
5172
5173 if ( !this.clipping ) {
5174 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5175 // will fail
5176 return this;
5177 }
5178
5179 function rectIntersection( a, b ) {
5180 var out = {};
5181 out.top = Math.max( a.top, b.top );
5182 out.left = Math.max( a.left, b.left );
5183 out.bottom = Math.min( a.bottom, b.bottom );
5184 out.right = Math.min( a.right, b.right );
5185 return out;
5186 }
5187
5188 viewportSpacing = OO.ui.getViewportSpacing();
5189
5190 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5191 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5192 // Dimensions of the browser window, rather than the element!
5193 viewportRect = {
5194 top: 0,
5195 left: 0,
5196 right: document.documentElement.clientWidth,
5197 bottom: document.documentElement.clientHeight
5198 };
5199 viewportRect.top += viewportSpacing.top;
5200 viewportRect.left += viewportSpacing.left;
5201 viewportRect.right -= viewportSpacing.right;
5202 viewportRect.bottom -= viewportSpacing.bottom;
5203 } else {
5204 $viewport = this.$clippableScrollableContainer;
5205 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5206 // Convert into a plain object
5207 viewportRect = $.extend( {}, viewportRect );
5208 }
5209
5210 // Account for scrollbar gutter
5211 direction = $viewport.css( 'direction' );
5212 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5213 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5214 viewportRect.bottom -= horizScrollbarHeight;
5215 if ( direction === 'rtl' ) {
5216 viewportRect.left += vertScrollbarWidth;
5217 } else {
5218 viewportRect.right -= vertScrollbarWidth;
5219 }
5220
5221 // Add arbitrary tolerance
5222 viewportRect.top += buffer;
5223 viewportRect.left += buffer;
5224 viewportRect.right -= buffer;
5225 viewportRect.bottom -= buffer;
5226
5227 $item = this.$clippableContainer || this.$clippable;
5228
5229 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5230 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5231
5232 itemRect = $item[ 0 ].getBoundingClientRect();
5233 // Convert into a plain object
5234 itemRect = $.extend( {}, itemRect );
5235
5236 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5237 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5238 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5239 itemRect.left = viewportRect.left;
5240 } else {
5241 itemRect.right = viewportRect.right;
5242 }
5243 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5244 itemRect.top = viewportRect.top;
5245 } else {
5246 itemRect.bottom = viewportRect.bottom;
5247 }
5248
5249 availableRect = rectIntersection( viewportRect, itemRect );
5250
5251 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5252 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5253 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5254 desiredWidth = Math.min( desiredWidth,
5255 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5256 desiredHeight = Math.min( desiredHeight,
5257 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5258 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5259 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5260 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5261 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5262 clipWidth = allotedWidth < naturalWidth;
5263 clipHeight = allotedHeight < naturalHeight;
5264
5265 if ( clipWidth ) {
5266 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5267 // See T157672.
5268 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5269 // this case.
5270 this.$clippable.css( 'overflowX', 'scroll' );
5271 // eslint-disable-next-line no-void
5272 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5273 this.$clippable.css( {
5274 width: Math.max( 0, allotedWidth ),
5275 maxWidth: ''
5276 } );
5277 } else {
5278 this.$clippable.css( {
5279 overflowX: '',
5280 width: this.idealWidth || '',
5281 maxWidth: Math.max( 0, allotedWidth )
5282 } );
5283 }
5284 if ( clipHeight ) {
5285 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5286 // See T157672.
5287 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5288 // this case.
5289 this.$clippable.css( 'overflowY', 'scroll' );
5290 // eslint-disable-next-line no-void
5291 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5292 this.$clippable.css( {
5293 height: Math.max( 0, allotedHeight ),
5294 maxHeight: ''
5295 } );
5296 } else {
5297 this.$clippable.css( {
5298 overflowY: '',
5299 height: this.idealHeight || '',
5300 maxHeight: Math.max( 0, allotedHeight )
5301 } );
5302 }
5303
5304 // If we stopped clipping in at least one of the dimensions
5305 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5306 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5307 }
5308
5309 this.clippedHorizontally = clipWidth;
5310 this.clippedVertically = clipHeight;
5311
5312 return this;
5313 };
5314
5315 /**
5316 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5317 * By default, each popup has an anchor that points toward its origin.
5318 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5319 *
5320 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5321 *
5322 * @example
5323 * // A PopupWidget.
5324 * var popup = new OO.ui.PopupWidget( {
5325 * $content: $( '<p>Hi there!</p>' ),
5326 * padded: true,
5327 * width: 300
5328 * } );
5329 *
5330 * $( document.body ).append( popup.$element );
5331 * // To display the popup, toggle the visibility to 'true'.
5332 * popup.toggle( true );
5333 *
5334 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5335 *
5336 * @class
5337 * @extends OO.ui.Widget
5338 * @mixins OO.ui.mixin.LabelElement
5339 * @mixins OO.ui.mixin.ClippableElement
5340 * @mixins OO.ui.mixin.FloatableElement
5341 *
5342 * @constructor
5343 * @param {Object} [config] Configuration options
5344 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5345 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5346 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5347 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5348 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5349 * of $floatableContainer
5350 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5351 * of $floatableContainer
5352 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5353 * endwards (right/left) to the vertical center of $floatableContainer
5354 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5355 * startwards (left/right) to the vertical center of $floatableContainer
5356 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5357 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5358 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5359 * move the popup as far downwards as possible.
5360 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5361 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5362 * move the popup as far upwards as possible.
5363 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5364 * center of the popup with the center of $floatableContainer.
5365 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5366 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5367 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5368 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5369 * desired direction to display the popup without clipping
5370 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5371 * See the [OOUI docs on MediaWiki][3] for an example.
5372 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5373 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a
5374 * number of pixels.
5375 * @cfg {jQuery} [$content] Content to append to the popup's body
5376 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5377 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5378 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5379 * This config option is only relevant if #autoClose is set to `true`. See the
5380 * [OOUI documentation on MediaWiki][2] for an example.
5381 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5382 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5383 * button.
5384 * @cfg {boolean} [padded=false] Add padding to the popup's body
5385 */
5386 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5387 // Configuration initialization
5388 config = config || {};
5389
5390 // Parent constructor
5391 OO.ui.PopupWidget.parent.call( this, config );
5392
5393 // Properties (must be set before ClippableElement constructor call)
5394 this.$body = $( '<div>' );
5395 this.$popup = $( '<div>' );
5396
5397 // Mixin constructors
5398 OO.ui.mixin.LabelElement.call( this, config );
5399 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5400 $clippable: this.$body,
5401 $clippableContainer: this.$popup
5402 } ) );
5403 OO.ui.mixin.FloatableElement.call( this, config );
5404
5405 // Properties
5406 this.$anchor = $( '<div>' );
5407 // If undefined, will be computed lazily in computePosition()
5408 this.$container = config.$container;
5409 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5410 this.autoClose = !!config.autoClose;
5411 this.transitionTimeout = null;
5412 this.anchored = false;
5413 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5414 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5415
5416 // Initialization
5417 this.setSize( config.width, config.height );
5418 this.toggleAnchor( config.anchor === undefined || config.anchor );
5419 this.setAlignment( config.align || 'center' );
5420 this.setPosition( config.position || 'below' );
5421 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5422 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5423 this.$body.addClass( 'oo-ui-popupWidget-body' );
5424 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5425 this.$popup
5426 .addClass( 'oo-ui-popupWidget-popup' )
5427 .append( this.$body );
5428 this.$element
5429 .addClass( 'oo-ui-popupWidget' )
5430 .append( this.$popup, this.$anchor );
5431 // Move content, which was added to #$element by OO.ui.Widget, to the body
5432 // FIXME This is gross, we should use '$body' or something for the config
5433 if ( config.$content instanceof $ ) {
5434 this.$body.append( config.$content );
5435 }
5436
5437 if ( config.padded ) {
5438 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5439 }
5440
5441 if ( config.head ) {
5442 this.closeButton = new OO.ui.ButtonWidget( {
5443 framed: false,
5444 icon: 'close'
5445 } );
5446 this.closeButton.connect( this, {
5447 click: 'onCloseButtonClick'
5448 } );
5449 this.$head = $( '<div>' )
5450 .addClass( 'oo-ui-popupWidget-head' )
5451 .append( this.$label, this.closeButton.$element );
5452 this.$popup.prepend( this.$head );
5453 }
5454
5455 if ( config.$footer ) {
5456 this.$footer = $( '<div>' )
5457 .addClass( 'oo-ui-popupWidget-footer' )
5458 .append( config.$footer );
5459 this.$popup.append( this.$footer );
5460 }
5461
5462 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5463 // that reference properties not initialized at that time of parent class construction
5464 // TODO: Find a better way to handle post-constructor setup
5465 this.visible = false;
5466 this.$element.addClass( 'oo-ui-element-hidden' );
5467 };
5468
5469 /* Setup */
5470
5471 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5472 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5473 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5474 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5475
5476 /* Events */
5477
5478 /**
5479 * @event ready
5480 *
5481 * The popup is ready: it is visible and has been positioned and clipped.
5482 */
5483
5484 /* Methods */
5485
5486 /**
5487 * Handles document mouse down events.
5488 *
5489 * @private
5490 * @param {MouseEvent} e Mouse down event
5491 */
5492 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5493 if (
5494 this.isVisible() &&
5495 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5496 ) {
5497 this.toggle( false );
5498 }
5499 };
5500
5501 // Deprecated alias since 0.28.3
5502 OO.ui.PopupWidget.prototype.onMouseDown = function () {
5503 OO.ui.warnDeprecation( 'onMouseDown is deprecated, use onDocumentMouseDown instead' );
5504 this.onDocumentMouseDown.apply( this, arguments );
5505 };
5506
5507 /**
5508 * Bind document mouse down listener.
5509 *
5510 * @private
5511 */
5512 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5513 // Capture clicks outside popup
5514 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5515 // We add 'click' event because iOS safari needs to respond to this event.
5516 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5517 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5518 // of occasionally not emitting 'click' properly, that event seems to be the standard
5519 // that it should be emitting, so we add it to this and will operate the event handler
5520 // on whichever of these events was triggered first
5521 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5522 };
5523
5524 // Deprecated alias since 0.28.3
5525 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5526 OO.ui.warnDeprecation( 'bindMouseDownListener is deprecated, use bindDocumentMouseDownListener instead' );
5527 this.bindDocumentMouseDownListener.apply( this, arguments );
5528 };
5529
5530 /**
5531 * Handles close button click events.
5532 *
5533 * @private
5534 */
5535 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5536 if ( this.isVisible() ) {
5537 this.toggle( false );
5538 }
5539 };
5540
5541 /**
5542 * Unbind document mouse down listener.
5543 *
5544 * @private
5545 */
5546 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5547 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5548 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5549 };
5550
5551 // Deprecated alias since 0.28.3
5552 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5553 OO.ui.warnDeprecation( 'unbindMouseDownListener is deprecated, use unbindDocumentMouseDownListener instead' );
5554 this.unbindDocumentMouseDownListener.apply( this, arguments );
5555 };
5556
5557 /**
5558 * Handles document key down events.
5559 *
5560 * @private
5561 * @param {KeyboardEvent} e Key down event
5562 */
5563 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5564 if (
5565 e.which === OO.ui.Keys.ESCAPE &&
5566 this.isVisible()
5567 ) {
5568 this.toggle( false );
5569 e.preventDefault();
5570 e.stopPropagation();
5571 }
5572 };
5573
5574 /**
5575 * Bind document key down listener.
5576 *
5577 * @private
5578 */
5579 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5580 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5581 };
5582
5583 // Deprecated alias since 0.28.3
5584 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5585 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
5586 this.bindDocumentKeyDownListener.apply( this, arguments );
5587 };
5588
5589 /**
5590 * Unbind document key down listener.
5591 *
5592 * @private
5593 */
5594 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5595 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5596 };
5597
5598 // Deprecated alias since 0.28.3
5599 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5600 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
5601 this.unbindDocumentKeyDownListener.apply( this, arguments );
5602 };
5603
5604 /**
5605 * Show, hide, or toggle the visibility of the anchor.
5606 *
5607 * @param {boolean} [show] Show anchor, omit to toggle
5608 */
5609 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5610 show = show === undefined ? !this.anchored : !!show;
5611
5612 if ( this.anchored !== show ) {
5613 if ( show ) {
5614 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5615 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5616 } else {
5617 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5618 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5619 }
5620 this.anchored = show;
5621 }
5622 };
5623
5624 /**
5625 * Change which edge the anchor appears on.
5626 *
5627 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5628 */
5629 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5630 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5631 throw new Error( 'Invalid value for edge: ' + edge );
5632 }
5633 if ( this.anchorEdge !== null ) {
5634 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5635 }
5636 this.anchorEdge = edge;
5637 if ( this.anchored ) {
5638 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5639 }
5640 };
5641
5642 /**
5643 * Check if the anchor is visible.
5644 *
5645 * @return {boolean} Anchor is visible
5646 */
5647 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5648 return this.anchored;
5649 };
5650
5651 /**
5652 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5653 * `.toggle( true )` after its #$element is attached to the DOM.
5654 *
5655 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5656 * it in the right place and with the right dimensions only work correctly while it is attached.
5657 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5658 * strictly enforced, so currently it only generates a warning in the browser console.
5659 *
5660 * @fires ready
5661 * @inheritdoc
5662 */
5663 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5664 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5665 show = show === undefined ? !this.isVisible() : !!show;
5666
5667 change = show !== this.isVisible();
5668
5669 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5670 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5671 this.warnedUnattached = true;
5672 }
5673 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5674 // Fall back to the parent node if the floatableContainer is not set
5675 this.setFloatableContainer( this.$element.parent() );
5676 }
5677
5678 if ( change && show && this.autoFlip ) {
5679 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
5680 // flip (e.g. if the user scrolled).
5681 this.isAutoFlipped = false;
5682 }
5683
5684 // Parent method
5685 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5686
5687 if ( change ) {
5688 this.togglePositioning( show && !!this.$floatableContainer );
5689
5690 if ( show ) {
5691 if ( this.autoClose ) {
5692 this.bindDocumentMouseDownListener();
5693 this.bindDocumentKeyDownListener();
5694 }
5695 this.updateDimensions();
5696 this.toggleClipping( true );
5697
5698 if ( this.autoFlip ) {
5699 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5700 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5701 // If opening the popup in the normal direction causes it to be clipped,
5702 // open in the opposite one instead
5703 normalHeight = this.$element.height();
5704 this.isAutoFlipped = !this.isAutoFlipped;
5705 this.position();
5706 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5707 // If that also causes it to be clipped, open in whichever direction
5708 // we have more space
5709 oppositeHeight = this.$element.height();
5710 if ( oppositeHeight < normalHeight ) {
5711 this.isAutoFlipped = !this.isAutoFlipped;
5712 this.position();
5713 }
5714 }
5715 }
5716 }
5717 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5718 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5719 // If opening the popup in the normal direction causes it to be clipped,
5720 // open in the opposite one instead
5721 normalWidth = this.$element.width();
5722 this.isAutoFlipped = !this.isAutoFlipped;
5723 // Due to T180173 horizontally clipped PopupWidgets have messed up
5724 // dimensions, which causes positioning to be off. Toggle clipping back and
5725 // forth to work around.
5726 this.toggleClipping( false );
5727 this.position();
5728 this.toggleClipping( true );
5729 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5730 // If that also causes it to be clipped, open in whichever direction
5731 // we have more space
5732 oppositeWidth = this.$element.width();
5733 if ( oppositeWidth < normalWidth ) {
5734 this.isAutoFlipped = !this.isAutoFlipped;
5735 // Due to T180173, horizontally clipped PopupWidgets have messed up
5736 // dimensions, which causes positioning to be off. Toggle clipping
5737 // back and forth to work around.
5738 this.toggleClipping( false );
5739 this.position();
5740 this.toggleClipping( true );
5741 }
5742 }
5743 }
5744 }
5745 }
5746
5747 this.emit( 'ready' );
5748 } else {
5749 this.toggleClipping( false );
5750 if ( this.autoClose ) {
5751 this.unbindDocumentMouseDownListener();
5752 this.unbindDocumentKeyDownListener();
5753 }
5754 }
5755 }
5756
5757 return this;
5758 };
5759
5760 /**
5761 * Set the size of the popup.
5762 *
5763 * Changing the size may also change the popup's position depending on the alignment.
5764 *
5765 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5766 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5767 * @param {boolean} [transition=false] Use a smooth transition
5768 * @chainable
5769 */
5770 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5771 this.width = width !== undefined ? width : 320;
5772 this.height = height !== undefined ? height : null;
5773 if ( this.isVisible() ) {
5774 this.updateDimensions( transition );
5775 }
5776 };
5777
5778 /**
5779 * Update the size and position.
5780 *
5781 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5782 * be called automatically.
5783 *
5784 * @param {boolean} [transition=false] Use a smooth transition
5785 * @chainable
5786 */
5787 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5788 var widget = this;
5789
5790 // Prevent transition from being interrupted
5791 clearTimeout( this.transitionTimeout );
5792 if ( transition ) {
5793 // Enable transition
5794 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5795 }
5796
5797 this.position();
5798
5799 if ( transition ) {
5800 // Prevent transitioning after transition is complete
5801 this.transitionTimeout = setTimeout( function () {
5802 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5803 }, 200 );
5804 } else {
5805 // Prevent transitioning immediately
5806 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5807 }
5808 };
5809
5810 /**
5811 * @inheritdoc
5812 */
5813 OO.ui.PopupWidget.prototype.computePosition = function () {
5814 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize,
5815 anchorPos, anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment,
5816 floatablePos, offsetParentPos, containerPos, popupPosition, viewportSpacing,
5817 popupPos = {},
5818 anchorCss = { left: '', right: '', top: '', bottom: '' },
5819 popupPositionOppositeMap = {
5820 above: 'below',
5821 below: 'above',
5822 before: 'after',
5823 after: 'before'
5824 },
5825 alignMap = {
5826 ltr: {
5827 'force-left': 'backwards',
5828 'force-right': 'forwards'
5829 },
5830 rtl: {
5831 'force-left': 'forwards',
5832 'force-right': 'backwards'
5833 }
5834 },
5835 anchorEdgeMap = {
5836 above: 'bottom',
5837 below: 'top',
5838 before: 'end',
5839 after: 'start'
5840 },
5841 hPosMap = {
5842 forwards: 'start',
5843 center: 'center',
5844 backwards: this.anchored ? 'before' : 'end'
5845 },
5846 vPosMap = {
5847 forwards: 'top',
5848 center: 'center',
5849 backwards: 'bottom'
5850 };
5851
5852 if ( !this.$container ) {
5853 // Lazy-initialize $container if not specified in constructor
5854 this.$container = $( this.getClosestScrollableElementContainer() );
5855 }
5856 direction = this.$container.css( 'direction' );
5857
5858 // Set height and width before we do anything else, since it might cause our measurements
5859 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5860 this.$popup.css( {
5861 width: this.width !== null ? this.width : 'auto',
5862 height: this.height !== null ? this.height : 'auto'
5863 } );
5864
5865 align = alignMap[ direction ][ this.align ] || this.align;
5866 popupPosition = this.popupPosition;
5867 if ( this.isAutoFlipped ) {
5868 popupPosition = popupPositionOppositeMap[ popupPosition ];
5869 }
5870
5871 // If the popup is positioned before or after, then the anchor positioning is vertical,
5872 // otherwise horizontal
5873 vertical = popupPosition === 'before' || popupPosition === 'after';
5874 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5875 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5876 near = vertical ? 'top' : 'left';
5877 far = vertical ? 'bottom' : 'right';
5878 sizeProp = vertical ? 'Height' : 'Width';
5879 popupSize = vertical ?
5880 ( this.height || this.$popup.height() ) :
5881 ( this.width || this.$popup.width() );
5882
5883 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5884 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5885 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5886
5887 // Parent method
5888 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5889 // Find out which property FloatableElement used for positioning, and adjust that value
5890 positionProp = vertical ?
5891 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5892 ( parentPosition.left !== '' ? 'left' : 'right' );
5893
5894 // Figure out where the near and far edges of the popup and $floatableContainer are
5895 floatablePos = this.$floatableContainer.offset();
5896 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5897 // Measure where the offsetParent is and compute our position based on that and parentPosition
5898 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5899 { top: 0, left: 0 } :
5900 this.$element.offsetParent().offset();
5901
5902 if ( positionProp === near ) {
5903 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5904 popupPos[ far ] = popupPos[ near ] + popupSize;
5905 } else {
5906 popupPos[ far ] = offsetParentPos[ near ] +
5907 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5908 popupPos[ near ] = popupPos[ far ] - popupSize;
5909 }
5910
5911 if ( this.anchored ) {
5912 // Position the anchor (which is positioned relative to the popup) to point to
5913 // $floatableContainer
5914 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5915 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5916
5917 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
5918 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
5919 // scrollWidth/Height
5920 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5921 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5922 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5923 // Not enough space for the anchor on the start side; pull the popup startwards
5924 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5925 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5926 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5927 // Not enough space for the anchor on the end side; pull the popup endwards
5928 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5929 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5930 } else {
5931 positionAdjustment = 0;
5932 }
5933 } else {
5934 positionAdjustment = 0;
5935 }
5936
5937 // Check if the popup will go beyond the edge of this.$container
5938 containerPos = this.$container[ 0 ] === document.documentElement ?
5939 { top: 0, left: 0 } :
5940 this.$container.offset();
5941 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5942 if ( this.$container[ 0 ] === document.documentElement ) {
5943 viewportSpacing = OO.ui.getViewportSpacing();
5944 containerPos[ near ] += viewportSpacing[ near ];
5945 containerPos[ far ] -= viewportSpacing[ far ];
5946 }
5947 // Take into account how much the popup will move because of the adjustments we're going to make
5948 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5949 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5950 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5951 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5952 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5953 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5954 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5955 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5956 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5957 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5958 }
5959
5960 if ( this.anchored ) {
5961 // Adjust anchorOffset for positionAdjustment
5962 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5963
5964 // Position the anchor
5965 anchorCss[ start ] = anchorOffset;
5966 this.$anchor.css( anchorCss );
5967 }
5968
5969 // Move the popup if needed
5970 parentPosition[ positionProp ] += positionAdjustment;
5971
5972 return parentPosition;
5973 };
5974
5975 /**
5976 * Set popup alignment
5977 *
5978 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5979 * `backwards` or `forwards`.
5980 */
5981 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5982 // Validate alignment
5983 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5984 this.align = align;
5985 } else {
5986 this.align = 'center';
5987 }
5988 this.position();
5989 };
5990
5991 /**
5992 * Get popup alignment
5993 *
5994 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5995 * `backwards` or `forwards`.
5996 */
5997 OO.ui.PopupWidget.prototype.getAlignment = function () {
5998 return this.align;
5999 };
6000
6001 /**
6002 * Change the positioning of the popup.
6003 *
6004 * @param {string} position 'above', 'below', 'before' or 'after'
6005 */
6006 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
6007 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
6008 position = 'below';
6009 }
6010 this.popupPosition = position;
6011 this.position();
6012 };
6013
6014 /**
6015 * Get popup positioning.
6016 *
6017 * @return {string} 'above', 'below', 'before' or 'after'
6018 */
6019 OO.ui.PopupWidget.prototype.getPosition = function () {
6020 return this.popupPosition;
6021 };
6022
6023 /**
6024 * Set popup auto-flipping.
6025 *
6026 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
6027 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
6028 * desired direction to display the popup without clipping
6029 */
6030 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
6031 autoFlip = !!autoFlip;
6032
6033 if ( this.autoFlip !== autoFlip ) {
6034 this.autoFlip = autoFlip;
6035 }
6036 };
6037
6038 /**
6039 * Set which elements will not close the popup when clicked.
6040 *
6041 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
6042 *
6043 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
6044 */
6045 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
6046 this.$autoCloseIgnore = $autoCloseIgnore;
6047 };
6048
6049 /**
6050 * Get an ID of the body element, this can be used as the
6051 * `aria-describedby` attribute for an input field.
6052 *
6053 * @return {string} The ID of the body element
6054 */
6055 OO.ui.PopupWidget.prototype.getBodyId = function () {
6056 var id = this.$body.attr( 'id' );
6057 if ( id === undefined ) {
6058 id = OO.ui.generateElementId();
6059 this.$body.attr( 'id', id );
6060 }
6061 return id;
6062 };
6063
6064 /**
6065 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
6066 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
6067 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
6068 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
6069 *
6070 * @abstract
6071 * @class
6072 *
6073 * @constructor
6074 * @param {Object} [config] Configuration options
6075 * @cfg {Object} [popup] Configuration to pass to popup
6076 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
6077 */
6078 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
6079 // Configuration initialization
6080 config = config || {};
6081
6082 // Properties
6083 this.popup = new OO.ui.PopupWidget( $.extend(
6084 {
6085 autoClose: true,
6086 $floatableContainer: this.$element
6087 },
6088 config.popup,
6089 {
6090 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
6091 }
6092 ) );
6093 };
6094
6095 /* Methods */
6096
6097 /**
6098 * Get popup.
6099 *
6100 * @return {OO.ui.PopupWidget} Popup widget
6101 */
6102 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
6103 return this.popup;
6104 };
6105
6106 /**
6107 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
6108 * which is used to display additional information or options.
6109 *
6110 * @example
6111 * // A PopupButtonWidget.
6112 * var popupButton = new OO.ui.PopupButtonWidget( {
6113 * label: 'Popup button with options',
6114 * icon: 'menu',
6115 * popup: {
6116 * $content: $( '<p>Additional options here.</p>' ),
6117 * padded: true,
6118 * align: 'force-left'
6119 * }
6120 * } );
6121 * // Append the button to the DOM.
6122 * $( document.body ).append( popupButton.$element );
6123 *
6124 * @class
6125 * @extends OO.ui.ButtonWidget
6126 * @mixins OO.ui.mixin.PopupElement
6127 *
6128 * @constructor
6129 * @param {Object} [config] Configuration options
6130 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful
6131 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
6132 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
6133 * uses relative positioning.
6134 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6135 */
6136 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6137 // Configuration initialization
6138 config = config || {};
6139
6140 // Parent constructor
6141 OO.ui.PopupButtonWidget.parent.call( this, config );
6142
6143 // Mixin constructors
6144 OO.ui.mixin.PopupElement.call( this, config );
6145
6146 // Properties
6147 this.$overlay = ( config.$overlay === true ?
6148 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6149
6150 // Events
6151 this.connect( this, {
6152 click: 'onAction'
6153 } );
6154
6155 // Initialization
6156 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6157 this.popup.$element
6158 .addClass( 'oo-ui-popupButtonWidget-popup' )
6159 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6160 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6161 this.$overlay.append( this.popup.$element );
6162 };
6163
6164 /* Setup */
6165
6166 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6167 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6168
6169 /* Methods */
6170
6171 /**
6172 * Handle the button action being triggered.
6173 *
6174 * @private
6175 */
6176 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6177 this.popup.toggle();
6178 };
6179
6180 /**
6181 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6182 *
6183 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6184 *
6185 * @private
6186 * @abstract
6187 * @class
6188 * @mixins OO.ui.mixin.GroupElement
6189 *
6190 * @constructor
6191 * @param {Object} [config] Configuration options
6192 */
6193 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6194 // Mixin constructors
6195 OO.ui.mixin.GroupElement.call( this, config );
6196 };
6197
6198 /* Setup */
6199
6200 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6201
6202 /* Methods */
6203
6204 /**
6205 * Set the disabled state of the widget.
6206 *
6207 * This will also update the disabled state of child widgets.
6208 *
6209 * @param {boolean} disabled Disable widget
6210 * @chainable
6211 * @return {OO.ui.Widget} The widget, for chaining
6212 */
6213 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6214 var i, len;
6215
6216 // Parent method
6217 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6218 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6219
6220 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6221 if ( this.items ) {
6222 for ( i = 0, len = this.items.length; i < len; i++ ) {
6223 this.items[ i ].updateDisabled();
6224 }
6225 }
6226
6227 return this;
6228 };
6229
6230 /**
6231 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6232 *
6233 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6234 * This allows bidirectional communication.
6235 *
6236 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6237 *
6238 * @private
6239 * @abstract
6240 * @class
6241 *
6242 * @constructor
6243 */
6244 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6245 //
6246 };
6247
6248 /* Methods */
6249
6250 /**
6251 * Check if widget is disabled.
6252 *
6253 * Checks parent if present, making disabled state inheritable.
6254 *
6255 * @return {boolean} Widget is disabled
6256 */
6257 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6258 return this.disabled ||
6259 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6260 };
6261
6262 /**
6263 * Set group element is in.
6264 *
6265 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6266 * @chainable
6267 * @return {OO.ui.Widget} The widget, for chaining
6268 */
6269 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6270 // Parent method
6271 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6272 OO.ui.Element.prototype.setElementGroup.call( this, group );
6273
6274 // Initialize item disabled states
6275 this.updateDisabled();
6276
6277 return this;
6278 };
6279
6280 /**
6281 * OptionWidgets are special elements that can be selected and configured with data. The
6282 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6283 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6284 * and examples, please see the [OOUI documentation on MediaWiki][1].
6285 *
6286 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6287 *
6288 * @class
6289 * @extends OO.ui.Widget
6290 * @mixins OO.ui.mixin.ItemWidget
6291 * @mixins OO.ui.mixin.LabelElement
6292 * @mixins OO.ui.mixin.FlaggedElement
6293 * @mixins OO.ui.mixin.AccessKeyedElement
6294 * @mixins OO.ui.mixin.TitledElement
6295 *
6296 * @constructor
6297 * @param {Object} [config] Configuration options
6298 */
6299 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6300 // Configuration initialization
6301 config = config || {};
6302
6303 // Parent constructor
6304 OO.ui.OptionWidget.parent.call( this, config );
6305
6306 // Mixin constructors
6307 OO.ui.mixin.ItemWidget.call( this );
6308 OO.ui.mixin.LabelElement.call( this, config );
6309 OO.ui.mixin.FlaggedElement.call( this, config );
6310 OO.ui.mixin.AccessKeyedElement.call( this, config );
6311 OO.ui.mixin.TitledElement.call( this, config );
6312
6313 // Properties
6314 this.selected = false;
6315 this.highlighted = false;
6316 this.pressed = false;
6317
6318 // Initialization
6319 this.$element
6320 .data( 'oo-ui-optionWidget', this )
6321 // Allow programmatic focussing (and by access key), but not tabbing
6322 .attr( 'tabindex', '-1' )
6323 .attr( 'role', 'option' )
6324 .attr( 'aria-selected', 'false' )
6325 .addClass( 'oo-ui-optionWidget' )
6326 .append( this.$label );
6327 };
6328
6329 /* Setup */
6330
6331 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6332 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6333 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6334 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6335 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6336 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6337
6338 /* Static Properties */
6339
6340 /**
6341 * Whether this option can be selected. See #setSelected.
6342 *
6343 * @static
6344 * @inheritable
6345 * @property {boolean}
6346 */
6347 OO.ui.OptionWidget.static.selectable = true;
6348
6349 /**
6350 * Whether this option can be highlighted. See #setHighlighted.
6351 *
6352 * @static
6353 * @inheritable
6354 * @property {boolean}
6355 */
6356 OO.ui.OptionWidget.static.highlightable = true;
6357
6358 /**
6359 * Whether this option can be pressed. See #setPressed.
6360 *
6361 * @static
6362 * @inheritable
6363 * @property {boolean}
6364 */
6365 OO.ui.OptionWidget.static.pressable = true;
6366
6367 /**
6368 * Whether this option will be scrolled into view when it is selected.
6369 *
6370 * @static
6371 * @inheritable
6372 * @property {boolean}
6373 */
6374 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6375
6376 /* Methods */
6377
6378 /**
6379 * Check if the option can be selected.
6380 *
6381 * @return {boolean} Item is selectable
6382 */
6383 OO.ui.OptionWidget.prototype.isSelectable = function () {
6384 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6385 };
6386
6387 /**
6388 * Check if the option can be highlighted. A highlight indicates that the option
6389 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6390 * be highlighted.
6391 *
6392 * @return {boolean} Item is highlightable
6393 */
6394 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6395 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6396 };
6397
6398 /**
6399 * Check if the option can be pressed. The pressed state occurs when a user mouses
6400 * down on an item, but has not yet let go of the mouse.
6401 *
6402 * @return {boolean} Item is pressable
6403 */
6404 OO.ui.OptionWidget.prototype.isPressable = function () {
6405 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6406 };
6407
6408 /**
6409 * Check if the option is selected.
6410 *
6411 * @return {boolean} Item is selected
6412 */
6413 OO.ui.OptionWidget.prototype.isSelected = function () {
6414 return this.selected;
6415 };
6416
6417 /**
6418 * Check if the option is highlighted. A highlight indicates that the
6419 * item may be selected when a user presses Enter key or clicks.
6420 *
6421 * @return {boolean} Item is highlighted
6422 */
6423 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6424 return this.highlighted;
6425 };
6426
6427 /**
6428 * Check if the option is pressed. The pressed state occurs when a user mouses
6429 * down on an item, but has not yet let go of the mouse. The item may appear
6430 * selected, but it will not be selected until the user releases the mouse.
6431 *
6432 * @return {boolean} Item is pressed
6433 */
6434 OO.ui.OptionWidget.prototype.isPressed = function () {
6435 return this.pressed;
6436 };
6437
6438 /**
6439 * Set the option’s selected state. In general, all modifications to the selection
6440 * should be handled by the SelectWidget’s
6441 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
6442 *
6443 * @param {boolean} [state=false] Select option
6444 * @chainable
6445 * @return {OO.ui.Widget} The widget, for chaining
6446 */
6447 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6448 if ( this.constructor.static.selectable ) {
6449 this.selected = !!state;
6450 this.$element
6451 .toggleClass( 'oo-ui-optionWidget-selected', state )
6452 .attr( 'aria-selected', state.toString() );
6453 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6454 this.scrollElementIntoView();
6455 }
6456 this.updateThemeClasses();
6457 }
6458 return this;
6459 };
6460
6461 /**
6462 * Set the option’s highlighted state. In general, all programmatic
6463 * modifications to the highlight should be handled by the
6464 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6465 * method instead of this method.
6466 *
6467 * @param {boolean} [state=false] Highlight option
6468 * @chainable
6469 * @return {OO.ui.Widget} The widget, for chaining
6470 */
6471 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6472 if ( this.constructor.static.highlightable ) {
6473 this.highlighted = !!state;
6474 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6475 this.updateThemeClasses();
6476 }
6477 return this;
6478 };
6479
6480 /**
6481 * Set the option’s pressed state. In general, all
6482 * programmatic modifications to the pressed state should be handled by the
6483 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6484 * method instead of this method.
6485 *
6486 * @param {boolean} [state=false] Press option
6487 * @chainable
6488 * @return {OO.ui.Widget} The widget, for chaining
6489 */
6490 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6491 if ( this.constructor.static.pressable ) {
6492 this.pressed = !!state;
6493 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6494 this.updateThemeClasses();
6495 }
6496 return this;
6497 };
6498
6499 /**
6500 * Get text to match search strings against.
6501 *
6502 * The default implementation returns the label text, but subclasses
6503 * can override this to provide more complex behavior.
6504 *
6505 * @return {string|boolean} String to match search string against
6506 */
6507 OO.ui.OptionWidget.prototype.getMatchText = function () {
6508 var label = this.getLabel();
6509 return typeof label === 'string' ? label : this.$label.text();
6510 };
6511
6512 /**
6513 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6514 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6515 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6516 * menu selects}.
6517 *
6518 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
6519 * more information, please see the [OOUI documentation on MediaWiki][1].
6520 *
6521 * @example
6522 * // A select widget with three options.
6523 * var select = new OO.ui.SelectWidget( {
6524 * items: [
6525 * new OO.ui.OptionWidget( {
6526 * data: 'a',
6527 * label: 'Option One',
6528 * } ),
6529 * new OO.ui.OptionWidget( {
6530 * data: 'b',
6531 * label: 'Option Two',
6532 * } ),
6533 * new OO.ui.OptionWidget( {
6534 * data: 'c',
6535 * label: 'Option Three',
6536 * } )
6537 * ]
6538 * } );
6539 * $( document.body ).append( select.$element );
6540 *
6541 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6542 *
6543 * @abstract
6544 * @class
6545 * @extends OO.ui.Widget
6546 * @mixins OO.ui.mixin.GroupWidget
6547 *
6548 * @constructor
6549 * @param {Object} [config] Configuration options
6550 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6551 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6552 * the [OOUI documentation on MediaWiki] [2] for examples.
6553 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6554 */
6555 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6556 // Configuration initialization
6557 config = config || {};
6558
6559 // Parent constructor
6560 OO.ui.SelectWidget.parent.call( this, config );
6561
6562 // Mixin constructors
6563 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, {
6564 $group: this.$element
6565 } ) );
6566
6567 // Properties
6568 this.pressed = false;
6569 this.selecting = null;
6570 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6571 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6572 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6573 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6574 this.keyPressBuffer = '';
6575 this.keyPressBufferTimer = null;
6576 this.blockMouseOverEvents = 0;
6577
6578 // Events
6579 this.connect( this, {
6580 toggle: 'onToggle'
6581 } );
6582 this.$element.on( {
6583 focusin: this.onFocus.bind( this ),
6584 mousedown: this.onMouseDown.bind( this ),
6585 mouseover: this.onMouseOver.bind( this ),
6586 mouseleave: this.onMouseLeave.bind( this )
6587 } );
6588
6589 // Initialization
6590 this.$element
6591 // -depressed is a deprecated alias of -unpressed
6592 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed' )
6593 .attr( 'role', 'listbox' );
6594 this.setFocusOwner( this.$element );
6595 if ( Array.isArray( config.items ) ) {
6596 this.addItems( config.items );
6597 }
6598 };
6599
6600 /* Setup */
6601
6602 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6603 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6604
6605 /* Events */
6606
6607 /**
6608 * @event highlight
6609 *
6610 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6611 *
6612 * @param {OO.ui.OptionWidget|null} item Highlighted item
6613 */
6614
6615 /**
6616 * @event press
6617 *
6618 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6619 * pressed state of an option.
6620 *
6621 * @param {OO.ui.OptionWidget|null} item Pressed item
6622 */
6623
6624 /**
6625 * @event select
6626 *
6627 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
6628 * method.
6629 *
6630 * @param {OO.ui.OptionWidget|null} item Selected item
6631 */
6632
6633 /**
6634 * @event choose
6635 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6636 * @param {OO.ui.OptionWidget} item Chosen item
6637 */
6638
6639 /**
6640 * @event add
6641 *
6642 * An `add` event is emitted when options are added to the select with the #addItems method.
6643 *
6644 * @param {OO.ui.OptionWidget[]} items Added items
6645 * @param {number} index Index of insertion point
6646 */
6647
6648 /**
6649 * @event remove
6650 *
6651 * A `remove` event is emitted when options are removed from the select with the #clearItems
6652 * or #removeItems methods.
6653 *
6654 * @param {OO.ui.OptionWidget[]} items Removed items
6655 */
6656
6657 /* Static methods */
6658
6659 /**
6660 * Normalize text for filter matching
6661 *
6662 * @param {string} text Text
6663 * @return {string} Normalized text
6664 */
6665 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
6666 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
6667 var normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
6668
6669 // Normalize Unicode
6670 // eslint-disable-next-line no-restricted-properties
6671 if ( normalized.normalize ) {
6672 // eslint-disable-next-line no-restricted-properties
6673 normalized = normalized.normalize();
6674 }
6675 return normalized;
6676 };
6677
6678 /* Methods */
6679
6680 /**
6681 * Handle focus events
6682 *
6683 * @private
6684 * @param {jQuery.Event} event
6685 */
6686 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6687 var item;
6688 if ( event.target === this.$element[ 0 ] ) {
6689 // This widget was focussed, e.g. by the user tabbing to it.
6690 // The styles for focus state depend on one of the items being selected.
6691 if ( !this.findSelectedItem() ) {
6692 item = this.findFirstSelectableItem();
6693 }
6694 } else {
6695 if ( event.target.tabIndex === -1 ) {
6696 // One of the options got focussed (and the event bubbled up here).
6697 // They can't be tabbed to, but they can be activated using access keys.
6698 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6699 item = this.findTargetItem( event );
6700 } else {
6701 // There is something actually user-focusable in one of the labels of the options, and
6702 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
6703 // the focus).
6704 return;
6705 }
6706 }
6707
6708 if ( item ) {
6709 if ( item.constructor.static.highlightable ) {
6710 this.highlightItem( item );
6711 } else {
6712 this.selectItem( item );
6713 }
6714 }
6715
6716 if ( event.target !== this.$element[ 0 ] ) {
6717 this.$focusOwner.trigger( 'focus' );
6718 }
6719 };
6720
6721 /**
6722 * Handle mouse down events.
6723 *
6724 * @private
6725 * @param {jQuery.Event} e Mouse down event
6726 * @return {undefined/boolean} False to prevent default if event is handled
6727 */
6728 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6729 var item;
6730
6731 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6732 this.togglePressed( true );
6733 item = this.findTargetItem( e );
6734 if ( item && item.isSelectable() ) {
6735 this.pressItem( item );
6736 this.selecting = item;
6737 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6738 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6739 }
6740 }
6741 return false;
6742 };
6743
6744 /**
6745 * Handle document mouse up events.
6746 *
6747 * @private
6748 * @param {MouseEvent} e Mouse up event
6749 * @return {undefined/boolean} False to prevent default if event is handled
6750 */
6751 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6752 var item;
6753
6754 this.togglePressed( false );
6755 if ( !this.selecting ) {
6756 item = this.findTargetItem( e );
6757 if ( item && item.isSelectable() ) {
6758 this.selecting = item;
6759 }
6760 }
6761 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6762 this.pressItem( null );
6763 this.chooseItem( this.selecting );
6764 this.selecting = null;
6765 }
6766
6767 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6768 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6769
6770 return false;
6771 };
6772
6773 // Deprecated alias since 0.28.3
6774 OO.ui.SelectWidget.prototype.onMouseUp = function () {
6775 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
6776 this.onDocumentMouseUp.apply( this, arguments );
6777 };
6778
6779 /**
6780 * Handle document mouse move events.
6781 *
6782 * @private
6783 * @param {MouseEvent} e Mouse move event
6784 */
6785 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6786 var item;
6787
6788 if ( !this.isDisabled() && this.pressed ) {
6789 item = this.findTargetItem( e );
6790 if ( item && item !== this.selecting && item.isSelectable() ) {
6791 this.pressItem( item );
6792 this.selecting = item;
6793 }
6794 }
6795 };
6796
6797 // Deprecated alias since 0.28.3
6798 OO.ui.SelectWidget.prototype.onMouseMove = function () {
6799 OO.ui.warnDeprecation( 'onMouseMove is deprecated, use onDocumentMouseMove instead' );
6800 this.onDocumentMouseMove.apply( this, arguments );
6801 };
6802
6803 /**
6804 * Handle mouse over events.
6805 *
6806 * @private
6807 * @param {jQuery.Event} e Mouse over event
6808 * @return {undefined/boolean} False to prevent default if event is handled
6809 */
6810 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6811 var item;
6812 if ( this.blockMouseOverEvents ) {
6813 return;
6814 }
6815 if ( !this.isDisabled() ) {
6816 item = this.findTargetItem( e );
6817 this.highlightItem( item && item.isHighlightable() ? item : null );
6818 }
6819 return false;
6820 };
6821
6822 /**
6823 * Handle mouse leave events.
6824 *
6825 * @private
6826 * @param {jQuery.Event} e Mouse over event
6827 * @return {undefined/boolean} False to prevent default if event is handled
6828 */
6829 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6830 if ( !this.isDisabled() ) {
6831 this.highlightItem( null );
6832 }
6833 return false;
6834 };
6835
6836 /**
6837 * Handle document key down events.
6838 *
6839 * @protected
6840 * @param {KeyboardEvent} e Key down event
6841 */
6842 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6843 var nextItem,
6844 handled = false,
6845 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6846
6847 if ( !this.isDisabled() && this.isVisible() ) {
6848 switch ( e.keyCode ) {
6849 case OO.ui.Keys.ENTER:
6850 if ( currentItem && currentItem.constructor.static.highlightable ) {
6851 // Was only highlighted, now let's select it. No-op if already selected.
6852 this.chooseItem( currentItem );
6853 handled = true;
6854 }
6855 break;
6856 case OO.ui.Keys.UP:
6857 case OO.ui.Keys.LEFT:
6858 this.clearKeyPressBuffer();
6859 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6860 handled = true;
6861 break;
6862 case OO.ui.Keys.DOWN:
6863 case OO.ui.Keys.RIGHT:
6864 this.clearKeyPressBuffer();
6865 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6866 handled = true;
6867 break;
6868 case OO.ui.Keys.ESCAPE:
6869 case OO.ui.Keys.TAB:
6870 if ( currentItem && currentItem.constructor.static.highlightable ) {
6871 currentItem.setHighlighted( false );
6872 }
6873 this.unbindDocumentKeyDownListener();
6874 this.unbindDocumentKeyPressListener();
6875 // Don't prevent tabbing away / defocusing
6876 handled = false;
6877 break;
6878 }
6879
6880 if ( nextItem ) {
6881 if ( nextItem.constructor.static.highlightable ) {
6882 this.highlightItem( nextItem );
6883 } else {
6884 this.chooseItem( nextItem );
6885 }
6886 this.scrollItemIntoView( nextItem );
6887 }
6888
6889 if ( handled ) {
6890 e.preventDefault();
6891 e.stopPropagation();
6892 }
6893 }
6894 };
6895
6896 // Deprecated alias since 0.28.3
6897 OO.ui.SelectWidget.prototype.onKeyDown = function () {
6898 OO.ui.warnDeprecation( 'onKeyDown is deprecated, use onDocumentKeyDown instead' );
6899 this.onDocumentKeyDown.apply( this, arguments );
6900 };
6901
6902 /**
6903 * Bind document key down listener.
6904 *
6905 * @protected
6906 */
6907 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6908 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6909 };
6910
6911 // Deprecated alias since 0.28.3
6912 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6913 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
6914 this.bindDocumentKeyDownListener.apply( this, arguments );
6915 };
6916
6917 /**
6918 * Unbind document key down listener.
6919 *
6920 * @protected
6921 */
6922 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6923 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6924 };
6925
6926 // Deprecated alias since 0.28.3
6927 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6928 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
6929 this.unbindDocumentKeyDownListener.apply( this, arguments );
6930 };
6931
6932 /**
6933 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6934 *
6935 * @param {OO.ui.OptionWidget} item Item to scroll into view
6936 */
6937 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6938 var widget = this;
6939 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
6940 // scrolling and around 100-150 ms after it is finished.
6941 this.blockMouseOverEvents++;
6942 item.scrollElementIntoView().done( function () {
6943 setTimeout( function () {
6944 widget.blockMouseOverEvents--;
6945 }, 200 );
6946 } );
6947 };
6948
6949 /**
6950 * Clear the key-press buffer
6951 *
6952 * @protected
6953 */
6954 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6955 if ( this.keyPressBufferTimer ) {
6956 clearTimeout( this.keyPressBufferTimer );
6957 this.keyPressBufferTimer = null;
6958 }
6959 this.keyPressBuffer = '';
6960 };
6961
6962 /**
6963 * Handle key press events.
6964 *
6965 * @protected
6966 * @param {KeyboardEvent} e Key press event
6967 * @return {undefined/boolean} False to prevent default if event is handled
6968 */
6969 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6970 var c, filter, item;
6971
6972 if ( !e.charCode ) {
6973 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6974 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6975 return false;
6976 }
6977 return;
6978 }
6979 // eslint-disable-next-line no-restricted-properties
6980 if ( String.fromCodePoint ) {
6981 // eslint-disable-next-line no-restricted-properties
6982 c = String.fromCodePoint( e.charCode );
6983 } else {
6984 c = String.fromCharCode( e.charCode );
6985 }
6986
6987 if ( this.keyPressBufferTimer ) {
6988 clearTimeout( this.keyPressBufferTimer );
6989 }
6990 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6991
6992 item = this.findHighlightedItem() || this.findSelectedItem();
6993
6994 if ( this.keyPressBuffer === c ) {
6995 // Common (if weird) special case: typing "xxxx" will cycle through all
6996 // the items beginning with "x".
6997 if ( item ) {
6998 item = this.findRelativeSelectableItem( item, 1 );
6999 }
7000 } else {
7001 this.keyPressBuffer += c;
7002 }
7003
7004 filter = this.getItemMatcher( this.keyPressBuffer, false );
7005 if ( !item || !filter( item ) ) {
7006 item = this.findRelativeSelectableItem( item, 1, filter );
7007 }
7008 if ( item ) {
7009 if ( this.isVisible() && item.constructor.static.highlightable ) {
7010 this.highlightItem( item );
7011 } else {
7012 this.chooseItem( item );
7013 }
7014 this.scrollItemIntoView( item );
7015 }
7016
7017 e.preventDefault();
7018 e.stopPropagation();
7019 };
7020
7021 // Deprecated alias since 0.28.3
7022 OO.ui.SelectWidget.prototype.onKeyPress = function () {
7023 OO.ui.warnDeprecation( 'onKeyPress is deprecated, use onDocumentKeyPress instead' );
7024 this.onDocumentKeyPress.apply( this, arguments );
7025 };
7026
7027 /**
7028 * Get a matcher for the specific string
7029 *
7030 * @protected
7031 * @param {string} query String to match against items
7032 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
7033 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
7034 */
7035 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
7036 var normalizeForMatching = this.constructor.static.normalizeForMatching,
7037 normalizedQuery = normalizeForMatching( query );
7038
7039 // Support deprecated exact=true argument
7040 if ( mode === true ) {
7041 mode = 'exact';
7042 }
7043
7044 return function ( item ) {
7045 var matchText = normalizeForMatching( item.getMatchText() );
7046
7047 if ( normalizedQuery === '' ) {
7048 // Empty string matches all, except if we are in 'exact'
7049 // mode, where it doesn't match at all
7050 return mode !== 'exact';
7051 }
7052
7053 switch ( mode ) {
7054 case 'exact':
7055 return matchText === normalizedQuery;
7056 case 'substring':
7057 return matchText.indexOf( normalizedQuery ) !== -1;
7058 // 'prefix'
7059 default:
7060 return matchText.indexOf( normalizedQuery ) === 0;
7061 }
7062 };
7063 };
7064
7065 /**
7066 * Bind document key press listener.
7067 *
7068 * @protected
7069 */
7070 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
7071 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7072 };
7073
7074 // Deprecated alias since 0.28.3
7075 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
7076 OO.ui.warnDeprecation( 'bindKeyPressListener is deprecated, use bindDocumentKeyPressListener instead' );
7077 this.bindDocumentKeyPressListener.apply( this, arguments );
7078 };
7079
7080 /**
7081 * Unbind document key down listener.
7082 *
7083 * If you override this, be sure to call this.clearKeyPressBuffer() from your
7084 * implementation.
7085 *
7086 * @protected
7087 */
7088 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7089 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7090 this.clearKeyPressBuffer();
7091 };
7092
7093 // Deprecated alias since 0.28.3
7094 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
7095 OO.ui.warnDeprecation( 'unbindKeyPressListener is deprecated, use unbindDocumentKeyPressListener instead' );
7096 this.unbindDocumentKeyPressListener.apply( this, arguments );
7097 };
7098
7099 /**
7100 * Visibility change handler
7101 *
7102 * @protected
7103 * @param {boolean} visible
7104 */
7105 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
7106 if ( !visible ) {
7107 this.clearKeyPressBuffer();
7108 }
7109 };
7110
7111 /**
7112 * Get the closest item to a jQuery.Event.
7113 *
7114 * @private
7115 * @param {jQuery.Event} e
7116 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
7117 */
7118 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
7119 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
7120 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
7121 return null;
7122 }
7123 return $option.data( 'oo-ui-optionWidget' ) || null;
7124 };
7125
7126 /**
7127 * Find selected item.
7128 *
7129 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
7130 */
7131 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
7132 var i, len;
7133
7134 for ( i = 0, len = this.items.length; i < len; i++ ) {
7135 if ( this.items[ i ].isSelected() ) {
7136 return this.items[ i ];
7137 }
7138 }
7139 return null;
7140 };
7141
7142 /**
7143 * Find highlighted item.
7144 *
7145 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
7146 */
7147 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
7148 var i, len;
7149
7150 for ( i = 0, len = this.items.length; i < len; i++ ) {
7151 if ( this.items[ i ].isHighlighted() ) {
7152 return this.items[ i ];
7153 }
7154 }
7155 return null;
7156 };
7157
7158 /**
7159 * Toggle pressed state.
7160 *
7161 * Press is a state that occurs when a user mouses down on an item, but
7162 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7163 * until the user releases the mouse.
7164 *
7165 * @param {boolean} pressed An option is being pressed
7166 */
7167 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7168 if ( pressed === undefined ) {
7169 pressed = !this.pressed;
7170 }
7171 if ( pressed !== this.pressed ) {
7172 this.$element
7173 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7174 // -depressed is a deprecated alias of -unpressed
7175 .toggleClass( 'oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed', !pressed );
7176 this.pressed = pressed;
7177 }
7178 };
7179
7180 /**
7181 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7182 * and any existing highlight will be removed. The highlight is mutually exclusive.
7183 *
7184 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7185 * @fires highlight
7186 * @chainable
7187 * @return {OO.ui.Widget} The widget, for chaining
7188 */
7189 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7190 var i, len, highlighted,
7191 changed = false;
7192
7193 for ( i = 0, len = this.items.length; i < len; i++ ) {
7194 highlighted = this.items[ i ] === item;
7195 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7196 this.items[ i ].setHighlighted( highlighted );
7197 changed = true;
7198 }
7199 }
7200 if ( changed ) {
7201 if ( item ) {
7202 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7203 } else {
7204 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7205 }
7206 this.emit( 'highlight', item );
7207 }
7208
7209 return this;
7210 };
7211
7212 /**
7213 * Fetch an item by its label.
7214 *
7215 * @param {string} label Label of the item to select.
7216 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7217 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7218 */
7219 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7220 var i, item, found,
7221 len = this.items.length,
7222 filter = this.getItemMatcher( label, 'exact' );
7223
7224 for ( i = 0; i < len; i++ ) {
7225 item = this.items[ i ];
7226 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7227 return item;
7228 }
7229 }
7230
7231 if ( prefix ) {
7232 found = null;
7233 filter = this.getItemMatcher( label, 'prefix' );
7234 for ( i = 0; i < len; i++ ) {
7235 item = this.items[ i ];
7236 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7237 if ( found ) {
7238 return null;
7239 }
7240 found = item;
7241 }
7242 }
7243 if ( found ) {
7244 return found;
7245 }
7246 }
7247
7248 return null;
7249 };
7250
7251 /**
7252 * Programmatically select an option by its label. If the item does not exist,
7253 * all options will be deselected.
7254 *
7255 * @param {string} [label] Label of the item to select.
7256 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7257 * @fires select
7258 * @chainable
7259 * @return {OO.ui.Widget} The widget, for chaining
7260 */
7261 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7262 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7263 if ( label === undefined || !itemFromLabel ) {
7264 return this.selectItem();
7265 }
7266 return this.selectItem( itemFromLabel );
7267 };
7268
7269 /**
7270 * Programmatically select an option by its data. If the `data` parameter is omitted,
7271 * or if the item does not exist, all options will be deselected.
7272 *
7273 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7274 * @fires select
7275 * @chainable
7276 * @return {OO.ui.Widget} The widget, for chaining
7277 */
7278 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7279 var itemFromData = this.findItemFromData( data );
7280 if ( data === undefined || !itemFromData ) {
7281 return this.selectItem();
7282 }
7283 return this.selectItem( itemFromData );
7284 };
7285
7286 /**
7287 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7288 * all options will be deselected.
7289 *
7290 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7291 * @fires select
7292 * @chainable
7293 * @return {OO.ui.Widget} The widget, for chaining
7294 */
7295 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7296 var i, len, selected,
7297 changed = false;
7298
7299 for ( i = 0, len = this.items.length; i < len; i++ ) {
7300 selected = this.items[ i ] === item;
7301 if ( this.items[ i ].isSelected() !== selected ) {
7302 this.items[ i ].setSelected( selected );
7303 changed = true;
7304 }
7305 }
7306 if ( changed ) {
7307 if ( item && !item.constructor.static.highlightable ) {
7308 if ( item ) {
7309 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7310 } else {
7311 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7312 }
7313 }
7314 this.emit( 'select', item );
7315 }
7316
7317 return this;
7318 };
7319
7320 /**
7321 * Press an item.
7322 *
7323 * Press is a state that occurs when a user mouses down on an item, but has not
7324 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7325 * releases the mouse.
7326 *
7327 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7328 * @fires press
7329 * @chainable
7330 * @return {OO.ui.Widget} The widget, for chaining
7331 */
7332 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7333 var i, len, pressed,
7334 changed = false;
7335
7336 for ( i = 0, len = this.items.length; i < len; i++ ) {
7337 pressed = this.items[ i ] === item;
7338 if ( this.items[ i ].isPressed() !== pressed ) {
7339 this.items[ i ].setPressed( pressed );
7340 changed = true;
7341 }
7342 }
7343 if ( changed ) {
7344 this.emit( 'press', item );
7345 }
7346
7347 return this;
7348 };
7349
7350 /**
7351 * Choose an item.
7352 *
7353 * Note that ‘choose’ should never be modified programmatically. A user can choose
7354 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7355 * use the #selectItem method.
7356 *
7357 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7358 * when users choose an item with the keyboard or mouse.
7359 *
7360 * @param {OO.ui.OptionWidget} item Item to choose
7361 * @fires choose
7362 * @chainable
7363 * @return {OO.ui.Widget} The widget, for chaining
7364 */
7365 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7366 if ( item ) {
7367 this.selectItem( item );
7368 this.emit( 'choose', item );
7369 }
7370
7371 return this;
7372 };
7373
7374 /**
7375 * Find an option by its position relative to the specified item (or to the start of the option
7376 * array, if item is `null`). The direction in which to search through the option array is specified
7377 * with a number: -1 for reverse (the default) or 1 for forward. The method will return an option,
7378 * or `null` if there are no options in the array.
7379 *
7380 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
7381 * the beginning of the array.
7382 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7383 * @param {Function} [filter] Only consider items for which this function returns
7384 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7385 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7386 */
7387 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7388 var currentIndex, nextIndex, i,
7389 increase = direction > 0 ? 1 : -1,
7390 len = this.items.length;
7391
7392 if ( item instanceof OO.ui.OptionWidget ) {
7393 currentIndex = this.items.indexOf( item );
7394 nextIndex = ( currentIndex + increase + len ) % len;
7395 } else {
7396 // If no item is selected and moving forward, start at the beginning.
7397 // If moving backward, start at the end.
7398 nextIndex = direction > 0 ? 0 : len - 1;
7399 }
7400
7401 for ( i = 0; i < len; i++ ) {
7402 item = this.items[ nextIndex ];
7403 if (
7404 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7405 ( !filter || filter( item ) )
7406 ) {
7407 return item;
7408 }
7409 nextIndex = ( nextIndex + increase + len ) % len;
7410 }
7411 return null;
7412 };
7413
7414 /**
7415 * Find the next selectable item or `null` if there are no selectable items.
7416 * Disabled options and menu-section markers and breaks are not selectable.
7417 *
7418 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7419 */
7420 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7421 return this.findRelativeSelectableItem( null, 1 );
7422 };
7423
7424 /**
7425 * Add an array of options to the select. Optionally, an index number can be used to
7426 * specify an insertion point.
7427 *
7428 * @param {OO.ui.OptionWidget[]} items Items to add
7429 * @param {number} [index] Index to insert items after
7430 * @fires add
7431 * @chainable
7432 * @return {OO.ui.Widget} The widget, for chaining
7433 */
7434 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7435 // Mixin method
7436 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7437
7438 // Always provide an index, even if it was omitted
7439 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7440
7441 return this;
7442 };
7443
7444 /**
7445 * Remove the specified array of options from the select. Options will be detached
7446 * from the DOM, not removed, so they can be reused later. To remove all options from
7447 * the select, you may wish to use the #clearItems method instead.
7448 *
7449 * @param {OO.ui.OptionWidget[]} items Items to remove
7450 * @fires remove
7451 * @chainable
7452 * @return {OO.ui.Widget} The widget, for chaining
7453 */
7454 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7455 var i, len, item;
7456
7457 // Deselect items being removed
7458 for ( i = 0, len = items.length; i < len; i++ ) {
7459 item = items[ i ];
7460 if ( item.isSelected() ) {
7461 this.selectItem( null );
7462 }
7463 }
7464
7465 // Mixin method
7466 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7467
7468 this.emit( 'remove', items );
7469
7470 return this;
7471 };
7472
7473 /**
7474 * Clear all options from the select. Options will be detached from the DOM, not removed,
7475 * so that they can be reused later. To remove a subset of options from the select, use
7476 * the #removeItems method.
7477 *
7478 * @fires remove
7479 * @chainable
7480 * @return {OO.ui.Widget} The widget, for chaining
7481 */
7482 OO.ui.SelectWidget.prototype.clearItems = function () {
7483 var items = this.items.slice();
7484
7485 // Mixin method
7486 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7487
7488 // Clear selection
7489 this.selectItem( null );
7490
7491 this.emit( 'remove', items );
7492
7493 return this;
7494 };
7495
7496 /**
7497 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7498 *
7499 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7500 *
7501 * @protected
7502 * @param {jQuery} $focusOwner
7503 */
7504 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7505 this.$focusOwner = $focusOwner;
7506 };
7507
7508 /**
7509 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7510 * with an {@link OO.ui.mixin.IconElement icon} and/or
7511 * {@link OO.ui.mixin.IndicatorElement indicator}.
7512 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7513 * options. For more information about options and selects, please see the
7514 * [OOUI documentation on MediaWiki][1].
7515 *
7516 * @example
7517 * // Decorated options in a select widget.
7518 * var select = new OO.ui.SelectWidget( {
7519 * items: [
7520 * new OO.ui.DecoratedOptionWidget( {
7521 * data: 'a',
7522 * label: 'Option with icon',
7523 * icon: 'help'
7524 * } ),
7525 * new OO.ui.DecoratedOptionWidget( {
7526 * data: 'b',
7527 * label: 'Option with indicator',
7528 * indicator: 'next'
7529 * } )
7530 * ]
7531 * } );
7532 * $( document.body ).append( select.$element );
7533 *
7534 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7535 *
7536 * @class
7537 * @extends OO.ui.OptionWidget
7538 * @mixins OO.ui.mixin.IconElement
7539 * @mixins OO.ui.mixin.IndicatorElement
7540 *
7541 * @constructor
7542 * @param {Object} [config] Configuration options
7543 */
7544 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7545 // Parent constructor
7546 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7547
7548 // Mixin constructors
7549 OO.ui.mixin.IconElement.call( this, config );
7550 OO.ui.mixin.IndicatorElement.call( this, config );
7551
7552 // Initialization
7553 this.$element
7554 .addClass( 'oo-ui-decoratedOptionWidget' )
7555 .prepend( this.$icon )
7556 .append( this.$indicator );
7557 };
7558
7559 /* Setup */
7560
7561 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7562 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7563 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7564
7565 /**
7566 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7567 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7568 * the [OOUI documentation on MediaWiki] [1] for more information.
7569 *
7570 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7571 *
7572 * @class
7573 * @extends OO.ui.DecoratedOptionWidget
7574 *
7575 * @constructor
7576 * @param {Object} [config] Configuration options
7577 */
7578 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7579 // Parent constructor
7580 OO.ui.MenuOptionWidget.parent.call( this, config );
7581
7582 // Properties
7583 this.checkIcon = new OO.ui.IconWidget( {
7584 icon: 'check',
7585 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7586 } );
7587
7588 // Initialization
7589 this.$element
7590 .prepend( this.checkIcon.$element )
7591 .addClass( 'oo-ui-menuOptionWidget' );
7592 };
7593
7594 /* Setup */
7595
7596 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7597
7598 /* Static Properties */
7599
7600 /**
7601 * @static
7602 * @inheritdoc
7603 */
7604 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7605
7606 /**
7607 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
7608 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
7609 * cannot be highlighted or selected.
7610 *
7611 * @example
7612 * var dropdown = new OO.ui.DropdownWidget( {
7613 * menu: {
7614 * items: [
7615 * new OO.ui.MenuSectionOptionWidget( {
7616 * label: 'Dogs'
7617 * } ),
7618 * new OO.ui.MenuOptionWidget( {
7619 * data: 'corgi',
7620 * label: 'Welsh Corgi'
7621 * } ),
7622 * new OO.ui.MenuOptionWidget( {
7623 * data: 'poodle',
7624 * label: 'Standard Poodle'
7625 * } ),
7626 * new OO.ui.MenuSectionOptionWidget( {
7627 * label: 'Cats'
7628 * } ),
7629 * new OO.ui.MenuOptionWidget( {
7630 * data: 'lion',
7631 * label: 'Lion'
7632 * } )
7633 * ]
7634 * }
7635 * } );
7636 * $( document.body ).append( dropdown.$element );
7637 *
7638 * @class
7639 * @extends OO.ui.DecoratedOptionWidget
7640 *
7641 * @constructor
7642 * @param {Object} [config] Configuration options
7643 */
7644 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7645 // Parent constructor
7646 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7647
7648 // Initialization
7649 this.$element
7650 .addClass( 'oo-ui-menuSectionOptionWidget' )
7651 .removeAttr( 'role aria-selected' );
7652 };
7653
7654 /* Setup */
7655
7656 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7657
7658 /* Static Properties */
7659
7660 /**
7661 * @static
7662 * @inheritdoc
7663 */
7664 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7665
7666 /**
7667 * @static
7668 * @inheritdoc
7669 */
7670 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7671
7672 /**
7673 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7674 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7675 * See {@link OO.ui.DropdownWidget DropdownWidget},
7676 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
7677 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7678 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7679 * and customized to be opened, closed, and displayed as needed.
7680 *
7681 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7682 * mouse outside the menu.
7683 *
7684 * Menus also have support for keyboard interaction:
7685 *
7686 * - Enter/Return key: choose and select a menu option
7687 * - Up-arrow key: highlight the previous menu option
7688 * - Down-arrow key: highlight the next menu option
7689 * - Escape key: hide the menu
7690 *
7691 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7692 *
7693 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7694 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7695 *
7696 * @class
7697 * @extends OO.ui.SelectWidget
7698 * @mixins OO.ui.mixin.ClippableElement
7699 * @mixins OO.ui.mixin.FloatableElement
7700 *
7701 * @constructor
7702 * @param {Object} [config] Configuration options
7703 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu
7704 * items that match the text the user types. This config is used by
7705 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
7706 * {@link OO.ui.mixin.LookupElement LookupElement}
7707 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7708 * the text the user types. This config is used by
7709 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7710 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks
7711 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
7712 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
7713 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
7714 * in here.
7715 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7716 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7717 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7718 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7719 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7720 * @cfg {string} [filterMode='prefix'] The mode by which the menu filters the results.
7721 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
7722 * @param {number|string} [width] Width of the menu as a number of pixels or CSS string with unit
7723 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
7724 */
7725 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7726 // Configuration initialization
7727 config = config || {};
7728
7729 // Parent constructor
7730 OO.ui.MenuSelectWidget.parent.call( this, config );
7731
7732 // Mixin constructors
7733 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7734 OO.ui.mixin.FloatableElement.call( this, config );
7735
7736 // Initial vertical positions other than 'center' will result in
7737 // the menu being flipped if there is not enough space in the container.
7738 // Store the original position so we know what to reset to.
7739 this.originalVerticalPosition = this.verticalPosition;
7740
7741 // Properties
7742 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7743 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7744 this.filterFromInput = !!config.filterFromInput;
7745 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7746 this.$widget = config.widget ? config.widget.$element : null;
7747 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7748 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7749 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7750 this.highlightOnFilter = !!config.highlightOnFilter;
7751 this.lastHighlightedItem = null;
7752 this.width = config.width;
7753 this.filterMode = config.filterMode;
7754
7755 // Initialization
7756 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7757 if ( config.widget ) {
7758 this.setFocusOwner( config.widget.$tabIndexed );
7759 }
7760
7761 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7762 // that reference properties not initialized at that time of parent class construction
7763 // TODO: Find a better way to handle post-constructor setup
7764 this.visible = false;
7765 this.$element.addClass( 'oo-ui-element-hidden' );
7766 this.$focusOwner.attr( 'aria-expanded', 'false' );
7767 };
7768
7769 /* Setup */
7770
7771 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7772 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7773 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7774
7775 /* Events */
7776
7777 /**
7778 * @event ready
7779 *
7780 * The menu is ready: it is visible and has been positioned and clipped.
7781 */
7782
7783 /* Static properties */
7784
7785 /**
7786 * Positions to flip to if there isn't room in the container for the
7787 * menu in a specific direction.
7788 *
7789 * @property {Object.<string,string>}
7790 */
7791 OO.ui.MenuSelectWidget.static.flippedPositions = {
7792 below: 'above',
7793 above: 'below',
7794 top: 'bottom',
7795 bottom: 'top'
7796 };
7797
7798 /* Methods */
7799
7800 /**
7801 * Handles document mouse down events.
7802 *
7803 * @protected
7804 * @param {MouseEvent} e Mouse down event
7805 */
7806 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7807 if (
7808 this.isVisible() &&
7809 !OO.ui.contains(
7810 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7811 e.target,
7812 true
7813 )
7814 ) {
7815 this.toggle( false );
7816 }
7817 };
7818
7819 /**
7820 * @inheritdoc
7821 */
7822 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7823 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7824
7825 if ( !this.isDisabled() && this.isVisible() ) {
7826 switch ( e.keyCode ) {
7827 case OO.ui.Keys.LEFT:
7828 case OO.ui.Keys.RIGHT:
7829 // Do nothing if a text field is associated, arrow keys will be handled natively
7830 if ( !this.$input ) {
7831 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7832 }
7833 break;
7834 case OO.ui.Keys.ESCAPE:
7835 case OO.ui.Keys.TAB:
7836 if ( currentItem ) {
7837 currentItem.setHighlighted( false );
7838 }
7839 this.toggle( false );
7840 // Don't prevent tabbing away, prevent defocusing
7841 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7842 e.preventDefault();
7843 e.stopPropagation();
7844 }
7845 break;
7846 default:
7847 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7848 return;
7849 }
7850 }
7851 };
7852
7853 /**
7854 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7855 * or after items were added/removed (always).
7856 *
7857 * @protected
7858 */
7859 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7860 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7861 anyVisible = false,
7862 len = this.items.length,
7863 showAll = !this.isVisible(),
7864 exactMatch = false;
7865
7866 if ( this.$input && this.filterFromInput ) {
7867 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
7868 exactFilter = this.getItemMatcher( this.$input.val(), 'exact' );
7869 // Hide non-matching options, and also hide section headers if all options
7870 // in their section are hidden.
7871 for ( i = 0; i < len; i++ ) {
7872 item = this.items[ i ];
7873 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7874 if ( section ) {
7875 // If the previous section was empty, hide its header
7876 section.toggle( showAll || !sectionEmpty );
7877 }
7878 section = item;
7879 sectionEmpty = true;
7880 } else if ( item instanceof OO.ui.OptionWidget ) {
7881 visible = showAll || filter( item );
7882 exactMatch = exactMatch || exactFilter( item );
7883 anyVisible = anyVisible || visible;
7884 sectionEmpty = sectionEmpty && !visible;
7885 item.toggle( visible );
7886 }
7887 }
7888 // Process the final section
7889 if ( section ) {
7890 section.toggle( showAll || !sectionEmpty );
7891 }
7892
7893 if ( anyVisible && this.items.length && !exactMatch ) {
7894 this.scrollItemIntoView( this.items[ 0 ] );
7895 }
7896
7897 if ( !anyVisible ) {
7898 this.highlightItem( null );
7899 }
7900
7901 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7902
7903 if (
7904 this.highlightOnFilter &&
7905 !( this.lastHighlightedItem && this.lastHighlightedItem.isVisible() )
7906 ) {
7907 // Highlight the first item on the list
7908 item = null;
7909 items = this.getItems();
7910 for ( i = 0; i < items.length; i++ ) {
7911 if ( items[ i ].isVisible() ) {
7912 item = items[ i ];
7913 break;
7914 }
7915 }
7916 this.highlightItem( item );
7917 this.lastHighlightedItem = item;
7918 }
7919
7920 }
7921
7922 // Reevaluate clipping
7923 this.clip();
7924 };
7925
7926 /**
7927 * @inheritdoc
7928 */
7929 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7930 if ( this.$input ) {
7931 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7932 } else {
7933 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7934 }
7935 };
7936
7937 /**
7938 * @inheritdoc
7939 */
7940 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7941 if ( this.$input ) {
7942 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7943 } else {
7944 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7945 }
7946 };
7947
7948 /**
7949 * @inheritdoc
7950 */
7951 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7952 if ( this.$input ) {
7953 if ( this.filterFromInput ) {
7954 this.$input.on(
7955 'keydown mouseup cut paste change input select',
7956 this.onInputEditHandler
7957 );
7958 this.updateItemVisibility();
7959 }
7960 } else {
7961 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7962 }
7963 };
7964
7965 /**
7966 * @inheritdoc
7967 */
7968 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7969 if ( this.$input ) {
7970 if ( this.filterFromInput ) {
7971 this.$input.off(
7972 'keydown mouseup cut paste change input select',
7973 this.onInputEditHandler
7974 );
7975 this.updateItemVisibility();
7976 }
7977 } else {
7978 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7979 }
7980 };
7981
7982 /**
7983 * Choose an item.
7984 *
7985 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
7986 * set to false.
7987 *
7988 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
7989 * the keyboard or mouse and it becomes selected. To select an item programmatically,
7990 * use the #selectItem method.
7991 *
7992 * @param {OO.ui.OptionWidget} item Item to choose
7993 * @chainable
7994 * @return {OO.ui.Widget} The widget, for chaining
7995 */
7996 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7997 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7998 if ( this.hideOnChoose ) {
7999 this.toggle( false );
8000 }
8001 return this;
8002 };
8003
8004 /**
8005 * @inheritdoc
8006 */
8007 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
8008 // Parent method
8009 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
8010
8011 this.updateItemVisibility();
8012
8013 return this;
8014 };
8015
8016 /**
8017 * @inheritdoc
8018 */
8019 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
8020 // Parent method
8021 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
8022
8023 this.updateItemVisibility();
8024
8025 return this;
8026 };
8027
8028 /**
8029 * @inheritdoc
8030 */
8031 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
8032 // Parent method
8033 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
8034
8035 this.updateItemVisibility();
8036
8037 return this;
8038 };
8039
8040 /**
8041 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
8042 * `.toggle( true )` after its #$element is attached to the DOM.
8043 *
8044 * Do not show the menu while it is not attached to the DOM. The calculations required to display
8045 * it in the right place and with the right dimensions only work correctly while it is attached.
8046 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
8047 * strictly enforced, so currently it only generates a warning in the browser console.
8048 *
8049 * @fires ready
8050 * @inheritdoc
8051 */
8052 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
8053 var change, originalHeight, flippedHeight;
8054
8055 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
8056 change = visible !== this.isVisible();
8057
8058 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
8059 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
8060 this.warnedUnattached = true;
8061 }
8062
8063 if ( change && visible ) {
8064 // Reset position before showing the popup again. It's possible we no longer need to flip
8065 // (e.g. if the user scrolled).
8066 this.setVerticalPosition( this.originalVerticalPosition );
8067 }
8068
8069 // Parent method
8070 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
8071
8072 if ( change ) {
8073 if ( visible ) {
8074
8075 if ( this.width ) {
8076 this.setIdealSize( this.width );
8077 } else if ( this.$floatableContainer ) {
8078 this.$clippable.css( 'width', 'auto' );
8079 this.setIdealSize(
8080 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
8081 // Dropdown is smaller than handle so expand to width
8082 this.$floatableContainer[ 0 ].offsetWidth :
8083 // Dropdown is larger than handle so auto size
8084 'auto'
8085 );
8086 this.$clippable.css( 'width', '' );
8087 }
8088
8089 this.togglePositioning( !!this.$floatableContainer );
8090 this.toggleClipping( true );
8091
8092 this.bindDocumentKeyDownListener();
8093 this.bindDocumentKeyPressListener();
8094
8095 if (
8096 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
8097 this.originalVerticalPosition !== 'center'
8098 ) {
8099 // If opening the menu in one direction causes it to be clipped, flip it
8100 originalHeight = this.$element.height();
8101 this.setVerticalPosition(
8102 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
8103 );
8104 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
8105 // If flipping also causes it to be clipped, open in whichever direction
8106 // we have more space
8107 flippedHeight = this.$element.height();
8108 if ( originalHeight > flippedHeight ) {
8109 this.setVerticalPosition( this.originalVerticalPosition );
8110 }
8111 }
8112 }
8113 // Note that we do not flip the menu's opening direction if the clipping changes
8114 // later (e.g. after the user scrolls), that seems like it would be annoying
8115
8116 this.$focusOwner.attr( 'aria-expanded', 'true' );
8117
8118 if ( this.findSelectedItem() ) {
8119 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
8120 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
8121 }
8122
8123 // Auto-hide
8124 if ( this.autoHide ) {
8125 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8126 }
8127
8128 this.emit( 'ready' );
8129 } else {
8130 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8131 this.unbindDocumentKeyDownListener();
8132 this.unbindDocumentKeyPressListener();
8133 this.$focusOwner.attr( 'aria-expanded', 'false' );
8134 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8135 this.togglePositioning( false );
8136 this.toggleClipping( false );
8137 }
8138 }
8139
8140 return this;
8141 };
8142
8143 /**
8144 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8145 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8146 * users can interact with it.
8147 *
8148 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8149 * OO.ui.DropdownInputWidget instead.
8150 *
8151 * @example
8152 * // A DropdownWidget with a menu that contains three options.
8153 * var dropDown = new OO.ui.DropdownWidget( {
8154 * label: 'Dropdown menu: Select a menu option',
8155 * menu: {
8156 * items: [
8157 * new OO.ui.MenuOptionWidget( {
8158 * data: 'a',
8159 * label: 'First'
8160 * } ),
8161 * new OO.ui.MenuOptionWidget( {
8162 * data: 'b',
8163 * label: 'Second'
8164 * } ),
8165 * new OO.ui.MenuOptionWidget( {
8166 * data: 'c',
8167 * label: 'Third'
8168 * } )
8169 * ]
8170 * }
8171 * } );
8172 *
8173 * $( document.body ).append( dropDown.$element );
8174 *
8175 * dropDown.getMenu().selectItemByData( 'b' );
8176 *
8177 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8178 *
8179 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8180 *
8181 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8182 *
8183 * @class
8184 * @extends OO.ui.Widget
8185 * @mixins OO.ui.mixin.IconElement
8186 * @mixins OO.ui.mixin.IndicatorElement
8187 * @mixins OO.ui.mixin.LabelElement
8188 * @mixins OO.ui.mixin.TitledElement
8189 * @mixins OO.ui.mixin.TabIndexedElement
8190 *
8191 * @constructor
8192 * @param {Object} [config] Configuration options
8193 * @cfg {Object} [menu] Configuration options to pass to
8194 * {@link OO.ui.MenuSelectWidget menu select widget}.
8195 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
8196 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
8197 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
8198 * uses relative positioning.
8199 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8200 */
8201 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8202 // Configuration initialization
8203 config = $.extend( { indicator: 'down' }, config );
8204
8205 // Parent constructor
8206 OO.ui.DropdownWidget.parent.call( this, config );
8207
8208 // Properties (must be set before TabIndexedElement constructor call)
8209 this.$handle = $( '<button>' );
8210 this.$overlay = ( config.$overlay === true ?
8211 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8212
8213 // Mixin constructors
8214 OO.ui.mixin.IconElement.call( this, config );
8215 OO.ui.mixin.IndicatorElement.call( this, config );
8216 OO.ui.mixin.LabelElement.call( this, config );
8217 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, {
8218 $titled: this.$label
8219 } ) );
8220 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
8221 $tabIndexed: this.$handle
8222 } ) );
8223
8224 // Properties
8225 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8226 widget: this,
8227 $floatableContainer: this.$element
8228 }, config.menu ) );
8229
8230 // Events
8231 this.$handle.on( {
8232 click: this.onClick.bind( this ),
8233 keydown: this.onKeyDown.bind( this ),
8234 // Hack? Handle type-to-search when menu is not expanded and not handling its own events.
8235 keypress: this.menu.onDocumentKeyPressHandler,
8236 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8237 } );
8238 this.menu.connect( this, {
8239 select: 'onMenuSelect',
8240 toggle: 'onMenuToggle'
8241 } );
8242
8243 // Initialization
8244 this.$handle
8245 .addClass( 'oo-ui-dropdownWidget-handle' )
8246 .attr( {
8247 type: 'button',
8248 'aria-owns': this.menu.getElementId(),
8249 'aria-haspopup': 'listbox'
8250 } )
8251 .append( this.$icon, this.$label, this.$indicator );
8252 this.$element
8253 .addClass( 'oo-ui-dropdownWidget' )
8254 .append( this.$handle );
8255 this.$overlay.append( this.menu.$element );
8256 };
8257
8258 /* Setup */
8259
8260 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8261 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8262 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8263 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8264 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8265 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8266
8267 /* Methods */
8268
8269 /**
8270 * Get the menu.
8271 *
8272 * @return {OO.ui.MenuSelectWidget} Menu of widget
8273 */
8274 OO.ui.DropdownWidget.prototype.getMenu = function () {
8275 return this.menu;
8276 };
8277
8278 /**
8279 * Handles menu select events.
8280 *
8281 * @private
8282 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8283 */
8284 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8285 var selectedLabel;
8286
8287 if ( !item ) {
8288 this.setLabel( null );
8289 return;
8290 }
8291
8292 selectedLabel = item.getLabel();
8293
8294 // If the label is a DOM element, clone it, because setLabel will append() it
8295 if ( selectedLabel instanceof $ ) {
8296 selectedLabel = selectedLabel.clone();
8297 }
8298
8299 this.setLabel( selectedLabel );
8300 };
8301
8302 /**
8303 * Handle menu toggle events.
8304 *
8305 * @private
8306 * @param {boolean} isVisible Open state of the menu
8307 */
8308 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8309 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8310 };
8311
8312 /**
8313 * Handle mouse click events.
8314 *
8315 * @private
8316 * @param {jQuery.Event} e Mouse click event
8317 * @return {undefined/boolean} False to prevent default if event is handled
8318 */
8319 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8320 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8321 this.menu.toggle();
8322 }
8323 return false;
8324 };
8325
8326 /**
8327 * Handle key down events.
8328 *
8329 * @private
8330 * @param {jQuery.Event} e Key down event
8331 * @return {undefined/boolean} False to prevent default if event is handled
8332 */
8333 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8334 if (
8335 !this.isDisabled() &&
8336 (
8337 e.which === OO.ui.Keys.ENTER ||
8338 (
8339 e.which === OO.ui.Keys.SPACE &&
8340 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8341 // Space only closes the menu is the user is not typing to search.
8342 this.menu.keyPressBuffer === ''
8343 ) ||
8344 (
8345 !this.menu.isVisible() &&
8346 (
8347 e.which === OO.ui.Keys.UP ||
8348 e.which === OO.ui.Keys.DOWN
8349 )
8350 )
8351 )
8352 ) {
8353 this.menu.toggle();
8354 return false;
8355 }
8356 };
8357
8358 /**
8359 * RadioOptionWidget is an option widget that looks like a radio button.
8360 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8361 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8362 *
8363 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8364 *
8365 * @class
8366 * @extends OO.ui.OptionWidget
8367 *
8368 * @constructor
8369 * @param {Object} [config] Configuration options
8370 */
8371 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8372 // Configuration initialization
8373 config = config || {};
8374
8375 // Properties (must be done before parent constructor which calls #setDisabled)
8376 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8377
8378 // Parent constructor
8379 OO.ui.RadioOptionWidget.parent.call( this, config );
8380
8381 // Initialization
8382 // Remove implicit role, we're handling it ourselves
8383 this.radio.$input.attr( 'role', 'presentation' );
8384 this.$element
8385 .addClass( 'oo-ui-radioOptionWidget' )
8386 .attr( 'role', 'radio' )
8387 .attr( 'aria-checked', 'false' )
8388 .removeAttr( 'aria-selected' )
8389 .prepend( this.radio.$element );
8390 };
8391
8392 /* Setup */
8393
8394 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8395
8396 /* Static Properties */
8397
8398 /**
8399 * @static
8400 * @inheritdoc
8401 */
8402 OO.ui.RadioOptionWidget.static.highlightable = false;
8403
8404 /**
8405 * @static
8406 * @inheritdoc
8407 */
8408 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8409
8410 /**
8411 * @static
8412 * @inheritdoc
8413 */
8414 OO.ui.RadioOptionWidget.static.pressable = false;
8415
8416 /**
8417 * @static
8418 * @inheritdoc
8419 */
8420 OO.ui.RadioOptionWidget.static.tagName = 'label';
8421
8422 /* Methods */
8423
8424 /**
8425 * @inheritdoc
8426 */
8427 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8428 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8429
8430 this.radio.setSelected( state );
8431 this.$element
8432 .attr( 'aria-checked', state.toString() )
8433 .removeAttr( 'aria-selected' );
8434
8435 return this;
8436 };
8437
8438 /**
8439 * @inheritdoc
8440 */
8441 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8442 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8443
8444 this.radio.setDisabled( this.isDisabled() );
8445
8446 return this;
8447 };
8448
8449 /**
8450 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8451 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8452 * an interface for adding, removing and selecting options.
8453 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8454 *
8455 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8456 * OO.ui.RadioSelectInputWidget instead.
8457 *
8458 * @example
8459 * // A RadioSelectWidget with RadioOptions.
8460 * var option1 = new OO.ui.RadioOptionWidget( {
8461 * data: 'a',
8462 * label: 'Selected radio option'
8463 * } ),
8464 * option2 = new OO.ui.RadioOptionWidget( {
8465 * data: 'b',
8466 * label: 'Unselected radio option'
8467 * } );
8468 * radioSelect = new OO.ui.RadioSelectWidget( {
8469 * items: [ option1, option2 ]
8470 * } );
8471 *
8472 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8473 * radioSelect.selectItem( option1 );
8474 *
8475 * $( document.body ).append( radioSelect.$element );
8476 *
8477 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8478
8479 *
8480 * @class
8481 * @extends OO.ui.SelectWidget
8482 * @mixins OO.ui.mixin.TabIndexedElement
8483 *
8484 * @constructor
8485 * @param {Object} [config] Configuration options
8486 */
8487 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8488 // Parent constructor
8489 OO.ui.RadioSelectWidget.parent.call( this, config );
8490
8491 // Mixin constructors
8492 OO.ui.mixin.TabIndexedElement.call( this, config );
8493
8494 // Events
8495 this.$element.on( {
8496 focus: this.bindDocumentKeyDownListener.bind( this ),
8497 blur: this.unbindDocumentKeyDownListener.bind( this )
8498 } );
8499
8500 // Initialization
8501 this.$element
8502 .addClass( 'oo-ui-radioSelectWidget' )
8503 .attr( 'role', 'radiogroup' );
8504 };
8505
8506 /* Setup */
8507
8508 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8509 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8510
8511 /**
8512 * MultioptionWidgets are special elements that can be selected and configured with data. The
8513 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8514 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8515 * and examples, please see the [OOUI documentation on MediaWiki][1].
8516 *
8517 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8518 *
8519 * @class
8520 * @extends OO.ui.Widget
8521 * @mixins OO.ui.mixin.ItemWidget
8522 * @mixins OO.ui.mixin.LabelElement
8523 * @mixins OO.ui.mixin.TitledElement
8524 *
8525 * @constructor
8526 * @param {Object} [config] Configuration options
8527 * @cfg {boolean} [selected=false] Whether the option is initially selected
8528 */
8529 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8530 // Configuration initialization
8531 config = config || {};
8532
8533 // Parent constructor
8534 OO.ui.MultioptionWidget.parent.call( this, config );
8535
8536 // Mixin constructors
8537 OO.ui.mixin.ItemWidget.call( this );
8538 OO.ui.mixin.LabelElement.call( this, config );
8539 OO.ui.mixin.TitledElement.call( this, config );
8540
8541 // Properties
8542 this.selected = null;
8543
8544 // Initialization
8545 this.$element
8546 .addClass( 'oo-ui-multioptionWidget' )
8547 .append( this.$label );
8548 this.setSelected( config.selected );
8549 };
8550
8551 /* Setup */
8552
8553 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8554 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8555 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8556 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8557
8558 /* Events */
8559
8560 /**
8561 * @event change
8562 *
8563 * A change event is emitted when the selected state of the option changes.
8564 *
8565 * @param {boolean} selected Whether the option is now selected
8566 */
8567
8568 /* Methods */
8569
8570 /**
8571 * Check if the option is selected.
8572 *
8573 * @return {boolean} Item is selected
8574 */
8575 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8576 return this.selected;
8577 };
8578
8579 /**
8580 * Set the option’s selected state. In general, all modifications to the selection
8581 * should be handled by the SelectWidget’s
8582 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
8583 *
8584 * @param {boolean} [state=false] Select option
8585 * @chainable
8586 * @return {OO.ui.Widget} The widget, for chaining
8587 */
8588 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8589 state = !!state;
8590 if ( this.selected !== state ) {
8591 this.selected = state;
8592 this.emit( 'change', state );
8593 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8594 }
8595 return this;
8596 };
8597
8598 /**
8599 * MultiselectWidget allows selecting multiple options from a list.
8600 *
8601 * For more information about menus and options, please see the [OOUI documentation
8602 * on MediaWiki][1].
8603 *
8604 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8605 *
8606 * @class
8607 * @abstract
8608 * @extends OO.ui.Widget
8609 * @mixins OO.ui.mixin.GroupWidget
8610 * @mixins OO.ui.mixin.TitledElement
8611 *
8612 * @constructor
8613 * @param {Object} [config] Configuration options
8614 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8615 */
8616 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8617 // Parent constructor
8618 OO.ui.MultiselectWidget.parent.call( this, config );
8619
8620 // Configuration initialization
8621 config = config || {};
8622
8623 // Mixin constructors
8624 OO.ui.mixin.GroupWidget.call( this, config );
8625 OO.ui.mixin.TitledElement.call( this, config );
8626
8627 // Events
8628 this.aggregate( {
8629 change: 'select'
8630 } );
8631 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8632 // by GroupElement only when items are added/removed
8633 this.connect( this, {
8634 select: [ 'emit', 'change' ]
8635 } );
8636
8637 // Initialization
8638 if ( config.items ) {
8639 this.addItems( config.items );
8640 }
8641 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8642 this.$element.addClass( 'oo-ui-multiselectWidget' )
8643 .append( this.$group );
8644 };
8645
8646 /* Setup */
8647
8648 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8649 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8650 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8651
8652 /* Events */
8653
8654 /**
8655 * @event change
8656 *
8657 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8658 */
8659
8660 /**
8661 * @event select
8662 *
8663 * A select event is emitted when an item is selected or deselected.
8664 */
8665
8666 /* Methods */
8667
8668 /**
8669 * Find options that are selected.
8670 *
8671 * @return {OO.ui.MultioptionWidget[]} Selected options
8672 */
8673 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8674 return this.items.filter( function ( item ) {
8675 return item.isSelected();
8676 } );
8677 };
8678
8679 /**
8680 * Find the data of options that are selected.
8681 *
8682 * @return {Object[]|string[]} Values of selected options
8683 */
8684 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8685 return this.findSelectedItems().map( function ( item ) {
8686 return item.data;
8687 } );
8688 };
8689
8690 /**
8691 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8692 *
8693 * @param {OO.ui.MultioptionWidget[]} items Items to select
8694 * @chainable
8695 * @return {OO.ui.Widget} The widget, for chaining
8696 */
8697 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8698 this.items.forEach( function ( item ) {
8699 var selected = items.indexOf( item ) !== -1;
8700 item.setSelected( selected );
8701 } );
8702 return this;
8703 };
8704
8705 /**
8706 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8707 *
8708 * @param {Object[]|string[]} datas Values of items to select
8709 * @chainable
8710 * @return {OO.ui.Widget} The widget, for chaining
8711 */
8712 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8713 var items,
8714 widget = this;
8715 items = datas.map( function ( data ) {
8716 return widget.findItemFromData( data );
8717 } );
8718 this.selectItems( items );
8719 return this;
8720 };
8721
8722 /**
8723 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8724 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8725 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8726 *
8727 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8728 *
8729 * @class
8730 * @extends OO.ui.MultioptionWidget
8731 *
8732 * @constructor
8733 * @param {Object} [config] Configuration options
8734 */
8735 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8736 // Configuration initialization
8737 config = config || {};
8738
8739 // Properties (must be done before parent constructor which calls #setDisabled)
8740 this.checkbox = new OO.ui.CheckboxInputWidget();
8741
8742 // Parent constructor
8743 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8744
8745 // Events
8746 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8747 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8748
8749 // Initialization
8750 this.$element
8751 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8752 .prepend( this.checkbox.$element );
8753 };
8754
8755 /* Setup */
8756
8757 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8758
8759 /* Static Properties */
8760
8761 /**
8762 * @static
8763 * @inheritdoc
8764 */
8765 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8766
8767 /* Methods */
8768
8769 /**
8770 * Handle checkbox selected state change.
8771 *
8772 * @private
8773 */
8774 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8775 this.setSelected( this.checkbox.isSelected() );
8776 };
8777
8778 /**
8779 * @inheritdoc
8780 */
8781 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8782 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8783 this.checkbox.setSelected( state );
8784 return this;
8785 };
8786
8787 /**
8788 * @inheritdoc
8789 */
8790 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8791 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8792 this.checkbox.setDisabled( this.isDisabled() );
8793 return this;
8794 };
8795
8796 /**
8797 * Focus the widget.
8798 */
8799 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8800 this.checkbox.focus();
8801 };
8802
8803 /**
8804 * Handle key down events.
8805 *
8806 * @protected
8807 * @param {jQuery.Event} e
8808 */
8809 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8810 var
8811 element = this.getElementGroup(),
8812 nextItem;
8813
8814 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8815 nextItem = element.getRelativeFocusableItem( this, -1 );
8816 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8817 nextItem = element.getRelativeFocusableItem( this, 1 );
8818 }
8819
8820 if ( nextItem ) {
8821 e.preventDefault();
8822 nextItem.focus();
8823 }
8824 };
8825
8826 /**
8827 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8828 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8829 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8830 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8831 *
8832 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8833 * OO.ui.CheckboxMultiselectInputWidget instead.
8834 *
8835 * @example
8836 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8837 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8838 * data: 'a',
8839 * selected: true,
8840 * label: 'Selected checkbox'
8841 * } ),
8842 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8843 * data: 'b',
8844 * label: 'Unselected checkbox'
8845 * } ),
8846 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8847 * items: [ option1, option2 ]
8848 * } );
8849 * $( document.body ).append( multiselect.$element );
8850 *
8851 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8852 *
8853 * @class
8854 * @extends OO.ui.MultiselectWidget
8855 *
8856 * @constructor
8857 * @param {Object} [config] Configuration options
8858 */
8859 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8860 // Parent constructor
8861 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8862
8863 // Properties
8864 this.$lastClicked = null;
8865
8866 // Events
8867 this.$group.on( 'click', this.onClick.bind( this ) );
8868
8869 // Initialization
8870 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
8871 };
8872
8873 /* Setup */
8874
8875 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8876
8877 /* Methods */
8878
8879 /**
8880 * Get an option by its position relative to the specified item (or to the start of the
8881 * option array, if item is `null`). The direction in which to search through the option array
8882 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
8883 * return an option, or `null` if there are no options in the array.
8884 *
8885 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
8886 * `null` to start at the beginning of the array.
8887 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8888 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
8889 * in the select.
8890 */
8891 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8892 var currentIndex, nextIndex, i,
8893 increase = direction > 0 ? 1 : -1,
8894 len = this.items.length;
8895
8896 if ( item ) {
8897 currentIndex = this.items.indexOf( item );
8898 nextIndex = ( currentIndex + increase + len ) % len;
8899 } else {
8900 // If no item is selected and moving forward, start at the beginning.
8901 // If moving backward, start at the end.
8902 nextIndex = direction > 0 ? 0 : len - 1;
8903 }
8904
8905 for ( i = 0; i < len; i++ ) {
8906 item = this.items[ nextIndex ];
8907 if ( item && !item.isDisabled() ) {
8908 return item;
8909 }
8910 nextIndex = ( nextIndex + increase + len ) % len;
8911 }
8912 return null;
8913 };
8914
8915 /**
8916 * Handle click events on checkboxes.
8917 *
8918 * @param {jQuery.Event} e
8919 */
8920 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8921 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8922 $lastClicked = this.$lastClicked,
8923 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8924 .not( '.oo-ui-widget-disabled' );
8925
8926 // Allow selecting multiple options at once by Shift-clicking them
8927 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8928 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8929 lastClickedIndex = $options.index( $lastClicked );
8930 nowClickedIndex = $options.index( $nowClicked );
8931 // If it's the same item, either the user is being silly, or it's a fake event generated
8932 // by the browser. In either case we don't need custom handling.
8933 if ( nowClickedIndex !== lastClickedIndex ) {
8934 items = this.items;
8935 wasSelected = items[ nowClickedIndex ].isSelected();
8936 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8937
8938 // This depends on the DOM order of the items and the order of the .items array being
8939 // the same.
8940 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8941 if ( !items[ i ].isDisabled() ) {
8942 items[ i ].setSelected( !wasSelected );
8943 }
8944 }
8945 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8946 // handling first, then set our value. The order in which events happen is different for
8947 // clicks on the <input> and on the <label> and there are additional fake clicks fired
8948 // for non-click actions that change the checkboxes.
8949 e.preventDefault();
8950 setTimeout( function () {
8951 if ( !items[ nowClickedIndex ].isDisabled() ) {
8952 items[ nowClickedIndex ].setSelected( !wasSelected );
8953 }
8954 } );
8955 }
8956 }
8957
8958 if ( $nowClicked.length ) {
8959 this.$lastClicked = $nowClicked;
8960 }
8961 };
8962
8963 /**
8964 * Focus the widget
8965 *
8966 * @chainable
8967 * @return {OO.ui.Widget} The widget, for chaining
8968 */
8969 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8970 var item;
8971 if ( !this.isDisabled() ) {
8972 item = this.getRelativeFocusableItem( null, 1 );
8973 if ( item ) {
8974 item.focus();
8975 }
8976 }
8977 return this;
8978 };
8979
8980 /**
8981 * @inheritdoc
8982 */
8983 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8984 this.focus();
8985 };
8986
8987 /**
8988 * Progress bars visually display the status of an operation, such as a download,
8989 * and can be either determinate or indeterminate:
8990 *
8991 * - **determinate** process bars show the percent of an operation that is complete.
8992 *
8993 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8994 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8995 * not use percentages.
8996 *
8997 * The value of the `progress` configuration determines whether the bar is determinate
8998 * or indeterminate.
8999 *
9000 * @example
9001 * // Examples of determinate and indeterminate progress bars.
9002 * var progressBar1 = new OO.ui.ProgressBarWidget( {
9003 * progress: 33
9004 * } );
9005 * var progressBar2 = new OO.ui.ProgressBarWidget();
9006 *
9007 * // Create a FieldsetLayout to layout progress bars.
9008 * var fieldset = new OO.ui.FieldsetLayout;
9009 * fieldset.addItems( [
9010 * new OO.ui.FieldLayout( progressBar1, {
9011 * label: 'Determinate',
9012 * align: 'top'
9013 * } ),
9014 * new OO.ui.FieldLayout( progressBar2, {
9015 * label: 'Indeterminate',
9016 * align: 'top'
9017 * } )
9018 * ] );
9019 * $( document.body ).append( fieldset.$element );
9020 *
9021 * @class
9022 * @extends OO.ui.Widget
9023 *
9024 * @constructor
9025 * @param {Object} [config] Configuration options
9026 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
9027 * To create a determinate progress bar, specify a number that reflects the initial
9028 * percent complete.
9029 * By default, the progress bar is indeterminate.
9030 */
9031 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
9032 // Configuration initialization
9033 config = config || {};
9034
9035 // Parent constructor
9036 OO.ui.ProgressBarWidget.parent.call( this, config );
9037
9038 // Properties
9039 this.$bar = $( '<div>' );
9040 this.progress = null;
9041
9042 // Initialization
9043 this.setProgress( config.progress !== undefined ? config.progress : false );
9044 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
9045 this.$element
9046 .attr( {
9047 role: 'progressbar',
9048 'aria-valuemin': 0,
9049 'aria-valuemax': 100
9050 } )
9051 .addClass( 'oo-ui-progressBarWidget' )
9052 .append( this.$bar );
9053 };
9054
9055 /* Setup */
9056
9057 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
9058
9059 /* Static Properties */
9060
9061 /**
9062 * @static
9063 * @inheritdoc
9064 */
9065 OO.ui.ProgressBarWidget.static.tagName = 'div';
9066
9067 /* Methods */
9068
9069 /**
9070 * Get the percent of the progress that has been completed. Indeterminate progresses will
9071 * return `false`.
9072 *
9073 * @return {number|boolean} Progress percent
9074 */
9075 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
9076 return this.progress;
9077 };
9078
9079 /**
9080 * Set the percent of the process completed or `false` for an indeterminate process.
9081 *
9082 * @param {number|boolean} progress Progress percent or `false` for indeterminate
9083 */
9084 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
9085 this.progress = progress;
9086
9087 if ( progress !== false ) {
9088 this.$bar.css( 'width', this.progress + '%' );
9089 this.$element.attr( 'aria-valuenow', this.progress );
9090 } else {
9091 this.$bar.css( 'width', '' );
9092 this.$element.removeAttr( 'aria-valuenow' );
9093 }
9094 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
9095 };
9096
9097 /**
9098 * InputWidget is the base class for all input widgets, which
9099 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
9100 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
9101 * {@link OO.ui.ButtonInputWidget button inputs}.
9102 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
9103 *
9104 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9105 *
9106 * @abstract
9107 * @class
9108 * @extends OO.ui.Widget
9109 * @mixins OO.ui.mixin.FlaggedElement
9110 * @mixins OO.ui.mixin.TabIndexedElement
9111 * @mixins OO.ui.mixin.TitledElement
9112 * @mixins OO.ui.mixin.AccessKeyedElement
9113 *
9114 * @constructor
9115 * @param {Object} [config] Configuration options
9116 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9117 * @cfg {string} [value=''] The value of the input.
9118 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
9119 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
9120 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the
9121 * value of an input before it is accepted.
9122 */
9123 OO.ui.InputWidget = function OoUiInputWidget( config ) {
9124 // Configuration initialization
9125 config = config || {};
9126
9127 // Parent constructor
9128 OO.ui.InputWidget.parent.call( this, config );
9129
9130 // Properties
9131 // See #reusePreInfuseDOM about config.$input
9132 this.$input = config.$input || this.getInputElement( config );
9133 this.value = '';
9134 this.inputFilter = config.inputFilter;
9135
9136 // Mixin constructors
9137 OO.ui.mixin.FlaggedElement.call( this, config );
9138 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
9139 $tabIndexed: this.$input
9140 } ) );
9141 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, {
9142 $titled: this.$input
9143 } ) );
9144 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, {
9145 $accessKeyed: this.$input
9146 } ) );
9147
9148 // Events
9149 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9150
9151 // Initialization
9152 this.$input
9153 .addClass( 'oo-ui-inputWidget-input' )
9154 .attr( 'name', config.name )
9155 .prop( 'disabled', this.isDisabled() );
9156 this.$element
9157 .addClass( 'oo-ui-inputWidget' )
9158 .append( this.$input );
9159 this.setValue( config.value );
9160 if ( config.dir ) {
9161 this.setDir( config.dir );
9162 }
9163 if ( config.inputId !== undefined ) {
9164 this.setInputId( config.inputId );
9165 }
9166 };
9167
9168 /* Setup */
9169
9170 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9171 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
9172 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9173 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9174 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9175
9176 /* Static Methods */
9177
9178 /**
9179 * @inheritdoc
9180 */
9181 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9182 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
9183 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9184 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
9185 return config;
9186 };
9187
9188 /**
9189 * @inheritdoc
9190 */
9191 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9192 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
9193 if ( config.$input && config.$input.length ) {
9194 state.value = config.$input.val();
9195 // Might be better in TabIndexedElement, but it's awkward to do there because
9196 // mixins are awkward
9197 state.focus = config.$input.is( ':focus' );
9198 }
9199 return state;
9200 };
9201
9202 /* Events */
9203
9204 /**
9205 * @event change
9206 *
9207 * A change event is emitted when the value of the input changes.
9208 *
9209 * @param {string} value
9210 */
9211
9212 /* Methods */
9213
9214 /**
9215 * Get input element.
9216 *
9217 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9218 * different circumstances. The element must have a `value` property (like form elements).
9219 *
9220 * @protected
9221 * @param {Object} config Configuration options
9222 * @return {jQuery} Input element
9223 */
9224 OO.ui.InputWidget.prototype.getInputElement = function () {
9225 return $( '<input>' );
9226 };
9227
9228 /**
9229 * Handle potentially value-changing events.
9230 *
9231 * @private
9232 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9233 */
9234 OO.ui.InputWidget.prototype.onEdit = function () {
9235 var widget = this;
9236 if ( !this.isDisabled() ) {
9237 // Allow the stack to clear so the value will be updated
9238 setTimeout( function () {
9239 widget.setValue( widget.$input.val() );
9240 } );
9241 }
9242 };
9243
9244 /**
9245 * Get the value of the input.
9246 *
9247 * @return {string} Input value
9248 */
9249 OO.ui.InputWidget.prototype.getValue = function () {
9250 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9251 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9252 var value = this.$input.val();
9253 if ( this.value !== value ) {
9254 this.setValue( value );
9255 }
9256 return this.value;
9257 };
9258
9259 /**
9260 * Set the directionality of the input.
9261 *
9262 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9263 * @chainable
9264 * @return {OO.ui.Widget} The widget, for chaining
9265 */
9266 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9267 this.$input.prop( 'dir', dir );
9268 return this;
9269 };
9270
9271 /**
9272 * Set the value of the input.
9273 *
9274 * @param {string} value New value
9275 * @fires change
9276 * @chainable
9277 * @return {OO.ui.Widget} The widget, for chaining
9278 */
9279 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9280 value = this.cleanUpValue( value );
9281 // Update the DOM if it has changed. Note that with cleanUpValue, it
9282 // is possible for the DOM value to change without this.value changing.
9283 if ( this.$input.val() !== value ) {
9284 this.$input.val( value );
9285 }
9286 if ( this.value !== value ) {
9287 this.value = value;
9288 this.emit( 'change', this.value );
9289 }
9290 // The first time that the value is set (probably while constructing the widget),
9291 // remember it in defaultValue. This property can be later used to check whether
9292 // the value of the input has been changed since it was created.
9293 if ( this.defaultValue === undefined ) {
9294 this.defaultValue = this.value;
9295 this.$input[ 0 ].defaultValue = this.defaultValue;
9296 }
9297 return this;
9298 };
9299
9300 /**
9301 * Clean up incoming value.
9302 *
9303 * Ensures value is a string, and converts undefined and null to empty string.
9304 *
9305 * @private
9306 * @param {string} value Original value
9307 * @return {string} Cleaned up value
9308 */
9309 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9310 if ( value === undefined || value === null ) {
9311 return '';
9312 } else if ( this.inputFilter ) {
9313 return this.inputFilter( String( value ) );
9314 } else {
9315 return String( value );
9316 }
9317 };
9318
9319 /**
9320 * @inheritdoc
9321 */
9322 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9323 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9324 if ( this.$input ) {
9325 this.$input.prop( 'disabled', this.isDisabled() );
9326 }
9327 return this;
9328 };
9329
9330 /**
9331 * Set the 'id' attribute of the `<input>` element.
9332 *
9333 * @param {string} id
9334 * @chainable
9335 * @return {OO.ui.Widget} The widget, for chaining
9336 */
9337 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9338 this.$input.attr( 'id', id );
9339 return this;
9340 };
9341
9342 /**
9343 * @inheritdoc
9344 */
9345 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9346 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9347 if ( state.value !== undefined && state.value !== this.getValue() ) {
9348 this.setValue( state.value );
9349 }
9350 if ( state.focus ) {
9351 this.focus();
9352 }
9353 };
9354
9355 /**
9356 * Data widget intended for creating `<input type="hidden">` inputs.
9357 *
9358 * @class
9359 * @extends OO.ui.Widget
9360 *
9361 * @constructor
9362 * @param {Object} [config] Configuration options
9363 * @cfg {string} [value=''] The value of the input.
9364 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9365 */
9366 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9367 // Configuration initialization
9368 config = $.extend( { value: '', name: '' }, config );
9369
9370 // Parent constructor
9371 OO.ui.HiddenInputWidget.parent.call( this, config );
9372
9373 // Initialization
9374 this.$element.attr( {
9375 type: 'hidden',
9376 value: config.value,
9377 name: config.name
9378 } );
9379 this.$element.removeAttr( 'aria-disabled' );
9380 };
9381
9382 /* Setup */
9383
9384 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9385
9386 /* Static Properties */
9387
9388 /**
9389 * @static
9390 * @inheritdoc
9391 */
9392 OO.ui.HiddenInputWidget.static.tagName = 'input';
9393
9394 /**
9395 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9396 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9397 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9398 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9399 * [OOUI documentation on MediaWiki] [1] for more information.
9400 *
9401 * @example
9402 * // A ButtonInputWidget rendered as an HTML button, the default.
9403 * var button = new OO.ui.ButtonInputWidget( {
9404 * label: 'Input button',
9405 * icon: 'check',
9406 * value: 'check'
9407 * } );
9408 * $( document.body ).append( button.$element );
9409 *
9410 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9411 *
9412 * @class
9413 * @extends OO.ui.InputWidget
9414 * @mixins OO.ui.mixin.ButtonElement
9415 * @mixins OO.ui.mixin.IconElement
9416 * @mixins OO.ui.mixin.IndicatorElement
9417 * @mixins OO.ui.mixin.LabelElement
9418 *
9419 * @constructor
9420 * @param {Object} [config] Configuration options
9421 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute:
9422 * 'button', 'submit' or 'reset'.
9423 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9424 * Widgets configured to be an `<input>` do not support {@link #icon icons} and
9425 * {@link #indicator indicators},
9426 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should
9427 * only be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9428 */
9429 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9430 // Configuration initialization
9431 config = $.extend( { type: 'button', useInputTag: false }, config );
9432
9433 // See InputWidget#reusePreInfuseDOM about config.$input
9434 if ( config.$input ) {
9435 config.$input.empty();
9436 }
9437
9438 // Properties (must be set before parent constructor, which calls #setValue)
9439 this.useInputTag = config.useInputTag;
9440
9441 // Parent constructor
9442 OO.ui.ButtonInputWidget.parent.call( this, config );
9443
9444 // Mixin constructors
9445 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, {
9446 $button: this.$input
9447 } ) );
9448 OO.ui.mixin.IconElement.call( this, config );
9449 OO.ui.mixin.IndicatorElement.call( this, config );
9450 OO.ui.mixin.LabelElement.call( this, config );
9451
9452 // Initialization
9453 if ( !config.useInputTag ) {
9454 this.$input.append( this.$icon, this.$label, this.$indicator );
9455 }
9456 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9457 };
9458
9459 /* Setup */
9460
9461 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9462 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9463 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9464 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9465 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9466
9467 /* Static Properties */
9468
9469 /**
9470 * @static
9471 * @inheritdoc
9472 */
9473 OO.ui.ButtonInputWidget.static.tagName = 'span';
9474
9475 /* Methods */
9476
9477 /**
9478 * @inheritdoc
9479 * @protected
9480 */
9481 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9482 var type;
9483 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9484 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9485 };
9486
9487 /**
9488 * Set label value.
9489 *
9490 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9491 *
9492 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9493 * text, or `null` for no label
9494 * @chainable
9495 * @return {OO.ui.Widget} The widget, for chaining
9496 */
9497 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9498 if ( typeof label === 'function' ) {
9499 label = OO.ui.resolveMsg( label );
9500 }
9501
9502 if ( this.useInputTag ) {
9503 // Discard non-plaintext labels
9504 if ( typeof label !== 'string' ) {
9505 label = '';
9506 }
9507
9508 this.$input.val( label );
9509 }
9510
9511 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9512 };
9513
9514 /**
9515 * Set the value of the input.
9516 *
9517 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9518 * they do not support {@link #value values}.
9519 *
9520 * @param {string} value New value
9521 * @chainable
9522 * @return {OO.ui.Widget} The widget, for chaining
9523 */
9524 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9525 if ( !this.useInputTag ) {
9526 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9527 }
9528 return this;
9529 };
9530
9531 /**
9532 * @inheritdoc
9533 */
9534 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9535 // Disable generating `<label>` elements for buttons. One would very rarely need additional
9536 // label for a button, and it's already a big clickable target, and it causes
9537 // unexpected rendering.
9538 return null;
9539 };
9540
9541 /**
9542 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9543 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9544 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9545 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9546 *
9547 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9548 *
9549 * @example
9550 * // An example of selected, unselected, and disabled checkbox inputs.
9551 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9552 * value: 'a',
9553 * selected: true
9554 * } ),
9555 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9556 * value: 'b'
9557 * } ),
9558 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9559 * value:'c',
9560 * disabled: true
9561 * } ),
9562 * // Create a fieldset layout with fields for each checkbox.
9563 * fieldset = new OO.ui.FieldsetLayout( {
9564 * label: 'Checkboxes'
9565 * } );
9566 * fieldset.addItems( [
9567 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9568 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9569 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9570 * ] );
9571 * $( document.body ).append( fieldset.$element );
9572 *
9573 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9574 *
9575 * @class
9576 * @extends OO.ui.InputWidget
9577 *
9578 * @constructor
9579 * @param {Object} [config] Configuration options
9580 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is
9581 * not selected.
9582 */
9583 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9584 // Configuration initialization
9585 config = config || {};
9586
9587 // Parent constructor
9588 OO.ui.CheckboxInputWidget.parent.call( this, config );
9589
9590 // Properties
9591 this.checkIcon = new OO.ui.IconWidget( {
9592 icon: 'check',
9593 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9594 } );
9595
9596 // Initialization
9597 this.$element
9598 .addClass( 'oo-ui-checkboxInputWidget' )
9599 // Required for pretty styling in WikimediaUI theme
9600 .append( this.checkIcon.$element );
9601 this.setSelected( config.selected !== undefined ? config.selected : false );
9602 };
9603
9604 /* Setup */
9605
9606 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9607
9608 /* Static Properties */
9609
9610 /**
9611 * @static
9612 * @inheritdoc
9613 */
9614 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9615
9616 /* Static Methods */
9617
9618 /**
9619 * @inheritdoc
9620 */
9621 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9622 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9623 state.checked = config.$input.prop( 'checked' );
9624 return state;
9625 };
9626
9627 /* Methods */
9628
9629 /**
9630 * @inheritdoc
9631 * @protected
9632 */
9633 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9634 return $( '<input>' ).attr( 'type', 'checkbox' );
9635 };
9636
9637 /**
9638 * @inheritdoc
9639 */
9640 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9641 var widget = this;
9642 if ( !this.isDisabled() ) {
9643 // Allow the stack to clear so the value will be updated
9644 setTimeout( function () {
9645 widget.setSelected( widget.$input.prop( 'checked' ) );
9646 } );
9647 }
9648 };
9649
9650 /**
9651 * Set selection state of this checkbox.
9652 *
9653 * @param {boolean} state `true` for selected
9654 * @chainable
9655 * @return {OO.ui.Widget} The widget, for chaining
9656 */
9657 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9658 state = !!state;
9659 if ( this.selected !== state ) {
9660 this.selected = state;
9661 this.$input.prop( 'checked', this.selected );
9662 this.emit( 'change', this.selected );
9663 }
9664 // The first time that the selection state is set (probably while constructing the widget),
9665 // remember it in defaultSelected. This property can be later used to check whether
9666 // the selection state of the input has been changed since it was created.
9667 if ( this.defaultSelected === undefined ) {
9668 this.defaultSelected = this.selected;
9669 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9670 }
9671 return this;
9672 };
9673
9674 /**
9675 * Check if this checkbox is selected.
9676 *
9677 * @return {boolean} Checkbox is selected
9678 */
9679 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9680 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9681 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9682 var selected = this.$input.prop( 'checked' );
9683 if ( this.selected !== selected ) {
9684 this.setSelected( selected );
9685 }
9686 return this.selected;
9687 };
9688
9689 /**
9690 * @inheritdoc
9691 */
9692 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9693 if ( !this.isDisabled() ) {
9694 this.$handle.trigger( 'click' );
9695 }
9696 this.focus();
9697 };
9698
9699 /**
9700 * @inheritdoc
9701 */
9702 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9703 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9704 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9705 this.setSelected( state.checked );
9706 }
9707 };
9708
9709 /**
9710 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9711 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
9712 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9713 * more information about input widgets.
9714 *
9715 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9716 * are no options. If no `value` configuration option is provided, the first option is selected.
9717 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9718 *
9719 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9720 *
9721 * @example
9722 * // A DropdownInputWidget with three options.
9723 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9724 * options: [
9725 * { data: 'a', label: 'First' },
9726 * { data: 'b', label: 'Second', disabled: true },
9727 * { optgroup: 'Group label' },
9728 * { data: 'c', label: 'First sub-item)' }
9729 * ]
9730 * } );
9731 * $( document.body ).append( dropdownInput.$element );
9732 *
9733 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9734 *
9735 * @class
9736 * @extends OO.ui.InputWidget
9737 *
9738 * @constructor
9739 * @param {Object} [config] Configuration options
9740 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9741 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9742 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
9743 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
9744 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
9745 * uses relative positioning.
9746 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9747 */
9748 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9749 // Configuration initialization
9750 config = config || {};
9751
9752 // Properties (must be done before parent constructor which calls #setDisabled)
9753 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9754 {
9755 $overlay: config.$overlay
9756 },
9757 config.dropdown
9758 ) );
9759 // Set up the options before parent constructor, which uses them to validate config.value.
9760 // Use this instead of setOptions() because this.$input is not set up yet.
9761 this.setOptionsData( config.options || [] );
9762
9763 // Parent constructor
9764 OO.ui.DropdownInputWidget.parent.call( this, config );
9765
9766 // Events
9767 this.dropdownWidget.getMenu().connect( this, {
9768 select: 'onMenuSelect'
9769 } );
9770
9771 // Initialization
9772 this.$element
9773 .addClass( 'oo-ui-dropdownInputWidget' )
9774 .append( this.dropdownWidget.$element );
9775 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9776 this.setTitledElement( this.dropdownWidget.$handle );
9777 };
9778
9779 /* Setup */
9780
9781 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9782
9783 /* Methods */
9784
9785 /**
9786 * @inheritdoc
9787 * @protected
9788 */
9789 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9790 return $( '<select>' );
9791 };
9792
9793 /**
9794 * Handles menu select events.
9795 *
9796 * @private
9797 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9798 */
9799 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9800 this.setValue( item ? item.getData() : '' );
9801 };
9802
9803 /**
9804 * @inheritdoc
9805 */
9806 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9807 var selected;
9808 value = this.cleanUpValue( value );
9809 // Only allow setting values that are actually present in the dropdown
9810 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9811 this.dropdownWidget.getMenu().findFirstSelectableItem();
9812 this.dropdownWidget.getMenu().selectItem( selected );
9813 value = selected ? selected.getData() : '';
9814 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9815 if ( this.optionsDirty ) {
9816 // We reached this from the constructor or from #setOptions.
9817 // We have to update the <select> element.
9818 this.updateOptionsInterface();
9819 }
9820 return this;
9821 };
9822
9823 /**
9824 * @inheritdoc
9825 */
9826 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9827 this.dropdownWidget.setDisabled( state );
9828 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9829 return this;
9830 };
9831
9832 /**
9833 * Set the options available for this input.
9834 *
9835 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9836 * @chainable
9837 * @return {OO.ui.Widget} The widget, for chaining
9838 */
9839 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9840 var value = this.getValue();
9841
9842 this.setOptionsData( options );
9843
9844 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9845 // In case the previous value is no longer an available option, select the first valid one.
9846 this.setValue( value );
9847
9848 return this;
9849 };
9850
9851 /**
9852 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9853 *
9854 * This method may be called before the parent constructor, so various properties may not be
9855 * initialized yet.
9856 *
9857 * @param {Object[]} options Array of menu options (see #constructor for details).
9858 * @private
9859 */
9860 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9861 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9862 widget = this;
9863
9864 this.optionsDirty = true;
9865
9866 // Go through all the supplied option configs and create either
9867 // MenuSectionOption or MenuOption widgets from each.
9868 optionWidgets = [];
9869 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9870 opt = options[ optIndex ];
9871
9872 if ( opt.optgroup !== undefined ) {
9873 // Create a <optgroup> menu item.
9874 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9875 previousOptgroup = optionWidget;
9876
9877 } else {
9878 // Create a normal <option> menu item.
9879 optValue = widget.cleanUpValue( opt.data );
9880 optionWidget = widget.createMenuOptionWidget(
9881 optValue,
9882 opt.label !== undefined ? opt.label : optValue
9883 );
9884 }
9885
9886 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9887 if (
9888 opt.disabled !== undefined ||
9889 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
9890 previousOptgroup.isDisabled()
9891 ) {
9892 optionWidget.setDisabled( true );
9893 }
9894
9895 optionWidgets.push( optionWidget );
9896 }
9897
9898 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9899 };
9900
9901 /**
9902 * Create a menu option widget.
9903 *
9904 * @protected
9905 * @param {string} data Item data
9906 * @param {string} label Item label
9907 * @return {OO.ui.MenuOptionWidget} Option widget
9908 */
9909 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9910 return new OO.ui.MenuOptionWidget( {
9911 data: data,
9912 label: label
9913 } );
9914 };
9915
9916 /**
9917 * Create a menu section option widget.
9918 *
9919 * @protected
9920 * @param {string} label Section item label
9921 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9922 */
9923 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9924 return new OO.ui.MenuSectionOptionWidget( {
9925 label: label
9926 } );
9927 };
9928
9929 /**
9930 * Update the user-visible interface to match the internal list of options and value.
9931 *
9932 * This method must only be called after the parent constructor.
9933 *
9934 * @private
9935 */
9936 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9937 var
9938 $optionsContainer = this.$input,
9939 defaultValue = this.defaultValue,
9940 widget = this;
9941
9942 this.$input.empty();
9943
9944 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9945 var $optionNode;
9946
9947 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9948 $optionNode = $( '<option>' )
9949 .attr( 'value', optionWidget.getData() )
9950 .text( optionWidget.getLabel() );
9951
9952 // Remember original selection state. This property can be later used to check whether
9953 // the selection state of the input has been changed since it was created.
9954 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9955
9956 $optionsContainer.append( $optionNode );
9957 } else {
9958 $optionNode = $( '<optgroup>' )
9959 .attr( 'label', optionWidget.getLabel() );
9960 widget.$input.append( $optionNode );
9961 $optionsContainer = $optionNode;
9962 }
9963
9964 // Disable the option or optgroup if required.
9965 if ( optionWidget.isDisabled() ) {
9966 $optionNode.prop( 'disabled', true );
9967 }
9968 } );
9969
9970 this.optionsDirty = false;
9971 };
9972
9973 /**
9974 * @inheritdoc
9975 */
9976 OO.ui.DropdownInputWidget.prototype.focus = function () {
9977 this.dropdownWidget.focus();
9978 return this;
9979 };
9980
9981 /**
9982 * @inheritdoc
9983 */
9984 OO.ui.DropdownInputWidget.prototype.blur = function () {
9985 this.dropdownWidget.blur();
9986 return this;
9987 };
9988
9989 /**
9990 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9991 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9992 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9993 * please see the [OOUI documentation on MediaWiki][1].
9994 *
9995 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9996 *
9997 * @example
9998 * // An example of selected, unselected, and disabled radio inputs
9999 * var radio1 = new OO.ui.RadioInputWidget( {
10000 * value: 'a',
10001 * selected: true
10002 * } );
10003 * var radio2 = new OO.ui.RadioInputWidget( {
10004 * value: 'b'
10005 * } );
10006 * var radio3 = new OO.ui.RadioInputWidget( {
10007 * value: 'c',
10008 * disabled: true
10009 * } );
10010 * // Create a fieldset layout with fields for each radio button.
10011 * var fieldset = new OO.ui.FieldsetLayout( {
10012 * label: 'Radio inputs'
10013 * } );
10014 * fieldset.addItems( [
10015 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
10016 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
10017 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
10018 * ] );
10019 * $( document.body ).append( fieldset.$element );
10020 *
10021 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10022 *
10023 * @class
10024 * @extends OO.ui.InputWidget
10025 *
10026 * @constructor
10027 * @param {Object} [config] Configuration options
10028 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button
10029 * is not selected.
10030 */
10031 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
10032 // Configuration initialization
10033 config = config || {};
10034
10035 // Parent constructor
10036 OO.ui.RadioInputWidget.parent.call( this, config );
10037
10038 // Initialization
10039 this.$element
10040 .addClass( 'oo-ui-radioInputWidget' )
10041 // Required for pretty styling in WikimediaUI theme
10042 .append( $( '<span>' ) );
10043 this.setSelected( config.selected !== undefined ? config.selected : false );
10044 };
10045
10046 /* Setup */
10047
10048 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
10049
10050 /* Static Properties */
10051
10052 /**
10053 * @static
10054 * @inheritdoc
10055 */
10056 OO.ui.RadioInputWidget.static.tagName = 'span';
10057
10058 /* Static Methods */
10059
10060 /**
10061 * @inheritdoc
10062 */
10063 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10064 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
10065 state.checked = config.$input.prop( 'checked' );
10066 return state;
10067 };
10068
10069 /* Methods */
10070
10071 /**
10072 * @inheritdoc
10073 * @protected
10074 */
10075 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
10076 return $( '<input>' ).attr( 'type', 'radio' );
10077 };
10078
10079 /**
10080 * @inheritdoc
10081 */
10082 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10083 // RadioInputWidget doesn't track its state.
10084 };
10085
10086 /**
10087 * Set selection state of this radio button.
10088 *
10089 * @param {boolean} state `true` for selected
10090 * @chainable
10091 * @return {OO.ui.Widget} The widget, for chaining
10092 */
10093 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10094 // RadioInputWidget doesn't track its state.
10095 this.$input.prop( 'checked', state );
10096 // The first time that the selection state is set (probably while constructing the widget),
10097 // remember it in defaultSelected. This property can be later used to check whether
10098 // the selection state of the input has been changed since it was created.
10099 if ( this.defaultSelected === undefined ) {
10100 this.defaultSelected = state;
10101 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10102 }
10103 return this;
10104 };
10105
10106 /**
10107 * Check if this radio button is selected.
10108 *
10109 * @return {boolean} Radio is selected
10110 */
10111 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10112 return this.$input.prop( 'checked' );
10113 };
10114
10115 /**
10116 * @inheritdoc
10117 */
10118 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10119 if ( !this.isDisabled() ) {
10120 this.$input.trigger( 'click' );
10121 }
10122 this.focus();
10123 };
10124
10125 /**
10126 * @inheritdoc
10127 */
10128 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10129 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10130 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10131 this.setSelected( state.checked );
10132 }
10133 };
10134
10135 /**
10136 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10137 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10138 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10139 * more information about input widgets.
10140 *
10141 * This and OO.ui.DropdownInputWidget support similar configuration options.
10142 *
10143 * @example
10144 * // A RadioSelectInputWidget with three options
10145 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
10146 * options: [
10147 * { data: 'a', label: 'First' },
10148 * { data: 'b', label: 'Second'},
10149 * { data: 'c', label: 'Third' }
10150 * ]
10151 * } );
10152 * $( document.body ).append( radioSelectInput.$element );
10153 *
10154 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10155 *
10156 * @class
10157 * @extends OO.ui.InputWidget
10158 *
10159 * @constructor
10160 * @param {Object} [config] Configuration options
10161 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10162 */
10163 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
10164 // Configuration initialization
10165 config = config || {};
10166
10167 // Properties (must be done before parent constructor which calls #setDisabled)
10168 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
10169 // Set up the options before parent constructor, which uses them to validate config.value.
10170 // Use this instead of setOptions() because this.$input is not set up yet
10171 this.setOptionsData( config.options || [] );
10172
10173 // Parent constructor
10174 OO.ui.RadioSelectInputWidget.parent.call( this, config );
10175
10176 // Events
10177 this.radioSelectWidget.connect( this, {
10178 select: 'onMenuSelect'
10179 } );
10180
10181 // Initialization
10182 this.$element
10183 .addClass( 'oo-ui-radioSelectInputWidget' )
10184 .append( this.radioSelectWidget.$element );
10185 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
10186 };
10187
10188 /* Setup */
10189
10190 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
10191
10192 /* Static Methods */
10193
10194 /**
10195 * @inheritdoc
10196 */
10197 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10198 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
10199 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
10200 return state;
10201 };
10202
10203 /**
10204 * @inheritdoc
10205 */
10206 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10207 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10208 // Cannot reuse the `<input type=radio>` set
10209 delete config.$input;
10210 return config;
10211 };
10212
10213 /* Methods */
10214
10215 /**
10216 * @inheritdoc
10217 * @protected
10218 */
10219 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10220 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10221 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10222 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10223 };
10224
10225 /**
10226 * Handles menu select events.
10227 *
10228 * @private
10229 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10230 */
10231 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10232 this.setValue( item.getData() );
10233 };
10234
10235 /**
10236 * @inheritdoc
10237 */
10238 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10239 var selected;
10240 value = this.cleanUpValue( value );
10241 // Only allow setting values that are actually present in the dropdown
10242 selected = this.radioSelectWidget.findItemFromData( value ) ||
10243 this.radioSelectWidget.findFirstSelectableItem();
10244 this.radioSelectWidget.selectItem( selected );
10245 value = selected ? selected.getData() : '';
10246 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10247 return this;
10248 };
10249
10250 /**
10251 * @inheritdoc
10252 */
10253 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10254 this.radioSelectWidget.setDisabled( state );
10255 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10256 return this;
10257 };
10258
10259 /**
10260 * Set the options available for this input.
10261 *
10262 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10263 * @chainable
10264 * @return {OO.ui.Widget} The widget, for chaining
10265 */
10266 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10267 var value = this.getValue();
10268
10269 this.setOptionsData( options );
10270
10271 // Re-set the value to update the visible interface (RadioSelectWidget).
10272 // In case the previous value is no longer an available option, select the first valid one.
10273 this.setValue( value );
10274
10275 return this;
10276 };
10277
10278 /**
10279 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10280 *
10281 * This method may be called before the parent constructor, so various properties may not be
10282 * intialized yet.
10283 *
10284 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10285 * @private
10286 */
10287 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10288 var widget = this;
10289
10290 this.radioSelectWidget
10291 .clearItems()
10292 .addItems( options.map( function ( opt ) {
10293 var optValue = widget.cleanUpValue( opt.data );
10294 return new OO.ui.RadioOptionWidget( {
10295 data: optValue,
10296 label: opt.label !== undefined ? opt.label : optValue
10297 } );
10298 } ) );
10299 };
10300
10301 /**
10302 * @inheritdoc
10303 */
10304 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10305 this.radioSelectWidget.focus();
10306 return this;
10307 };
10308
10309 /**
10310 * @inheritdoc
10311 */
10312 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10313 this.radioSelectWidget.blur();
10314 return this;
10315 };
10316
10317 /**
10318 * CheckboxMultiselectInputWidget is a
10319 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10320 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10321 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10322 * more information about input widgets.
10323 *
10324 * @example
10325 * // A CheckboxMultiselectInputWidget with three options.
10326 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10327 * options: [
10328 * { data: 'a', label: 'First' },
10329 * { data: 'b', label: 'Second' },
10330 * { data: 'c', label: 'Third' }
10331 * ]
10332 * } );
10333 * $( document.body ).append( multiselectInput.$element );
10334 *
10335 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10336 *
10337 * @class
10338 * @extends OO.ui.InputWidget
10339 *
10340 * @constructor
10341 * @param {Object} [config] Configuration options
10342 * @cfg {Object[]} [options=[]] Array of menu options in the format
10343 * `{ data: …, label: …, disabled: … }`
10344 */
10345 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10346 // Configuration initialization
10347 config = config || {};
10348
10349 // Properties (must be done before parent constructor which calls #setDisabled)
10350 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10351 // Must be set before the #setOptionsData call below
10352 this.inputName = config.name;
10353 // Set up the options before parent constructor, which uses them to validate config.value.
10354 // Use this instead of setOptions() because this.$input is not set up yet
10355 this.setOptionsData( config.options || [] );
10356
10357 // Parent constructor
10358 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10359
10360 // Events
10361 this.checkboxMultiselectWidget.connect( this, {
10362 select: 'onCheckboxesSelect'
10363 } );
10364
10365 // Initialization
10366 this.$element
10367 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10368 .append( this.checkboxMultiselectWidget.$element );
10369 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10370 this.$input.detach();
10371 };
10372
10373 /* Setup */
10374
10375 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10376
10377 /* Static Methods */
10378
10379 /**
10380 * @inheritdoc
10381 */
10382 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10383 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState(
10384 node, config
10385 );
10386 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10387 .toArray().map( function ( el ) { return el.value; } );
10388 return state;
10389 };
10390
10391 /**
10392 * @inheritdoc
10393 */
10394 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10395 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10396 // Cannot reuse the `<input type=checkbox>` set
10397 delete config.$input;
10398 return config;
10399 };
10400
10401 /* Methods */
10402
10403 /**
10404 * @inheritdoc
10405 * @protected
10406 */
10407 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10408 // Actually unused
10409 return $( '<unused>' );
10410 };
10411
10412 /**
10413 * Handles CheckboxMultiselectWidget select events.
10414 *
10415 * @private
10416 */
10417 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10418 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10419 };
10420
10421 /**
10422 * @inheritdoc
10423 */
10424 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10425 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10426 .toArray().map( function ( el ) { return el.value; } );
10427 if ( this.value !== value ) {
10428 this.setValue( value );
10429 }
10430 return this.value;
10431 };
10432
10433 /**
10434 * @inheritdoc
10435 */
10436 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10437 value = this.cleanUpValue( value );
10438 this.checkboxMultiselectWidget.selectItemsByData( value );
10439 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10440 if ( this.optionsDirty ) {
10441 // We reached this from the constructor or from #setOptions.
10442 // We have to update the <select> element.
10443 this.updateOptionsInterface();
10444 }
10445 return this;
10446 };
10447
10448 /**
10449 * Clean up incoming value.
10450 *
10451 * @param {string[]} value Original value
10452 * @return {string[]} Cleaned up value
10453 */
10454 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10455 var i, singleValue,
10456 cleanValue = [];
10457 if ( !Array.isArray( value ) ) {
10458 return cleanValue;
10459 }
10460 for ( i = 0; i < value.length; i++ ) {
10461 singleValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10462 .call( this, value[ i ] );
10463 // Remove options that we don't have here
10464 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10465 continue;
10466 }
10467 cleanValue.push( singleValue );
10468 }
10469 return cleanValue;
10470 };
10471
10472 /**
10473 * @inheritdoc
10474 */
10475 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10476 this.checkboxMultiselectWidget.setDisabled( state );
10477 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10478 return this;
10479 };
10480
10481 /**
10482 * Set the options available for this input.
10483 *
10484 * @param {Object[]} options Array of menu options in the format
10485 * `{ data: …, label: …, disabled: … }`
10486 * @chainable
10487 * @return {OO.ui.Widget} The widget, for chaining
10488 */
10489 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10490 var value = this.getValue();
10491
10492 this.setOptionsData( options );
10493
10494 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10495 // This will also get rid of any stale options that we just removed.
10496 this.setValue( value );
10497
10498 return this;
10499 };
10500
10501 /**
10502 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10503 *
10504 * This method may be called before the parent constructor, so various properties may not be
10505 * intialized yet.
10506 *
10507 * @param {Object[]} options Array of menu options in the format
10508 * `{ data: …, label: … }`
10509 * @private
10510 */
10511 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10512 var widget = this;
10513
10514 this.optionsDirty = true;
10515
10516 this.checkboxMultiselectWidget
10517 .clearItems()
10518 .addItems( options.map( function ( opt ) {
10519 var optValue, item, optDisabled;
10520 optValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10521 .call( widget, opt.data );
10522 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10523 item = new OO.ui.CheckboxMultioptionWidget( {
10524 data: optValue,
10525 label: opt.label !== undefined ? opt.label : optValue,
10526 disabled: optDisabled
10527 } );
10528 // Set the 'name' and 'value' for form submission
10529 item.checkbox.$input.attr( 'name', widget.inputName );
10530 item.checkbox.setValue( optValue );
10531 return item;
10532 } ) );
10533 };
10534
10535 /**
10536 * Update the user-visible interface to match the internal list of options and value.
10537 *
10538 * This method must only be called after the parent constructor.
10539 *
10540 * @private
10541 */
10542 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10543 var defaultValue = this.defaultValue;
10544
10545 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10546 // Remember original selection state. This property can be later used to check whether
10547 // the selection state of the input has been changed since it was created.
10548 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10549 item.checkbox.defaultSelected = isDefault;
10550 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10551 } );
10552
10553 this.optionsDirty = false;
10554 };
10555
10556 /**
10557 * @inheritdoc
10558 */
10559 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10560 this.checkboxMultiselectWidget.focus();
10561 return this;
10562 };
10563
10564 /**
10565 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10566 * size of the field as well as its presentation. In addition, these widgets can be configured
10567 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
10568 * optional validation-pattern (used to determine if an input value is valid or not) and an input
10569 * filter, which modifies incoming values rather than validating them.
10570 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10571 *
10572 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10573 *
10574 * @example
10575 * // A TextInputWidget.
10576 * var textInput = new OO.ui.TextInputWidget( {
10577 * value: 'Text input'
10578 * } )
10579 * $( document.body ).append( textInput.$element );
10580 *
10581 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10582 *
10583 * @class
10584 * @extends OO.ui.InputWidget
10585 * @mixins OO.ui.mixin.IconElement
10586 * @mixins OO.ui.mixin.IndicatorElement
10587 * @mixins OO.ui.mixin.PendingElement
10588 * @mixins OO.ui.mixin.LabelElement
10589 *
10590 * @constructor
10591 * @param {Object} [config] Configuration options
10592 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10593 * 'email', 'url' or 'number'.
10594 * @cfg {string} [placeholder] Placeholder text
10595 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10596 * instruct the browser to focus this widget.
10597 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10598 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10599 *
10600 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10601 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10602 * many emojis) count as 2 characters each.
10603 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10604 * the value or placeholder text: `'before'` or `'after'`
10605 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator:
10606 * 'required'`. Note that `false` & setting `indicator: 'required' will result in no indicator
10607 * shown.
10608 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10609 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined`
10610 * means leaving it up to the browser).
10611 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10612 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10613 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10614 * value for it to be considered valid; when Function, a function receiving the value as parameter
10615 * that must return true, or promise resolving to true, for it to be considered valid.
10616 */
10617 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10618 // Configuration initialization
10619 config = $.extend( {
10620 type: 'text',
10621 labelPosition: 'after'
10622 }, config );
10623
10624 // Parent constructor
10625 OO.ui.TextInputWidget.parent.call( this, config );
10626
10627 // Mixin constructors
10628 OO.ui.mixin.IconElement.call( this, config );
10629 OO.ui.mixin.IndicatorElement.call( this, config );
10630 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10631 OO.ui.mixin.LabelElement.call( this, config );
10632
10633 // Properties
10634 this.type = this.getSaneType( config );
10635 this.readOnly = false;
10636 this.required = false;
10637 this.validate = null;
10638 this.scrollWidth = null;
10639
10640 this.setValidation( config.validate );
10641 this.setLabelPosition( config.labelPosition );
10642
10643 // Events
10644 this.$input.on( {
10645 keypress: this.onKeyPress.bind( this ),
10646 blur: this.onBlur.bind( this ),
10647 focus: this.onFocus.bind( this )
10648 } );
10649 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10650 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10651 this.on( 'labelChange', this.updatePosition.bind( this ) );
10652 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10653
10654 // Initialization
10655 this.$element
10656 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10657 .append( this.$icon, this.$indicator );
10658 this.setReadOnly( !!config.readOnly );
10659 this.setRequired( !!config.required );
10660 if ( config.placeholder !== undefined ) {
10661 this.$input.attr( 'placeholder', config.placeholder );
10662 }
10663 if ( config.maxLength !== undefined ) {
10664 this.$input.attr( 'maxlength', config.maxLength );
10665 }
10666 if ( config.autofocus ) {
10667 this.$input.attr( 'autofocus', 'autofocus' );
10668 }
10669 if ( config.autocomplete === false ) {
10670 this.$input.attr( 'autocomplete', 'off' );
10671 // Turning off autocompletion also disables "form caching" when the user navigates to a
10672 // different page and then clicks "Back". Re-enable it when leaving.
10673 // Borrowed from jQuery UI.
10674 $( window ).on( {
10675 beforeunload: function () {
10676 this.$input.removeAttr( 'autocomplete' );
10677 }.bind( this ),
10678 pageshow: function () {
10679 // Browsers don't seem to actually fire this event on "Back", they instead just
10680 // reload the whole page... it shouldn't hurt, though.
10681 this.$input.attr( 'autocomplete', 'off' );
10682 }.bind( this )
10683 } );
10684 }
10685 if ( config.spellcheck !== undefined ) {
10686 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10687 }
10688 if ( this.label ) {
10689 this.isWaitingToBeAttached = true;
10690 this.installParentChangeDetector();
10691 }
10692 };
10693
10694 /* Setup */
10695
10696 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10697 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10698 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10699 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10700 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10701
10702 /* Static Properties */
10703
10704 OO.ui.TextInputWidget.static.validationPatterns = {
10705 'non-empty': /.+/,
10706 integer: /^\d+$/
10707 };
10708
10709 /* Events */
10710
10711 /**
10712 * An `enter` event is emitted when the user presses Enter key inside the text box.
10713 *
10714 * @event enter
10715 */
10716
10717 /* Methods */
10718
10719 /**
10720 * Handle icon mouse down events.
10721 *
10722 * @private
10723 * @param {jQuery.Event} e Mouse down event
10724 * @return {undefined/boolean} False to prevent default if event is handled
10725 */
10726 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10727 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10728 this.focus();
10729 return false;
10730 }
10731 };
10732
10733 /**
10734 * Handle indicator mouse down events.
10735 *
10736 * @private
10737 * @param {jQuery.Event} e Mouse down event
10738 * @return {undefined/boolean} False to prevent default if event is handled
10739 */
10740 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10741 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10742 this.focus();
10743 return false;
10744 }
10745 };
10746
10747 /**
10748 * Handle key press events.
10749 *
10750 * @private
10751 * @param {jQuery.Event} e Key press event
10752 * @fires enter If Enter key is pressed
10753 */
10754 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10755 if ( e.which === OO.ui.Keys.ENTER ) {
10756 this.emit( 'enter', e );
10757 }
10758 };
10759
10760 /**
10761 * Handle blur events.
10762 *
10763 * @private
10764 * @param {jQuery.Event} e Blur event
10765 */
10766 OO.ui.TextInputWidget.prototype.onBlur = function () {
10767 this.setValidityFlag();
10768 };
10769
10770 /**
10771 * Handle focus events.
10772 *
10773 * @private
10774 * @param {jQuery.Event} e Focus event
10775 */
10776 OO.ui.TextInputWidget.prototype.onFocus = function () {
10777 if ( this.isWaitingToBeAttached ) {
10778 // If we've received focus, then we must be attached to the document, and if
10779 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10780 this.onElementAttach();
10781 }
10782 this.setValidityFlag( true );
10783 };
10784
10785 /**
10786 * Handle element attach events.
10787 *
10788 * @private
10789 * @param {jQuery.Event} e Element attach event
10790 */
10791 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10792 this.isWaitingToBeAttached = false;
10793 // Any previously calculated size is now probably invalid if we reattached elsewhere
10794 this.valCache = null;
10795 this.positionLabel();
10796 };
10797
10798 /**
10799 * Handle debounced change events.
10800 *
10801 * @param {string} value
10802 * @private
10803 */
10804 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10805 this.setValidityFlag();
10806 };
10807
10808 /**
10809 * Check if the input is {@link #readOnly read-only}.
10810 *
10811 * @return {boolean}
10812 */
10813 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10814 return this.readOnly;
10815 };
10816
10817 /**
10818 * Set the {@link #readOnly read-only} state of the input.
10819 *
10820 * @param {boolean} state Make input read-only
10821 * @chainable
10822 * @return {OO.ui.Widget} The widget, for chaining
10823 */
10824 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10825 this.readOnly = !!state;
10826 this.$input.prop( 'readOnly', this.readOnly );
10827 return this;
10828 };
10829
10830 /**
10831 * Check if the input is {@link #required required}.
10832 *
10833 * @return {boolean}
10834 */
10835 OO.ui.TextInputWidget.prototype.isRequired = function () {
10836 return this.required;
10837 };
10838
10839 /**
10840 * Set the {@link #required required} state of the input.
10841 *
10842 * @param {boolean} state Make input required
10843 * @chainable
10844 * @return {OO.ui.Widget} The widget, for chaining
10845 */
10846 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10847 this.required = !!state;
10848 if ( this.required ) {
10849 this.$input
10850 .prop( 'required', true )
10851 .attr( 'aria-required', 'true' );
10852 if ( this.getIndicator() === null ) {
10853 this.setIndicator( 'required' );
10854 }
10855 } else {
10856 this.$input
10857 .prop( 'required', false )
10858 .removeAttr( 'aria-required' );
10859 if ( this.getIndicator() === 'required' ) {
10860 this.setIndicator( null );
10861 }
10862 }
10863 return this;
10864 };
10865
10866 /**
10867 * Support function for making #onElementAttach work across browsers.
10868 *
10869 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10870 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10871 *
10872 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10873 * first time that the element gets attached to the documented.
10874 */
10875 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10876 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10877 MutationObserver = window.MutationObserver ||
10878 window.WebKitMutationObserver ||
10879 window.MozMutationObserver,
10880 widget = this;
10881
10882 if ( MutationObserver ) {
10883 // The new way. If only it wasn't so ugly.
10884
10885 if ( this.isElementAttached() ) {
10886 // Widget is attached already, do nothing. This breaks the functionality of this
10887 // function when the widget is detached and reattached. Alas, doing this correctly with
10888 // MutationObserver would require observation of the whole document, which would hurt
10889 // performance of other, more important code.
10890 return;
10891 }
10892
10893 // Find topmost node in the tree
10894 topmostNode = this.$element[ 0 ];
10895 while ( topmostNode.parentNode ) {
10896 topmostNode = topmostNode.parentNode;
10897 }
10898
10899 // We have no way to detect the $element being attached somewhere without observing the
10900 // entire DOM with subtree modifications, which would hurt performance. So we cheat: we hook
10901 // to the parent node of $element, and instead detect when $element is removed from it (and
10902 // thus probably attached somewhere else). If there is no parent, we create a "fake" one. If
10903 // it doesn't get attached, we end up back here and create the parent.
10904 mutationObserver = new MutationObserver( function ( mutations ) {
10905 var i, j, removedNodes;
10906 for ( i = 0; i < mutations.length; i++ ) {
10907 removedNodes = mutations[ i ].removedNodes;
10908 for ( j = 0; j < removedNodes.length; j++ ) {
10909 if ( removedNodes[ j ] === topmostNode ) {
10910 setTimeout( onRemove, 0 );
10911 return;
10912 }
10913 }
10914 }
10915 } );
10916
10917 onRemove = function () {
10918 // If the node was attached somewhere else, report it
10919 if ( widget.isElementAttached() ) {
10920 widget.onElementAttach();
10921 }
10922 mutationObserver.disconnect();
10923 widget.installParentChangeDetector();
10924 };
10925
10926 // Create a fake parent and observe it
10927 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10928 mutationObserver.observe( fakeParentNode, { childList: true } );
10929 } else {
10930 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10931 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10932 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10933 }
10934 };
10935
10936 /**
10937 * @inheritdoc
10938 * @protected
10939 */
10940 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10941 if ( this.getSaneType( config ) === 'number' ) {
10942 return $( '<input>' )
10943 .attr( 'step', 'any' )
10944 .attr( 'type', 'number' );
10945 } else {
10946 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10947 }
10948 };
10949
10950 /**
10951 * Get sanitized value for 'type' for given config.
10952 *
10953 * @param {Object} config Configuration options
10954 * @return {string|null}
10955 * @protected
10956 */
10957 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10958 var allowedTypes = [
10959 'text',
10960 'password',
10961 'email',
10962 'url',
10963 'number'
10964 ];
10965 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10966 };
10967
10968 /**
10969 * Focus the input and select a specified range within the text.
10970 *
10971 * @param {number} from Select from offset
10972 * @param {number} [to] Select to offset, defaults to from
10973 * @chainable
10974 * @return {OO.ui.Widget} The widget, for chaining
10975 */
10976 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10977 var isBackwards, start, end,
10978 input = this.$input[ 0 ];
10979
10980 to = to || from;
10981
10982 isBackwards = to < from;
10983 start = isBackwards ? to : from;
10984 end = isBackwards ? from : to;
10985
10986 this.focus();
10987
10988 try {
10989 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10990 } catch ( e ) {
10991 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10992 // Rather than expensively check if the input is attached every time, just check
10993 // if it was the cause of an error being thrown. If not, rethrow the error.
10994 if ( this.getElementDocument().body.contains( input ) ) {
10995 throw e;
10996 }
10997 }
10998 return this;
10999 };
11000
11001 /**
11002 * Get an object describing the current selection range in a directional manner
11003 *
11004 * @return {Object} Object containing 'from' and 'to' offsets
11005 */
11006 OO.ui.TextInputWidget.prototype.getRange = function () {
11007 var input = this.$input[ 0 ],
11008 start = input.selectionStart,
11009 end = input.selectionEnd,
11010 isBackwards = input.selectionDirection === 'backward';
11011
11012 return {
11013 from: isBackwards ? end : start,
11014 to: isBackwards ? start : end
11015 };
11016 };
11017
11018 /**
11019 * Get the length of the text input value.
11020 *
11021 * This could differ from the length of #getValue if the
11022 * value gets filtered
11023 *
11024 * @return {number} Input length
11025 */
11026 OO.ui.TextInputWidget.prototype.getInputLength = function () {
11027 return this.$input[ 0 ].value.length;
11028 };
11029
11030 /**
11031 * Focus the input and select the entire text.
11032 *
11033 * @chainable
11034 * @return {OO.ui.Widget} The widget, for chaining
11035 */
11036 OO.ui.TextInputWidget.prototype.select = function () {
11037 return this.selectRange( 0, this.getInputLength() );
11038 };
11039
11040 /**
11041 * Focus the input and move the cursor to the start.
11042 *
11043 * @chainable
11044 * @return {OO.ui.Widget} The widget, for chaining
11045 */
11046 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
11047 return this.selectRange( 0 );
11048 };
11049
11050 /**
11051 * Focus the input and move the cursor to the end.
11052 *
11053 * @chainable
11054 * @return {OO.ui.Widget} The widget, for chaining
11055 */
11056 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
11057 return this.selectRange( this.getInputLength() );
11058 };
11059
11060 /**
11061 * Insert new content into the input.
11062 *
11063 * @param {string} content Content to be inserted
11064 * @chainable
11065 * @return {OO.ui.Widget} The widget, for chaining
11066 */
11067 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
11068 var start, end,
11069 range = this.getRange(),
11070 value = this.getValue();
11071
11072 start = Math.min( range.from, range.to );
11073 end = Math.max( range.from, range.to );
11074
11075 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11076 this.selectRange( start + content.length );
11077 return this;
11078 };
11079
11080 /**
11081 * Insert new content either side of a selection.
11082 *
11083 * @param {string} pre Content to be inserted before the selection
11084 * @param {string} post Content to be inserted after the selection
11085 * @chainable
11086 * @return {OO.ui.Widget} The widget, for chaining
11087 */
11088 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11089 var start, end,
11090 range = this.getRange(),
11091 offset = pre.length;
11092
11093 start = Math.min( range.from, range.to );
11094 end = Math.max( range.from, range.to );
11095
11096 this.selectRange( start ).insertContent( pre );
11097 this.selectRange( offset + end ).insertContent( post );
11098
11099 this.selectRange( offset + start, offset + end );
11100 return this;
11101 };
11102
11103 /**
11104 * Set the validation pattern.
11105 *
11106 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11107 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11108 * value must contain only numbers).
11109 *
11110 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11111 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11112 */
11113 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11114 if ( validate instanceof RegExp || validate instanceof Function ) {
11115 this.validate = validate;
11116 } else {
11117 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
11118 }
11119 };
11120
11121 /**
11122 * Sets the 'invalid' flag appropriately.
11123 *
11124 * @param {boolean} [isValid] Optionally override validation result
11125 */
11126 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11127 var widget = this,
11128 setFlag = function ( valid ) {
11129 if ( !valid ) {
11130 widget.$input.attr( 'aria-invalid', 'true' );
11131 } else {
11132 widget.$input.removeAttr( 'aria-invalid' );
11133 }
11134 widget.setFlags( { invalid: !valid } );
11135 };
11136
11137 if ( isValid !== undefined ) {
11138 setFlag( isValid );
11139 } else {
11140 this.getValidity().then( function () {
11141 setFlag( true );
11142 }, function () {
11143 setFlag( false );
11144 } );
11145 }
11146 };
11147
11148 /**
11149 * Get the validity of current value.
11150 *
11151 * This method returns a promise that resolves if the value is valid and rejects if
11152 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
11153 *
11154 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11155 */
11156 OO.ui.TextInputWidget.prototype.getValidity = function () {
11157 var result;
11158
11159 function rejectOrResolve( valid ) {
11160 if ( valid ) {
11161 return $.Deferred().resolve().promise();
11162 } else {
11163 return $.Deferred().reject().promise();
11164 }
11165 }
11166
11167 // Check browser validity and reject if it is invalid
11168 if (
11169 this.$input[ 0 ].checkValidity !== undefined &&
11170 this.$input[ 0 ].checkValidity() === false
11171 ) {
11172 return rejectOrResolve( false );
11173 }
11174
11175 // Run our checks if the browser thinks the field is valid
11176 if ( this.validate instanceof Function ) {
11177 result = this.validate( this.getValue() );
11178 if ( result && typeof result.promise === 'function' ) {
11179 return result.promise().then( function ( valid ) {
11180 return rejectOrResolve( valid );
11181 } );
11182 } else {
11183 return rejectOrResolve( result );
11184 }
11185 } else {
11186 return rejectOrResolve( this.getValue().match( this.validate ) );
11187 }
11188 };
11189
11190 /**
11191 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11192 *
11193 * @param {string} labelPosition Label position, 'before' or 'after'
11194 * @chainable
11195 * @return {OO.ui.Widget} The widget, for chaining
11196 */
11197 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11198 this.labelPosition = labelPosition;
11199 if ( this.label ) {
11200 // If there is no label and we only change the position, #updatePosition is a no-op,
11201 // but it takes really a lot of work to do nothing.
11202 this.updatePosition();
11203 }
11204 return this;
11205 };
11206
11207 /**
11208 * Update the position of the inline label.
11209 *
11210 * This method is called by #setLabelPosition, and can also be called on its own if
11211 * something causes the label to be mispositioned.
11212 *
11213 * @chainable
11214 * @return {OO.ui.Widget} The widget, for chaining
11215 */
11216 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11217 var after = this.labelPosition === 'after';
11218
11219 this.$element
11220 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11221 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11222
11223 this.valCache = null;
11224 this.scrollWidth = null;
11225 this.positionLabel();
11226
11227 return this;
11228 };
11229
11230 /**
11231 * Position the label by setting the correct padding on the input.
11232 *
11233 * @private
11234 * @chainable
11235 * @return {OO.ui.Widget} The widget, for chaining
11236 */
11237 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11238 var after, rtl, property, newCss;
11239
11240 if ( this.isWaitingToBeAttached ) {
11241 // #onElementAttach will be called soon, which calls this method
11242 return this;
11243 }
11244
11245 newCss = {
11246 'padding-right': '',
11247 'padding-left': ''
11248 };
11249
11250 if ( this.label ) {
11251 this.$element.append( this.$label );
11252 } else {
11253 this.$label.detach();
11254 // Clear old values if present
11255 this.$input.css( newCss );
11256 return;
11257 }
11258
11259 after = this.labelPosition === 'after';
11260 rtl = this.$element.css( 'direction' ) === 'rtl';
11261 property = after === rtl ? 'padding-left' : 'padding-right';
11262
11263 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11264 // We have to clear the padding on the other side, in case the element direction changed
11265 this.$input.css( newCss );
11266
11267 return this;
11268 };
11269
11270 /**
11271 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11272 * {@link OO.ui.mixin.IconElement search icon} by default.
11273 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11274 *
11275 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11276 *
11277 * @class
11278 * @extends OO.ui.TextInputWidget
11279 *
11280 * @constructor
11281 * @param {Object} [config] Configuration options
11282 */
11283 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11284 config = $.extend( {
11285 icon: 'search'
11286 }, config );
11287
11288 // Parent constructor
11289 OO.ui.SearchInputWidget.parent.call( this, config );
11290
11291 // Events
11292 this.connect( this, {
11293 change: 'onChange'
11294 } );
11295
11296 // Initialization
11297 this.updateSearchIndicator();
11298 this.connect( this, {
11299 disable: 'onDisable'
11300 } );
11301 };
11302
11303 /* Setup */
11304
11305 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11306
11307 /* Methods */
11308
11309 /**
11310 * @inheritdoc
11311 * @protected
11312 */
11313 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11314 return 'search';
11315 };
11316
11317 /**
11318 * @inheritdoc
11319 */
11320 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11321 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11322 // Clear the text field
11323 this.setValue( '' );
11324 this.focus();
11325 return false;
11326 }
11327 };
11328
11329 /**
11330 * Update the 'clear' indicator displayed on type: 'search' text
11331 * fields, hiding it when the field is already empty or when it's not
11332 * editable.
11333 */
11334 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11335 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11336 this.setIndicator( null );
11337 } else {
11338 this.setIndicator( 'clear' );
11339 }
11340 };
11341
11342 /**
11343 * Handle change events.
11344 *
11345 * @private
11346 */
11347 OO.ui.SearchInputWidget.prototype.onChange = function () {
11348 this.updateSearchIndicator();
11349 };
11350
11351 /**
11352 * Handle disable events.
11353 *
11354 * @param {boolean} disabled Element is disabled
11355 * @private
11356 */
11357 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11358 this.updateSearchIndicator();
11359 };
11360
11361 /**
11362 * @inheritdoc
11363 */
11364 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11365 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11366 this.updateSearchIndicator();
11367 return this;
11368 };
11369
11370 /**
11371 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11372 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11373 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11374 * {@link OO.ui.mixin.IndicatorElement indicators}.
11375 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11376 *
11377 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11378 *
11379 * @example
11380 * // A MultilineTextInputWidget.
11381 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11382 * value: 'Text input on multiple lines'
11383 * } )
11384 * $( 'body' ).append( multilineTextInput.$element );
11385 *
11386 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11387 *
11388 * @class
11389 * @extends OO.ui.TextInputWidget
11390 *
11391 * @constructor
11392 * @param {Object} [config] Configuration options
11393 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11394 * specifies minimum number of rows to display.
11395 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11396 * Use the #maxRows config to specify a maximum number of displayed rows.
11397 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11398 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11399 */
11400 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11401 config = $.extend( {
11402 type: 'text'
11403 }, config );
11404 // Parent constructor
11405 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11406
11407 // Properties
11408 this.autosize = !!config.autosize;
11409 this.styleHeight = null;
11410 this.minRows = config.rows !== undefined ? config.rows : '';
11411 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11412
11413 // Clone for resizing
11414 if ( this.autosize ) {
11415 this.$clone = this.$input
11416 .clone()
11417 .removeAttr( 'id' )
11418 .removeAttr( 'name' )
11419 .insertAfter( this.$input )
11420 .attr( 'aria-hidden', 'true' )
11421 .addClass( 'oo-ui-element-hidden' );
11422 }
11423
11424 // Events
11425 this.connect( this, {
11426 change: 'onChange'
11427 } );
11428
11429 // Initialization
11430 if ( config.rows ) {
11431 this.$input.attr( 'rows', config.rows );
11432 }
11433 if ( this.autosize ) {
11434 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11435 this.isWaitingToBeAttached = true;
11436 this.installParentChangeDetector();
11437 }
11438 };
11439
11440 /* Setup */
11441
11442 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11443
11444 /* Static Methods */
11445
11446 /**
11447 * @inheritdoc
11448 */
11449 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11450 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11451 state.scrollTop = config.$input.scrollTop();
11452 return state;
11453 };
11454
11455 /* Methods */
11456
11457 /**
11458 * @inheritdoc
11459 */
11460 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11461 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11462 this.adjustSize();
11463 };
11464
11465 /**
11466 * Handle change events.
11467 *
11468 * @private
11469 */
11470 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11471 this.adjustSize();
11472 };
11473
11474 /**
11475 * @inheritdoc
11476 */
11477 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11478 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11479 this.adjustSize();
11480 };
11481
11482 /**
11483 * @inheritdoc
11484 *
11485 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11486 */
11487 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11488 if (
11489 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11490 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
11491 e.which === 10
11492 ) {
11493 this.emit( 'enter', e );
11494 }
11495 };
11496
11497 /**
11498 * Automatically adjust the size of the text input.
11499 *
11500 * This only affects multiline inputs that are {@link #autosize autosized}.
11501 *
11502 * @chainable
11503 * @return {OO.ui.Widget} The widget, for chaining
11504 * @fires resize
11505 */
11506 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11507 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11508 idealHeight, newHeight, scrollWidth, property;
11509
11510 if ( this.$input.val() !== this.valCache ) {
11511 if ( this.autosize ) {
11512 this.$clone
11513 .val( this.$input.val() )
11514 .attr( 'rows', this.minRows )
11515 // Set inline height property to 0 to measure scroll height
11516 .css( 'height', 0 );
11517
11518 this.$clone.removeClass( 'oo-ui-element-hidden' );
11519
11520 this.valCache = this.$input.val();
11521
11522 scrollHeight = this.$clone[ 0 ].scrollHeight;
11523
11524 // Remove inline height property to measure natural heights
11525 this.$clone.css( 'height', '' );
11526 innerHeight = this.$clone.innerHeight();
11527 outerHeight = this.$clone.outerHeight();
11528
11529 // Measure max rows height
11530 this.$clone
11531 .attr( 'rows', this.maxRows )
11532 .css( 'height', 'auto' )
11533 .val( '' );
11534 maxInnerHeight = this.$clone.innerHeight();
11535
11536 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11537 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11538 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11539 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11540
11541 this.$clone.addClass( 'oo-ui-element-hidden' );
11542
11543 // Only apply inline height when expansion beyond natural height is needed
11544 // Use the difference between the inner and outer height as a buffer
11545 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11546 if ( newHeight !== this.styleHeight ) {
11547 this.$input.css( 'height', newHeight );
11548 this.styleHeight = newHeight;
11549 this.emit( 'resize' );
11550 }
11551 }
11552 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11553 if ( scrollWidth !== this.scrollWidth ) {
11554 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11555 // Reset
11556 this.$label.css( { right: '', left: '' } );
11557 this.$indicator.css( { right: '', left: '' } );
11558
11559 if ( scrollWidth ) {
11560 this.$indicator.css( property, scrollWidth );
11561 if ( this.labelPosition === 'after' ) {
11562 this.$label.css( property, scrollWidth );
11563 }
11564 }
11565
11566 this.scrollWidth = scrollWidth;
11567 this.positionLabel();
11568 }
11569 }
11570 return this;
11571 };
11572
11573 /**
11574 * @inheritdoc
11575 * @protected
11576 */
11577 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11578 return $( '<textarea>' );
11579 };
11580
11581 /**
11582 * Check if the input automatically adjusts its size.
11583 *
11584 * @return {boolean}
11585 */
11586 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11587 return !!this.autosize;
11588 };
11589
11590 /**
11591 * @inheritdoc
11592 */
11593 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11594 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11595 if ( state.scrollTop !== undefined ) {
11596 this.$input.scrollTop( state.scrollTop );
11597 }
11598 };
11599
11600 /**
11601 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11602 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11603 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11604 *
11605 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11606 * option, that option will appear to be selected.
11607 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11608 * input field.
11609 *
11610 * After the user chooses an option, its `data` will be used as a new value for the widget.
11611 * A `label` also can be specified for each option: if given, it will be shown instead of the
11612 * `data` in the dropdown menu.
11613 *
11614 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11615 *
11616 * For more information about menus and options, please see the
11617 * [OOUI documentation on MediaWiki][1].
11618 *
11619 * @example
11620 * // A ComboBoxInputWidget.
11621 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11622 * value: 'Option 1',
11623 * options: [
11624 * { data: 'Option 1' },
11625 * { data: 'Option 2' },
11626 * { data: 'Option 3' }
11627 * ]
11628 * } );
11629 * $( document.body ).append( comboBox.$element );
11630 *
11631 * @example
11632 * // Example: A ComboBoxInputWidget with additional option labels.
11633 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11634 * value: 'Option 1',
11635 * options: [
11636 * {
11637 * data: 'Option 1',
11638 * label: 'Option One'
11639 * },
11640 * {
11641 * data: 'Option 2',
11642 * label: 'Option Two'
11643 * },
11644 * {
11645 * data: 'Option 3',
11646 * label: 'Option Three'
11647 * }
11648 * ]
11649 * } );
11650 * $( document.body ).append( comboBox.$element );
11651 *
11652 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11653 *
11654 * @class
11655 * @extends OO.ui.TextInputWidget
11656 *
11657 * @constructor
11658 * @param {Object} [config] Configuration options
11659 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11660 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
11661 * select widget}.
11662 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
11663 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
11664 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
11665 * uses relative positioning.
11666 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11667 */
11668 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11669 // Configuration initialization
11670 config = $.extend( {
11671 autocomplete: false
11672 }, config );
11673
11674 // ComboBoxInputWidget shouldn't support `multiline`
11675 config.multiline = false;
11676
11677 // See InputWidget#reusePreInfuseDOM about `config.$input`
11678 if ( config.$input ) {
11679 config.$input.removeAttr( 'list' );
11680 }
11681
11682 // Parent constructor
11683 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11684
11685 // Properties
11686 this.$overlay = ( config.$overlay === true ?
11687 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11688 this.dropdownButton = new OO.ui.ButtonWidget( {
11689 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11690 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11691 indicator: 'down',
11692 invisibleLabel: true,
11693 disabled: this.disabled
11694 } );
11695 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11696 {
11697 widget: this,
11698 input: this,
11699 $floatableContainer: this.$element,
11700 disabled: this.isDisabled()
11701 },
11702 config.menu
11703 ) );
11704
11705 // Events
11706 this.connect( this, {
11707 change: 'onInputChange',
11708 enter: 'onInputEnter'
11709 } );
11710 this.dropdownButton.connect( this, {
11711 click: 'onDropdownButtonClick'
11712 } );
11713 this.menu.connect( this, {
11714 choose: 'onMenuChoose',
11715 add: 'onMenuItemsChange',
11716 remove: 'onMenuItemsChange',
11717 toggle: 'onMenuToggle'
11718 } );
11719
11720 // Initialization
11721 this.$input.attr( {
11722 role: 'combobox',
11723 'aria-owns': this.menu.getElementId(),
11724 'aria-autocomplete': 'list'
11725 } );
11726 this.dropdownButton.$button.attr( {
11727 'aria-controls': this.menu.getElementId()
11728 } );
11729 // Do not override options set via config.menu.items
11730 if ( config.options !== undefined ) {
11731 this.setOptions( config.options );
11732 }
11733 this.$field = $( '<div>' )
11734 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11735 .append( this.$input, this.dropdownButton.$element );
11736 this.$element
11737 .addClass( 'oo-ui-comboBoxInputWidget' )
11738 .append( this.$field );
11739 this.$overlay.append( this.menu.$element );
11740 this.onMenuItemsChange();
11741 };
11742
11743 /* Setup */
11744
11745 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11746
11747 /* Methods */
11748
11749 /**
11750 * Get the combobox's menu.
11751 *
11752 * @return {OO.ui.MenuSelectWidget} Menu widget
11753 */
11754 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11755 return this.menu;
11756 };
11757
11758 /**
11759 * Get the combobox's text input widget.
11760 *
11761 * @return {OO.ui.TextInputWidget} Text input widget
11762 */
11763 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11764 return this;
11765 };
11766
11767 /**
11768 * Handle input change events.
11769 *
11770 * @private
11771 * @param {string} value New value
11772 */
11773 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11774 var match = this.menu.findItemFromData( value );
11775
11776 this.menu.selectItem( match );
11777 if ( this.menu.findHighlightedItem() ) {
11778 this.menu.highlightItem( match );
11779 }
11780
11781 if ( !this.isDisabled() ) {
11782 this.menu.toggle( true );
11783 }
11784 };
11785
11786 /**
11787 * Handle input enter events.
11788 *
11789 * @private
11790 */
11791 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11792 if ( !this.isDisabled() ) {
11793 this.menu.toggle( false );
11794 }
11795 };
11796
11797 /**
11798 * Handle button click events.
11799 *
11800 * @private
11801 */
11802 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11803 this.menu.toggle();
11804 this.focus();
11805 };
11806
11807 /**
11808 * Handle menu choose events.
11809 *
11810 * @private
11811 * @param {OO.ui.OptionWidget} item Chosen item
11812 */
11813 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11814 this.setValue( item.getData() );
11815 };
11816
11817 /**
11818 * Handle menu item change events.
11819 *
11820 * @private
11821 */
11822 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11823 var match = this.menu.findItemFromData( this.getValue() );
11824 this.menu.selectItem( match );
11825 if ( this.menu.findHighlightedItem() ) {
11826 this.menu.highlightItem( match );
11827 }
11828 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11829 };
11830
11831 /**
11832 * Handle menu toggle events.
11833 *
11834 * @private
11835 * @param {boolean} isVisible Open state of the menu
11836 */
11837 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11838 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11839 };
11840
11841 /**
11842 * Update the disabled state of the controls
11843 *
11844 * @chainable
11845 * @protected
11846 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
11847 */
11848 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
11849 var disabled = this.isDisabled() || this.isReadOnly();
11850 if ( this.dropdownButton ) {
11851 this.dropdownButton.setDisabled( disabled );
11852 }
11853 if ( this.menu ) {
11854 this.menu.setDisabled( disabled );
11855 }
11856 return this;
11857 };
11858
11859 /**
11860 * @inheritdoc
11861 */
11862 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
11863 // Parent method
11864 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.apply( this, arguments );
11865 this.updateControlsDisabled();
11866 return this;
11867 };
11868
11869 /**
11870 * @inheritdoc
11871 */
11872 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
11873 // Parent method
11874 OO.ui.ComboBoxInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
11875 this.updateControlsDisabled();
11876 return this;
11877 };
11878
11879 /**
11880 * Set the options available for this input.
11881 *
11882 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11883 * @chainable
11884 * @return {OO.ui.Widget} The widget, for chaining
11885 */
11886 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11887 this.getMenu()
11888 .clearItems()
11889 .addItems( options.map( function ( opt ) {
11890 return new OO.ui.MenuOptionWidget( {
11891 data: opt.data,
11892 label: opt.label !== undefined ? opt.label : opt.data
11893 } );
11894 } ) );
11895
11896 return this;
11897 };
11898
11899 /**
11900 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11901 * which is a widget that is specified by reference before any optional configuration settings.
11902 *
11903 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
11904 * four ways:
11905 *
11906 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11907 * A left-alignment is used for forms with many fields.
11908 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11909 * A right-alignment is used for long but familiar forms which users tab through,
11910 * verifying the current field with a quick glance at the label.
11911 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11912 * that users fill out from top to bottom.
11913 * - **inline**: The label is placed after the field-widget and aligned to the left.
11914 * An inline-alignment is best used with checkboxes or radio buttons.
11915 *
11916 * Help text can either be:
11917 *
11918 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
11919 * or
11920 * - shown as a subtle explanation below the label.
11921 *
11922 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
11923 * If it is long or not essential, leave `helpInline` to its default, `false`.
11924 *
11925 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11926 *
11927 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11928 *
11929 * @class
11930 * @extends OO.ui.Layout
11931 * @mixins OO.ui.mixin.LabelElement
11932 * @mixins OO.ui.mixin.TitledElement
11933 *
11934 * @constructor
11935 * @param {OO.ui.Widget} fieldWidget Field widget
11936 * @param {Object} [config] Configuration options
11937 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11938 * or 'inline'
11939 * @cfg {Array} [errors] Error messages about the widget, which will be
11940 * displayed below the widget.
11941 * @cfg {Array} [warnings] Warning messages about the widget, which will be
11942 * displayed below the widget.
11943 * The array may contain strings or OO.ui.HtmlSnippet instances.
11944 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11945 * below the widget.
11946 * The array may contain strings or OO.ui.HtmlSnippet instances.
11947 * These are more visible than `help` messages when `helpInline` is set, and so
11948 * might be good for transient messages.
11949 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11950 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11951 * corner of the rendered field; clicking it will display the text in a popup.
11952 * If `helpInline` is `true`, then a subtle description will be shown after the
11953 * label.
11954 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11955 * or shown when the "help" icon is clicked.
11956 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11957 * `help` is given.
11958 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11959 *
11960 * @throws {Error} An error is thrown if no widget is specified
11961 */
11962 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11963 // Allow passing positional parameters inside the config object
11964 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11965 config = fieldWidget;
11966 fieldWidget = config.fieldWidget;
11967 }
11968
11969 // Make sure we have required constructor arguments
11970 if ( fieldWidget === undefined ) {
11971 throw new Error( 'Widget not found' );
11972 }
11973
11974 // Configuration initialization
11975 config = $.extend( { align: 'left', helpInline: false }, config );
11976
11977 // Parent constructor
11978 OO.ui.FieldLayout.parent.call( this, config );
11979
11980 // Mixin constructors
11981 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11982 $label: $( '<label>' )
11983 } ) );
11984 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11985
11986 // Properties
11987 this.fieldWidget = fieldWidget;
11988 this.errors = [];
11989 this.warnings = [];
11990 this.notices = [];
11991 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11992 this.$messages = $( '<ul>' );
11993 this.$header = $( '<span>' );
11994 this.$body = $( '<div>' );
11995 this.align = null;
11996 this.helpInline = config.helpInline;
11997
11998 // Events
11999 this.fieldWidget.connect( this, {
12000 disable: 'onFieldDisable'
12001 } );
12002
12003 // Initialization
12004 this.$help = config.help ?
12005 this.createHelpElement( config.help, config.$overlay ) :
12006 $( [] );
12007 if ( this.fieldWidget.getInputId() ) {
12008 this.$label.attr( 'for', this.fieldWidget.getInputId() );
12009 if ( this.helpInline ) {
12010 this.$help.attr( 'for', this.fieldWidget.getInputId() );
12011 }
12012 } else {
12013 this.$label.on( 'click', function () {
12014 this.fieldWidget.simulateLabelClick();
12015 }.bind( this ) );
12016 if ( this.helpInline ) {
12017 this.$help.on( 'click', function () {
12018 this.fieldWidget.simulateLabelClick();
12019 }.bind( this ) );
12020 }
12021 }
12022 this.$element
12023 .addClass( 'oo-ui-fieldLayout' )
12024 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
12025 .append( this.$body );
12026 this.$body.addClass( 'oo-ui-fieldLayout-body' );
12027 this.$header.addClass( 'oo-ui-fieldLayout-header' );
12028 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
12029 this.$field
12030 .addClass( 'oo-ui-fieldLayout-field' )
12031 .append( this.fieldWidget.$element );
12032
12033 this.setErrors( config.errors || [] );
12034 this.setWarnings( config.warnings || [] );
12035 this.setNotices( config.notices || [] );
12036 this.setAlignment( config.align );
12037 // Call this again to take into account the widget's accessKey
12038 this.updateTitle();
12039 };
12040
12041 /* Setup */
12042
12043 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
12044 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
12045 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
12046
12047 /* Methods */
12048
12049 /**
12050 * Handle field disable events.
12051 *
12052 * @private
12053 * @param {boolean} value Field is disabled
12054 */
12055 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
12056 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
12057 };
12058
12059 /**
12060 * Get the widget contained by the field.
12061 *
12062 * @return {OO.ui.Widget} Field widget
12063 */
12064 OO.ui.FieldLayout.prototype.getField = function () {
12065 return this.fieldWidget;
12066 };
12067
12068 /**
12069 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12070 * #setAlignment). Return `false` if it can't or if this can't be determined.
12071 *
12072 * @return {boolean}
12073 */
12074 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12075 // This is very simplistic, but should be good enough.
12076 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12077 };
12078
12079 /**
12080 * @protected
12081 * @param {string} kind 'error' or 'notice'
12082 * @param {string|OO.ui.HtmlSnippet} text
12083 * @return {jQuery}
12084 */
12085 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12086 var $listItem, $icon, message;
12087 $listItem = $( '<li>' );
12088 if ( kind === 'error' ) {
12089 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'error' ] } ).$element;
12090 $listItem.attr( 'role', 'alert' );
12091 } else if ( kind === 'warning' ) {
12092 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
12093 $listItem.attr( 'role', 'alert' );
12094 } else if ( kind === 'notice' ) {
12095 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
12096 } else {
12097 $icon = '';
12098 }
12099 message = new OO.ui.LabelWidget( { label: text } );
12100 $listItem
12101 .append( $icon, message.$element )
12102 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
12103 return $listItem;
12104 };
12105
12106 /**
12107 * Set the field alignment mode.
12108 *
12109 * @private
12110 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12111 * @chainable
12112 * @return {OO.ui.BookletLayout} The layout, for chaining
12113 */
12114 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12115 if ( value !== this.align ) {
12116 // Default to 'left'
12117 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12118 value = 'left';
12119 }
12120 // Validate
12121 if ( value === 'inline' && !this.isFieldInline() ) {
12122 value = 'top';
12123 }
12124 // Reorder elements
12125
12126 if ( this.helpInline ) {
12127 if ( value === 'top' ) {
12128 this.$header.append( this.$label );
12129 this.$body.append( this.$header, this.$field, this.$help );
12130 } else if ( value === 'inline' ) {
12131 this.$header.append( this.$label, this.$help );
12132 this.$body.append( this.$field, this.$header );
12133 } else {
12134 this.$header.append( this.$label, this.$help );
12135 this.$body.append( this.$header, this.$field );
12136 }
12137 } else {
12138 if ( value === 'top' ) {
12139 this.$header.append( this.$help, this.$label );
12140 this.$body.append( this.$header, this.$field );
12141 } else if ( value === 'inline' ) {
12142 this.$header.append( this.$help, this.$label );
12143 this.$body.append( this.$field, this.$header );
12144 } else {
12145 this.$header.append( this.$label );
12146 this.$body.append( this.$header, this.$help, this.$field );
12147 }
12148 }
12149 // Set classes. The following classes can be used here:
12150 // * oo-ui-fieldLayout-align-left
12151 // * oo-ui-fieldLayout-align-right
12152 // * oo-ui-fieldLayout-align-top
12153 // * oo-ui-fieldLayout-align-inline
12154 if ( this.align ) {
12155 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
12156 }
12157 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
12158 this.align = value;
12159 }
12160
12161 return this;
12162 };
12163
12164 /**
12165 * Set the list of error messages.
12166 *
12167 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
12168 * The array may contain strings or OO.ui.HtmlSnippet instances.
12169 * @chainable
12170 * @return {OO.ui.BookletLayout} The layout, for chaining
12171 */
12172 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
12173 this.errors = errors.slice();
12174 this.updateMessages();
12175 return this;
12176 };
12177
12178 /**
12179 * Set the list of warning messages.
12180 *
12181 * @param {Array} warnings Warning messages about the widget, which will be displayed below
12182 * the widget.
12183 * The array may contain strings or OO.ui.HtmlSnippet instances.
12184 * @chainable
12185 * @return {OO.ui.BookletLayout} The layout, for chaining
12186 */
12187 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
12188 this.warnings = warnings.slice();
12189 this.updateMessages();
12190 return this;
12191 };
12192
12193 /**
12194 * Set the list of notice messages.
12195 *
12196 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
12197 * The array may contain strings or OO.ui.HtmlSnippet instances.
12198 * @chainable
12199 * @return {OO.ui.BookletLayout} The layout, for chaining
12200 */
12201 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
12202 this.notices = notices.slice();
12203 this.updateMessages();
12204 return this;
12205 };
12206
12207 /**
12208 * Update the rendering of error, warning and notice messages.
12209 *
12210 * @private
12211 */
12212 OO.ui.FieldLayout.prototype.updateMessages = function () {
12213 var i;
12214 this.$messages.empty();
12215
12216 if ( this.errors.length || this.warnings.length || this.notices.length ) {
12217 this.$body.after( this.$messages );
12218 } else {
12219 this.$messages.remove();
12220 return;
12221 }
12222
12223 for ( i = 0; i < this.errors.length; i++ ) {
12224 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
12225 }
12226 for ( i = 0; i < this.warnings.length; i++ ) {
12227 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
12228 }
12229 for ( i = 0; i < this.notices.length; i++ ) {
12230 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
12231 }
12232 };
12233
12234 /**
12235 * Include information about the widget's accessKey in our title. TitledElement calls this method.
12236 * (This is a bit of a hack.)
12237 *
12238 * @protected
12239 * @param {string} title Tooltip label for 'title' attribute
12240 * @return {string}
12241 */
12242 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
12243 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
12244 return this.fieldWidget.formatTitleWithAccessKey( title );
12245 }
12246 return title;
12247 };
12248
12249 /**
12250 * Creates and returns the help element. Also sets the `aria-describedby`
12251 * attribute on the main element of the `fieldWidget`.
12252 *
12253 * @private
12254 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
12255 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
12256 * @return {jQuery} The element that should become `this.$help`.
12257 */
12258 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
12259 var helpId, helpWidget;
12260
12261 if ( this.helpInline ) {
12262 helpWidget = new OO.ui.LabelWidget( {
12263 label: help,
12264 classes: [ 'oo-ui-inline-help' ]
12265 } );
12266
12267 helpId = helpWidget.getElementId();
12268 } else {
12269 helpWidget = new OO.ui.PopupButtonWidget( {
12270 $overlay: $overlay,
12271 popup: {
12272 padded: true
12273 },
12274 classes: [ 'oo-ui-fieldLayout-help' ],
12275 framed: false,
12276 icon: 'info',
12277 label: OO.ui.msg( 'ooui-field-help' ),
12278 invisibleLabel: true
12279 } );
12280 if ( help instanceof OO.ui.HtmlSnippet ) {
12281 helpWidget.getPopup().$body.html( help.toString() );
12282 } else {
12283 helpWidget.getPopup().$body.text( help );
12284 }
12285
12286 helpId = helpWidget.getPopup().getBodyId();
12287 }
12288
12289 // Set the 'aria-describedby' attribute on the fieldWidget
12290 // Preference given to an input or a button
12291 (
12292 this.fieldWidget.$input ||
12293 this.fieldWidget.$button ||
12294 this.fieldWidget.$element
12295 ).attr( 'aria-describedby', helpId );
12296
12297 return helpWidget.$element;
12298 };
12299
12300 /**
12301 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
12302 * a button, and an optional label and/or help text. The field-widget (e.g., a
12303 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
12304 * configuration settings.
12305 *
12306 * Labels can be aligned in one of four ways:
12307 *
12308 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12309 * A left-alignment is used for forms with many fields.
12310 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12311 * A right-alignment is used for long but familiar forms which users tab through,
12312 * verifying the current field with a quick glance at the label.
12313 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12314 * that users fill out from top to bottom.
12315 * - **inline**: The label is placed after the field-widget and aligned to the left.
12316 * An inline-alignment is best used with checkboxes or radio buttons.
12317 *
12318 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
12319 * field layout when help text is specified.
12320 *
12321 * @example
12322 * // Example of an ActionFieldLayout
12323 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12324 * new OO.ui.TextInputWidget( {
12325 * placeholder: 'Field widget'
12326 * } ),
12327 * new OO.ui.ButtonWidget( {
12328 * label: 'Button'
12329 * } ),
12330 * {
12331 * label: 'An ActionFieldLayout. This label is aligned top',
12332 * align: 'top',
12333 * help: 'This is help text'
12334 * }
12335 * );
12336 *
12337 * $( document.body ).append( actionFieldLayout.$element );
12338 *
12339 * @class
12340 * @extends OO.ui.FieldLayout
12341 *
12342 * @constructor
12343 * @param {OO.ui.Widget} fieldWidget Field widget
12344 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12345 * @param {Object} config
12346 */
12347 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12348 // Allow passing positional parameters inside the config object
12349 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12350 config = fieldWidget;
12351 fieldWidget = config.fieldWidget;
12352 buttonWidget = config.buttonWidget;
12353 }
12354
12355 // Parent constructor
12356 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12357
12358 // Properties
12359 this.buttonWidget = buttonWidget;
12360 this.$button = $( '<span>' );
12361 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12362
12363 // Initialization
12364 this.$element.addClass( 'oo-ui-actionFieldLayout' );
12365 this.$button
12366 .addClass( 'oo-ui-actionFieldLayout-button' )
12367 .append( this.buttonWidget.$element );
12368 this.$input
12369 .addClass( 'oo-ui-actionFieldLayout-input' )
12370 .append( this.fieldWidget.$element );
12371 this.$field.append( this.$input, this.$button );
12372 };
12373
12374 /* Setup */
12375
12376 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12377
12378 /**
12379 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12380 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12381 * configured with a label as well. For more information and examples,
12382 * please see the [OOUI documentation on MediaWiki][1].
12383 *
12384 * @example
12385 * // Example of a fieldset layout
12386 * var input1 = new OO.ui.TextInputWidget( {
12387 * placeholder: 'A text input field'
12388 * } );
12389 *
12390 * var input2 = new OO.ui.TextInputWidget( {
12391 * placeholder: 'A text input field'
12392 * } );
12393 *
12394 * var fieldset = new OO.ui.FieldsetLayout( {
12395 * label: 'Example of a fieldset layout'
12396 * } );
12397 *
12398 * fieldset.addItems( [
12399 * new OO.ui.FieldLayout( input1, {
12400 * label: 'Field One'
12401 * } ),
12402 * new OO.ui.FieldLayout( input2, {
12403 * label: 'Field Two'
12404 * } )
12405 * ] );
12406 * $( document.body ).append( fieldset.$element );
12407 *
12408 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12409 *
12410 * @class
12411 * @extends OO.ui.Layout
12412 * @mixins OO.ui.mixin.IconElement
12413 * @mixins OO.ui.mixin.LabelElement
12414 * @mixins OO.ui.mixin.GroupElement
12415 *
12416 * @constructor
12417 * @param {Object} [config] Configuration options
12418 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset.
12419 * See OO.ui.FieldLayout for more information about fields.
12420 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon
12421 * will appear in the upper-right corner of the rendered field; clicking it will display the text
12422 * in a popup. For important messages, you are advised to use `notices`, as they are always shown.
12423 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12424 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12425 */
12426 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12427 // Configuration initialization
12428 config = config || {};
12429
12430 // Parent constructor
12431 OO.ui.FieldsetLayout.parent.call( this, config );
12432
12433 // Mixin constructors
12434 OO.ui.mixin.IconElement.call( this, config );
12435 OO.ui.mixin.LabelElement.call( this, config );
12436 OO.ui.mixin.GroupElement.call( this, config );
12437
12438 // Properties
12439 this.$header = $( '<legend>' );
12440 if ( config.help ) {
12441 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12442 $overlay: config.$overlay,
12443 popup: {
12444 padded: true
12445 },
12446 classes: [ 'oo-ui-fieldsetLayout-help' ],
12447 framed: false,
12448 icon: 'info',
12449 label: OO.ui.msg( 'ooui-field-help' ),
12450 invisibleLabel: true
12451 } );
12452 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12453 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12454 } else {
12455 this.popupButtonWidget.getPopup().$body.text( config.help );
12456 }
12457 this.$help = this.popupButtonWidget.$element;
12458 } else {
12459 this.$help = $( [] );
12460 }
12461
12462 // Initialization
12463 this.$header
12464 .addClass( 'oo-ui-fieldsetLayout-header' )
12465 .append( this.$icon, this.$label, this.$help );
12466 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12467 this.$element
12468 .addClass( 'oo-ui-fieldsetLayout' )
12469 .prepend( this.$header, this.$group );
12470 if ( Array.isArray( config.items ) ) {
12471 this.addItems( config.items );
12472 }
12473 };
12474
12475 /* Setup */
12476
12477 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12478 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12479 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12480 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12481
12482 /* Static Properties */
12483
12484 /**
12485 * @static
12486 * @inheritdoc
12487 */
12488 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12489
12490 /**
12491 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
12492 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
12493 * can be configured with an HTML form action, an encoding type, and a method using the #action,
12494 * #enctype, and #method configs, respectively.
12495 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12496 *
12497 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12498 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12499 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12500 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12501 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12502 * often have simplified APIs to match the capabilities of HTML forms.
12503 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12504 *
12505 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12506 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12507 *
12508 * @example
12509 * // Example of a form layout that wraps a fieldset layout
12510 * var input1 = new OO.ui.TextInputWidget( {
12511 * placeholder: 'Username'
12512 * } );
12513 * var input2 = new OO.ui.TextInputWidget( {
12514 * placeholder: 'Password',
12515 * type: 'password'
12516 * } );
12517 * var submit = new OO.ui.ButtonInputWidget( {
12518 * label: 'Submit'
12519 * } );
12520 *
12521 * var fieldset = new OO.ui.FieldsetLayout( {
12522 * label: 'A form layout'
12523 * } );
12524 * fieldset.addItems( [
12525 * new OO.ui.FieldLayout( input1, {
12526 * label: 'Username',
12527 * align: 'top'
12528 * } ),
12529 * new OO.ui.FieldLayout( input2, {
12530 * label: 'Password',
12531 * align: 'top'
12532 * } ),
12533 * new OO.ui.FieldLayout( submit )
12534 * ] );
12535 * var form = new OO.ui.FormLayout( {
12536 * items: [ fieldset ],
12537 * action: '/api/formhandler',
12538 * method: 'get'
12539 * } )
12540 * $( document.body ).append( form.$element );
12541 *
12542 * @class
12543 * @extends OO.ui.Layout
12544 * @mixins OO.ui.mixin.GroupElement
12545 *
12546 * @constructor
12547 * @param {Object} [config] Configuration options
12548 * @cfg {string} [method] HTML form `method` attribute
12549 * @cfg {string} [action] HTML form `action` attribute
12550 * @cfg {string} [enctype] HTML form `enctype` attribute
12551 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12552 */
12553 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12554 var action;
12555
12556 // Configuration initialization
12557 config = config || {};
12558
12559 // Parent constructor
12560 OO.ui.FormLayout.parent.call( this, config );
12561
12562 // Mixin constructors
12563 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12564
12565 // Events
12566 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12567
12568 // Make sure the action is safe
12569 action = config.action;
12570 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12571 action = './' + action;
12572 }
12573
12574 // Initialization
12575 this.$element
12576 .addClass( 'oo-ui-formLayout' )
12577 .attr( {
12578 method: config.method,
12579 action: action,
12580 enctype: config.enctype
12581 } );
12582 if ( Array.isArray( config.items ) ) {
12583 this.addItems( config.items );
12584 }
12585 };
12586
12587 /* Setup */
12588
12589 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12590 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12591
12592 /* Events */
12593
12594 /**
12595 * A 'submit' event is emitted when the form is submitted.
12596 *
12597 * @event submit
12598 */
12599
12600 /* Static Properties */
12601
12602 /**
12603 * @static
12604 * @inheritdoc
12605 */
12606 OO.ui.FormLayout.static.tagName = 'form';
12607
12608 /* Methods */
12609
12610 /**
12611 * Handle form submit events.
12612 *
12613 * @private
12614 * @param {jQuery.Event} e Submit event
12615 * @fires submit
12616 * @return {OO.ui.FormLayout} The layout, for chaining
12617 */
12618 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12619 if ( this.emit( 'submit' ) ) {
12620 return false;
12621 }
12622 };
12623
12624 /**
12625 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
12626 * scrolling, padding, and a frame, and are often used together with
12627 * {@link OO.ui.StackLayout StackLayouts}.
12628 *
12629 * @example
12630 * // Example of a panel layout
12631 * var panel = new OO.ui.PanelLayout( {
12632 * expanded: false,
12633 * framed: true,
12634 * padded: true,
12635 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12636 * } );
12637 * $( document.body ).append( panel.$element );
12638 *
12639 * @class
12640 * @extends OO.ui.Layout
12641 *
12642 * @constructor
12643 * @param {Object} [config] Configuration options
12644 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12645 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12646 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12647 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside
12648 * content.
12649 */
12650 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12651 // Configuration initialization
12652 config = $.extend( {
12653 scrollable: false,
12654 padded: false,
12655 expanded: true,
12656 framed: false
12657 }, config );
12658
12659 // Parent constructor
12660 OO.ui.PanelLayout.parent.call( this, config );
12661
12662 // Initialization
12663 this.$element.addClass( 'oo-ui-panelLayout' );
12664 if ( config.scrollable ) {
12665 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12666 }
12667 if ( config.padded ) {
12668 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12669 }
12670 if ( config.expanded ) {
12671 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12672 }
12673 if ( config.framed ) {
12674 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12675 }
12676 };
12677
12678 /* Setup */
12679
12680 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12681
12682 /* Methods */
12683
12684 /**
12685 * Focus the panel layout
12686 *
12687 * The default implementation just focuses the first focusable element in the panel
12688 */
12689 OO.ui.PanelLayout.prototype.focus = function () {
12690 OO.ui.findFocusable( this.$element ).focus();
12691 };
12692
12693 /**
12694 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12695 * items), with small margins between them. Convenient when you need to put a number of block-level
12696 * widgets on a single line next to each other.
12697 *
12698 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12699 *
12700 * @example
12701 * // HorizontalLayout with a text input and a label
12702 * var layout = new OO.ui.HorizontalLayout( {
12703 * items: [
12704 * new OO.ui.LabelWidget( { label: 'Label' } ),
12705 * new OO.ui.TextInputWidget( { value: 'Text' } )
12706 * ]
12707 * } );
12708 * $( document.body ).append( layout.$element );
12709 *
12710 * @class
12711 * @extends OO.ui.Layout
12712 * @mixins OO.ui.mixin.GroupElement
12713 *
12714 * @constructor
12715 * @param {Object} [config] Configuration options
12716 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12717 */
12718 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12719 // Configuration initialization
12720 config = config || {};
12721
12722 // Parent constructor
12723 OO.ui.HorizontalLayout.parent.call( this, config );
12724
12725 // Mixin constructors
12726 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12727
12728 // Initialization
12729 this.$element.addClass( 'oo-ui-horizontalLayout' );
12730 if ( Array.isArray( config.items ) ) {
12731 this.addItems( config.items );
12732 }
12733 };
12734
12735 /* Setup */
12736
12737 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12738 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12739
12740 /**
12741 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12742 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12743 * (to adjust the value in increments) to allow the user to enter a number.
12744 *
12745 * @example
12746 * // A NumberInputWidget.
12747 * var numberInput = new OO.ui.NumberInputWidget( {
12748 * label: 'NumberInputWidget',
12749 * input: { value: 5 },
12750 * min: 1,
12751 * max: 10
12752 * } );
12753 * $( document.body ).append( numberInput.$element );
12754 *
12755 * @class
12756 * @extends OO.ui.TextInputWidget
12757 *
12758 * @constructor
12759 * @param {Object} [config] Configuration options
12760 * @cfg {Object} [minusButton] Configuration options to pass to the
12761 * {@link OO.ui.ButtonWidget decrementing button widget}.
12762 * @cfg {Object} [plusButton] Configuration options to pass to the
12763 * {@link OO.ui.ButtonWidget incrementing button widget}.
12764 * @cfg {number} [min=-Infinity] Minimum allowed value
12765 * @cfg {number} [max=Infinity] Maximum allowed value
12766 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12767 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
12768 * Defaults to `step` if specified, otherwise `1`.
12769 * @cfg {number} [pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
12770 * Defaults to 10 times `buttonStep`.
12771 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12772 */
12773 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12774 var $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
12775
12776 // Configuration initialization
12777 config = $.extend( {
12778 min: -Infinity,
12779 max: Infinity,
12780 showButtons: true
12781 }, config );
12782
12783 // For backward compatibility
12784 $.extend( config, config.input );
12785 this.input = this;
12786
12787 // Parent constructor
12788 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12789 type: 'number'
12790 } ) );
12791
12792 if ( config.showButtons ) {
12793 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12794 {
12795 disabled: this.isDisabled(),
12796 tabIndex: -1,
12797 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12798 icon: 'subtract'
12799 },
12800 config.minusButton
12801 ) );
12802 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12803 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12804 {
12805 disabled: this.isDisabled(),
12806 tabIndex: -1,
12807 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12808 icon: 'add'
12809 },
12810 config.plusButton
12811 ) );
12812 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12813 }
12814
12815 // Events
12816 this.$input.on( {
12817 keydown: this.onKeyDown.bind( this ),
12818 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12819 } );
12820 if ( config.showButtons ) {
12821 this.plusButton.connect( this, {
12822 click: [ 'onButtonClick', +1 ]
12823 } );
12824 this.minusButton.connect( this, {
12825 click: [ 'onButtonClick', -1 ]
12826 } );
12827 }
12828
12829 // Build the field
12830 $field.append( this.$input );
12831 if ( config.showButtons ) {
12832 $field
12833 .prepend( this.minusButton.$element )
12834 .append( this.plusButton.$element );
12835 }
12836
12837 // Initialization
12838 if ( config.allowInteger || config.isInteger ) {
12839 // Backward compatibility
12840 config.step = 1;
12841 }
12842 this.setRange( config.min, config.max );
12843 this.setStep( config.buttonStep, config.pageStep, config.step );
12844 // Set the validation method after we set step and range
12845 // so that it doesn't immediately call setValidityFlag
12846 this.setValidation( this.validateNumber.bind( this ) );
12847
12848 this.$element
12849 .addClass( 'oo-ui-numberInputWidget' )
12850 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12851 .append( $field );
12852 };
12853
12854 /* Setup */
12855
12856 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12857
12858 /* Methods */
12859
12860 // Backward compatibility
12861 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12862 this.setStep( flag ? 1 : null );
12863 };
12864 // Backward compatibility
12865 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12866
12867 // Backward compatibility
12868 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12869 return this.step === 1;
12870 };
12871 // Backward compatibility
12872 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12873
12874 /**
12875 * Set the range of allowed values
12876 *
12877 * @param {number} min Minimum allowed value
12878 * @param {number} max Maximum allowed value
12879 */
12880 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12881 if ( min > max ) {
12882 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12883 }
12884 this.min = min;
12885 this.max = max;
12886 this.$input.attr( 'min', this.min );
12887 this.$input.attr( 'max', this.max );
12888 this.setValidityFlag();
12889 };
12890
12891 /**
12892 * Get the current range
12893 *
12894 * @return {number[]} Minimum and maximum values
12895 */
12896 OO.ui.NumberInputWidget.prototype.getRange = function () {
12897 return [ this.min, this.max ];
12898 };
12899
12900 /**
12901 * Set the stepping deltas
12902 *
12903 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12904 * Defaults to `step` if specified, otherwise `1`.
12905 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12906 * Defaults to 10 times `buttonStep`.
12907 * @param {number|null} [step] If specified, the field only accepts values that are multiples
12908 * of this.
12909 */
12910 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12911 if ( buttonStep === undefined ) {
12912 buttonStep = step || 1;
12913 }
12914 if ( pageStep === undefined ) {
12915 pageStep = 10 * buttonStep;
12916 }
12917 if ( step !== null && step <= 0 ) {
12918 throw new Error( 'Step value, if given, must be positive' );
12919 }
12920 if ( buttonStep <= 0 ) {
12921 throw new Error( 'Button step value must be positive' );
12922 }
12923 if ( pageStep <= 0 ) {
12924 throw new Error( 'Page step value must be positive' );
12925 }
12926 this.step = step;
12927 this.buttonStep = buttonStep;
12928 this.pageStep = pageStep;
12929 this.$input.attr( 'step', this.step || 'any' );
12930 this.setValidityFlag();
12931 };
12932
12933 /**
12934 * @inheritdoc
12935 */
12936 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12937 if ( value === '' ) {
12938 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12939 // so here we make sure an 'empty' value is actually displayed as such.
12940 this.$input.val( '' );
12941 }
12942 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12943 };
12944
12945 /**
12946 * Get the current stepping values
12947 *
12948 * @return {number[]} Button step, page step, and validity step
12949 */
12950 OO.ui.NumberInputWidget.prototype.getStep = function () {
12951 return [ this.buttonStep, this.pageStep, this.step ];
12952 };
12953
12954 /**
12955 * Get the current value of the widget as a number
12956 *
12957 * @return {number} May be NaN, or an invalid number
12958 */
12959 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12960 return +this.getValue();
12961 };
12962
12963 /**
12964 * Adjust the value of the widget
12965 *
12966 * @param {number} delta Adjustment amount
12967 */
12968 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12969 var n, v = this.getNumericValue();
12970
12971 delta = +delta;
12972 if ( isNaN( delta ) || !isFinite( delta ) ) {
12973 throw new Error( 'Delta must be a finite number' );
12974 }
12975
12976 if ( isNaN( v ) ) {
12977 n = 0;
12978 } else {
12979 n = v + delta;
12980 n = Math.max( Math.min( n, this.max ), this.min );
12981 if ( this.step ) {
12982 n = Math.round( n / this.step ) * this.step;
12983 }
12984 }
12985
12986 if ( n !== v ) {
12987 this.setValue( n );
12988 }
12989 };
12990 /**
12991 * Validate input
12992 *
12993 * @private
12994 * @param {string} value Field value
12995 * @return {boolean}
12996 */
12997 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12998 var n = +value;
12999 if ( value === '' ) {
13000 return !this.isRequired();
13001 }
13002
13003 if ( isNaN( n ) || !isFinite( n ) ) {
13004 return false;
13005 }
13006
13007 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
13008 return false;
13009 }
13010
13011 if ( n < this.min || n > this.max ) {
13012 return false;
13013 }
13014
13015 return true;
13016 };
13017
13018 /**
13019 * Handle mouse click events.
13020 *
13021 * @private
13022 * @param {number} dir +1 or -1
13023 */
13024 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
13025 this.adjustValue( dir * this.buttonStep );
13026 };
13027
13028 /**
13029 * Handle mouse wheel events.
13030 *
13031 * @private
13032 * @param {jQuery.Event} event
13033 * @return {undefined/boolean} False to prevent default if event is handled
13034 */
13035 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13036 var delta = 0;
13037
13038 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
13039 // Standard 'wheel' event
13040 if ( event.originalEvent.deltaMode !== undefined ) {
13041 this.sawWheelEvent = true;
13042 }
13043 if ( event.originalEvent.deltaY ) {
13044 delta = -event.originalEvent.deltaY;
13045 } else if ( event.originalEvent.deltaX ) {
13046 delta = event.originalEvent.deltaX;
13047 }
13048
13049 // Non-standard events
13050 if ( !this.sawWheelEvent ) {
13051 if ( event.originalEvent.wheelDeltaX ) {
13052 delta = -event.originalEvent.wheelDeltaX;
13053 } else if ( event.originalEvent.wheelDeltaY ) {
13054 delta = event.originalEvent.wheelDeltaY;
13055 } else if ( event.originalEvent.wheelDelta ) {
13056 delta = event.originalEvent.wheelDelta;
13057 } else if ( event.originalEvent.detail ) {
13058 delta = -event.originalEvent.detail;
13059 }
13060 }
13061
13062 if ( delta ) {
13063 delta = delta < 0 ? -1 : 1;
13064 this.adjustValue( delta * this.buttonStep );
13065 }
13066
13067 return false;
13068 }
13069 };
13070
13071 /**
13072 * Handle key down events.
13073 *
13074 * @private
13075 * @param {jQuery.Event} e Key down event
13076 * @return {undefined/boolean} False to prevent default if event is handled
13077 */
13078 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
13079 if ( !this.isDisabled() ) {
13080 switch ( e.which ) {
13081 case OO.ui.Keys.UP:
13082 this.adjustValue( this.buttonStep );
13083 return false;
13084 case OO.ui.Keys.DOWN:
13085 this.adjustValue( -this.buttonStep );
13086 return false;
13087 case OO.ui.Keys.PAGEUP:
13088 this.adjustValue( this.pageStep );
13089 return false;
13090 case OO.ui.Keys.PAGEDOWN:
13091 this.adjustValue( -this.pageStep );
13092 return false;
13093 }
13094 }
13095 };
13096
13097 /**
13098 * @inheritdoc
13099 */
13100 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
13101 // Parent method
13102 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
13103
13104 if ( this.minusButton ) {
13105 this.minusButton.setDisabled( this.isDisabled() );
13106 }
13107 if ( this.plusButton ) {
13108 this.plusButton.setDisabled( this.isDisabled() );
13109 }
13110
13111 return this;
13112 };
13113
13114 }( OO ) );
13115
13116 //# sourceMappingURL=oojs-ui-core.js.map.json