Update OOUI to v0.31.2
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.31.2
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-26T23:00:40Z
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 = Date.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 - ( Date.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 * @deprecated Since 0.31.1; use `Date.now()` instead.
327 * @return {number} Current timestamp, in milliseconds since the Unix epoch
328 */
329 OO.ui.now = function () {
330 OO.ui.warnDeprecation( 'OO.ui.now() is deprecated, use Date.now() instead' );
331 return Date.now();
332 };
333
334 /**
335 * Reconstitute a JavaScript object corresponding to a widget created by
336 * the PHP implementation.
337 *
338 * This is an alias for `OO.ui.Element.static.infuse()`.
339 *
340 * @param {string|HTMLElement|jQuery} idOrNode
341 * A DOM id (if a string) or node for the widget to infuse.
342 * @param {Object} [config] Configuration options
343 * @return {OO.ui.Element}
344 * The `OO.ui.Element` corresponding to this (infusable) document node.
345 */
346 OO.ui.infuse = function ( idOrNode, config ) {
347 return OO.ui.Element.static.infuse( idOrNode, config );
348 };
349
350 ( function () {
351 /**
352 * Message store for the default implementation of OO.ui.msg.
353 *
354 * Environments that provide a localization system should not use this, but should override
355 * OO.ui.msg altogether.
356 *
357 * @private
358 */
359 var messages = {
360 // Tool tip for a button that moves items in a list down one place
361 'ooui-outline-control-move-down': 'Move item down',
362 // Tool tip for a button that moves items in a list up one place
363 'ooui-outline-control-move-up': 'Move item up',
364 // Tool tip for a button that removes items from a list
365 'ooui-outline-control-remove': 'Remove item',
366 // Label for the toolbar group that contains a list of all other available tools
367 'ooui-toolbar-more': 'More',
368 // Label for the fake tool that expands the full list of tools in a toolbar group
369 'ooui-toolgroup-expand': 'More',
370 // Label for the fake tool that collapses the full list of tools in a toolbar group
371 'ooui-toolgroup-collapse': 'Fewer',
372 // Default label for the tooltip for the button that removes a tag item
373 'ooui-item-remove': 'Remove',
374 // Default label for the accept button of a confirmation dialog
375 'ooui-dialog-message-accept': 'OK',
376 // Default label for the reject button of a confirmation dialog
377 'ooui-dialog-message-reject': 'Cancel',
378 // Title for process dialog error description
379 'ooui-dialog-process-error': 'Something went wrong',
380 // Label for process dialog dismiss error button, visible when describing errors
381 'ooui-dialog-process-dismiss': 'Dismiss',
382 // Label for process dialog retry action button, visible when describing only recoverable
383 // errors
384 'ooui-dialog-process-retry': 'Try again',
385 // Label for process dialog retry action button, visible when describing only warnings
386 'ooui-dialog-process-continue': 'Continue',
387 // Label for button in combobox input that triggers its dropdown
388 'ooui-combobox-button-label': 'Dropdown for combobox',
389 // Label for the file selection widget's select file button
390 'ooui-selectfile-button-select': 'Select a file',
391 // Label for the file selection widget if file selection is not supported
392 'ooui-selectfile-not-supported': 'File selection is not supported',
393 // Label for the file selection widget when no file is currently selected
394 'ooui-selectfile-placeholder': 'No file is selected',
395 // Label for the file selection widget's drop target
396 'ooui-selectfile-dragdrop-placeholder': 'Drop file here',
397 // Label for the help icon attached to a form field
398 'ooui-field-help': 'Help'
399 };
400
401 /**
402 * Get a localized message.
403 *
404 * After the message key, message parameters may optionally be passed. In the default
405 * implementation, any occurrences of $1 are replaced with the first parameter, $2 with the
406 * second parameter, etc.
407 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long
408 * as they support unnamed, ordered message parameters.
409 *
410 * In environments that provide a localization system, this function should be overridden to
411 * return the message translated in the user's language. The default implementation always
412 * returns English messages. An example of doing this with
413 * [jQuery.i18n](https://github.com/wikimedia/jquery.i18n) follows.
414 *
415 * @example
416 * var i, iLen, button,
417 * messagePath = 'oojs-ui/dist/i18n/',
418 * languages = [ $.i18n().locale, 'ur', 'en' ],
419 * languageMap = {};
420 *
421 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
422 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
423 * }
424 *
425 * $.i18n().load( languageMap ).done( function() {
426 * // Replace the built-in `msg` only once we've loaded the internationalization.
427 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
428 * // you put off creating any widgets until this promise is complete, no English
429 * // will be displayed.
430 * OO.ui.msg = $.i18n;
431 *
432 * // A button displaying "OK" in the default locale
433 * button = new OO.ui.ButtonWidget( {
434 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
435 * icon: 'check'
436 * } );
437 * $( document.body ).append( button.$element );
438 *
439 * // A button displaying "OK" in Urdu
440 * $.i18n().locale = 'ur';
441 * button = new OO.ui.ButtonWidget( {
442 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
443 * icon: 'check'
444 * } );
445 * $( document.body ).append( button.$element );
446 * } );
447 *
448 * @param {string} key Message key
449 * @param {...Mixed} [params] Message parameters
450 * @return {string} Translated message with parameters substituted
451 */
452 OO.ui.msg = function ( key ) {
453 var message = messages[ key ],
454 params = Array.prototype.slice.call( arguments, 1 );
455 if ( typeof message === 'string' ) {
456 // Perform $1 substitution
457 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
458 var i = parseInt( n, 10 );
459 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
460 } );
461 } else {
462 // Return placeholder if message not found
463 message = '[' + key + ']';
464 }
465 return message;
466 };
467 }() );
468
469 /**
470 * Package a message and arguments for deferred resolution.
471 *
472 * Use this when you are statically specifying a message and the message may not yet be present.
473 *
474 * @param {string} key Message key
475 * @param {...Mixed} [params] Message parameters
476 * @return {Function} Function that returns the resolved message when executed
477 */
478 OO.ui.deferMsg = function () {
479 var args = arguments;
480 return function () {
481 return OO.ui.msg.apply( OO.ui, args );
482 };
483 };
484
485 /**
486 * Resolve a message.
487 *
488 * If the message is a function it will be executed, otherwise it will pass through directly.
489 *
490 * @param {Function|string} msg Deferred message, or message text
491 * @return {string} Resolved message
492 */
493 OO.ui.resolveMsg = function ( msg ) {
494 if ( typeof msg === 'function' ) {
495 return msg();
496 }
497 return msg;
498 };
499
500 /**
501 * @param {string} url
502 * @return {boolean}
503 */
504 OO.ui.isSafeUrl = function ( url ) {
505 // Keep this function in sync with php/Tag.php
506 var i, protocolWhitelist;
507
508 function stringStartsWith( haystack, needle ) {
509 return haystack.substr( 0, needle.length ) === needle;
510 }
511
512 protocolWhitelist = [
513 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
514 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
515 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
516 ];
517
518 if ( url === '' ) {
519 return true;
520 }
521
522 for ( i = 0; i < protocolWhitelist.length; i++ ) {
523 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
524 return true;
525 }
526 }
527
528 // This matches '//' too
529 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
530 return true;
531 }
532 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
533 return true;
534 }
535
536 return false;
537 };
538
539 /**
540 * Check if the user has a 'mobile' device.
541 *
542 * For our purposes this means the user is primarily using an
543 * on-screen keyboard, touch input instead of a mouse and may
544 * have a physically small display.
545 *
546 * It is left up to implementors to decide how to compute this
547 * so the default implementation always returns false.
548 *
549 * @return {boolean} User is on a mobile device
550 */
551 OO.ui.isMobile = function () {
552 return false;
553 };
554
555 /**
556 * Get the additional spacing that should be taken into account when displaying elements that are
557 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
558 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
559 *
560 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
561 * the extra spacing from that edge of viewport (in pixels)
562 */
563 OO.ui.getViewportSpacing = function () {
564 return {
565 top: 0,
566 right: 0,
567 bottom: 0,
568 left: 0
569 };
570 };
571
572 /**
573 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
574 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
575 *
576 * @return {jQuery} Default overlay node
577 */
578 OO.ui.getDefaultOverlay = function () {
579 if ( !OO.ui.$defaultOverlay ) {
580 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
581 $( document.body ).append( OO.ui.$defaultOverlay );
582 }
583 return OO.ui.$defaultOverlay;
584 };
585
586 /*!
587 * Mixin namespace.
588 */
589
590 /**
591 * Namespace for OOUI mixins.
592 *
593 * Mixins are named according to the type of object they are intended to
594 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
595 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
596 * is intended to be mixed in to an instance of OO.ui.Widget.
597 *
598 * @class
599 * @singleton
600 */
601 OO.ui.mixin = {};
602
603 /**
604 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
605 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not
606 * have events connected to them and can't be interacted with.
607 *
608 * @abstract
609 * @class
610 *
611 * @constructor
612 * @param {Object} [config] Configuration options
613 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are
614 * added to the top level (e.g., the outermost div) of the element. See the
615 * [OOUI documentation on MediaWiki][2] for an example.
616 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
617 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
618 * @cfg {string} [text] Text to insert
619 * @cfg {Array} [content] An array of content elements to append (after #text).
620 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
621 * Instances of OO.ui.Element will have their $element appended.
622 * @cfg {jQuery} [$content] Content elements to append (after #text).
623 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
624 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number,
625 * array, object).
626 * Data can also be specified with the #setData method.
627 */
628 OO.ui.Element = function OoUiElement( config ) {
629 if ( OO.ui.isDemo ) {
630 this.initialConfig = config;
631 }
632 // Configuration initialization
633 config = config || {};
634
635 // Properties
636 this.$ = function () {
637 OO.ui.warnDeprecation( 'this.$ is deprecated, use global $ instead' );
638 return $.apply( this, arguments );
639 };
640 this.elementId = null;
641 this.visible = true;
642 this.data = config.data;
643 this.$element = config.$element ||
644 $( document.createElement( this.getTagName() ) );
645 this.elementGroup = null;
646
647 // Initialization
648 if ( Array.isArray( config.classes ) ) {
649 this.$element.addClass( config.classes );
650 }
651 if ( config.id ) {
652 this.setElementId( config.id );
653 }
654 if ( config.text ) {
655 this.$element.text( config.text );
656 }
657 if ( config.content ) {
658 // The `content` property treats plain strings as text; use an
659 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
660 // appropriate $element appended.
661 this.$element.append( config.content.map( function ( v ) {
662 if ( typeof v === 'string' ) {
663 // Escape string so it is properly represented in HTML.
664 // Don't create empty text nodes for empty strings.
665 return v ? document.createTextNode( v ) : undefined;
666 } else if ( v instanceof OO.ui.HtmlSnippet ) {
667 // Bypass escaping.
668 return v.toString();
669 } else if ( v instanceof OO.ui.Element ) {
670 return v.$element;
671 }
672 return v;
673 } ) );
674 }
675 if ( config.$content ) {
676 // The `$content` property treats plain strings as HTML.
677 this.$element.append( config.$content );
678 }
679 };
680
681 /* Setup */
682
683 OO.initClass( OO.ui.Element );
684
685 /* Static Properties */
686
687 /**
688 * The name of the HTML tag used by the element.
689 *
690 * The static value may be ignored if the #getTagName method is overridden.
691 *
692 * @static
693 * @inheritable
694 * @property {string}
695 */
696 OO.ui.Element.static.tagName = 'div';
697
698 /* Static Methods */
699
700 /**
701 * Reconstitute a JavaScript object corresponding to a widget created
702 * by the PHP implementation.
703 *
704 * @param {string|HTMLElement|jQuery} idOrNode
705 * A DOM id (if a string) or node for the widget to infuse.
706 * @param {Object} [config] Configuration options
707 * @return {OO.ui.Element}
708 * The `OO.ui.Element` corresponding to this (infusable) document node.
709 * For `Tag` objects emitted on the HTML side (used occasionally for content)
710 * the value returned is a newly-created Element wrapping around the existing
711 * DOM node.
712 */
713 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
714 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
715
716 if ( typeof idOrNode === 'string' ) {
717 // IDs deprecated since 0.29.7
718 OO.ui.warnDeprecation(
719 'Passing a string ID to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
720 );
721 }
722 // Verify that the type matches up.
723 // FIXME: uncomment after T89721 is fixed, see T90929.
724 /*
725 if ( !( obj instanceof this['class'] ) ) {
726 throw new Error( 'Infusion type mismatch!' );
727 }
728 */
729 return obj;
730 };
731
732 /**
733 * Implementation helper for `infuse`; skips the type check and has an
734 * extra property so that only the top-level invocation touches the DOM.
735 *
736 * @private
737 * @param {string|HTMLElement|jQuery} idOrNode
738 * @param {Object} [config] Configuration options
739 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
740 * when the top-level widget of this infusion is inserted into DOM,
741 * replacing the original node; only used internally.
742 * @return {OO.ui.Element}
743 */
744 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
745 // look for a cached result of a previous infusion.
746 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
747 if ( typeof idOrNode === 'string' ) {
748 id = idOrNode;
749 $elem = $( document.getElementById( id ) );
750 } else {
751 $elem = $( idOrNode );
752 id = $elem.attr( 'id' );
753 }
754 if ( !$elem.length ) {
755 if ( typeof idOrNode === 'string' ) {
756 error = 'Widget not found: ' + idOrNode;
757 } else if ( idOrNode && idOrNode.selector ) {
758 error = 'Widget not found: ' + idOrNode.selector;
759 } else {
760 error = 'Widget not found';
761 }
762 throw new Error( error );
763 }
764 if ( $elem[ 0 ].oouiInfused ) {
765 $elem = $elem[ 0 ].oouiInfused;
766 }
767 data = $elem.data( 'ooui-infused' );
768 if ( data ) {
769 // cached!
770 if ( data === true ) {
771 throw new Error( 'Circular dependency! ' + id );
772 }
773 if ( domPromise ) {
774 // Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
775 state = data.constructor.static.gatherPreInfuseState( $elem, data );
776 // Restore dynamic state after the new element is re-inserted into DOM under
777 // infused parent.
778 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
779 infusedChildren = $elem.data( 'ooui-infused-children' );
780 if ( infusedChildren && infusedChildren.length ) {
781 infusedChildren.forEach( function ( data ) {
782 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
783 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
784 } );
785 }
786 }
787 return data;
788 }
789 data = $elem.attr( 'data-ooui' );
790 if ( !data ) {
791 throw new Error( 'No infusion data found: ' + id );
792 }
793 try {
794 data = JSON.parse( data );
795 } catch ( _ ) {
796 data = null;
797 }
798 if ( !( data && data._ ) ) {
799 throw new Error( 'No valid infusion data found: ' + id );
800 }
801 if ( data._ === 'Tag' ) {
802 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
803 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
804 }
805 parts = data._.split( '.' );
806 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
807 if ( cls === undefined ) {
808 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
809 }
810
811 // Verify that we're creating an OO.ui.Element instance
812 parent = cls.parent;
813
814 while ( parent !== undefined ) {
815 if ( parent === OO.ui.Element ) {
816 // Safe
817 break;
818 }
819
820 parent = parent.parent;
821 }
822
823 if ( parent !== OO.ui.Element ) {
824 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
825 }
826
827 if ( !domPromise ) {
828 top = $.Deferred();
829 domPromise = top.promise();
830 }
831 $elem.data( 'ooui-infused', true ); // prevent loops
832 data.id = id; // implicit
833 infusedChildren = [];
834 data = OO.copy( data, null, function deserialize( value ) {
835 var infused;
836 if ( OO.isPlainObject( value ) ) {
837 if ( value.tag ) {
838 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
839 infusedChildren.push( infused );
840 // Flatten the structure
841 infusedChildren.push.apply(
842 infusedChildren,
843 infused.$element.data( 'ooui-infused-children' ) || []
844 );
845 infused.$element.removeData( 'ooui-infused-children' );
846 return infused;
847 }
848 if ( value.html !== undefined ) {
849 return new OO.ui.HtmlSnippet( value.html );
850 }
851 }
852 } );
853 // allow widgets to reuse parts of the DOM
854 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
855 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
856 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
857 // rebuild widget
858 // eslint-disable-next-line new-cap
859 obj = new cls( $.extend( {}, config, data ) );
860 // If anyone is holding a reference to the old DOM element,
861 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
862 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
863 $elem[ 0 ].oouiInfused = obj.$element;
864 // now replace old DOM with this new DOM.
865 if ( top ) {
866 // An efficient constructor might be able to reuse the entire DOM tree of the original
867 // element, so only mutate the DOM if we need to.
868 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
869 $elem.replaceWith( obj.$element );
870 }
871 top.resolve();
872 }
873 obj.$element.data( 'ooui-infused', obj );
874 obj.$element.data( 'ooui-infused-children', infusedChildren );
875 // set the 'data-ooui' attribute so we can identify infused widgets
876 obj.$element.attr( 'data-ooui', '' );
877 // restore dynamic state after the new element is inserted into DOM
878 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
879 return obj;
880 };
881
882 /**
883 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
884 *
885 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
886 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
887 * constructor, which will be given the enhanced config.
888 *
889 * @protected
890 * @param {HTMLElement} node
891 * @param {Object} config
892 * @return {Object}
893 */
894 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
895 return config;
896 };
897
898 /**
899 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
900 * node (and its children) that represent an Element of the same class and the given configuration,
901 * generated by the PHP implementation.
902 *
903 * This method is called just before `node` is detached from the DOM. The return value of this
904 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
905 * is inserted into DOM to replace `node`.
906 *
907 * @protected
908 * @param {HTMLElement} node
909 * @param {Object} config
910 * @return {Object}
911 */
912 OO.ui.Element.static.gatherPreInfuseState = function () {
913 return {};
914 };
915
916 /**
917 * Get a jQuery function within a specific document.
918 *
919 * @static
920 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
921 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
922 * not in an iframe
923 * @return {Function} Bound jQuery function
924 */
925 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
926 function wrapper( selector ) {
927 return $( selector, wrapper.context );
928 }
929
930 wrapper.context = this.getDocument( context );
931
932 if ( $iframe ) {
933 wrapper.$iframe = $iframe;
934 }
935
936 return wrapper;
937 };
938
939 /**
940 * Get the document of an element.
941 *
942 * @static
943 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
944 * @return {HTMLDocument|null} Document object
945 */
946 OO.ui.Element.static.getDocument = function ( obj ) {
947 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
948 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
949 // Empty jQuery selections might have a context
950 obj.context ||
951 // HTMLElement
952 obj.ownerDocument ||
953 // Window
954 obj.document ||
955 // HTMLDocument
956 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
957 null;
958 };
959
960 /**
961 * Get the window of an element or document.
962 *
963 * @static
964 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
965 * @return {Window} Window object
966 */
967 OO.ui.Element.static.getWindow = function ( obj ) {
968 var doc = this.getDocument( obj );
969 return doc.defaultView;
970 };
971
972 /**
973 * Get the direction of an element or document.
974 *
975 * @static
976 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
977 * @return {string} Text direction, either 'ltr' or 'rtl'
978 */
979 OO.ui.Element.static.getDir = function ( obj ) {
980 var isDoc, isWin;
981
982 if ( obj instanceof $ ) {
983 obj = obj[ 0 ];
984 }
985 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
986 isWin = obj.document !== undefined;
987 if ( isDoc || isWin ) {
988 if ( isWin ) {
989 obj = obj.document;
990 }
991 obj = obj.body;
992 }
993 return $( obj ).css( 'direction' );
994 };
995
996 /**
997 * Get the offset between two frames.
998 *
999 * TODO: Make this function not use recursion.
1000 *
1001 * @static
1002 * @param {Window} from Window of the child frame
1003 * @param {Window} [to=window] Window of the parent frame
1004 * @param {Object} [offset] Offset to start with, used internally
1005 * @return {Object} Offset object, containing left and top properties
1006 */
1007 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
1008 var i, len, frames, frame, rect;
1009
1010 if ( !to ) {
1011 to = window;
1012 }
1013 if ( !offset ) {
1014 offset = { top: 0, left: 0 };
1015 }
1016 if ( from.parent === from ) {
1017 return offset;
1018 }
1019
1020 // Get iframe element
1021 frames = from.parent.document.getElementsByTagName( 'iframe' );
1022 for ( i = 0, len = frames.length; i < len; i++ ) {
1023 if ( frames[ i ].contentWindow === from ) {
1024 frame = frames[ i ];
1025 break;
1026 }
1027 }
1028
1029 // Recursively accumulate offset values
1030 if ( frame ) {
1031 rect = frame.getBoundingClientRect();
1032 offset.left += rect.left;
1033 offset.top += rect.top;
1034 if ( from !== to ) {
1035 this.getFrameOffset( from.parent, offset );
1036 }
1037 }
1038 return offset;
1039 };
1040
1041 /**
1042 * Get the offset between two elements.
1043 *
1044 * The two elements may be in a different frame, but in that case the frame $element is in must
1045 * be contained in the frame $anchor is in.
1046 *
1047 * @static
1048 * @param {jQuery} $element Element whose position to get
1049 * @param {jQuery} $anchor Element to get $element's position relative to
1050 * @return {Object} Translated position coordinates, containing top and left properties
1051 */
1052 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1053 var iframe, iframePos,
1054 pos = $element.offset(),
1055 anchorPos = $anchor.offset(),
1056 elementDocument = this.getDocument( $element ),
1057 anchorDocument = this.getDocument( $anchor );
1058
1059 // If $element isn't in the same document as $anchor, traverse up
1060 while ( elementDocument !== anchorDocument ) {
1061 iframe = elementDocument.defaultView.frameElement;
1062 if ( !iframe ) {
1063 throw new Error( '$element frame is not contained in $anchor frame' );
1064 }
1065 iframePos = $( iframe ).offset();
1066 pos.left += iframePos.left;
1067 pos.top += iframePos.top;
1068 elementDocument = iframe.ownerDocument;
1069 }
1070 pos.left -= anchorPos.left;
1071 pos.top -= anchorPos.top;
1072 return pos;
1073 };
1074
1075 /**
1076 * Get element border sizes.
1077 *
1078 * @static
1079 * @param {HTMLElement} el Element to measure
1080 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1081 */
1082 OO.ui.Element.static.getBorders = function ( el ) {
1083 var doc = el.ownerDocument,
1084 win = doc.defaultView,
1085 style = win.getComputedStyle( el, null ),
1086 $el = $( el ),
1087 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1088 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1089 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1090 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1091
1092 return {
1093 top: top,
1094 left: left,
1095 bottom: bottom,
1096 right: right
1097 };
1098 };
1099
1100 /**
1101 * Get dimensions of an element or window.
1102 *
1103 * @static
1104 * @param {HTMLElement|Window} el Element to measure
1105 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1106 */
1107 OO.ui.Element.static.getDimensions = function ( el ) {
1108 var $el, $win,
1109 doc = el.ownerDocument || el.document,
1110 win = doc.defaultView;
1111
1112 if ( win === el || el === doc.documentElement ) {
1113 $win = $( win );
1114 return {
1115 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1116 scroll: {
1117 top: $win.scrollTop(),
1118 left: $win.scrollLeft()
1119 },
1120 scrollbar: { right: 0, bottom: 0 },
1121 rect: {
1122 top: 0,
1123 left: 0,
1124 bottom: $win.innerHeight(),
1125 right: $win.innerWidth()
1126 }
1127 };
1128 } else {
1129 $el = $( el );
1130 return {
1131 borders: this.getBorders( el ),
1132 scroll: {
1133 top: $el.scrollTop(),
1134 left: $el.scrollLeft()
1135 },
1136 scrollbar: {
1137 right: $el.innerWidth() - el.clientWidth,
1138 bottom: $el.innerHeight() - el.clientHeight
1139 },
1140 rect: el.getBoundingClientRect()
1141 };
1142 }
1143 };
1144
1145 /**
1146 * Get the number of pixels that an element's content is scrolled to the left.
1147 *
1148 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1149 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1150 *
1151 * This function smooths out browser inconsistencies (nicely described in the README at
1152 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1153 * with Firefox's 'scrollLeft', which seems the sanest.
1154 *
1155 * @static
1156 * @method
1157 * @param {HTMLElement|Window} el Element to measure
1158 * @return {number} Scroll position from the left.
1159 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1160 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1161 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1162 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1163 */
1164 OO.ui.Element.static.getScrollLeft = ( function () {
1165 var rtlScrollType = null;
1166
1167 function test() {
1168 var $definer = $( '<div>' ).attr( {
1169 dir: 'rtl',
1170 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1171 } ).text( 'ABCD' ),
1172 definer = $definer[ 0 ];
1173
1174 $definer.appendTo( 'body' );
1175 if ( definer.scrollLeft > 0 ) {
1176 // Safari, Chrome
1177 rtlScrollType = 'default';
1178 } else {
1179 definer.scrollLeft = 1;
1180 if ( definer.scrollLeft === 0 ) {
1181 // Firefox, old Opera
1182 rtlScrollType = 'negative';
1183 } else {
1184 // Internet Explorer, Edge
1185 rtlScrollType = 'reverse';
1186 }
1187 }
1188 $definer.remove();
1189 }
1190
1191 return function getScrollLeft( el ) {
1192 var isRoot = el.window === el ||
1193 el === el.ownerDocument.body ||
1194 el === el.ownerDocument.documentElement,
1195 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1196 // All browsers use the correct scroll type ('negative') on the root, so don't
1197 // do any fixups when looking at the root element
1198 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1199
1200 if ( direction === 'rtl' ) {
1201 if ( rtlScrollType === null ) {
1202 test();
1203 }
1204 if ( rtlScrollType === 'reverse' ) {
1205 scrollLeft = -scrollLeft;
1206 } else if ( rtlScrollType === 'default' ) {
1207 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1208 }
1209 }
1210
1211 return scrollLeft;
1212 };
1213 }() );
1214
1215 /**
1216 * Get the root scrollable element of given element's document.
1217 *
1218 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1219 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1220 * lets us use 'body' or 'documentElement' based on what is working.
1221 *
1222 * https://code.google.com/p/chromium/issues/detail?id=303131
1223 *
1224 * @static
1225 * @param {HTMLElement} el Element to find root scrollable parent for
1226 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1227 * depending on browser
1228 */
1229 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1230 var scrollTop, body;
1231
1232 if ( OO.ui.scrollableElement === undefined ) {
1233 body = el.ownerDocument.body;
1234 scrollTop = body.scrollTop;
1235 body.scrollTop = 1;
1236
1237 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1238 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1239 if ( Math.round( body.scrollTop ) === 1 ) {
1240 body.scrollTop = scrollTop;
1241 OO.ui.scrollableElement = 'body';
1242 } else {
1243 OO.ui.scrollableElement = 'documentElement';
1244 }
1245 }
1246
1247 return el.ownerDocument[ OO.ui.scrollableElement ];
1248 };
1249
1250 /**
1251 * Get closest scrollable container.
1252 *
1253 * Traverses up until either a scrollable element or the root is reached, in which case the root
1254 * scrollable element will be returned (see #getRootScrollableElement).
1255 *
1256 * @static
1257 * @param {HTMLElement} el Element to find scrollable container for
1258 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1259 * @return {HTMLElement} Closest scrollable container
1260 */
1261 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1262 var i, val,
1263 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1264 // 'overflow-y' have different values, so we need to check the separate properties.
1265 props = [ 'overflow-x', 'overflow-y' ],
1266 $parent = $( el ).parent();
1267
1268 if ( dimension === 'x' || dimension === 'y' ) {
1269 props = [ 'overflow-' + dimension ];
1270 }
1271
1272 // Special case for the document root (which doesn't really have any scrollable container,
1273 // since it is the ultimate scrollable container, but this is probably saner than null or
1274 // exception).
1275 if ( $( el ).is( 'html, body' ) ) {
1276 return this.getRootScrollableElement( el );
1277 }
1278
1279 while ( $parent.length ) {
1280 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1281 return $parent[ 0 ];
1282 }
1283 i = props.length;
1284 while ( i-- ) {
1285 val = $parent.css( props[ i ] );
1286 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1287 // never be scrolled in that direction, but they can actually be scrolled
1288 // programatically. The user can unintentionally perform a scroll in such case even if
1289 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1290 // when using built-in find functionality.
1291 // This could cause funny issues...
1292 if ( val === 'auto' || val === 'scroll' ) {
1293 return $parent[ 0 ];
1294 }
1295 }
1296 $parent = $parent.parent();
1297 }
1298 // The element is unattached... return something mostly sane
1299 return this.getRootScrollableElement( el );
1300 };
1301
1302 /**
1303 * Scroll element into view.
1304 *
1305 * @static
1306 * @param {HTMLElement} el Element to scroll into view
1307 * @param {Object} [config] Configuration options
1308 * @param {string} [config.duration='fast'] jQuery animation duration value
1309 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1310 * to scroll in both directions
1311 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1312 */
1313 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1314 var position, animations, container, $container, elementDimensions, containerDimensions,
1315 $window,
1316 deferred = $.Deferred();
1317
1318 // Configuration initialization
1319 config = config || {};
1320
1321 animations = {};
1322 container = this.getClosestScrollableContainer( el, config.direction );
1323 $container = $( container );
1324 elementDimensions = this.getDimensions( el );
1325 containerDimensions = this.getDimensions( container );
1326 $window = $( this.getWindow( el ) );
1327
1328 // Compute the element's position relative to the container
1329 if ( $container.is( 'html, body' ) ) {
1330 // If the scrollable container is the root, this is easy
1331 position = {
1332 top: elementDimensions.rect.top,
1333 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1334 left: elementDimensions.rect.left,
1335 right: $window.innerWidth() - elementDimensions.rect.right
1336 };
1337 } else {
1338 // Otherwise, we have to subtract el's coordinates from container's coordinates
1339 position = {
1340 top: elementDimensions.rect.top -
1341 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1342 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1343 containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1344 left: elementDimensions.rect.left -
1345 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1346 right: containerDimensions.rect.right - containerDimensions.borders.right -
1347 containerDimensions.scrollbar.right - elementDimensions.rect.right
1348 };
1349 }
1350
1351 if ( !config.direction || config.direction === 'y' ) {
1352 if ( position.top < 0 ) {
1353 animations.scrollTop = containerDimensions.scroll.top + position.top;
1354 } else if ( position.top > 0 && position.bottom < 0 ) {
1355 animations.scrollTop = containerDimensions.scroll.top +
1356 Math.min( position.top, -position.bottom );
1357 }
1358 }
1359 if ( !config.direction || config.direction === 'x' ) {
1360 if ( position.left < 0 ) {
1361 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1362 } else if ( position.left > 0 && position.right < 0 ) {
1363 animations.scrollLeft = containerDimensions.scroll.left +
1364 Math.min( position.left, -position.right );
1365 }
1366 }
1367 if ( !$.isEmptyObject( animations ) ) {
1368 // eslint-disable-next-line no-jquery/no-animate
1369 $container.stop( true ).animate( animations, config.duration === undefined ?
1370 'fast' : config.duration );
1371 $container.queue( function ( next ) {
1372 deferred.resolve();
1373 next();
1374 } );
1375 } else {
1376 deferred.resolve();
1377 }
1378 return deferred.promise();
1379 };
1380
1381 /**
1382 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1383 * and reserve space for them, because it probably doesn't.
1384 *
1385 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1386 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1387 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1388 * reflow, and then reattach (or show) them back.
1389 *
1390 * @static
1391 * @param {HTMLElement} el Element to reconsider the scrollbars on
1392 */
1393 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1394 var i, len, scrollLeft, scrollTop, nodes = [];
1395 // Save scroll position
1396 scrollLeft = el.scrollLeft;
1397 scrollTop = el.scrollTop;
1398 // Detach all children
1399 while ( el.firstChild ) {
1400 nodes.push( el.firstChild );
1401 el.removeChild( el.firstChild );
1402 }
1403 // Force reflow
1404 // eslint-disable-next-line no-void
1405 void el.offsetHeight;
1406 // Reattach all children
1407 for ( i = 0, len = nodes.length; i < len; i++ ) {
1408 el.appendChild( nodes[ i ] );
1409 }
1410 // Restore scroll position (no-op if scrollbars disappeared)
1411 el.scrollLeft = scrollLeft;
1412 el.scrollTop = scrollTop;
1413 };
1414
1415 /* Methods */
1416
1417 /**
1418 * Toggle visibility of an element.
1419 *
1420 * @param {boolean} [show] Make element visible, omit to toggle visibility
1421 * @fires visible
1422 * @chainable
1423 * @return {OO.ui.Element} The element, for chaining
1424 */
1425 OO.ui.Element.prototype.toggle = function ( show ) {
1426 show = show === undefined ? !this.visible : !!show;
1427
1428 if ( show !== this.isVisible() ) {
1429 this.visible = show;
1430 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1431 this.emit( 'toggle', show );
1432 }
1433
1434 return this;
1435 };
1436
1437 /**
1438 * Check if element is visible.
1439 *
1440 * @return {boolean} element is visible
1441 */
1442 OO.ui.Element.prototype.isVisible = function () {
1443 return this.visible;
1444 };
1445
1446 /**
1447 * Get element data.
1448 *
1449 * @return {Mixed} Element data
1450 */
1451 OO.ui.Element.prototype.getData = function () {
1452 return this.data;
1453 };
1454
1455 /**
1456 * Set element data.
1457 *
1458 * @param {Mixed} data Element data
1459 * @chainable
1460 * @return {OO.ui.Element} The element, for chaining
1461 */
1462 OO.ui.Element.prototype.setData = function ( data ) {
1463 this.data = data;
1464 return this;
1465 };
1466
1467 /**
1468 * Set the element has an 'id' attribute.
1469 *
1470 * @param {string} id
1471 * @chainable
1472 * @return {OO.ui.Element} The element, for chaining
1473 */
1474 OO.ui.Element.prototype.setElementId = function ( id ) {
1475 this.elementId = id;
1476 this.$element.attr( 'id', id );
1477 return this;
1478 };
1479
1480 /**
1481 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1482 * and return its value.
1483 *
1484 * @return {string}
1485 */
1486 OO.ui.Element.prototype.getElementId = function () {
1487 if ( this.elementId === null ) {
1488 this.setElementId( OO.ui.generateElementId() );
1489 }
1490 return this.elementId;
1491 };
1492
1493 /**
1494 * Check if element supports one or more methods.
1495 *
1496 * @param {string|string[]} methods Method or list of methods to check
1497 * @return {boolean} All methods are supported
1498 */
1499 OO.ui.Element.prototype.supports = function ( methods ) {
1500 var i, len,
1501 support = 0;
1502
1503 methods = Array.isArray( methods ) ? methods : [ methods ];
1504 for ( i = 0, len = methods.length; i < len; i++ ) {
1505 if ( typeof this[ methods[ i ] ] === 'function' ) {
1506 support++;
1507 }
1508 }
1509
1510 return methods.length === support;
1511 };
1512
1513 /**
1514 * Update the theme-provided classes.
1515 *
1516 * @localdoc This is called in element mixins and widget classes any time state changes.
1517 * Updating is debounced, minimizing overhead of changing multiple attributes and
1518 * guaranteeing that theme updates do not occur within an element's constructor
1519 */
1520 OO.ui.Element.prototype.updateThemeClasses = function () {
1521 OO.ui.theme.queueUpdateElementClasses( this );
1522 };
1523
1524 /**
1525 * Get the HTML tag name.
1526 *
1527 * Override this method to base the result on instance information.
1528 *
1529 * @return {string} HTML tag name
1530 */
1531 OO.ui.Element.prototype.getTagName = function () {
1532 return this.constructor.static.tagName;
1533 };
1534
1535 /**
1536 * Check if the element is attached to the DOM
1537 *
1538 * @return {boolean} The element is attached to the DOM
1539 */
1540 OO.ui.Element.prototype.isElementAttached = function () {
1541 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1542 };
1543
1544 /**
1545 * Get the DOM document.
1546 *
1547 * @return {HTMLDocument} Document object
1548 */
1549 OO.ui.Element.prototype.getElementDocument = function () {
1550 // Don't cache this in other ways either because subclasses could can change this.$element
1551 return OO.ui.Element.static.getDocument( this.$element );
1552 };
1553
1554 /**
1555 * Get the DOM window.
1556 *
1557 * @return {Window} Window object
1558 */
1559 OO.ui.Element.prototype.getElementWindow = function () {
1560 return OO.ui.Element.static.getWindow( this.$element );
1561 };
1562
1563 /**
1564 * Get closest scrollable container.
1565 *
1566 * @return {HTMLElement} Closest scrollable container
1567 */
1568 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1569 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1570 };
1571
1572 /**
1573 * Get group element is in.
1574 *
1575 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1576 */
1577 OO.ui.Element.prototype.getElementGroup = function () {
1578 return this.elementGroup;
1579 };
1580
1581 /**
1582 * Set group element is in.
1583 *
1584 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1585 * @chainable
1586 * @return {OO.ui.Element} The element, for chaining
1587 */
1588 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1589 this.elementGroup = group;
1590 return this;
1591 };
1592
1593 /**
1594 * Scroll element into view.
1595 *
1596 * @param {Object} [config] Configuration options
1597 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1598 */
1599 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1600 if (
1601 !this.isElementAttached() ||
1602 !this.isVisible() ||
1603 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1604 ) {
1605 return $.Deferred().resolve();
1606 }
1607 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1608 };
1609
1610 /**
1611 * Restore the pre-infusion dynamic state for this widget.
1612 *
1613 * This method is called after #$element has been inserted into DOM. The parameter is the return
1614 * value of #gatherPreInfuseState.
1615 *
1616 * @protected
1617 * @param {Object} state
1618 */
1619 OO.ui.Element.prototype.restorePreInfuseState = function () {
1620 };
1621
1622 /**
1623 * Wraps an HTML snippet for use with configuration values which default
1624 * to strings. This bypasses the default html-escaping done to string
1625 * values.
1626 *
1627 * @class
1628 *
1629 * @constructor
1630 * @param {string} [content] HTML content
1631 */
1632 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1633 // Properties
1634 this.content = content;
1635 };
1636
1637 /* Setup */
1638
1639 OO.initClass( OO.ui.HtmlSnippet );
1640
1641 /* Methods */
1642
1643 /**
1644 * Render into HTML.
1645 *
1646 * @return {string} Unchanged HTML snippet.
1647 */
1648 OO.ui.HtmlSnippet.prototype.toString = function () {
1649 return this.content;
1650 };
1651
1652 /**
1653 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1654 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1655 * are, combined.
1656 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1657 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1658 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1659 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1660 * for more information and examples.
1661 *
1662 * @abstract
1663 * @class
1664 * @extends OO.ui.Element
1665 * @mixins OO.EventEmitter
1666 *
1667 * @constructor
1668 * @param {Object} [config] Configuration options
1669 */
1670 OO.ui.Layout = function OoUiLayout( config ) {
1671 // Configuration initialization
1672 config = config || {};
1673
1674 // Parent constructor
1675 OO.ui.Layout.parent.call( this, config );
1676
1677 // Mixin constructors
1678 OO.EventEmitter.call( this );
1679
1680 // Initialization
1681 this.$element.addClass( 'oo-ui-layout' );
1682 };
1683
1684 /* Setup */
1685
1686 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1687 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1688
1689 /* Methods */
1690
1691 /**
1692 * Reset scroll offsets
1693 *
1694 * @chainable
1695 * @return {OO.ui.Layout} The layout, for chaining
1696 */
1697 OO.ui.Layout.prototype.resetScroll = function () {
1698 this.$element[ 0 ].scrollTop = 0;
1699 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1700
1701 return this;
1702 };
1703
1704 /**
1705 * Widgets are compositions of one or more OOUI elements that users can both view
1706 * and interact with. All widgets can be configured and modified via a standard API,
1707 * and their state can change dynamically according to a model.
1708 *
1709 * @abstract
1710 * @class
1711 * @extends OO.ui.Element
1712 * @mixins OO.EventEmitter
1713 *
1714 * @constructor
1715 * @param {Object} [config] Configuration options
1716 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1717 * appearance reflects this state.
1718 */
1719 OO.ui.Widget = function OoUiWidget( config ) {
1720 // Initialize config
1721 config = $.extend( { disabled: false }, config );
1722
1723 // Parent constructor
1724 OO.ui.Widget.parent.call( this, config );
1725
1726 // Mixin constructors
1727 OO.EventEmitter.call( this );
1728
1729 // Properties
1730 this.disabled = null;
1731 this.wasDisabled = null;
1732
1733 // Initialization
1734 this.$element.addClass( 'oo-ui-widget' );
1735 this.setDisabled( !!config.disabled );
1736 };
1737
1738 /* Setup */
1739
1740 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1741 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1742
1743 /* Events */
1744
1745 /**
1746 * @event disable
1747 *
1748 * A 'disable' event is emitted when the disabled state of the widget changes
1749 * (i.e. on disable **and** enable).
1750 *
1751 * @param {boolean} disabled Widget is disabled
1752 */
1753
1754 /**
1755 * @event toggle
1756 *
1757 * A 'toggle' event is emitted when the visibility of the widget changes.
1758 *
1759 * @param {boolean} visible Widget is visible
1760 */
1761
1762 /* Methods */
1763
1764 /**
1765 * Check if the widget is disabled.
1766 *
1767 * @return {boolean} Widget is disabled
1768 */
1769 OO.ui.Widget.prototype.isDisabled = function () {
1770 return this.disabled;
1771 };
1772
1773 /**
1774 * Set the 'disabled' state of the widget.
1775 *
1776 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1777 *
1778 * @param {boolean} disabled Disable widget
1779 * @chainable
1780 * @return {OO.ui.Widget} The widget, for chaining
1781 */
1782 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1783 var isDisabled;
1784
1785 this.disabled = !!disabled;
1786 isDisabled = this.isDisabled();
1787 if ( isDisabled !== this.wasDisabled ) {
1788 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1789 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1790 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1791 this.emit( 'disable', isDisabled );
1792 this.updateThemeClasses();
1793 }
1794 this.wasDisabled = isDisabled;
1795
1796 return this;
1797 };
1798
1799 /**
1800 * Update the disabled state, in case of changes in parent widget.
1801 *
1802 * @chainable
1803 * @return {OO.ui.Widget} The widget, for chaining
1804 */
1805 OO.ui.Widget.prototype.updateDisabled = function () {
1806 this.setDisabled( this.disabled );
1807 return this;
1808 };
1809
1810 /**
1811 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1812 * value.
1813 *
1814 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1815 * instead.
1816 *
1817 * @return {string|null} The ID of the labelable element
1818 */
1819 OO.ui.Widget.prototype.getInputId = function () {
1820 return null;
1821 };
1822
1823 /**
1824 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1825 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1826 * override this method to provide intuitive, accessible behavior.
1827 *
1828 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1829 * Individual widgets may override it too.
1830 *
1831 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1832 * directly.
1833 */
1834 OO.ui.Widget.prototype.simulateLabelClick = function () {
1835 };
1836
1837 /**
1838 * Theme logic.
1839 *
1840 * @abstract
1841 * @class
1842 *
1843 * @constructor
1844 */
1845 OO.ui.Theme = function OoUiTheme() {
1846 this.elementClassesQueue = [];
1847 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1848 };
1849
1850 /* Setup */
1851
1852 OO.initClass( OO.ui.Theme );
1853
1854 /* Methods */
1855
1856 /**
1857 * Get a list of classes to be applied to a widget.
1858 *
1859 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1860 * otherwise state transitions will not work properly.
1861 *
1862 * @param {OO.ui.Element} element Element for which to get classes
1863 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1864 */
1865 OO.ui.Theme.prototype.getElementClasses = function () {
1866 return { on: [], off: [] };
1867 };
1868
1869 /**
1870 * Update CSS classes provided by the theme.
1871 *
1872 * For elements with theme logic hooks, this should be called any time there's a state change.
1873 *
1874 * @param {OO.ui.Element} element Element for which to update classes
1875 */
1876 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1877 var $elements = $( [] ),
1878 classes = this.getElementClasses( element );
1879
1880 if ( element.$icon ) {
1881 $elements = $elements.add( element.$icon );
1882 }
1883 if ( element.$indicator ) {
1884 $elements = $elements.add( element.$indicator );
1885 }
1886
1887 $elements
1888 .removeClass( classes.off )
1889 .addClass( classes.on );
1890 };
1891
1892 /**
1893 * @private
1894 */
1895 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1896 var i;
1897 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1898 this.updateElementClasses( this.elementClassesQueue[ i ] );
1899 }
1900 // Clear the queue
1901 this.elementClassesQueue = [];
1902 };
1903
1904 /**
1905 * Queue #updateElementClasses to be called for this element.
1906 *
1907 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1908 * to make them synchronous.
1909 *
1910 * @param {OO.ui.Element} element Element for which to update classes
1911 */
1912 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1913 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1914 // the most common case (this method is often called repeatedly for the same element).
1915 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1916 return;
1917 }
1918 this.elementClassesQueue.push( element );
1919 this.debouncedUpdateQueuedElementClasses();
1920 };
1921
1922 /**
1923 * Get the transition duration in milliseconds for dialogs opening/closing
1924 *
1925 * The dialog should be fully rendered this many milliseconds after the
1926 * ready process has executed.
1927 *
1928 * @return {number} Transition duration in milliseconds
1929 */
1930 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1931 return 0;
1932 };
1933
1934 /**
1935 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1936 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1937 * order in which users will navigate through the focusable elements via the Tab key.
1938 *
1939 * @example
1940 * // TabIndexedElement is mixed into the ButtonWidget class
1941 * // to provide a tabIndex property.
1942 * var button1 = new OO.ui.ButtonWidget( {
1943 * label: 'fourth',
1944 * tabIndex: 4
1945 * } ),
1946 * button2 = new OO.ui.ButtonWidget( {
1947 * label: 'second',
1948 * tabIndex: 2
1949 * } ),
1950 * button3 = new OO.ui.ButtonWidget( {
1951 * label: 'third',
1952 * tabIndex: 3
1953 * } ),
1954 * button4 = new OO.ui.ButtonWidget( {
1955 * label: 'first',
1956 * tabIndex: 1
1957 * } );
1958 * $( document.body ).append(
1959 * button1.$element,
1960 * button2.$element,
1961 * button3.$element,
1962 * button4.$element
1963 * );
1964 *
1965 * @abstract
1966 * @class
1967 *
1968 * @constructor
1969 * @param {Object} [config] Configuration options
1970 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1971 * the functionality is applied to the element created by the class ($element). If a different
1972 * element is specified, the tabindex functionality will be applied to it instead.
1973 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the
1974 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
1975 * navigation order; use -1 to remove the element from the tab-navigation flow.
1976 */
1977 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1978 // Configuration initialization
1979 config = $.extend( { tabIndex: 0 }, config );
1980
1981 // Properties
1982 this.$tabIndexed = null;
1983 this.tabIndex = null;
1984
1985 // Events
1986 this.connect( this, {
1987 disable: 'onTabIndexedElementDisable'
1988 } );
1989
1990 // Initialization
1991 this.setTabIndex( config.tabIndex );
1992 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1993 };
1994
1995 /* Setup */
1996
1997 OO.initClass( OO.ui.mixin.TabIndexedElement );
1998
1999 /* Methods */
2000
2001 /**
2002 * Set the element that should use the tabindex functionality.
2003 *
2004 * This method is used to retarget a tabindex mixin so that its functionality applies
2005 * to the specified element. If an element is currently using the functionality, the mixin’s
2006 * effect on that element is removed before the new element is set up.
2007 *
2008 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
2009 * @chainable
2010 * @return {OO.ui.Element} The element, for chaining
2011 */
2012 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
2013 var tabIndex = this.tabIndex;
2014 // Remove attributes from old $tabIndexed
2015 this.setTabIndex( null );
2016 // Force update of new $tabIndexed
2017 this.$tabIndexed = $tabIndexed;
2018 this.tabIndex = tabIndex;
2019 return this.updateTabIndex();
2020 };
2021
2022 /**
2023 * Set the value of the tabindex.
2024 *
2025 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
2026 * @chainable
2027 * @return {OO.ui.Element} The element, for chaining
2028 */
2029 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
2030 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2031
2032 if ( this.tabIndex !== tabIndex ) {
2033 this.tabIndex = tabIndex;
2034 this.updateTabIndex();
2035 }
2036
2037 return this;
2038 };
2039
2040 /**
2041 * Update the `tabindex` attribute, in case of changes to tab index or
2042 * disabled state.
2043 *
2044 * @private
2045 * @chainable
2046 * @return {OO.ui.Element} The element, for chaining
2047 */
2048 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2049 if ( this.$tabIndexed ) {
2050 if ( this.tabIndex !== null ) {
2051 // Do not index over disabled elements
2052 this.$tabIndexed.attr( {
2053 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2054 // Support: ChromeVox and NVDA
2055 // These do not seem to inherit aria-disabled from parent elements
2056 'aria-disabled': this.isDisabled().toString()
2057 } );
2058 } else {
2059 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2060 }
2061 }
2062 return this;
2063 };
2064
2065 /**
2066 * Handle disable events.
2067 *
2068 * @private
2069 * @param {boolean} disabled Element is disabled
2070 */
2071 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2072 this.updateTabIndex();
2073 };
2074
2075 /**
2076 * Get the value of the tabindex.
2077 *
2078 * @return {number|null} Tabindex value
2079 */
2080 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2081 return this.tabIndex;
2082 };
2083
2084 /**
2085 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2086 *
2087 * If the element already has an ID then that is returned, otherwise unique ID is
2088 * generated, set on the element, and returned.
2089 *
2090 * @return {string|null} The ID of the focusable element
2091 */
2092 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2093 var id;
2094
2095 if ( !this.$tabIndexed ) {
2096 return null;
2097 }
2098 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2099 return null;
2100 }
2101
2102 id = this.$tabIndexed.attr( 'id' );
2103 if ( id === undefined ) {
2104 id = OO.ui.generateElementId();
2105 this.$tabIndexed.attr( 'id', id );
2106 }
2107
2108 return id;
2109 };
2110
2111 /**
2112 * Whether the node is 'labelable' according to the HTML spec
2113 * (i.e., whether it can be interacted with through a `<label for="…">`).
2114 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2115 *
2116 * @private
2117 * @param {jQuery} $node
2118 * @return {boolean}
2119 */
2120 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2121 var
2122 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2123 tagName = ( $node.prop( 'tagName' ) || '' ).toLowerCase();
2124
2125 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2126 return true;
2127 }
2128 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2129 return true;
2130 }
2131 return false;
2132 };
2133
2134 /**
2135 * Focus this element.
2136 *
2137 * @chainable
2138 * @return {OO.ui.Element} The element, for chaining
2139 */
2140 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2141 if ( !this.isDisabled() ) {
2142 this.$tabIndexed.trigger( 'focus' );
2143 }
2144 return this;
2145 };
2146
2147 /**
2148 * Blur this element.
2149 *
2150 * @chainable
2151 * @return {OO.ui.Element} The element, for chaining
2152 */
2153 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2154 this.$tabIndexed.trigger( 'blur' );
2155 return this;
2156 };
2157
2158 /**
2159 * @inheritdoc OO.ui.Widget
2160 */
2161 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2162 this.focus();
2163 };
2164
2165 /**
2166 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2167 * interface element that can be configured with access keys for keyboard interaction.
2168 * See the [OOUI documentation on MediaWiki] [1] for examples.
2169 *
2170 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2171 *
2172 * @abstract
2173 * @class
2174 *
2175 * @constructor
2176 * @param {Object} [config] Configuration options
2177 * @cfg {jQuery} [$button] The button element created by the class.
2178 * If this configuration is omitted, the button element will use a generated `<a>`.
2179 * @cfg {boolean} [framed=true] Render the button with a frame
2180 */
2181 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2182 // Configuration initialization
2183 config = config || {};
2184
2185 // Properties
2186 this.$button = null;
2187 this.framed = null;
2188 this.active = config.active !== undefined && config.active;
2189 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2190 this.onMouseDownHandler = this.onMouseDown.bind( this );
2191 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2192 this.onKeyDownHandler = this.onKeyDown.bind( this );
2193 this.onClickHandler = this.onClick.bind( this );
2194 this.onKeyPressHandler = this.onKeyPress.bind( this );
2195
2196 // Initialization
2197 this.$element.addClass( 'oo-ui-buttonElement' );
2198 this.toggleFramed( config.framed === undefined || config.framed );
2199 this.setButtonElement( config.$button || $( '<a>' ) );
2200 };
2201
2202 /* Setup */
2203
2204 OO.initClass( OO.ui.mixin.ButtonElement );
2205
2206 /* Static Properties */
2207
2208 /**
2209 * Cancel mouse down events.
2210 *
2211 * This property is usually set to `true` to prevent the focus from changing when the button is
2212 * clicked.
2213 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2214 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2215 * behavior is possible and mousedown events can be handled by a parent widget.
2216 *
2217 * @static
2218 * @inheritable
2219 * @property {boolean}
2220 */
2221 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2222
2223 /* Events */
2224
2225 /**
2226 * A 'click' event is emitted when the button element is clicked.
2227 *
2228 * @event click
2229 */
2230
2231 /* Methods */
2232
2233 /**
2234 * Set the button element.
2235 *
2236 * This method is used to retarget a button mixin so that its functionality applies to
2237 * the specified button element instead of the one created by the class. If a button element
2238 * is already set, the method will remove the mixin’s effect on that element.
2239 *
2240 * @param {jQuery} $button Element to use as button
2241 */
2242 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2243 if ( this.$button ) {
2244 this.$button
2245 .removeClass( 'oo-ui-buttonElement-button' )
2246 .removeAttr( 'role accesskey' )
2247 .off( {
2248 mousedown: this.onMouseDownHandler,
2249 keydown: this.onKeyDownHandler,
2250 click: this.onClickHandler,
2251 keypress: this.onKeyPressHandler
2252 } );
2253 }
2254
2255 this.$button = $button
2256 .addClass( 'oo-ui-buttonElement-button' )
2257 .on( {
2258 mousedown: this.onMouseDownHandler,
2259 keydown: this.onKeyDownHandler,
2260 click: this.onClickHandler,
2261 keypress: this.onKeyPressHandler
2262 } );
2263
2264 // Add `role="button"` on `<a>` elements, where it's needed
2265 // `toUpperCase()` is added for XHTML documents
2266 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2267 this.$button.attr( 'role', 'button' );
2268 }
2269 };
2270
2271 /**
2272 * Handles mouse down events.
2273 *
2274 * @protected
2275 * @param {jQuery.Event} e Mouse down event
2276 * @return {undefined/boolean} False to prevent default if event is handled
2277 */
2278 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2279 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2280 return;
2281 }
2282 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2283 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2284 // reliably remove the pressed class
2285 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2286 // Prevent change of focus unless specifically configured otherwise
2287 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2288 return false;
2289 }
2290 };
2291
2292 /**
2293 * Handles document mouse up events.
2294 *
2295 * @protected
2296 * @param {MouseEvent} e Mouse up event
2297 */
2298 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2299 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2300 return;
2301 }
2302 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2303 // Stop listening for mouseup, since we only needed this once
2304 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2305 };
2306
2307 /**
2308 * Handles mouse click events.
2309 *
2310 * @protected
2311 * @param {jQuery.Event} e Mouse click event
2312 * @fires click
2313 * @return {undefined/boolean} False to prevent default if event is handled
2314 */
2315 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2316 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2317 if ( this.emit( 'click' ) ) {
2318 return false;
2319 }
2320 }
2321 };
2322
2323 /**
2324 * Handles key down events.
2325 *
2326 * @protected
2327 * @param {jQuery.Event} e Key down event
2328 */
2329 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2330 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2331 return;
2332 }
2333 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2334 // Run the keyup handler no matter where the key is when the button is let go, so we can
2335 // reliably remove the pressed class
2336 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2337 };
2338
2339 /**
2340 * Handles document key up events.
2341 *
2342 * @protected
2343 * @param {KeyboardEvent} e Key up event
2344 */
2345 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2346 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2347 return;
2348 }
2349 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2350 // Stop listening for keyup, since we only needed this once
2351 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2352 };
2353
2354 /**
2355 * Handles key press events.
2356 *
2357 * @protected
2358 * @param {jQuery.Event} e Key press event
2359 * @fires click
2360 * @return {undefined/boolean} False to prevent default if event is handled
2361 */
2362 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2363 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2364 if ( this.emit( 'click' ) ) {
2365 return false;
2366 }
2367 }
2368 };
2369
2370 /**
2371 * Check if button has a frame.
2372 *
2373 * @return {boolean} Button is framed
2374 */
2375 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2376 return this.framed;
2377 };
2378
2379 /**
2380 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2381 * on and off.
2382 *
2383 * @param {boolean} [framed] Make button framed, omit to toggle
2384 * @chainable
2385 * @return {OO.ui.Element} The element, for chaining
2386 */
2387 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2388 framed = framed === undefined ? !this.framed : !!framed;
2389 if ( framed !== this.framed ) {
2390 this.framed = framed;
2391 this.$element
2392 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2393 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2394 this.updateThemeClasses();
2395 }
2396
2397 return this;
2398 };
2399
2400 /**
2401 * Set the button's active state.
2402 *
2403 * The active state can be set on:
2404 *
2405 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2406 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2407 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2408 *
2409 * @protected
2410 * @param {boolean} value Make button active
2411 * @chainable
2412 * @return {OO.ui.Element} The element, for chaining
2413 */
2414 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2415 this.active = !!value;
2416 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2417 this.updateThemeClasses();
2418 return this;
2419 };
2420
2421 /**
2422 * Check if the button is active
2423 *
2424 * @protected
2425 * @return {boolean} The button is active
2426 */
2427 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2428 return this.active;
2429 };
2430
2431 /**
2432 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2433 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2434 * items from the group is done through the interface the class provides.
2435 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2436 *
2437 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2438 *
2439 * @abstract
2440 * @mixins OO.EmitterList
2441 * @class
2442 *
2443 * @constructor
2444 * @param {Object} [config] Configuration options
2445 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2446 * is omitted, the group element will use a generated `<div>`.
2447 */
2448 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2449 // Configuration initialization
2450 config = config || {};
2451
2452 // Mixin constructors
2453 OO.EmitterList.call( this, config );
2454
2455 // Properties
2456 this.$group = null;
2457
2458 // Initialization
2459 this.setGroupElement( config.$group || $( '<div>' ) );
2460 };
2461
2462 /* Setup */
2463
2464 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2465
2466 /* Events */
2467
2468 /**
2469 * @event change
2470 *
2471 * A change event is emitted when the set of selected items changes.
2472 *
2473 * @param {OO.ui.Element[]} items Items currently in the group
2474 */
2475
2476 /* Methods */
2477
2478 /**
2479 * Set the group element.
2480 *
2481 * If an element is already set, items will be moved to the new element.
2482 *
2483 * @param {jQuery} $group Element to use as group
2484 */
2485 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2486 var i, len;
2487
2488 this.$group = $group;
2489 for ( i = 0, len = this.items.length; i < len; i++ ) {
2490 this.$group.append( this.items[ i ].$element );
2491 }
2492 };
2493
2494 /**
2495 * Find an item by its data.
2496 *
2497 * Only the first item with matching data will be returned. To return all matching items,
2498 * use the #findItemsFromData method.
2499 *
2500 * @param {Object} data Item data to search for
2501 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2502 */
2503 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2504 var i, len, item,
2505 hash = OO.getHash( data );
2506
2507 for ( i = 0, len = this.items.length; i < len; i++ ) {
2508 item = this.items[ i ];
2509 if ( hash === OO.getHash( item.getData() ) ) {
2510 return item;
2511 }
2512 }
2513
2514 return null;
2515 };
2516
2517 /**
2518 * Find items by their data.
2519 *
2520 * All items with matching data will be returned. To return only the first match, use the
2521 * #findItemFromData method instead.
2522 *
2523 * @param {Object} data Item data to search for
2524 * @return {OO.ui.Element[]} Items with equivalent data
2525 */
2526 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2527 var i, len, item,
2528 hash = OO.getHash( data ),
2529 items = [];
2530
2531 for ( i = 0, len = this.items.length; i < len; i++ ) {
2532 item = this.items[ i ];
2533 if ( hash === OO.getHash( item.getData() ) ) {
2534 items.push( item );
2535 }
2536 }
2537
2538 return items;
2539 };
2540
2541 /**
2542 * Add items to the group.
2543 *
2544 * Items will be added to the end of the group array unless the optional `index` parameter
2545 * specifies a different insertion point. Adding an existing item will move it to the end of the
2546 * array or the point specified by the `index`.
2547 *
2548 * @param {OO.ui.Element[]} items An array of items to add to the group
2549 * @param {number} [index] Index of the insertion point
2550 * @chainable
2551 * @return {OO.ui.Element} The element, for chaining
2552 */
2553 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2554
2555 if ( items.length === 0 ) {
2556 return this;
2557 }
2558
2559 // Mixin method
2560 OO.EmitterList.prototype.addItems.call( this, items, index );
2561
2562 this.emit( 'change', this.getItems() );
2563 return this;
2564 };
2565
2566 /**
2567 * @inheritdoc
2568 */
2569 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2570 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2571 this.insertItemElements( items, newIndex );
2572
2573 // Mixin method
2574 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2575
2576 return newIndex;
2577 };
2578
2579 /**
2580 * @inheritdoc
2581 */
2582 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2583 item.setElementGroup( this );
2584 this.insertItemElements( item, index );
2585
2586 // Mixin method
2587 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2588
2589 return index;
2590 };
2591
2592 /**
2593 * Insert elements into the group
2594 *
2595 * @private
2596 * @param {OO.ui.Element} itemWidget Item to insert
2597 * @param {number} index Insertion index
2598 */
2599 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2600 if ( index === undefined || index < 0 || index >= this.items.length ) {
2601 this.$group.append( itemWidget.$element );
2602 } else if ( index === 0 ) {
2603 this.$group.prepend( itemWidget.$element );
2604 } else {
2605 this.items[ index ].$element.before( itemWidget.$element );
2606 }
2607 };
2608
2609 /**
2610 * Remove the specified items from a group.
2611 *
2612 * Removed items are detached (not removed) from the DOM so that they may be reused.
2613 * To remove all items from a group, you may wish to use the #clearItems method instead.
2614 *
2615 * @param {OO.ui.Element[]} items An array of items to remove
2616 * @chainable
2617 * @return {OO.ui.Element} The element, for chaining
2618 */
2619 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2620 var i, len, item, index;
2621
2622 if ( items.length === 0 ) {
2623 return this;
2624 }
2625
2626 // Remove specific items elements
2627 for ( i = 0, len = items.length; i < len; i++ ) {
2628 item = items[ i ];
2629 index = this.items.indexOf( item );
2630 if ( index !== -1 ) {
2631 item.setElementGroup( null );
2632 item.$element.detach();
2633 }
2634 }
2635
2636 // Mixin method
2637 OO.EmitterList.prototype.removeItems.call( this, items );
2638
2639 this.emit( 'change', this.getItems() );
2640 return this;
2641 };
2642
2643 /**
2644 * Clear all items from the group.
2645 *
2646 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2647 * To remove only a subset of items from a group, use the #removeItems method.
2648 *
2649 * @chainable
2650 * @return {OO.ui.Element} The element, for chaining
2651 */
2652 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2653 var i, len;
2654
2655 // Remove all item elements
2656 for ( i = 0, len = this.items.length; i < len; i++ ) {
2657 this.items[ i ].setElementGroup( null );
2658 this.items[ i ].$element.detach();
2659 }
2660
2661 // Mixin method
2662 OO.EmitterList.prototype.clearItems.call( this );
2663
2664 this.emit( 'change', this.getItems() );
2665 return this;
2666 };
2667
2668 /**
2669 * LabelElement is often mixed into other classes to generate a label, which
2670 * helps identify the function of an interface element.
2671 * See the [OOUI documentation on MediaWiki] [1] for more information.
2672 *
2673 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2674 *
2675 * @abstract
2676 * @class
2677 *
2678 * @constructor
2679 * @param {Object} [config] Configuration options
2680 * @cfg {jQuery} [$label] The label element created by the class. If this
2681 * configuration is omitted, the label element will use a generated `<span>`.
2682 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be
2683 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2684 * produce a string in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2685 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2686 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still
2687 * accessible to screen-readers).
2688 */
2689 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2690 // Configuration initialization
2691 config = config || {};
2692
2693 // Properties
2694 this.$label = null;
2695 this.label = null;
2696 this.invisibleLabel = null;
2697
2698 // Initialization
2699 this.setLabel( config.label || this.constructor.static.label );
2700 this.setLabelElement( config.$label || $( '<span>' ) );
2701 this.setInvisibleLabel( config.invisibleLabel );
2702 };
2703
2704 /* Setup */
2705
2706 OO.initClass( OO.ui.mixin.LabelElement );
2707
2708 /* Events */
2709
2710 /**
2711 * @event labelChange
2712 * @param {string} value
2713 */
2714
2715 /* Static Properties */
2716
2717 /**
2718 * The label text. The label can be specified as a plaintext string, a function that will
2719 * produce a string in the future, or `null` for no label. The static value will
2720 * be overridden if a label is specified with the #label config option.
2721 *
2722 * @static
2723 * @inheritable
2724 * @property {string|Function|null}
2725 */
2726 OO.ui.mixin.LabelElement.static.label = null;
2727
2728 /* Static methods */
2729
2730 /**
2731 * Highlight the first occurrence of the query in the given text
2732 *
2733 * @param {string} text Text
2734 * @param {string} query Query to find
2735 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2736 * @return {jQuery} Text with the first match of the query
2737 * sub-string wrapped in highlighted span
2738 */
2739 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2740 var i, tLen, qLen,
2741 offset = -1,
2742 $result = $( '<span>' );
2743
2744 if ( compare ) {
2745 tLen = text.length;
2746 qLen = query.length;
2747 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2748 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2749 offset = i;
2750 }
2751 }
2752 } else {
2753 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2754 }
2755
2756 if ( !query.length || offset === -1 ) {
2757 $result.text( text );
2758 } else {
2759 $result.append(
2760 document.createTextNode( text.slice( 0, offset ) ),
2761 $( '<span>' )
2762 .addClass( 'oo-ui-labelElement-label-highlight' )
2763 .text( text.slice( offset, offset + query.length ) ),
2764 document.createTextNode( text.slice( offset + query.length ) )
2765 );
2766 }
2767 return $result.contents();
2768 };
2769
2770 /* Methods */
2771
2772 /**
2773 * Set the label element.
2774 *
2775 * If an element is already set, it will be cleaned up before setting up the new element.
2776 *
2777 * @param {jQuery} $label Element to use as label
2778 */
2779 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2780 if ( this.$label ) {
2781 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2782 }
2783
2784 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2785 this.setLabelContent( this.label );
2786 };
2787
2788 /**
2789 * Set the label.
2790 *
2791 * An empty string will result in the label being hidden. A string containing only whitespace will
2792 * be converted to a single `&nbsp;`.
2793 *
2794 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2795 * returns nodes or text; or null for no label
2796 * @chainable
2797 * @return {OO.ui.Element} The element, for chaining
2798 */
2799 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2800 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2801 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2802
2803 if ( this.label !== label ) {
2804 if ( this.$label ) {
2805 this.setLabelContent( label );
2806 }
2807 this.label = label;
2808 this.emit( 'labelChange' );
2809 }
2810
2811 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2812
2813 return this;
2814 };
2815
2816 /**
2817 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2818 *
2819 * @param {boolean} invisibleLabel
2820 * @chainable
2821 * @return {OO.ui.Element} The element, for chaining
2822 */
2823 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2824 invisibleLabel = !!invisibleLabel;
2825
2826 if ( this.invisibleLabel !== invisibleLabel ) {
2827 this.invisibleLabel = invisibleLabel;
2828 this.emit( 'labelChange' );
2829 }
2830
2831 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2832 // Pretend that there is no label, a lot of CSS has been written with this assumption
2833 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2834
2835 return this;
2836 };
2837
2838 /**
2839 * Set the label as plain text with a highlighted query
2840 *
2841 * @param {string} text Text label to set
2842 * @param {string} query Substring of text to highlight
2843 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2844 * @chainable
2845 * @return {OO.ui.Element} The element, for chaining
2846 */
2847 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2848 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2849 };
2850
2851 /**
2852 * Get the label.
2853 *
2854 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2855 * text; or null for no label
2856 */
2857 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2858 return this.label;
2859 };
2860
2861 /**
2862 * Set the content of the label.
2863 *
2864 * Do not call this method until after the label element has been set by #setLabelElement.
2865 *
2866 * @private
2867 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2868 * text; or null for no label
2869 */
2870 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2871 if ( typeof label === 'string' ) {
2872 if ( label.match( /^\s*$/ ) ) {
2873 // Convert whitespace only string to a single non-breaking space
2874 this.$label.html( '&nbsp;' );
2875 } else {
2876 this.$label.text( label );
2877 }
2878 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2879 this.$label.html( label.toString() );
2880 } else if ( label instanceof $ ) {
2881 this.$label.empty().append( label );
2882 } else {
2883 this.$label.empty();
2884 }
2885 };
2886
2887 /**
2888 * IconElement is often mixed into other classes to generate an icon.
2889 * Icons are graphics, about the size of normal text. They are used to aid the user
2890 * in locating a control or to convey information in a space-efficient way. See the
2891 * [OOUI documentation on MediaWiki] [1] for a list of icons
2892 * included in the library.
2893 *
2894 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2895 *
2896 * @abstract
2897 * @class
2898 *
2899 * @constructor
2900 * @param {Object} [config] Configuration options
2901 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2902 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2903 * the icon element be set to an existing icon instead of the one generated by this class, set a
2904 * value using a jQuery selection. For example:
2905 *
2906 * // Use a <div> tag instead of a <span>
2907 * $icon: $( '<div>' )
2908 * // Use an existing icon element instead of the one generated by the class
2909 * $icon: this.$element
2910 * // Use an icon element from a child widget
2911 * $icon: this.childwidget.$element
2912 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
2913 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
2914 * name and additional names keyed by language code. The `default` name is used when no icon is
2915 * keyed by the user's language.
2916 *
2917 * Example of an i18n map:
2918 *
2919 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2920 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2921 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2922 */
2923 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2924 // Configuration initialization
2925 config = config || {};
2926
2927 // Properties
2928 this.$icon = null;
2929 this.icon = null;
2930
2931 // Initialization
2932 this.setIcon( config.icon || this.constructor.static.icon );
2933 this.setIconElement( config.$icon || $( '<span>' ) );
2934 };
2935
2936 /* Setup */
2937
2938 OO.initClass( OO.ui.mixin.IconElement );
2939
2940 /* Static Properties */
2941
2942 /**
2943 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
2944 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
2945 * language code. The `default` name is used when no icon is keyed by the user's language.
2946 *
2947 * Example of an i18n map:
2948 *
2949 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2950 *
2951 * Note: the static property will be overridden if the #icon configuration is used.
2952 *
2953 * @static
2954 * @inheritable
2955 * @property {Object|string}
2956 */
2957 OO.ui.mixin.IconElement.static.icon = null;
2958
2959 /**
2960 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2961 * function that returns title text, or `null` for no title.
2962 *
2963 * The static property will be overridden if the #iconTitle configuration is used.
2964 *
2965 * @static
2966 * @inheritable
2967 * @property {string|Function|null}
2968 */
2969 OO.ui.mixin.IconElement.static.iconTitle = null;
2970
2971 /* Methods */
2972
2973 /**
2974 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2975 * applies to the specified icon element instead of the one created by the class. If an icon
2976 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2977 * and mixin methods will no longer affect the element.
2978 *
2979 * @param {jQuery} $icon Element to use as icon
2980 */
2981 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2982 if ( this.$icon ) {
2983 this.$icon
2984 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2985 .removeAttr( 'title' );
2986 }
2987
2988 this.$icon = $icon
2989 .addClass( 'oo-ui-iconElement-icon' )
2990 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2991 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2992 if ( this.iconTitle !== null ) {
2993 this.$icon.attr( 'title', this.iconTitle );
2994 }
2995
2996 this.updateThemeClasses();
2997 };
2998
2999 /**
3000 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
3001 * The icon parameter can also be set to a map of icon names. See the #icon config setting
3002 * for an example.
3003 *
3004 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
3005 * by language code, or `null` to remove the icon.
3006 * @chainable
3007 * @return {OO.ui.Element} The element, for chaining
3008 */
3009 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
3010 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
3011 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
3012
3013 if ( this.icon !== icon ) {
3014 if ( this.$icon ) {
3015 if ( this.icon !== null ) {
3016 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
3017 }
3018 if ( icon !== null ) {
3019 this.$icon.addClass( 'oo-ui-icon-' + icon );
3020 }
3021 }
3022 this.icon = icon;
3023 }
3024
3025 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
3026 if ( this.$icon ) {
3027 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
3028 }
3029 this.updateThemeClasses();
3030
3031 return this;
3032 };
3033
3034 /**
3035 * Get the symbolic name of the icon.
3036 *
3037 * @return {string} Icon name
3038 */
3039 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3040 return this.icon;
3041 };
3042
3043 /**
3044 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3045 *
3046 * @return {string} Icon title text
3047 * @deprecated
3048 */
3049 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3050 return this.iconTitle;
3051 };
3052
3053 /**
3054 * IndicatorElement is often mixed into other classes to generate an indicator.
3055 * Indicators are small graphics that are generally used in two ways:
3056 *
3057 * - To draw attention to the status of an item. For example, an indicator might be
3058 * used to show that an item in a list has errors that need to be resolved.
3059 * - To clarify the function of a control that acts in an exceptional way (a button
3060 * that opens a menu instead of performing an action directly, for example).
3061 *
3062 * For a list of indicators included in the library, please see the
3063 * [OOUI documentation on MediaWiki] [1].
3064 *
3065 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3066 *
3067 * @abstract
3068 * @class
3069 *
3070 * @constructor
3071 * @param {Object} [config] Configuration options
3072 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3073 * configuration is omitted, the indicator element will use a generated `<span>`.
3074 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3075 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3076 * in the library.
3077 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3078 */
3079 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3080 // Configuration initialization
3081 config = config || {};
3082
3083 // Properties
3084 this.$indicator = null;
3085 this.indicator = null;
3086
3087 // Initialization
3088 this.setIndicator( config.indicator || this.constructor.static.indicator );
3089 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3090 };
3091
3092 /* Setup */
3093
3094 OO.initClass( OO.ui.mixin.IndicatorElement );
3095
3096 /* Static Properties */
3097
3098 /**
3099 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3100 * The static property will be overridden if the #indicator configuration is used.
3101 *
3102 * @static
3103 * @inheritable
3104 * @property {string|null}
3105 */
3106 OO.ui.mixin.IndicatorElement.static.indicator = null;
3107
3108 /**
3109 * A text string used as the indicator title, a function that returns title text, or `null`
3110 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3111 * used.
3112 *
3113 * @static
3114 * @inheritable
3115 * @property {string|Function|null}
3116 */
3117 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3118
3119 /* Methods */
3120
3121 /**
3122 * Set the indicator element.
3123 *
3124 * If an element is already set, it will be cleaned up before setting up the new element.
3125 *
3126 * @param {jQuery} $indicator Element to use as indicator
3127 */
3128 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3129 if ( this.$indicator ) {
3130 this.$indicator
3131 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3132 .removeAttr( 'title' );
3133 }
3134
3135 this.$indicator = $indicator
3136 .addClass( 'oo-ui-indicatorElement-indicator' )
3137 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3138 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3139 if ( this.indicatorTitle !== null ) {
3140 this.$indicator.attr( 'title', this.indicatorTitle );
3141 }
3142
3143 this.updateThemeClasses();
3144 };
3145
3146 /**
3147 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null`
3148 * to remove the indicator.
3149 *
3150 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3151 * @chainable
3152 * @return {OO.ui.Element} The element, for chaining
3153 */
3154 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3155 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3156
3157 if ( this.indicator !== indicator ) {
3158 if ( this.$indicator ) {
3159 if ( this.indicator !== null ) {
3160 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3161 }
3162 if ( indicator !== null ) {
3163 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3164 }
3165 }
3166 this.indicator = indicator;
3167 }
3168
3169 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3170 if ( this.$indicator ) {
3171 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3172 }
3173 this.updateThemeClasses();
3174
3175 return this;
3176 };
3177
3178 /**
3179 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3180 *
3181 * @return {string} Symbolic name of indicator
3182 */
3183 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3184 return this.indicator;
3185 };
3186
3187 /**
3188 * Get the indicator title.
3189 *
3190 * The title is displayed when a user moves the mouse over the indicator.
3191 *
3192 * @return {string} Indicator title text
3193 * @deprecated
3194 */
3195 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3196 return this.indicatorTitle;
3197 };
3198
3199 /**
3200 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3201 * additional functionality to an element created by another class. The class provides
3202 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3203 * which are used to customize the look and feel of a widget to better describe its
3204 * importance and functionality.
3205 *
3206 * The library currently contains the following styling flags for general use:
3207 *
3208 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3209 * forward in a process.
3210 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3211 * something.
3212 *
3213 * The flags affect the appearance of the buttons:
3214 *
3215 * @example
3216 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3217 * var button1 = new OO.ui.ButtonWidget( {
3218 * label: 'Progressive',
3219 * flags: 'progressive'
3220 * } ),
3221 * button2 = new OO.ui.ButtonWidget( {
3222 * label: 'Destructive',
3223 * flags: 'destructive'
3224 * } );
3225 * $( document.body ).append( button1.$element, button2.$element );
3226 *
3227 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3228 * action, use these flags: **primary** and **safe**.
3229 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3230 *
3231 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3232 *
3233 * @abstract
3234 * @class
3235 *
3236 * @constructor
3237 * @param {Object} [config] Configuration options
3238 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3239 * to apply.
3240 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3241 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3242 * @cfg {jQuery} [$flagged] The flagged element. By default,
3243 * the flagged functionality is applied to the element created by the class ($element).
3244 * If a different element is specified, the flagged functionality will be applied to it instead.
3245 */
3246 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3247 // Configuration initialization
3248 config = config || {};
3249
3250 // Properties
3251 this.flags = {};
3252 this.$flagged = null;
3253
3254 // Initialization
3255 this.setFlags( config.flags );
3256 this.setFlaggedElement( config.$flagged || this.$element );
3257 };
3258
3259 /* Events */
3260
3261 /**
3262 * @event flag
3263 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3264 * parameter contains the name of each modified flag and indicates whether it was
3265 * added or removed.
3266 *
3267 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3268 * that the flag was added, `false` that the flag was removed.
3269 */
3270
3271 /* Methods */
3272
3273 /**
3274 * Set the flagged element.
3275 *
3276 * This method is used to retarget a flagged mixin so that its functionality applies to the
3277 * specified element.
3278 * If an element is already set, the method will remove the mixin’s effect on that element.
3279 *
3280 * @param {jQuery} $flagged Element that should be flagged
3281 */
3282 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3283 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3284 return 'oo-ui-flaggedElement-' + flag;
3285 } );
3286
3287 if ( this.$flagged ) {
3288 this.$flagged.removeClass( classNames );
3289 }
3290
3291 this.$flagged = $flagged.addClass( classNames );
3292 };
3293
3294 /**
3295 * Check if the specified flag is set.
3296 *
3297 * @param {string} flag Name of flag
3298 * @return {boolean} The flag is set
3299 */
3300 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3301 // This may be called before the constructor, thus before this.flags is set
3302 return this.flags && ( flag in this.flags );
3303 };
3304
3305 /**
3306 * Get the names of all flags set.
3307 *
3308 * @return {string[]} Flag names
3309 */
3310 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3311 // This may be called before the constructor, thus before this.flags is set
3312 return Object.keys( this.flags || {} );
3313 };
3314
3315 /**
3316 * Clear all flags.
3317 *
3318 * @chainable
3319 * @return {OO.ui.Element} The element, for chaining
3320 * @fires flag
3321 */
3322 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3323 var flag, className,
3324 changes = {},
3325 remove = [],
3326 classPrefix = 'oo-ui-flaggedElement-';
3327
3328 for ( flag in this.flags ) {
3329 className = classPrefix + flag;
3330 changes[ flag ] = false;
3331 delete this.flags[ flag ];
3332 remove.push( className );
3333 }
3334
3335 if ( this.$flagged ) {
3336 this.$flagged.removeClass( remove );
3337 }
3338
3339 this.updateThemeClasses();
3340 this.emit( 'flag', changes );
3341
3342 return this;
3343 };
3344
3345 /**
3346 * Add one or more flags.
3347 *
3348 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3349 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3350 * be added (`true`) or removed (`false`).
3351 * @chainable
3352 * @return {OO.ui.Element} The element, for chaining
3353 * @fires flag
3354 */
3355 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3356 var i, len, flag, className,
3357 changes = {},
3358 add = [],
3359 remove = [],
3360 classPrefix = 'oo-ui-flaggedElement-';
3361
3362 if ( typeof flags === 'string' ) {
3363 className = classPrefix + flags;
3364 // Set
3365 if ( !this.flags[ flags ] ) {
3366 this.flags[ flags ] = true;
3367 add.push( className );
3368 }
3369 } else if ( Array.isArray( flags ) ) {
3370 for ( i = 0, len = flags.length; i < len; i++ ) {
3371 flag = flags[ i ];
3372 className = classPrefix + flag;
3373 // Set
3374 if ( !this.flags[ flag ] ) {
3375 changes[ flag ] = true;
3376 this.flags[ flag ] = true;
3377 add.push( className );
3378 }
3379 }
3380 } else if ( OO.isPlainObject( flags ) ) {
3381 for ( flag in flags ) {
3382 className = classPrefix + flag;
3383 if ( flags[ flag ] ) {
3384 // Set
3385 if ( !this.flags[ flag ] ) {
3386 changes[ flag ] = true;
3387 this.flags[ flag ] = true;
3388 add.push( className );
3389 }
3390 } else {
3391 // Remove
3392 if ( this.flags[ flag ] ) {
3393 changes[ flag ] = false;
3394 delete this.flags[ flag ];
3395 remove.push( className );
3396 }
3397 }
3398 }
3399 }
3400
3401 if ( this.$flagged ) {
3402 this.$flagged
3403 .addClass( add )
3404 .removeClass( remove );
3405 }
3406
3407 this.updateThemeClasses();
3408 this.emit( 'flag', changes );
3409
3410 return this;
3411 };
3412
3413 /**
3414 * TitledElement is mixed into other classes to provide a `title` attribute.
3415 * Titles are rendered by the browser and are made visible when the user moves
3416 * the mouse over the element. Titles are not visible on touch devices.
3417 *
3418 * @example
3419 * // TitledElement provides a `title` attribute to the
3420 * // ButtonWidget class.
3421 * var button = new OO.ui.ButtonWidget( {
3422 * label: 'Button with Title',
3423 * title: 'I am a button'
3424 * } );
3425 * $( document.body ).append( button.$element );
3426 *
3427 * @abstract
3428 * @class
3429 *
3430 * @constructor
3431 * @param {Object} [config] Configuration options
3432 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3433 * If this config is omitted, the title functionality is applied to $element, the
3434 * element created by the class.
3435 * @cfg {string|Function} [title] The title text or a function that returns text. If
3436 * this config is omitted, the value of the {@link #static-title static title} property is used.
3437 */
3438 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3439 // Configuration initialization
3440 config = config || {};
3441
3442 // Properties
3443 this.$titled = null;
3444 this.title = null;
3445
3446 // Initialization
3447 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3448 this.setTitledElement( config.$titled || this.$element );
3449 };
3450
3451 /* Setup */
3452
3453 OO.initClass( OO.ui.mixin.TitledElement );
3454
3455 /* Static Properties */
3456
3457 /**
3458 * The title text, a function that returns text, or `null` for no title. The value of the static
3459 * property is overridden if the #title config option is used.
3460 *
3461 * @static
3462 * @inheritable
3463 * @property {string|Function|null}
3464 */
3465 OO.ui.mixin.TitledElement.static.title = null;
3466
3467 /* Methods */
3468
3469 /**
3470 * Set the titled element.
3471 *
3472 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3473 * specified element.
3474 * If an element is already set, the mixin’s effect on that element is removed before the new
3475 * element is set up.
3476 *
3477 * @param {jQuery} $titled Element that should use the 'titled' functionality
3478 */
3479 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3480 if ( this.$titled ) {
3481 this.$titled.removeAttr( 'title' );
3482 }
3483
3484 this.$titled = $titled;
3485 if ( this.title ) {
3486 this.updateTitle();
3487 }
3488 };
3489
3490 /**
3491 * Set title.
3492 *
3493 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3494 * for no title
3495 * @chainable
3496 * @return {OO.ui.Element} The element, for chaining
3497 */
3498 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3499 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3500 title = ( typeof title === 'string' && title.length ) ? title : null;
3501
3502 if ( this.title !== title ) {
3503 this.title = title;
3504 this.updateTitle();
3505 }
3506
3507 return this;
3508 };
3509
3510 /**
3511 * Update the title attribute, in case of changes to title or accessKey.
3512 *
3513 * @protected
3514 * @chainable
3515 * @return {OO.ui.Element} The element, for chaining
3516 */
3517 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3518 var title = this.getTitle();
3519 if ( this.$titled ) {
3520 if ( title !== null ) {
3521 // Only if this is an AccessKeyedElement
3522 if ( this.formatTitleWithAccessKey ) {
3523 title = this.formatTitleWithAccessKey( title );
3524 }
3525 this.$titled.attr( 'title', title );
3526 } else {
3527 this.$titled.removeAttr( 'title' );
3528 }
3529 }
3530 return this;
3531 };
3532
3533 /**
3534 * Get title.
3535 *
3536 * @return {string} Title string
3537 */
3538 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3539 return this.title;
3540 };
3541
3542 /**
3543 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3544 * Access keys allow an user to go to a specific element by using
3545 * a shortcut combination of a browser specific keys + the key
3546 * set to the field.
3547 *
3548 * @example
3549 * // AccessKeyedElement provides an `accesskey` attribute to the
3550 * // ButtonWidget class.
3551 * var button = new OO.ui.ButtonWidget( {
3552 * label: 'Button with access key',
3553 * accessKey: 'k'
3554 * } );
3555 * $( document.body ).append( button.$element );
3556 *
3557 * @abstract
3558 * @class
3559 *
3560 * @constructor
3561 * @param {Object} [config] Configuration options
3562 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3563 * If this config is omitted, the access key functionality is applied to $element, the
3564 * element created by the class.
3565 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3566 * this config is omitted, no access key will be added.
3567 */
3568 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3569 // Configuration initialization
3570 config = config || {};
3571
3572 // Properties
3573 this.$accessKeyed = null;
3574 this.accessKey = null;
3575
3576 // Initialization
3577 this.setAccessKey( config.accessKey || null );
3578 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3579
3580 // If this is also a TitledElement and it initialized before we did, we may have
3581 // to update the title with the access key
3582 if ( this.updateTitle ) {
3583 this.updateTitle();
3584 }
3585 };
3586
3587 /* Setup */
3588
3589 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3590
3591 /* Static Properties */
3592
3593 /**
3594 * The access key, a function that returns a key, or `null` for no access key.
3595 *
3596 * @static
3597 * @inheritable
3598 * @property {string|Function|null}
3599 */
3600 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3601
3602 /* Methods */
3603
3604 /**
3605 * Set the access keyed element.
3606 *
3607 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3608 * the specified element.
3609 * If an element is already set, the mixin's effect on that element is removed before the new
3610 * element is set up.
3611 *
3612 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3613 */
3614 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3615 if ( this.$accessKeyed ) {
3616 this.$accessKeyed.removeAttr( 'accesskey' );
3617 }
3618
3619 this.$accessKeyed = $accessKeyed;
3620 if ( this.accessKey ) {
3621 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3622 }
3623 };
3624
3625 /**
3626 * Set access key.
3627 *
3628 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3629 * access key
3630 * @chainable
3631 * @return {OO.ui.Element} The element, for chaining
3632 */
3633 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3634 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3635
3636 if ( this.accessKey !== accessKey ) {
3637 if ( this.$accessKeyed ) {
3638 if ( accessKey !== null ) {
3639 this.$accessKeyed.attr( 'accesskey', accessKey );
3640 } else {
3641 this.$accessKeyed.removeAttr( 'accesskey' );
3642 }
3643 }
3644 this.accessKey = accessKey;
3645
3646 // Only if this is a TitledElement
3647 if ( this.updateTitle ) {
3648 this.updateTitle();
3649 }
3650 }
3651
3652 return this;
3653 };
3654
3655 /**
3656 * Get access key.
3657 *
3658 * @return {string} accessKey string
3659 */
3660 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3661 return this.accessKey;
3662 };
3663
3664 /**
3665 * Add information about the access key to the element's tooltip label.
3666 * (This is only public for hacky usage in FieldLayout.)
3667 *
3668 * @param {string} title Tooltip label for `title` attribute
3669 * @return {string}
3670 */
3671 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3672 var accessKey;
3673
3674 if ( !this.$accessKeyed ) {
3675 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3676 // function.
3677 return title;
3678 }
3679 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3680 // single key.
3681 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3682 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3683 } else {
3684 accessKey = this.getAccessKey();
3685 }
3686 if ( accessKey ) {
3687 title += ' [' + accessKey + ']';
3688 }
3689 return title;
3690 };
3691
3692 /**
3693 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3694 * feels, and functionality can be customized via the class’s configuration options
3695 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3696 * and examples.
3697 *
3698 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3699 *
3700 * @example
3701 * // A button widget.
3702 * var button = new OO.ui.ButtonWidget( {
3703 * label: 'Button with Icon',
3704 * icon: 'trash',
3705 * title: 'Remove'
3706 * } );
3707 * $( document.body ).append( button.$element );
3708 *
3709 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3710 *
3711 * @class
3712 * @extends OO.ui.Widget
3713 * @mixins OO.ui.mixin.ButtonElement
3714 * @mixins OO.ui.mixin.IconElement
3715 * @mixins OO.ui.mixin.IndicatorElement
3716 * @mixins OO.ui.mixin.LabelElement
3717 * @mixins OO.ui.mixin.TitledElement
3718 * @mixins OO.ui.mixin.FlaggedElement
3719 * @mixins OO.ui.mixin.TabIndexedElement
3720 * @mixins OO.ui.mixin.AccessKeyedElement
3721 *
3722 * @constructor
3723 * @param {Object} [config] Configuration options
3724 * @cfg {boolean} [active=false] Whether button should be shown as active
3725 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3726 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3727 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3728 */
3729 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3730 // Configuration initialization
3731 config = config || {};
3732
3733 // Parent constructor
3734 OO.ui.ButtonWidget.parent.call( this, config );
3735
3736 // Mixin constructors
3737 OO.ui.mixin.ButtonElement.call( this, config );
3738 OO.ui.mixin.IconElement.call( this, config );
3739 OO.ui.mixin.IndicatorElement.call( this, config );
3740 OO.ui.mixin.LabelElement.call( this, config );
3741 OO.ui.mixin.TitledElement.call( this, $.extend( {
3742 $titled: this.$button
3743 }, config ) );
3744 OO.ui.mixin.FlaggedElement.call( this, config );
3745 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
3746 $tabIndexed: this.$button
3747 }, config ) );
3748 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
3749 $accessKeyed: this.$button
3750 }, config ) );
3751
3752 // Properties
3753 this.href = null;
3754 this.target = null;
3755 this.noFollow = false;
3756
3757 // Events
3758 this.connect( this, {
3759 disable: 'onDisable'
3760 } );
3761
3762 // Initialization
3763 this.$button.append( this.$icon, this.$label, this.$indicator );
3764 this.$element
3765 .addClass( 'oo-ui-buttonWidget' )
3766 .append( this.$button );
3767 this.setActive( config.active );
3768 this.setHref( config.href );
3769 this.setTarget( config.target );
3770 this.setNoFollow( config.noFollow );
3771 };
3772
3773 /* Setup */
3774
3775 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3776 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3777 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3778 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3779 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3780 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3781 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3782 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3783 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3784
3785 /* Static Properties */
3786
3787 /**
3788 * @static
3789 * @inheritdoc
3790 */
3791 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3792
3793 /**
3794 * @static
3795 * @inheritdoc
3796 */
3797 OO.ui.ButtonWidget.static.tagName = 'span';
3798
3799 /* Methods */
3800
3801 /**
3802 * Get hyperlink location.
3803 *
3804 * @return {string} Hyperlink location
3805 */
3806 OO.ui.ButtonWidget.prototype.getHref = function () {
3807 return this.href;
3808 };
3809
3810 /**
3811 * Get hyperlink target.
3812 *
3813 * @return {string} Hyperlink target
3814 */
3815 OO.ui.ButtonWidget.prototype.getTarget = function () {
3816 return this.target;
3817 };
3818
3819 /**
3820 * Get search engine traversal hint.
3821 *
3822 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3823 */
3824 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3825 return this.noFollow;
3826 };
3827
3828 /**
3829 * Set hyperlink location.
3830 *
3831 * @param {string|null} href Hyperlink location, null to remove
3832 * @chainable
3833 * @return {OO.ui.Widget} The widget, for chaining
3834 */
3835 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3836 href = typeof href === 'string' ? href : null;
3837 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3838 href = './' + href;
3839 }
3840
3841 if ( href !== this.href ) {
3842 this.href = href;
3843 this.updateHref();
3844 }
3845
3846 return this;
3847 };
3848
3849 /**
3850 * Update the `href` attribute, in case of changes to href or
3851 * disabled state.
3852 *
3853 * @private
3854 * @chainable
3855 * @return {OO.ui.Widget} The widget, for chaining
3856 */
3857 OO.ui.ButtonWidget.prototype.updateHref = function () {
3858 if ( this.href !== null && !this.isDisabled() ) {
3859 this.$button.attr( 'href', this.href );
3860 } else {
3861 this.$button.removeAttr( 'href' );
3862 }
3863
3864 return this;
3865 };
3866
3867 /**
3868 * Handle disable events.
3869 *
3870 * @private
3871 * @param {boolean} disabled Element is disabled
3872 */
3873 OO.ui.ButtonWidget.prototype.onDisable = function () {
3874 this.updateHref();
3875 };
3876
3877 /**
3878 * Set hyperlink target.
3879 *
3880 * @param {string|null} target Hyperlink target, null to remove
3881 * @return {OO.ui.Widget} The widget, for chaining
3882 */
3883 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3884 target = typeof target === 'string' ? target : null;
3885
3886 if ( target !== this.target ) {
3887 this.target = target;
3888 if ( target !== null ) {
3889 this.$button.attr( 'target', target );
3890 } else {
3891 this.$button.removeAttr( 'target' );
3892 }
3893 }
3894
3895 return this;
3896 };
3897
3898 /**
3899 * Set search engine traversal hint.
3900 *
3901 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3902 * @return {OO.ui.Widget} The widget, for chaining
3903 */
3904 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3905 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3906
3907 if ( noFollow !== this.noFollow ) {
3908 this.noFollow = noFollow;
3909 if ( noFollow ) {
3910 this.$button.attr( 'rel', 'nofollow' );
3911 } else {
3912 this.$button.removeAttr( 'rel' );
3913 }
3914 }
3915
3916 return this;
3917 };
3918
3919 // Override method visibility hints from ButtonElement
3920 /**
3921 * @method setActive
3922 * @inheritdoc
3923 */
3924 /**
3925 * @method isActive
3926 * @inheritdoc
3927 */
3928
3929 /**
3930 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3931 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3932 * removed, and cleared from the group.
3933 *
3934 * @example
3935 * // A ButtonGroupWidget with two buttons.
3936 * var button1 = new OO.ui.PopupButtonWidget( {
3937 * label: 'Select a category',
3938 * icon: 'menu',
3939 * popup: {
3940 * $content: $( '<p>List of categories…</p>' ),
3941 * padded: true,
3942 * align: 'left'
3943 * }
3944 * } ),
3945 * button2 = new OO.ui.ButtonWidget( {
3946 * label: 'Add item'
3947 * } ),
3948 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3949 * items: [ button1, button2 ]
3950 * } );
3951 * $( document.body ).append( buttonGroup.$element );
3952 *
3953 * @class
3954 * @extends OO.ui.Widget
3955 * @mixins OO.ui.mixin.GroupElement
3956 * @mixins OO.ui.mixin.TitledElement
3957 *
3958 * @constructor
3959 * @param {Object} [config] Configuration options
3960 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3961 */
3962 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3963 // Configuration initialization
3964 config = config || {};
3965
3966 // Parent constructor
3967 OO.ui.ButtonGroupWidget.parent.call( this, config );
3968
3969 // Mixin constructors
3970 OO.ui.mixin.GroupElement.call( this, $.extend( {
3971 $group: this.$element
3972 }, config ) );
3973 OO.ui.mixin.TitledElement.call( this, config );
3974
3975 // Initialization
3976 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3977 if ( Array.isArray( config.items ) ) {
3978 this.addItems( config.items );
3979 }
3980 };
3981
3982 /* Setup */
3983
3984 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3985 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3986 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
3987
3988 /* Static Properties */
3989
3990 /**
3991 * @static
3992 * @inheritdoc
3993 */
3994 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3995
3996 /* Methods */
3997
3998 /**
3999 * Focus the widget
4000 *
4001 * @chainable
4002 * @return {OO.ui.Widget} The widget, for chaining
4003 */
4004 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4005 if ( !this.isDisabled() ) {
4006 if ( this.items[ 0 ] ) {
4007 this.items[ 0 ].focus();
4008 }
4009 }
4010 return this;
4011 };
4012
4013 /**
4014 * @inheritdoc
4015 */
4016 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4017 this.focus();
4018 };
4019
4020 /**
4021 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
4022 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
4023 * identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4024 * for a list of icons included in the library.
4025 *
4026 * @example
4027 * // An IconWidget with a label via LabelWidget.
4028 * var myIcon = new OO.ui.IconWidget( {
4029 * icon: 'help',
4030 * title: 'Help'
4031 * } ),
4032 * // Create a label.
4033 * iconLabel = new OO.ui.LabelWidget( {
4034 * label: 'Help'
4035 * } );
4036 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4037 *
4038 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4039 *
4040 * @class
4041 * @extends OO.ui.Widget
4042 * @mixins OO.ui.mixin.IconElement
4043 * @mixins OO.ui.mixin.TitledElement
4044 * @mixins OO.ui.mixin.LabelElement
4045 * @mixins OO.ui.mixin.FlaggedElement
4046 *
4047 * @constructor
4048 * @param {Object} [config] Configuration options
4049 */
4050 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4051 // Configuration initialization
4052 config = config || {};
4053
4054 // Parent constructor
4055 OO.ui.IconWidget.parent.call( this, config );
4056
4057 // Mixin constructors
4058 OO.ui.mixin.IconElement.call( this, $.extend( {
4059 $icon: this.$element
4060 }, config ) );
4061 OO.ui.mixin.TitledElement.call( this, $.extend( {
4062 $titled: this.$element
4063 }, config ) );
4064 OO.ui.mixin.LabelElement.call( this, $.extend( {
4065 $label: this.$element,
4066 invisibleLabel: true
4067 }, config ) );
4068 OO.ui.mixin.FlaggedElement.call( this, $.extend( {
4069 $flagged: this.$element
4070 }, config ) );
4071
4072 // Initialization
4073 this.$element.addClass( 'oo-ui-iconWidget' );
4074 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4075 // nested in other widgets, because this widget used to not mix in LabelElement.
4076 this.$element.removeClass( 'oo-ui-labelElement-label' );
4077 };
4078
4079 /* Setup */
4080
4081 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4082 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4083 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4084 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4085 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4086
4087 /* Static Properties */
4088
4089 /**
4090 * @static
4091 * @inheritdoc
4092 */
4093 OO.ui.IconWidget.static.tagName = 'span';
4094
4095 /**
4096 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4097 * attention to the status of an item or to clarify the function within a control. For a list of
4098 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4099 *
4100 * @example
4101 * // An indicator widget.
4102 * var indicator1 = new OO.ui.IndicatorWidget( {
4103 * indicator: 'required'
4104 * } ),
4105 * // Create a fieldset layout to add a label.
4106 * fieldset = new OO.ui.FieldsetLayout();
4107 * fieldset.addItems( [
4108 * new OO.ui.FieldLayout( indicator1, {
4109 * label: 'A required indicator:'
4110 * } )
4111 * ] );
4112 * $( document.body ).append( fieldset.$element );
4113 *
4114 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4115 *
4116 * @class
4117 * @extends OO.ui.Widget
4118 * @mixins OO.ui.mixin.IndicatorElement
4119 * @mixins OO.ui.mixin.TitledElement
4120 * @mixins OO.ui.mixin.LabelElement
4121 *
4122 * @constructor
4123 * @param {Object} [config] Configuration options
4124 */
4125 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4126 // Configuration initialization
4127 config = config || {};
4128
4129 // Parent constructor
4130 OO.ui.IndicatorWidget.parent.call( this, config );
4131
4132 // Mixin constructors
4133 OO.ui.mixin.IndicatorElement.call( this, $.extend( {
4134 $indicator: this.$element
4135 }, config ) );
4136 OO.ui.mixin.TitledElement.call( this, $.extend( {
4137 $titled: this.$element
4138 }, config ) );
4139 OO.ui.mixin.LabelElement.call( this, $.extend( {
4140 $label: this.$element,
4141 invisibleLabel: true
4142 }, config ) );
4143
4144 // Initialization
4145 this.$element.addClass( 'oo-ui-indicatorWidget' );
4146 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4147 // nested in other widgets, because this widget used to not mix in LabelElement.
4148 this.$element.removeClass( 'oo-ui-labelElement-label' );
4149 };
4150
4151 /* Setup */
4152
4153 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4154 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4155 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4156 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4157
4158 /* Static Properties */
4159
4160 /**
4161 * @static
4162 * @inheritdoc
4163 */
4164 OO.ui.IndicatorWidget.static.tagName = 'span';
4165
4166 /**
4167 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4168 * be configured with a `label` option that is set to a string, a label node, or a function:
4169 *
4170 * - String: a plaintext string
4171 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4172 * label that includes a link or special styling, such as a gray color or additional
4173 * graphical elements.
4174 * - Function: a function that will produce a string in the future. Functions are used
4175 * in cases where the value of the label is not currently defined.
4176 *
4177 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4178 * which will come into focus when the label is clicked.
4179 *
4180 * @example
4181 * // Two LabelWidgets.
4182 * var label1 = new OO.ui.LabelWidget( {
4183 * label: 'plaintext label'
4184 * } ),
4185 * label2 = new OO.ui.LabelWidget( {
4186 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4187 * } ),
4188 * // Create a fieldset layout with fields for each example.
4189 * fieldset = new OO.ui.FieldsetLayout();
4190 * fieldset.addItems( [
4191 * new OO.ui.FieldLayout( label1 ),
4192 * new OO.ui.FieldLayout( label2 )
4193 * ] );
4194 * $( document.body ).append( fieldset.$element );
4195 *
4196 * @class
4197 * @extends OO.ui.Widget
4198 * @mixins OO.ui.mixin.LabelElement
4199 * @mixins OO.ui.mixin.TitledElement
4200 *
4201 * @constructor
4202 * @param {Object} [config] Configuration options
4203 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4204 * Clicking the label will focus the specified input field.
4205 */
4206 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4207 // Configuration initialization
4208 config = config || {};
4209
4210 // Parent constructor
4211 OO.ui.LabelWidget.parent.call( this, config );
4212
4213 // Mixin constructors
4214 OO.ui.mixin.LabelElement.call( this, $.extend( {
4215 $label: this.$element
4216 }, config ) );
4217 OO.ui.mixin.TitledElement.call( this, config );
4218
4219 // Properties
4220 this.input = config.input;
4221
4222 // Initialization
4223 if ( this.input ) {
4224 if ( this.input.getInputId() ) {
4225 this.$element.attr( 'for', this.input.getInputId() );
4226 } else {
4227 this.$label.on( 'click', function () {
4228 this.input.simulateLabelClick();
4229 }.bind( this ) );
4230 }
4231 }
4232 this.$element.addClass( 'oo-ui-labelWidget' );
4233 };
4234
4235 /* Setup */
4236
4237 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4238 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4239 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4240
4241 /* Static Properties */
4242
4243 /**
4244 * @static
4245 * @inheritdoc
4246 */
4247 OO.ui.LabelWidget.static.tagName = 'label';
4248
4249 /**
4250 * PendingElement is a mixin that is used to create elements that notify users that something is
4251 * happening and that they should wait before proceeding. The pending state is visually represented
4252 * with a pending texture that appears in the head of a pending
4253 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4254 * {@link OO.ui.TextInputWidget text input widget}.
4255 *
4256 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4257 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4258 * not currently supported for action widgets used in process dialogs.
4259 *
4260 * @example
4261 * function MessageDialog( config ) {
4262 * MessageDialog.parent.call( this, config );
4263 * }
4264 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4265 *
4266 * MessageDialog.static.name = 'myMessageDialog';
4267 * MessageDialog.static.actions = [
4268 * { action: 'save', label: 'Done', flags: 'primary' },
4269 * { label: 'Cancel', flags: 'safe' }
4270 * ];
4271 *
4272 * MessageDialog.prototype.initialize = function () {
4273 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4274 * this.content = new OO.ui.PanelLayout( { padded: true } );
4275 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4276 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4277 * 'process dialogs.</p>' );
4278 * this.$body.append( this.content.$element );
4279 * };
4280 * MessageDialog.prototype.getBodyHeight = function () {
4281 * return 100;
4282 * }
4283 * MessageDialog.prototype.getActionProcess = function ( action ) {
4284 * var dialog = this;
4285 * if ( action === 'save' ) {
4286 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4287 * return new OO.ui.Process()
4288 * .next( 1000 )
4289 * .next( function () {
4290 * dialog.getActions().get({actions: 'save'})[0].popPending();
4291 * } );
4292 * }
4293 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4294 * };
4295 *
4296 * var windowManager = new OO.ui.WindowManager();
4297 * $( document.body ).append( windowManager.$element );
4298 *
4299 * var dialog = new MessageDialog();
4300 * windowManager.addWindows( [ dialog ] );
4301 * windowManager.openWindow( dialog );
4302 *
4303 * @abstract
4304 * @class
4305 *
4306 * @constructor
4307 * @param {Object} [config] Configuration options
4308 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4309 */
4310 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4311 // Configuration initialization
4312 config = config || {};
4313
4314 // Properties
4315 this.pending = 0;
4316 this.$pending = null;
4317
4318 // Initialisation
4319 this.setPendingElement( config.$pending || this.$element );
4320 };
4321
4322 /* Setup */
4323
4324 OO.initClass( OO.ui.mixin.PendingElement );
4325
4326 /* Methods */
4327
4328 /**
4329 * Set the pending element (and clean up any existing one).
4330 *
4331 * @param {jQuery} $pending The element to set to pending.
4332 */
4333 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4334 if ( this.$pending ) {
4335 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4336 }
4337
4338 this.$pending = $pending;
4339 if ( this.pending > 0 ) {
4340 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4341 }
4342 };
4343
4344 /**
4345 * Check if an element is pending.
4346 *
4347 * @return {boolean} Element is pending
4348 */
4349 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4350 return !!this.pending;
4351 };
4352
4353 /**
4354 * Increase the pending counter. The pending state will remain active until the counter is zero
4355 * (i.e., the number of calls to #pushPending and #popPending is the same).
4356 *
4357 * @chainable
4358 * @return {OO.ui.Element} The element, for chaining
4359 */
4360 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4361 if ( this.pending === 0 ) {
4362 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4363 this.updateThemeClasses();
4364 }
4365 this.pending++;
4366
4367 return this;
4368 };
4369
4370 /**
4371 * Decrease the pending counter. The pending state will remain active until the counter is zero
4372 * (i.e., the number of calls to #pushPending and #popPending is the same).
4373 *
4374 * @chainable
4375 * @return {OO.ui.Element} The element, for chaining
4376 */
4377 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4378 if ( this.pending === 1 ) {
4379 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4380 this.updateThemeClasses();
4381 }
4382 this.pending = Math.max( 0, this.pending - 1 );
4383
4384 return this;
4385 };
4386
4387 /**
4388 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4389 * in the document (for example, in an OO.ui.Window's $overlay).
4390 *
4391 * The elements's position is automatically calculated and maintained when window is resized or the
4392 * page is scrolled. If you reposition the container manually, you have to call #position to make
4393 * sure the element is still placed correctly.
4394 *
4395 * As positioning is only possible when both the element and the container are attached to the DOM
4396 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4397 * the #toggle method to display a floating popup, for example.
4398 *
4399 * @abstract
4400 * @class
4401 *
4402 * @constructor
4403 * @param {Object} [config] Configuration options
4404 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4405 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4406 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4407 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4408 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4409 * 'top': Align the top edge with $floatableContainer's top edge
4410 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4411 * 'center': Vertically align the center with $floatableContainer's center
4412 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4413 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4414 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4415 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4416 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4417 * 'center': Horizontally align the center with $floatableContainer's center
4418 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4419 * is out of view
4420 */
4421 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4422 // Configuration initialization
4423 config = config || {};
4424
4425 // Properties
4426 this.$floatable = null;
4427 this.$floatableContainer = null;
4428 this.$floatableWindow = null;
4429 this.$floatableClosestScrollable = null;
4430 this.floatableOutOfView = false;
4431 this.onFloatableScrollHandler = this.position.bind( this );
4432 this.onFloatableWindowResizeHandler = this.position.bind( this );
4433
4434 // Initialization
4435 this.setFloatableContainer( config.$floatableContainer );
4436 this.setFloatableElement( config.$floatable || this.$element );
4437 this.setVerticalPosition( config.verticalPosition || 'below' );
4438 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4439 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
4440 true : !!config.hideWhenOutOfView;
4441 };
4442
4443 /* Methods */
4444
4445 /**
4446 * Set floatable element.
4447 *
4448 * If an element is already set, it will be cleaned up before setting up the new element.
4449 *
4450 * @param {jQuery} $floatable Element to make floatable
4451 */
4452 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4453 if ( this.$floatable ) {
4454 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4455 this.$floatable.css( { left: '', top: '' } );
4456 }
4457
4458 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4459 this.position();
4460 };
4461
4462 /**
4463 * Set floatable container.
4464 *
4465 * The element will be positioned relative to the specified container.
4466 *
4467 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4468 */
4469 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4470 this.$floatableContainer = $floatableContainer;
4471 if ( this.$floatable ) {
4472 this.position();
4473 }
4474 };
4475
4476 /**
4477 * Change how the element is positioned vertically.
4478 *
4479 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4480 */
4481 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4482 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4483 throw new Error( 'Invalid value for vertical position: ' + position );
4484 }
4485 if ( this.verticalPosition !== position ) {
4486 this.verticalPosition = position;
4487 if ( this.$floatable ) {
4488 this.position();
4489 }
4490 }
4491 };
4492
4493 /**
4494 * Change how the element is positioned horizontally.
4495 *
4496 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4497 */
4498 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4499 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4500 throw new Error( 'Invalid value for horizontal position: ' + position );
4501 }
4502 if ( this.horizontalPosition !== position ) {
4503 this.horizontalPosition = position;
4504 if ( this.$floatable ) {
4505 this.position();
4506 }
4507 }
4508 };
4509
4510 /**
4511 * Toggle positioning.
4512 *
4513 * Do not turn positioning on until after the element is attached to the DOM and visible.
4514 *
4515 * @param {boolean} [positioning] Enable positioning, omit to toggle
4516 * @chainable
4517 * @return {OO.ui.Element} The element, for chaining
4518 */
4519 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4520 var closestScrollableOfContainer;
4521
4522 if ( !this.$floatable || !this.$floatableContainer ) {
4523 return this;
4524 }
4525
4526 positioning = positioning === undefined ? !this.positioning : !!positioning;
4527
4528 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4529 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4530 this.warnedUnattached = true;
4531 }
4532
4533 if ( this.positioning !== positioning ) {
4534 this.positioning = positioning;
4535
4536 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
4537 this.$floatableContainer[ 0 ]
4538 );
4539 // If the scrollable is the root, we have to listen to scroll events
4540 // on the window because of browser inconsistencies.
4541 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4542 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
4543 closestScrollableOfContainer
4544 );
4545 }
4546
4547 if ( positioning ) {
4548 this.$floatableWindow = $( this.getElementWindow() );
4549 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4550
4551 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4552 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4553
4554 // Initial position after visible
4555 this.position();
4556 } else {
4557 if ( this.$floatableWindow ) {
4558 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4559 this.$floatableWindow = null;
4560 }
4561
4562 if ( this.$floatableClosestScrollable ) {
4563 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4564 this.$floatableClosestScrollable = null;
4565 }
4566
4567 this.$floatable.css( { left: '', right: '', top: '' } );
4568 }
4569 }
4570
4571 return this;
4572 };
4573
4574 /**
4575 * Check whether the bottom edge of the given element is within the viewport of the given
4576 * container.
4577 *
4578 * @private
4579 * @param {jQuery} $element
4580 * @param {jQuery} $container
4581 * @return {boolean}
4582 */
4583 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4584 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds,
4585 rightEdgeInBounds, startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4586 direction = $element.css( 'direction' );
4587
4588 elemRect = $element[ 0 ].getBoundingClientRect();
4589 if ( $container[ 0 ] === window ) {
4590 viewportSpacing = OO.ui.getViewportSpacing();
4591 contRect = {
4592 top: 0,
4593 left: 0,
4594 right: document.documentElement.clientWidth,
4595 bottom: document.documentElement.clientHeight
4596 };
4597 contRect.top += viewportSpacing.top;
4598 contRect.left += viewportSpacing.left;
4599 contRect.right -= viewportSpacing.right;
4600 contRect.bottom -= viewportSpacing.bottom;
4601 } else {
4602 contRect = $container[ 0 ].getBoundingClientRect();
4603 }
4604
4605 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4606 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4607 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4608 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4609 if ( direction === 'rtl' ) {
4610 startEdgeInBounds = rightEdgeInBounds;
4611 endEdgeInBounds = leftEdgeInBounds;
4612 } else {
4613 startEdgeInBounds = leftEdgeInBounds;
4614 endEdgeInBounds = rightEdgeInBounds;
4615 }
4616
4617 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4618 return false;
4619 }
4620 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4621 return false;
4622 }
4623 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4624 return false;
4625 }
4626 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4627 return false;
4628 }
4629
4630 // The other positioning values are all about being inside the container,
4631 // so in those cases all we care about is that any part of the container is visible.
4632 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4633 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4634 };
4635
4636 /**
4637 * Check if the floatable is hidden to the user because it was offscreen.
4638 *
4639 * @return {boolean} Floatable is out of view
4640 */
4641 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4642 return this.floatableOutOfView;
4643 };
4644
4645 /**
4646 * Position the floatable below its container.
4647 *
4648 * This should only be done when both of them are attached to the DOM and visible.
4649 *
4650 * @chainable
4651 * @return {OO.ui.Element} The element, for chaining
4652 */
4653 OO.ui.mixin.FloatableElement.prototype.position = function () {
4654 if ( !this.positioning ) {
4655 return this;
4656 }
4657
4658 if ( !(
4659 // To continue, some things need to be true:
4660 // The element must actually be in the DOM
4661 this.isElementAttached() && (
4662 // The closest scrollable is the current window
4663 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4664 // OR is an element in the element's DOM
4665 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4666 )
4667 ) ) {
4668 // Abort early if important parts of the widget are no longer attached to the DOM
4669 return this;
4670 }
4671
4672 this.floatableOutOfView = this.hideWhenOutOfView &&
4673 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4674 if ( this.floatableOutOfView ) {
4675 this.$floatable.addClass( 'oo-ui-element-hidden' );
4676 return this;
4677 } else {
4678 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4679 }
4680
4681 this.$floatable.css( this.computePosition() );
4682
4683 // We updated the position, so re-evaluate the clipping state.
4684 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4685 // will not notice the need to update itself.)
4686 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
4687 // Why does it not listen to the right events in the right places?
4688 if ( this.clip ) {
4689 this.clip();
4690 }
4691
4692 return this;
4693 };
4694
4695 /**
4696 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4697 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4698 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4699 *
4700 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4701 */
4702 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4703 var isBody, scrollableX, scrollableY, containerPos,
4704 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4705 newPos = { top: '', left: '', bottom: '', right: '' },
4706 direction = this.$floatableContainer.css( 'direction' ),
4707 $offsetParent = this.$floatable.offsetParent();
4708
4709 if ( $offsetParent.is( 'html' ) ) {
4710 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4711 // <html> element, but they do work on the <body>
4712 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4713 }
4714 isBody = $offsetParent.is( 'body' );
4715 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
4716 $offsetParent.css( 'overflow-x' ) === 'auto';
4717 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
4718 $offsetParent.css( 'overflow-y' ) === 'auto';
4719
4720 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4721 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4722 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
4723 // is the body, or if it isn't scrollable
4724 scrollTop = scrollableY && !isBody ?
4725 $offsetParent.scrollTop() : 0;
4726 scrollLeft = scrollableX && !isBody ?
4727 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4728
4729 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4730 // if the <body> has a margin
4731 containerPos = isBody ?
4732 this.$floatableContainer.offset() :
4733 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4734 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4735 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4736 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4737 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4738
4739 if ( this.verticalPosition === 'below' ) {
4740 newPos.top = containerPos.bottom;
4741 } else if ( this.verticalPosition === 'above' ) {
4742 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4743 } else if ( this.verticalPosition === 'top' ) {
4744 newPos.top = containerPos.top;
4745 } else if ( this.verticalPosition === 'bottom' ) {
4746 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4747 } else if ( this.verticalPosition === 'center' ) {
4748 newPos.top = containerPos.top +
4749 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4750 }
4751
4752 if ( this.horizontalPosition === 'before' ) {
4753 newPos.end = containerPos.start;
4754 } else if ( this.horizontalPosition === 'after' ) {
4755 newPos.start = containerPos.end;
4756 } else if ( this.horizontalPosition === 'start' ) {
4757 newPos.start = containerPos.start;
4758 } else if ( this.horizontalPosition === 'end' ) {
4759 newPos.end = containerPos.end;
4760 } else if ( this.horizontalPosition === 'center' ) {
4761 newPos.left = containerPos.left +
4762 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4763 }
4764
4765 if ( newPos.start !== undefined ) {
4766 if ( direction === 'rtl' ) {
4767 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4768 $offsetParent ).outerWidth() - newPos.start;
4769 } else {
4770 newPos.left = newPos.start;
4771 }
4772 delete newPos.start;
4773 }
4774 if ( newPos.end !== undefined ) {
4775 if ( direction === 'rtl' ) {
4776 newPos.left = newPos.end;
4777 } else {
4778 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4779 $offsetParent ).outerWidth() - newPos.end;
4780 }
4781 delete newPos.end;
4782 }
4783
4784 // Account for scroll position
4785 if ( newPos.top !== '' ) {
4786 newPos.top += scrollTop;
4787 }
4788 if ( newPos.bottom !== '' ) {
4789 newPos.bottom -= scrollTop;
4790 }
4791 if ( newPos.left !== '' ) {
4792 newPos.left += scrollLeft;
4793 }
4794 if ( newPos.right !== '' ) {
4795 newPos.right -= scrollLeft;
4796 }
4797
4798 // Account for scrollbar gutter
4799 if ( newPos.bottom !== '' ) {
4800 newPos.bottom -= horizScrollbarHeight;
4801 }
4802 if ( direction === 'rtl' ) {
4803 if ( newPos.left !== '' ) {
4804 newPos.left -= vertScrollbarWidth;
4805 }
4806 } else {
4807 if ( newPos.right !== '' ) {
4808 newPos.right -= vertScrollbarWidth;
4809 }
4810 }
4811
4812 return newPos;
4813 };
4814
4815 /**
4816 * Element that can be automatically clipped to visible boundaries.
4817 *
4818 * Whenever the element's natural height changes, you have to call
4819 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4820 * clipping correctly.
4821 *
4822 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4823 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4824 * then #$clippable will be given a fixed reduced height and/or width and will be made
4825 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4826 * but you can build a static footer by setting #$clippableContainer to an element that contains
4827 * #$clippable and the footer.
4828 *
4829 * @abstract
4830 * @class
4831 *
4832 * @constructor
4833 * @param {Object} [config] Configuration options
4834 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4835 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4836 * omit to use #$clippable
4837 */
4838 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4839 // Configuration initialization
4840 config = config || {};
4841
4842 // Properties
4843 this.$clippable = null;
4844 this.$clippableContainer = null;
4845 this.clipping = false;
4846 this.clippedHorizontally = false;
4847 this.clippedVertically = false;
4848 this.$clippableScrollableContainer = null;
4849 this.$clippableScroller = null;
4850 this.$clippableWindow = null;
4851 this.idealWidth = null;
4852 this.idealHeight = null;
4853 this.onClippableScrollHandler = this.clip.bind( this );
4854 this.onClippableWindowResizeHandler = this.clip.bind( this );
4855
4856 // Initialization
4857 if ( config.$clippableContainer ) {
4858 this.setClippableContainer( config.$clippableContainer );
4859 }
4860 this.setClippableElement( config.$clippable || this.$element );
4861 };
4862
4863 /* Methods */
4864
4865 /**
4866 * Set clippable element.
4867 *
4868 * If an element is already set, it will be cleaned up before setting up the new element.
4869 *
4870 * @param {jQuery} $clippable Element to make clippable
4871 */
4872 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4873 if ( this.$clippable ) {
4874 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4875 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4876 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4877 }
4878
4879 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4880 this.clip();
4881 };
4882
4883 /**
4884 * Set clippable container.
4885 *
4886 * This is the container that will be measured when deciding whether to clip. When clipping,
4887 * #$clippable will be resized in order to keep the clippable container fully visible.
4888 *
4889 * If the clippable container is unset, #$clippable will be used.
4890 *
4891 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4892 */
4893 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4894 this.$clippableContainer = $clippableContainer;
4895 if ( this.$clippable ) {
4896 this.clip();
4897 }
4898 };
4899
4900 /**
4901 * Toggle clipping.
4902 *
4903 * Do not turn clipping on until after the element is attached to the DOM and visible.
4904 *
4905 * @param {boolean} [clipping] Enable clipping, omit to toggle
4906 * @chainable
4907 * @return {OO.ui.Element} The element, for chaining
4908 */
4909 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4910 clipping = clipping === undefined ? !this.clipping : !!clipping;
4911
4912 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4913 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4914 this.warnedUnattached = true;
4915 }
4916
4917 if ( this.clipping !== clipping ) {
4918 this.clipping = clipping;
4919 if ( clipping ) {
4920 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4921 // If the clippable container is the root, we have to listen to scroll events and check
4922 // jQuery.scrollTop on the window because of browser inconsistencies
4923 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4924 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4925 this.$clippableScrollableContainer;
4926 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4927 this.$clippableWindow = $( this.getElementWindow() )
4928 .on( 'resize', this.onClippableWindowResizeHandler );
4929 // Initial clip after visible
4930 this.clip();
4931 } else {
4932 this.$clippable.css( {
4933 width: '',
4934 height: '',
4935 maxWidth: '',
4936 maxHeight: '',
4937 overflowX: '',
4938 overflowY: ''
4939 } );
4940 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4941
4942 this.$clippableScrollableContainer = null;
4943 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4944 this.$clippableScroller = null;
4945 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4946 this.$clippableWindow = null;
4947 }
4948 }
4949
4950 return this;
4951 };
4952
4953 /**
4954 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4955 *
4956 * @return {boolean} Element will be clipped to the visible area
4957 */
4958 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4959 return this.clipping;
4960 };
4961
4962 /**
4963 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4964 *
4965 * @return {boolean} Part of the element is being clipped
4966 */
4967 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4968 return this.clippedHorizontally || this.clippedVertically;
4969 };
4970
4971 /**
4972 * Check if the right of the element is being clipped by the nearest scrollable container.
4973 *
4974 * @return {boolean} Part of the element is being clipped
4975 */
4976 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4977 return this.clippedHorizontally;
4978 };
4979
4980 /**
4981 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4982 *
4983 * @return {boolean} Part of the element is being clipped
4984 */
4985 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4986 return this.clippedVertically;
4987 };
4988
4989 /**
4990 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4991 *
4992 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4993 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4994 */
4995 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4996 this.idealWidth = width;
4997 this.idealHeight = height;
4998
4999 if ( !this.clipping ) {
5000 // Update dimensions
5001 this.$clippable.css( { width: width, height: height } );
5002 }
5003 // While clipping, idealWidth and idealHeight are not considered
5004 };
5005
5006 /**
5007 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5008 * ClippableElement will clip the opposite side when reducing element's width.
5009 *
5010 * Classes that mix in ClippableElement should override this to return 'right' if their
5011 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5012 * If your class also mixes in FloatableElement, this is handled automatically.
5013 *
5014 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5015 * always in pixels, even if they were unset or set to 'auto'.)
5016 *
5017 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5018 *
5019 * @return {string} 'left' or 'right'
5020 */
5021 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5022 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5023 return 'right';
5024 }
5025 return 'left';
5026 };
5027
5028 /**
5029 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5030 * ClippableElement will clip the opposite side when reducing element's width.
5031 *
5032 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5033 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5034 * If your class also mixes in FloatableElement, this is handled automatically.
5035 *
5036 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5037 * always in pixels, even if they were unset or set to 'auto'.)
5038 *
5039 * When in doubt, 'top' is a sane fallback.
5040 *
5041 * @return {string} 'top' or 'bottom'
5042 */
5043 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5044 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5045 return 'bottom';
5046 }
5047 return 'top';
5048 };
5049
5050 /**
5051 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5052 * when the element's natural height changes.
5053 *
5054 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5055 * overlapped by, the visible area of the nearest scrollable container.
5056 *
5057 * Because calling clip() when the natural height changes isn't always possible, we also set
5058 * max-height when the element isn't being clipped. This means that if the element tries to grow
5059 * beyond the edge, something reasonable will happen before clip() is called.
5060 *
5061 * @chainable
5062 * @return {OO.ui.Element} The element, for chaining
5063 */
5064 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5065 var extraHeight, extraWidth, viewportSpacing,
5066 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5067 naturalWidth, naturalHeight, clipWidth, clipHeight,
5068 $item, itemRect, $viewport, viewportRect, availableRect,
5069 direction, vertScrollbarWidth, horizScrollbarHeight,
5070 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5071 // by one or two pixels. (And also so that we have space to display drop shadows.)
5072 // Chosen by fair dice roll.
5073 buffer = 7;
5074
5075 if ( !this.clipping ) {
5076 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5077 // will fail
5078 return this;
5079 }
5080
5081 function rectIntersection( a, b ) {
5082 var out = {};
5083 out.top = Math.max( a.top, b.top );
5084 out.left = Math.max( a.left, b.left );
5085 out.bottom = Math.min( a.bottom, b.bottom );
5086 out.right = Math.min( a.right, b.right );
5087 return out;
5088 }
5089
5090 viewportSpacing = OO.ui.getViewportSpacing();
5091
5092 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5093 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5094 // Dimensions of the browser window, rather than the element!
5095 viewportRect = {
5096 top: 0,
5097 left: 0,
5098 right: document.documentElement.clientWidth,
5099 bottom: document.documentElement.clientHeight
5100 };
5101 viewportRect.top += viewportSpacing.top;
5102 viewportRect.left += viewportSpacing.left;
5103 viewportRect.right -= viewportSpacing.right;
5104 viewportRect.bottom -= viewportSpacing.bottom;
5105 } else {
5106 $viewport = this.$clippableScrollableContainer;
5107 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5108 // Convert into a plain object
5109 viewportRect = $.extend( {}, viewportRect );
5110 }
5111
5112 // Account for scrollbar gutter
5113 direction = $viewport.css( 'direction' );
5114 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5115 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5116 viewportRect.bottom -= horizScrollbarHeight;
5117 if ( direction === 'rtl' ) {
5118 viewportRect.left += vertScrollbarWidth;
5119 } else {
5120 viewportRect.right -= vertScrollbarWidth;
5121 }
5122
5123 // Add arbitrary tolerance
5124 viewportRect.top += buffer;
5125 viewportRect.left += buffer;
5126 viewportRect.right -= buffer;
5127 viewportRect.bottom -= buffer;
5128
5129 $item = this.$clippableContainer || this.$clippable;
5130
5131 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5132 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5133
5134 itemRect = $item[ 0 ].getBoundingClientRect();
5135 // Convert into a plain object
5136 itemRect = $.extend( {}, itemRect );
5137
5138 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5139 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5140 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5141 itemRect.left = viewportRect.left;
5142 } else {
5143 itemRect.right = viewportRect.right;
5144 }
5145 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5146 itemRect.top = viewportRect.top;
5147 } else {
5148 itemRect.bottom = viewportRect.bottom;
5149 }
5150
5151 availableRect = rectIntersection( viewportRect, itemRect );
5152
5153 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5154 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5155 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5156 desiredWidth = Math.min( desiredWidth,
5157 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5158 desiredHeight = Math.min( desiredHeight,
5159 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5160 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5161 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5162 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5163 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5164 clipWidth = allotedWidth < naturalWidth;
5165 clipHeight = allotedHeight < naturalHeight;
5166
5167 if ( clipWidth ) {
5168 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5169 // See T157672.
5170 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5171 // this case.
5172 this.$clippable.css( 'overflowX', 'scroll' );
5173 // eslint-disable-next-line no-void
5174 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5175 this.$clippable.css( {
5176 width: Math.max( 0, allotedWidth ),
5177 maxWidth: ''
5178 } );
5179 } else {
5180 this.$clippable.css( {
5181 overflowX: '',
5182 width: this.idealWidth || '',
5183 maxWidth: Math.max( 0, allotedWidth )
5184 } );
5185 }
5186 if ( clipHeight ) {
5187 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5188 // See T157672.
5189 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5190 // this case.
5191 this.$clippable.css( 'overflowY', 'scroll' );
5192 // eslint-disable-next-line no-void
5193 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5194 this.$clippable.css( {
5195 height: Math.max( 0, allotedHeight ),
5196 maxHeight: ''
5197 } );
5198 } else {
5199 this.$clippable.css( {
5200 overflowY: '',
5201 height: this.idealHeight || '',
5202 maxHeight: Math.max( 0, allotedHeight )
5203 } );
5204 }
5205
5206 // If we stopped clipping in at least one of the dimensions
5207 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5208 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5209 }
5210
5211 this.clippedHorizontally = clipWidth;
5212 this.clippedVertically = clipHeight;
5213
5214 return this;
5215 };
5216
5217 /**
5218 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5219 * By default, each popup has an anchor that points toward its origin.
5220 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5221 *
5222 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5223 *
5224 * @example
5225 * // A PopupWidget.
5226 * var popup = new OO.ui.PopupWidget( {
5227 * $content: $( '<p>Hi there!</p>' ),
5228 * padded: true,
5229 * width: 300
5230 * } );
5231 *
5232 * $( document.body ).append( popup.$element );
5233 * // To display the popup, toggle the visibility to 'true'.
5234 * popup.toggle( true );
5235 *
5236 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5237 *
5238 * @class
5239 * @extends OO.ui.Widget
5240 * @mixins OO.ui.mixin.LabelElement
5241 * @mixins OO.ui.mixin.ClippableElement
5242 * @mixins OO.ui.mixin.FloatableElement
5243 *
5244 * @constructor
5245 * @param {Object} [config] Configuration options
5246 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5247 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5248 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5249 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5250 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5251 * of $floatableContainer
5252 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5253 * of $floatableContainer
5254 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5255 * endwards (right/left) to the vertical center of $floatableContainer
5256 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5257 * startwards (left/right) to the vertical center of $floatableContainer
5258 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5259 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5260 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5261 * move the popup as far downwards as possible.
5262 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5263 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5264 * move the popup as far upwards as possible.
5265 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5266 * center of the popup with the center of $floatableContainer.
5267 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5268 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5269 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5270 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5271 * desired direction to display the popup without clipping
5272 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5273 * See the [OOUI docs on MediaWiki][3] for an example.
5274 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5275 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a
5276 * number of pixels.
5277 * @cfg {jQuery} [$content] Content to append to the popup's body
5278 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5279 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5280 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5281 * This config option is only relevant if #autoClose is set to `true`. See the
5282 * [OOUI documentation on MediaWiki][2] for an example.
5283 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5284 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5285 * button.
5286 * @cfg {boolean} [padded=false] Add padding to the popup's body
5287 */
5288 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5289 // Configuration initialization
5290 config = config || {};
5291
5292 // Parent constructor
5293 OO.ui.PopupWidget.parent.call( this, config );
5294
5295 // Properties (must be set before ClippableElement constructor call)
5296 this.$body = $( '<div>' );
5297 this.$popup = $( '<div>' );
5298
5299 // Mixin constructors
5300 OO.ui.mixin.LabelElement.call( this, config );
5301 OO.ui.mixin.ClippableElement.call( this, $.extend( {
5302 $clippable: this.$body,
5303 $clippableContainer: this.$popup
5304 }, config ) );
5305 OO.ui.mixin.FloatableElement.call( this, config );
5306
5307 // Properties
5308 this.$anchor = $( '<div>' );
5309 // If undefined, will be computed lazily in computePosition()
5310 this.$container = config.$container;
5311 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5312 this.autoClose = !!config.autoClose;
5313 this.transitionTimeout = null;
5314 this.anchored = false;
5315 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5316 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5317
5318 // Initialization
5319 this.setSize( config.width, config.height );
5320 this.toggleAnchor( config.anchor === undefined || config.anchor );
5321 this.setAlignment( config.align || 'center' );
5322 this.setPosition( config.position || 'below' );
5323 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5324 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5325 this.$body.addClass( 'oo-ui-popupWidget-body' );
5326 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5327 this.$popup
5328 .addClass( 'oo-ui-popupWidget-popup' )
5329 .append( this.$body );
5330 this.$element
5331 .addClass( 'oo-ui-popupWidget' )
5332 .append( this.$popup, this.$anchor );
5333 // Move content, which was added to #$element by OO.ui.Widget, to the body
5334 // FIXME This is gross, we should use '$body' or something for the config
5335 if ( config.$content instanceof $ ) {
5336 this.$body.append( config.$content );
5337 }
5338
5339 if ( config.padded ) {
5340 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5341 }
5342
5343 if ( config.head ) {
5344 this.closeButton = new OO.ui.ButtonWidget( {
5345 framed: false,
5346 icon: 'close'
5347 } );
5348 this.closeButton.connect( this, {
5349 click: 'onCloseButtonClick'
5350 } );
5351 this.$head = $( '<div>' )
5352 .addClass( 'oo-ui-popupWidget-head' )
5353 .append( this.$label, this.closeButton.$element );
5354 this.$popup.prepend( this.$head );
5355 }
5356
5357 if ( config.$footer ) {
5358 this.$footer = $( '<div>' )
5359 .addClass( 'oo-ui-popupWidget-footer' )
5360 .append( config.$footer );
5361 this.$popup.append( this.$footer );
5362 }
5363
5364 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5365 // that reference properties not initialized at that time of parent class construction
5366 // TODO: Find a better way to handle post-constructor setup
5367 this.visible = false;
5368 this.$element.addClass( 'oo-ui-element-hidden' );
5369 };
5370
5371 /* Setup */
5372
5373 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5374 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5375 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5376 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5377
5378 /* Events */
5379
5380 /**
5381 * @event ready
5382 *
5383 * The popup is ready: it is visible and has been positioned and clipped.
5384 */
5385
5386 /* Methods */
5387
5388 /**
5389 * Handles document mouse down events.
5390 *
5391 * @private
5392 * @param {MouseEvent} e Mouse down event
5393 */
5394 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5395 if (
5396 this.isVisible() &&
5397 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5398 ) {
5399 this.toggle( false );
5400 }
5401 };
5402
5403 /**
5404 * Bind document mouse down listener.
5405 *
5406 * @private
5407 */
5408 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5409 // Capture clicks outside popup
5410 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5411 // We add 'click' event because iOS safari needs to respond to this event.
5412 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5413 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5414 // of occasionally not emitting 'click' properly, that event seems to be the standard
5415 // that it should be emitting, so we add it to this and will operate the event handler
5416 // on whichever of these events was triggered first
5417 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5418 };
5419
5420 /**
5421 * Handles close button click events.
5422 *
5423 * @private
5424 */
5425 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5426 if ( this.isVisible() ) {
5427 this.toggle( false );
5428 }
5429 };
5430
5431 /**
5432 * Unbind document mouse down listener.
5433 *
5434 * @private
5435 */
5436 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5437 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5438 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5439 };
5440
5441 /**
5442 * Handles document key down events.
5443 *
5444 * @private
5445 * @param {KeyboardEvent} e Key down event
5446 */
5447 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5448 if (
5449 e.which === OO.ui.Keys.ESCAPE &&
5450 this.isVisible()
5451 ) {
5452 this.toggle( false );
5453 e.preventDefault();
5454 e.stopPropagation();
5455 }
5456 };
5457
5458 /**
5459 * Bind document key down listener.
5460 *
5461 * @private
5462 */
5463 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5464 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5465 };
5466
5467 /**
5468 * Unbind document key down listener.
5469 *
5470 * @private
5471 */
5472 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5473 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5474 };
5475
5476 /**
5477 * Show, hide, or toggle the visibility of the anchor.
5478 *
5479 * @param {boolean} [show] Show anchor, omit to toggle
5480 */
5481 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5482 show = show === undefined ? !this.anchored : !!show;
5483
5484 if ( this.anchored !== show ) {
5485 if ( show ) {
5486 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5487 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5488 } else {
5489 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5490 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5491 }
5492 this.anchored = show;
5493 }
5494 };
5495
5496 /**
5497 * Change which edge the anchor appears on.
5498 *
5499 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5500 */
5501 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5502 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5503 throw new Error( 'Invalid value for edge: ' + edge );
5504 }
5505 if ( this.anchorEdge !== null ) {
5506 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5507 }
5508 this.anchorEdge = edge;
5509 if ( this.anchored ) {
5510 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5511 }
5512 };
5513
5514 /**
5515 * Check if the anchor is visible.
5516 *
5517 * @return {boolean} Anchor is visible
5518 */
5519 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5520 return this.anchored;
5521 };
5522
5523 /**
5524 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5525 * `.toggle( true )` after its #$element is attached to the DOM.
5526 *
5527 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5528 * it in the right place and with the right dimensions only work correctly while it is attached.
5529 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5530 * strictly enforced, so currently it only generates a warning in the browser console.
5531 *
5532 * @fires ready
5533 * @inheritdoc
5534 */
5535 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5536 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5537 show = show === undefined ? !this.isVisible() : !!show;
5538
5539 change = show !== this.isVisible();
5540
5541 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5542 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5543 this.warnedUnattached = true;
5544 }
5545 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5546 // Fall back to the parent node if the floatableContainer is not set
5547 this.setFloatableContainer( this.$element.parent() );
5548 }
5549
5550 if ( change && show && this.autoFlip ) {
5551 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
5552 // flip (e.g. if the user scrolled).
5553 this.isAutoFlipped = false;
5554 }
5555
5556 // Parent method
5557 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5558
5559 if ( change ) {
5560 this.togglePositioning( show && !!this.$floatableContainer );
5561
5562 if ( show ) {
5563 if ( this.autoClose ) {
5564 this.bindDocumentMouseDownListener();
5565 this.bindDocumentKeyDownListener();
5566 }
5567 this.updateDimensions();
5568 this.toggleClipping( true );
5569
5570 if ( this.autoFlip ) {
5571 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5572 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5573 // If opening the popup in the normal direction causes it to be clipped,
5574 // open in the opposite one instead
5575 normalHeight = this.$element.height();
5576 this.isAutoFlipped = !this.isAutoFlipped;
5577 this.position();
5578 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5579 // If that also causes it to be clipped, open in whichever direction
5580 // we have more space
5581 oppositeHeight = this.$element.height();
5582 if ( oppositeHeight < normalHeight ) {
5583 this.isAutoFlipped = !this.isAutoFlipped;
5584 this.position();
5585 }
5586 }
5587 }
5588 }
5589 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5590 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5591 // If opening the popup in the normal direction causes it to be clipped,
5592 // open in the opposite one instead
5593 normalWidth = this.$element.width();
5594 this.isAutoFlipped = !this.isAutoFlipped;
5595 // Due to T180173 horizontally clipped PopupWidgets have messed up
5596 // dimensions, which causes positioning to be off. Toggle clipping back and
5597 // forth to work around.
5598 this.toggleClipping( false );
5599 this.position();
5600 this.toggleClipping( true );
5601 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5602 // If that also causes it to be clipped, open in whichever direction
5603 // we have more space
5604 oppositeWidth = this.$element.width();
5605 if ( oppositeWidth < normalWidth ) {
5606 this.isAutoFlipped = !this.isAutoFlipped;
5607 // Due to T180173, horizontally clipped PopupWidgets have messed up
5608 // dimensions, which causes positioning to be off. Toggle clipping
5609 // back and forth to work around.
5610 this.toggleClipping( false );
5611 this.position();
5612 this.toggleClipping( true );
5613 }
5614 }
5615 }
5616 }
5617 }
5618
5619 this.emit( 'ready' );
5620 } else {
5621 this.toggleClipping( false );
5622 if ( this.autoClose ) {
5623 this.unbindDocumentMouseDownListener();
5624 this.unbindDocumentKeyDownListener();
5625 }
5626 }
5627 }
5628
5629 return this;
5630 };
5631
5632 /**
5633 * Set the size of the popup.
5634 *
5635 * Changing the size may also change the popup's position depending on the alignment.
5636 *
5637 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5638 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5639 * @param {boolean} [transition=false] Use a smooth transition
5640 * @chainable
5641 */
5642 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5643 this.width = width !== undefined ? width : 320;
5644 this.height = height !== undefined ? height : null;
5645 if ( this.isVisible() ) {
5646 this.updateDimensions( transition );
5647 }
5648 };
5649
5650 /**
5651 * Update the size and position.
5652 *
5653 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5654 * be called automatically.
5655 *
5656 * @param {boolean} [transition=false] Use a smooth transition
5657 * @chainable
5658 */
5659 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5660 var widget = this;
5661
5662 // Prevent transition from being interrupted
5663 clearTimeout( this.transitionTimeout );
5664 if ( transition ) {
5665 // Enable transition
5666 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5667 }
5668
5669 this.position();
5670
5671 if ( transition ) {
5672 // Prevent transitioning after transition is complete
5673 this.transitionTimeout = setTimeout( function () {
5674 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5675 }, 200 );
5676 } else {
5677 // Prevent transitioning immediately
5678 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5679 }
5680 };
5681
5682 /**
5683 * @inheritdoc
5684 */
5685 OO.ui.PopupWidget.prototype.computePosition = function () {
5686 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize,
5687 anchorPos, anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment,
5688 floatablePos, offsetParentPos, containerPos, popupPosition, viewportSpacing,
5689 popupPos = {},
5690 anchorCss = { left: '', right: '', top: '', bottom: '' },
5691 popupPositionOppositeMap = {
5692 above: 'below',
5693 below: 'above',
5694 before: 'after',
5695 after: 'before'
5696 },
5697 alignMap = {
5698 ltr: {
5699 'force-left': 'backwards',
5700 'force-right': 'forwards'
5701 },
5702 rtl: {
5703 'force-left': 'forwards',
5704 'force-right': 'backwards'
5705 }
5706 },
5707 anchorEdgeMap = {
5708 above: 'bottom',
5709 below: 'top',
5710 before: 'end',
5711 after: 'start'
5712 },
5713 hPosMap = {
5714 forwards: 'start',
5715 center: 'center',
5716 backwards: this.anchored ? 'before' : 'end'
5717 },
5718 vPosMap = {
5719 forwards: 'top',
5720 center: 'center',
5721 backwards: 'bottom'
5722 };
5723
5724 if ( !this.$container ) {
5725 // Lazy-initialize $container if not specified in constructor
5726 this.$container = $( this.getClosestScrollableElementContainer() );
5727 }
5728 direction = this.$container.css( 'direction' );
5729
5730 // Set height and width before we do anything else, since it might cause our measurements
5731 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5732 this.$popup.css( {
5733 width: this.width !== null ? this.width : 'auto',
5734 height: this.height !== null ? this.height : 'auto'
5735 } );
5736
5737 align = alignMap[ direction ][ this.align ] || this.align;
5738 popupPosition = this.popupPosition;
5739 if ( this.isAutoFlipped ) {
5740 popupPosition = popupPositionOppositeMap[ popupPosition ];
5741 }
5742
5743 // If the popup is positioned before or after, then the anchor positioning is vertical,
5744 // otherwise horizontal
5745 vertical = popupPosition === 'before' || popupPosition === 'after';
5746 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5747 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5748 near = vertical ? 'top' : 'left';
5749 far = vertical ? 'bottom' : 'right';
5750 sizeProp = vertical ? 'Height' : 'Width';
5751 popupSize = vertical ?
5752 ( this.height || this.$popup.height() ) :
5753 ( this.width || this.$popup.width() );
5754
5755 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5756 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5757 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5758
5759 // Parent method
5760 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5761 // Find out which property FloatableElement used for positioning, and adjust that value
5762 positionProp = vertical ?
5763 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5764 ( parentPosition.left !== '' ? 'left' : 'right' );
5765
5766 // Figure out where the near and far edges of the popup and $floatableContainer are
5767 floatablePos = this.$floatableContainer.offset();
5768 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5769 // Measure where the offsetParent is and compute our position based on that and parentPosition
5770 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5771 { top: 0, left: 0 } :
5772 this.$element.offsetParent().offset();
5773
5774 if ( positionProp === near ) {
5775 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5776 popupPos[ far ] = popupPos[ near ] + popupSize;
5777 } else {
5778 popupPos[ far ] = offsetParentPos[ near ] +
5779 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5780 popupPos[ near ] = popupPos[ far ] - popupSize;
5781 }
5782
5783 if ( this.anchored ) {
5784 // Position the anchor (which is positioned relative to the popup) to point to
5785 // $floatableContainer
5786 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5787 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5788
5789 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
5790 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
5791 // scrollWidth/Height
5792 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5793 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5794 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5795 // Not enough space for the anchor on the start side; pull the popup startwards
5796 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5797 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5798 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5799 // Not enough space for the anchor on the end side; pull the popup endwards
5800 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5801 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5802 } else {
5803 positionAdjustment = 0;
5804 }
5805 } else {
5806 positionAdjustment = 0;
5807 }
5808
5809 // Check if the popup will go beyond the edge of this.$container
5810 containerPos = this.$container[ 0 ] === document.documentElement ?
5811 { top: 0, left: 0 } :
5812 this.$container.offset();
5813 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5814 if ( this.$container[ 0 ] === document.documentElement ) {
5815 viewportSpacing = OO.ui.getViewportSpacing();
5816 containerPos[ near ] += viewportSpacing[ near ];
5817 containerPos[ far ] -= viewportSpacing[ far ];
5818 }
5819 // Take into account how much the popup will move because of the adjustments we're going to make
5820 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5821 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5822 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5823 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5824 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5825 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5826 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5827 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5828 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5829 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5830 }
5831
5832 if ( this.anchored ) {
5833 // Adjust anchorOffset for positionAdjustment
5834 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5835
5836 // Position the anchor
5837 anchorCss[ start ] = anchorOffset;
5838 this.$anchor.css( anchorCss );
5839 }
5840
5841 // Move the popup if needed
5842 parentPosition[ positionProp ] += positionAdjustment;
5843
5844 return parentPosition;
5845 };
5846
5847 /**
5848 * Set popup alignment
5849 *
5850 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5851 * `backwards` or `forwards`.
5852 */
5853 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5854 // Validate alignment
5855 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5856 this.align = align;
5857 } else {
5858 this.align = 'center';
5859 }
5860 this.position();
5861 };
5862
5863 /**
5864 * Get popup alignment
5865 *
5866 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5867 * `backwards` or `forwards`.
5868 */
5869 OO.ui.PopupWidget.prototype.getAlignment = function () {
5870 return this.align;
5871 };
5872
5873 /**
5874 * Change the positioning of the popup.
5875 *
5876 * @param {string} position 'above', 'below', 'before' or 'after'
5877 */
5878 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5879 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5880 position = 'below';
5881 }
5882 this.popupPosition = position;
5883 this.position();
5884 };
5885
5886 /**
5887 * Get popup positioning.
5888 *
5889 * @return {string} 'above', 'below', 'before' or 'after'
5890 */
5891 OO.ui.PopupWidget.prototype.getPosition = function () {
5892 return this.popupPosition;
5893 };
5894
5895 /**
5896 * Set popup auto-flipping.
5897 *
5898 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5899 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5900 * desired direction to display the popup without clipping
5901 */
5902 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5903 autoFlip = !!autoFlip;
5904
5905 if ( this.autoFlip !== autoFlip ) {
5906 this.autoFlip = autoFlip;
5907 }
5908 };
5909
5910 /**
5911 * Set which elements will not close the popup when clicked.
5912 *
5913 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5914 *
5915 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5916 */
5917 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5918 this.$autoCloseIgnore = $autoCloseIgnore;
5919 };
5920
5921 /**
5922 * Get an ID of the body element, this can be used as the
5923 * `aria-describedby` attribute for an input field.
5924 *
5925 * @return {string} The ID of the body element
5926 */
5927 OO.ui.PopupWidget.prototype.getBodyId = function () {
5928 var id = this.$body.attr( 'id' );
5929 if ( id === undefined ) {
5930 id = OO.ui.generateElementId();
5931 this.$body.attr( 'id', id );
5932 }
5933 return id;
5934 };
5935
5936 /**
5937 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5938 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5939 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5940 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5941 *
5942 * @abstract
5943 * @class
5944 *
5945 * @constructor
5946 * @param {Object} [config] Configuration options
5947 * @cfg {Object} [popup] Configuration to pass to popup
5948 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5949 */
5950 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5951 // Configuration initialization
5952 config = config || {};
5953
5954 // Properties
5955 this.popup = new OO.ui.PopupWidget( $.extend(
5956 {
5957 autoClose: true,
5958 $floatableContainer: this.$element
5959 },
5960 config.popup,
5961 {
5962 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5963 }
5964 ) );
5965 };
5966
5967 /* Methods */
5968
5969 /**
5970 * Get popup.
5971 *
5972 * @return {OO.ui.PopupWidget} Popup widget
5973 */
5974 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5975 return this.popup;
5976 };
5977
5978 /**
5979 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5980 * which is used to display additional information or options.
5981 *
5982 * @example
5983 * // A PopupButtonWidget.
5984 * var popupButton = new OO.ui.PopupButtonWidget( {
5985 * label: 'Popup button with options',
5986 * icon: 'menu',
5987 * popup: {
5988 * $content: $( '<p>Additional options here.</p>' ),
5989 * padded: true,
5990 * align: 'force-left'
5991 * }
5992 * } );
5993 * // Append the button to the DOM.
5994 * $( document.body ).append( popupButton.$element );
5995 *
5996 * @class
5997 * @extends OO.ui.ButtonWidget
5998 * @mixins OO.ui.mixin.PopupElement
5999 *
6000 * @constructor
6001 * @param {Object} [config] Configuration options
6002 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful
6003 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
6004 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
6005 * uses relative positioning.
6006 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6007 */
6008 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6009 // Configuration initialization
6010 config = config || {};
6011
6012 // Parent constructor
6013 OO.ui.PopupButtonWidget.parent.call( this, config );
6014
6015 // Mixin constructors
6016 OO.ui.mixin.PopupElement.call( this, config );
6017
6018 // Properties
6019 this.$overlay = ( config.$overlay === true ?
6020 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6021
6022 // Events
6023 this.connect( this, {
6024 click: 'onAction'
6025 } );
6026
6027 // Initialization
6028 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6029 this.popup.$element
6030 .addClass( 'oo-ui-popupButtonWidget-popup' )
6031 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6032 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6033 this.$overlay.append( this.popup.$element );
6034 };
6035
6036 /* Setup */
6037
6038 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6039 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6040
6041 /* Methods */
6042
6043 /**
6044 * Handle the button action being triggered.
6045 *
6046 * @private
6047 */
6048 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6049 this.popup.toggle();
6050 };
6051
6052 /**
6053 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6054 *
6055 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6056 *
6057 * @private
6058 * @abstract
6059 * @class
6060 * @mixins OO.ui.mixin.GroupElement
6061 *
6062 * @constructor
6063 * @param {Object} [config] Configuration options
6064 */
6065 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6066 // Mixin constructors
6067 OO.ui.mixin.GroupElement.call( this, config );
6068 };
6069
6070 /* Setup */
6071
6072 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6073
6074 /* Methods */
6075
6076 /**
6077 * Set the disabled state of the widget.
6078 *
6079 * This will also update the disabled state of child widgets.
6080 *
6081 * @param {boolean} disabled Disable widget
6082 * @chainable
6083 * @return {OO.ui.Widget} The widget, for chaining
6084 */
6085 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6086 var i, len;
6087
6088 // Parent method
6089 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6090 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6091
6092 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6093 if ( this.items ) {
6094 for ( i = 0, len = this.items.length; i < len; i++ ) {
6095 this.items[ i ].updateDisabled();
6096 }
6097 }
6098
6099 return this;
6100 };
6101
6102 /**
6103 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6104 *
6105 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6106 * This allows bidirectional communication.
6107 *
6108 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6109 *
6110 * @private
6111 * @abstract
6112 * @class
6113 *
6114 * @constructor
6115 */
6116 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6117 //
6118 };
6119
6120 /* Methods */
6121
6122 /**
6123 * Check if widget is disabled.
6124 *
6125 * Checks parent if present, making disabled state inheritable.
6126 *
6127 * @return {boolean} Widget is disabled
6128 */
6129 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6130 return this.disabled ||
6131 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6132 };
6133
6134 /**
6135 * Set group element is in.
6136 *
6137 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6138 * @chainable
6139 * @return {OO.ui.Widget} The widget, for chaining
6140 */
6141 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6142 // Parent method
6143 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6144 OO.ui.Element.prototype.setElementGroup.call( this, group );
6145
6146 // Initialize item disabled states
6147 this.updateDisabled();
6148
6149 return this;
6150 };
6151
6152 /**
6153 * OptionWidgets are special elements that can be selected and configured with data. The
6154 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6155 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6156 * and examples, please see the [OOUI documentation on MediaWiki][1].
6157 *
6158 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6159 *
6160 * @class
6161 * @extends OO.ui.Widget
6162 * @mixins OO.ui.mixin.ItemWidget
6163 * @mixins OO.ui.mixin.LabelElement
6164 * @mixins OO.ui.mixin.FlaggedElement
6165 * @mixins OO.ui.mixin.AccessKeyedElement
6166 * @mixins OO.ui.mixin.TitledElement
6167 *
6168 * @constructor
6169 * @param {Object} [config] Configuration options
6170 */
6171 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6172 // Configuration initialization
6173 config = config || {};
6174
6175 // Parent constructor
6176 OO.ui.OptionWidget.parent.call( this, config );
6177
6178 // Mixin constructors
6179 OO.ui.mixin.ItemWidget.call( this );
6180 OO.ui.mixin.LabelElement.call( this, config );
6181 OO.ui.mixin.FlaggedElement.call( this, config );
6182 OO.ui.mixin.AccessKeyedElement.call( this, config );
6183 OO.ui.mixin.TitledElement.call( this, config );
6184
6185 // Properties
6186 this.highlighted = false;
6187 this.pressed = false;
6188 this.setSelected( !!config.selected );
6189
6190 // Initialization
6191 this.$element
6192 .data( 'oo-ui-optionWidget', this )
6193 // Allow programmatic focussing (and by access key), but not tabbing
6194 .attr( 'tabindex', '-1' )
6195 .attr( 'role', 'option' )
6196 .addClass( 'oo-ui-optionWidget' )
6197 .append( this.$label );
6198 };
6199
6200 /* Setup */
6201
6202 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6203 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6204 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6205 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6206 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6207 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6208
6209 /* Static Properties */
6210
6211 /**
6212 * Whether this option can be selected. See #setSelected.
6213 *
6214 * @static
6215 * @inheritable
6216 * @property {boolean}
6217 */
6218 OO.ui.OptionWidget.static.selectable = true;
6219
6220 /**
6221 * Whether this option can be highlighted. See #setHighlighted.
6222 *
6223 * @static
6224 * @inheritable
6225 * @property {boolean}
6226 */
6227 OO.ui.OptionWidget.static.highlightable = true;
6228
6229 /**
6230 * Whether this option can be pressed. See #setPressed.
6231 *
6232 * @static
6233 * @inheritable
6234 * @property {boolean}
6235 */
6236 OO.ui.OptionWidget.static.pressable = true;
6237
6238 /**
6239 * Whether this option will be scrolled into view when it is selected.
6240 *
6241 * @static
6242 * @inheritable
6243 * @property {boolean}
6244 */
6245 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6246
6247 /* Methods */
6248
6249 /**
6250 * Check if the option can be selected.
6251 *
6252 * @return {boolean} Item is selectable
6253 */
6254 OO.ui.OptionWidget.prototype.isSelectable = function () {
6255 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6256 };
6257
6258 /**
6259 * Check if the option can be highlighted. A highlight indicates that the option
6260 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6261 * be highlighted.
6262 *
6263 * @return {boolean} Item is highlightable
6264 */
6265 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6266 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6267 };
6268
6269 /**
6270 * Check if the option can be pressed. The pressed state occurs when a user mouses
6271 * down on an item, but has not yet let go of the mouse.
6272 *
6273 * @return {boolean} Item is pressable
6274 */
6275 OO.ui.OptionWidget.prototype.isPressable = function () {
6276 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6277 };
6278
6279 /**
6280 * Check if the option is selected.
6281 *
6282 * @return {boolean} Item is selected
6283 */
6284 OO.ui.OptionWidget.prototype.isSelected = function () {
6285 return this.selected;
6286 };
6287
6288 /**
6289 * Check if the option is highlighted. A highlight indicates that the
6290 * item may be selected when a user presses Enter key or clicks.
6291 *
6292 * @return {boolean} Item is highlighted
6293 */
6294 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6295 return this.highlighted;
6296 };
6297
6298 /**
6299 * Check if the option is pressed. The pressed state occurs when a user mouses
6300 * down on an item, but has not yet let go of the mouse. The item may appear
6301 * selected, but it will not be selected until the user releases the mouse.
6302 *
6303 * @return {boolean} Item is pressed
6304 */
6305 OO.ui.OptionWidget.prototype.isPressed = function () {
6306 return this.pressed;
6307 };
6308
6309 /**
6310 * Set the option’s selected state. In general, all modifications to the selection
6311 * should be handled by the SelectWidget’s
6312 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
6313 *
6314 * @param {boolean} [state=false] Select option
6315 * @chainable
6316 * @return {OO.ui.Widget} The widget, for chaining
6317 */
6318 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6319 if ( this.constructor.static.selectable ) {
6320 this.selected = !!state;
6321 this.$element
6322 .toggleClass( 'oo-ui-optionWidget-selected', state )
6323 .attr( 'aria-selected', state.toString() );
6324 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6325 this.scrollElementIntoView();
6326 }
6327 this.updateThemeClasses();
6328 }
6329 return this;
6330 };
6331
6332 /**
6333 * Set the option’s highlighted state. In general, all programmatic
6334 * modifications to the highlight should be handled by the
6335 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6336 * method instead of this method.
6337 *
6338 * @param {boolean} [state=false] Highlight option
6339 * @chainable
6340 * @return {OO.ui.Widget} The widget, for chaining
6341 */
6342 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6343 if ( this.constructor.static.highlightable ) {
6344 this.highlighted = !!state;
6345 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6346 this.updateThemeClasses();
6347 }
6348 return this;
6349 };
6350
6351 /**
6352 * Set the option’s pressed state. In general, all
6353 * programmatic modifications to the pressed state should be handled by the
6354 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6355 * method instead of this method.
6356 *
6357 * @param {boolean} [state=false] Press option
6358 * @chainable
6359 * @return {OO.ui.Widget} The widget, for chaining
6360 */
6361 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6362 if ( this.constructor.static.pressable ) {
6363 this.pressed = !!state;
6364 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6365 this.updateThemeClasses();
6366 }
6367 return this;
6368 };
6369
6370 /**
6371 * Get text to match search strings against.
6372 *
6373 * The default implementation returns the label text, but subclasses
6374 * can override this to provide more complex behavior.
6375 *
6376 * @return {string|boolean} String to match search string against
6377 */
6378 OO.ui.OptionWidget.prototype.getMatchText = function () {
6379 var label = this.getLabel();
6380 return typeof label === 'string' ? label : this.$label.text();
6381 };
6382
6383 /**
6384 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6385 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6386 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6387 * menu selects}.
6388 *
6389 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
6390 * more information, please see the [OOUI documentation on MediaWiki][1].
6391 *
6392 * @example
6393 * // A select widget with three options.
6394 * var select = new OO.ui.SelectWidget( {
6395 * items: [
6396 * new OO.ui.OptionWidget( {
6397 * data: 'a',
6398 * label: 'Option One',
6399 * } ),
6400 * new OO.ui.OptionWidget( {
6401 * data: 'b',
6402 * label: 'Option Two',
6403 * } ),
6404 * new OO.ui.OptionWidget( {
6405 * data: 'c',
6406 * label: 'Option Three',
6407 * } )
6408 * ]
6409 * } );
6410 * $( document.body ).append( select.$element );
6411 *
6412 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6413 *
6414 * @abstract
6415 * @class
6416 * @extends OO.ui.Widget
6417 * @mixins OO.ui.mixin.GroupWidget
6418 *
6419 * @constructor
6420 * @param {Object} [config] Configuration options
6421 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6422 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6423 * the [OOUI documentation on MediaWiki] [2] for examples.
6424 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6425 * @cfg {boolean} [multiselect] Allow for multiple selections
6426 */
6427 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6428 // Configuration initialization
6429 config = config || {};
6430
6431 // Parent constructor
6432 OO.ui.SelectWidget.parent.call( this, config );
6433
6434 // Mixin constructors
6435 OO.ui.mixin.GroupWidget.call( this, $.extend( {
6436 $group: this.$element
6437 }, config ) );
6438
6439 // Properties
6440 this.pressed = false;
6441 this.selecting = null;
6442 this.multiselect = !!config.multiselect;
6443 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6444 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6445 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6446 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6447 this.keyPressBuffer = '';
6448 this.keyPressBufferTimer = null;
6449 this.blockMouseOverEvents = 0;
6450
6451 // Events
6452 this.connect( this, {
6453 toggle: 'onToggle'
6454 } );
6455 this.$element.on( {
6456 focusin: this.onFocus.bind( this ),
6457 mousedown: this.onMouseDown.bind( this ),
6458 mouseover: this.onMouseOver.bind( this ),
6459 mouseleave: this.onMouseLeave.bind( this )
6460 } );
6461
6462 // Initialization
6463 this.$element
6464 // -depressed is a deprecated alias of -unpressed
6465 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed' )
6466 .attr( 'role', 'listbox' );
6467 this.setFocusOwner( this.$element );
6468 if ( Array.isArray( config.items ) ) {
6469 this.addItems( config.items );
6470 }
6471 };
6472
6473 /* Setup */
6474
6475 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6476 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6477
6478 /* Events */
6479
6480 /**
6481 * @event highlight
6482 *
6483 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6484 *
6485 * @param {OO.ui.OptionWidget|null} item Highlighted item
6486 */
6487
6488 /**
6489 * @event press
6490 *
6491 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6492 * pressed state of an option.
6493 *
6494 * @param {OO.ui.OptionWidget|null} item Pressed item
6495 */
6496
6497 /**
6498 * @event select
6499 *
6500 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
6501 * method.
6502 *
6503 * @param {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} items Currently selected items
6504 */
6505
6506 /**
6507 * @event choose
6508 *
6509 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6510 *
6511 * @param {OO.ui.OptionWidget} item Chosen item
6512 * @param {boolean} selected Item is selected
6513 */
6514
6515 /**
6516 * @event add
6517 *
6518 * An `add` event is emitted when options are added to the select with the #addItems method.
6519 *
6520 * @param {OO.ui.OptionWidget[]} items Added items
6521 * @param {number} index Index of insertion point
6522 */
6523
6524 /**
6525 * @event remove
6526 *
6527 * A `remove` event is emitted when options are removed from the select with the #clearItems
6528 * or #removeItems methods.
6529 *
6530 * @param {OO.ui.OptionWidget[]} items Removed items
6531 */
6532
6533 /* Static methods */
6534
6535 /**
6536 * Normalize text for filter matching
6537 *
6538 * @param {string} text Text
6539 * @return {string} Normalized text
6540 */
6541 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
6542 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
6543 var normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
6544
6545 // Normalize Unicode
6546 // eslint-disable-next-line no-restricted-properties
6547 if ( normalized.normalize ) {
6548 // eslint-disable-next-line no-restricted-properties
6549 normalized = normalized.normalize();
6550 }
6551 return normalized;
6552 };
6553
6554 /* Methods */
6555
6556 /**
6557 * Handle focus events
6558 *
6559 * @private
6560 * @param {jQuery.Event} event
6561 */
6562 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6563 var item;
6564 if ( event.target === this.$element[ 0 ] ) {
6565 // This widget was focussed, e.g. by the user tabbing to it.
6566 // The styles for focus state depend on one of the items being selected.
6567 if ( !this.findSelectedItem() ) {
6568 item = this.findFirstSelectableItem();
6569 }
6570 } else {
6571 if ( event.target.tabIndex === -1 ) {
6572 // One of the options got focussed (and the event bubbled up here).
6573 // They can't be tabbed to, but they can be activated using access keys.
6574 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6575 item = this.findTargetItem( event );
6576 } else {
6577 // There is something actually user-focusable in one of the labels of the options, and
6578 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
6579 // the focus).
6580 return;
6581 }
6582 }
6583
6584 if ( item ) {
6585 if ( item.constructor.static.highlightable ) {
6586 this.highlightItem( item );
6587 } else {
6588 this.selectItem( item );
6589 }
6590 }
6591
6592 if ( event.target !== this.$element[ 0 ] ) {
6593 this.$focusOwner.trigger( 'focus' );
6594 }
6595 };
6596
6597 /**
6598 * Handle mouse down events.
6599 *
6600 * @private
6601 * @param {jQuery.Event} e Mouse down event
6602 * @return {undefined/boolean} False to prevent default if event is handled
6603 */
6604 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6605 var item;
6606
6607 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6608 this.togglePressed( true );
6609 item = this.findTargetItem( e );
6610 if ( item && item.isSelectable() ) {
6611 this.pressItem( item );
6612 this.selecting = item;
6613 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6614 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6615 }
6616 }
6617 return false;
6618 };
6619
6620 /**
6621 * Handle document mouse up events.
6622 *
6623 * @private
6624 * @param {MouseEvent} e Mouse up event
6625 * @return {undefined/boolean} False to prevent default if event is handled
6626 */
6627 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6628 var item;
6629
6630 this.togglePressed( false );
6631 if ( !this.selecting ) {
6632 item = this.findTargetItem( e );
6633 if ( item && item.isSelectable() ) {
6634 this.selecting = item;
6635 }
6636 }
6637 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6638 this.pressItem( null );
6639 this.chooseItem( this.selecting );
6640 this.selecting = null;
6641 }
6642
6643 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6644 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6645
6646 return false;
6647 };
6648
6649 /**
6650 * Handle document mouse move events.
6651 *
6652 * @private
6653 * @param {MouseEvent} e Mouse move event
6654 */
6655 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6656 var item;
6657
6658 if ( !this.isDisabled() && this.pressed ) {
6659 item = this.findTargetItem( e );
6660 if ( item && item !== this.selecting && item.isSelectable() ) {
6661 this.pressItem( item );
6662 this.selecting = item;
6663 }
6664 }
6665 };
6666
6667 /**
6668 * Handle mouse over events.
6669 *
6670 * @private
6671 * @param {jQuery.Event} e Mouse over event
6672 * @return {undefined/boolean} False to prevent default if event is handled
6673 */
6674 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6675 var item;
6676 if ( this.blockMouseOverEvents ) {
6677 return;
6678 }
6679 if ( !this.isDisabled() ) {
6680 item = this.findTargetItem( e );
6681 this.highlightItem( item && item.isHighlightable() ? item : null );
6682 }
6683 return false;
6684 };
6685
6686 /**
6687 * Handle mouse leave events.
6688 *
6689 * @private
6690 * @param {jQuery.Event} e Mouse over event
6691 * @return {undefined/boolean} False to prevent default if event is handled
6692 */
6693 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6694 if ( !this.isDisabled() ) {
6695 this.highlightItem( null );
6696 }
6697 return false;
6698 };
6699
6700 /**
6701 * Handle document key down events.
6702 *
6703 * @protected
6704 * @param {KeyboardEvent} e Key down event
6705 */
6706 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6707 var nextItem,
6708 handled = false,
6709 currentItem = this.findHighlightedItem(),
6710 firstItem = this.getItems()[ 0 ];
6711
6712 if ( !this.isDisabled() && this.isVisible() ) {
6713 switch ( e.keyCode ) {
6714 case OO.ui.Keys.ENTER:
6715 if ( currentItem ) {
6716 // Was only highlighted, now let's select it. No-op if already selected.
6717 this.chooseItem( currentItem );
6718 handled = true;
6719 }
6720 break;
6721 case OO.ui.Keys.UP:
6722 case OO.ui.Keys.LEFT:
6723 this.clearKeyPressBuffer();
6724 nextItem = currentItem ? this.findRelativeSelectableItem( currentItem, -1 ) : firstItem;
6725 handled = true;
6726 break;
6727 case OO.ui.Keys.DOWN:
6728 case OO.ui.Keys.RIGHT:
6729 this.clearKeyPressBuffer();
6730 nextItem = currentItem ? this.findRelativeSelectableItem( currentItem, 1 ) : firstItem;
6731 handled = true;
6732 break;
6733 case OO.ui.Keys.ESCAPE:
6734 case OO.ui.Keys.TAB:
6735 if ( currentItem ) {
6736 currentItem.setHighlighted( false );
6737 }
6738 this.unbindDocumentKeyDownListener();
6739 this.unbindDocumentKeyPressListener();
6740 // Don't prevent tabbing away / defocusing
6741 handled = false;
6742 break;
6743 }
6744
6745 if ( nextItem ) {
6746 if ( nextItem.constructor.static.highlightable ) {
6747 this.highlightItem( nextItem );
6748 } else {
6749 this.chooseItem( nextItem );
6750 }
6751 this.scrollItemIntoView( nextItem );
6752 }
6753
6754 if ( handled ) {
6755 e.preventDefault();
6756 e.stopPropagation();
6757 }
6758 }
6759 };
6760
6761 /**
6762 * Bind document key down listener.
6763 *
6764 * @protected
6765 */
6766 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6767 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6768 };
6769
6770 /**
6771 * Unbind document key down listener.
6772 *
6773 * @protected
6774 */
6775 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6776 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6777 };
6778
6779 /**
6780 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6781 *
6782 * @param {OO.ui.OptionWidget} item Item to scroll into view
6783 */
6784 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6785 var widget = this;
6786 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
6787 // scrolling and around 100-150 ms after it is finished.
6788 this.blockMouseOverEvents++;
6789 item.scrollElementIntoView().done( function () {
6790 setTimeout( function () {
6791 widget.blockMouseOverEvents--;
6792 }, 200 );
6793 } );
6794 };
6795
6796 /**
6797 * Clear the key-press buffer
6798 *
6799 * @protected
6800 */
6801 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6802 if ( this.keyPressBufferTimer ) {
6803 clearTimeout( this.keyPressBufferTimer );
6804 this.keyPressBufferTimer = null;
6805 }
6806 this.keyPressBuffer = '';
6807 };
6808
6809 /**
6810 * Handle key press events.
6811 *
6812 * @protected
6813 * @param {KeyboardEvent} e Key press event
6814 * @return {undefined/boolean} False to prevent default if event is handled
6815 */
6816 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6817 var c, filter, item;
6818
6819 if ( !e.charCode ) {
6820 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6821 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6822 return false;
6823 }
6824 return;
6825 }
6826 // eslint-disable-next-line no-restricted-properties
6827 if ( String.fromCodePoint ) {
6828 // eslint-disable-next-line no-restricted-properties
6829 c = String.fromCodePoint( e.charCode );
6830 } else {
6831 c = String.fromCharCode( e.charCode );
6832 }
6833
6834 if ( this.keyPressBufferTimer ) {
6835 clearTimeout( this.keyPressBufferTimer );
6836 }
6837 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6838
6839 item = this.findHighlightedItem() || this.findSelectedItem();
6840
6841 if ( this.keyPressBuffer === c ) {
6842 // Common (if weird) special case: typing "xxxx" will cycle through all
6843 // the items beginning with "x".
6844 if ( item ) {
6845 item = this.findRelativeSelectableItem( item, 1 );
6846 }
6847 } else {
6848 this.keyPressBuffer += c;
6849 }
6850
6851 filter = this.getItemMatcher( this.keyPressBuffer, false );
6852 if ( !item || !filter( item ) ) {
6853 item = this.findRelativeSelectableItem( item, 1, filter );
6854 }
6855 if ( item ) {
6856 if ( this.isVisible() && item.constructor.static.highlightable ) {
6857 this.highlightItem( item );
6858 } else {
6859 this.chooseItem( item );
6860 }
6861 this.scrollItemIntoView( item );
6862 }
6863
6864 e.preventDefault();
6865 e.stopPropagation();
6866 };
6867
6868 /**
6869 * Get a matcher for the specific string
6870 *
6871 * @protected
6872 * @param {string} query String to match against items
6873 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
6874 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6875 */
6876 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
6877 var normalizeForMatching = this.constructor.static.normalizeForMatching,
6878 normalizedQuery = normalizeForMatching( query );
6879
6880 // Support deprecated exact=true argument
6881 if ( mode === true ) {
6882 mode = 'exact';
6883 }
6884
6885 return function ( item ) {
6886 var matchText = normalizeForMatching( item.getMatchText() );
6887
6888 if ( normalizedQuery === '' ) {
6889 // Empty string matches all, except if we are in 'exact'
6890 // mode, where it doesn't match at all
6891 return mode !== 'exact';
6892 }
6893
6894 switch ( mode ) {
6895 case 'exact':
6896 return matchText === normalizedQuery;
6897 case 'substring':
6898 return matchText.indexOf( normalizedQuery ) !== -1;
6899 // 'prefix'
6900 default:
6901 return matchText.indexOf( normalizedQuery ) === 0;
6902 }
6903 };
6904 };
6905
6906 /**
6907 * Bind document key press listener.
6908 *
6909 * @protected
6910 */
6911 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6912 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6913 };
6914
6915 /**
6916 * Unbind document key down listener.
6917 *
6918 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6919 * implementation.
6920 *
6921 * @protected
6922 */
6923 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6924 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6925 this.clearKeyPressBuffer();
6926 };
6927
6928 /**
6929 * Visibility change handler
6930 *
6931 * @protected
6932 * @param {boolean} visible
6933 */
6934 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6935 if ( !visible ) {
6936 this.clearKeyPressBuffer();
6937 }
6938 };
6939
6940 /**
6941 * Get the closest item to a jQuery.Event.
6942 *
6943 * @private
6944 * @param {jQuery.Event} e
6945 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6946 */
6947 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6948 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6949 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6950 return null;
6951 }
6952 return $option.data( 'oo-ui-optionWidget' ) || null;
6953 };
6954
6955 /**
6956 * Find all selected items, if there are any. If the widget allows for multiselect
6957 * it will return an array of selected options. If the widget doesn't allow for
6958 * multiselect, it will return the selected option or null if no item is selected.
6959 *
6960 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
6961 * then return an array of selected items (or empty array),
6962 * if the widget is not multiselect, return a single selected item, or `null`
6963 * if no item is selected
6964 */
6965 OO.ui.SelectWidget.prototype.findSelectedItems = function () {
6966 var selected = this.items.filter( function ( item ) {
6967 return item.isSelected();
6968 } );
6969
6970 return this.multiselect ?
6971 selected :
6972 selected[ 0 ] || null;
6973 };
6974
6975 /**
6976 * Find selected item.
6977 *
6978 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
6979 * then return an array of selected items (or empty array),
6980 * if the widget is not multiselect, return a single selected item, or `null`
6981 * if no item is selected
6982 */
6983 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6984 return this.findSelectedItems();
6985 };
6986
6987 /**
6988 * Find highlighted item.
6989 *
6990 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6991 */
6992 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6993 var i, len;
6994
6995 for ( i = 0, len = this.items.length; i < len; i++ ) {
6996 if ( this.items[ i ].isHighlighted() ) {
6997 return this.items[ i ];
6998 }
6999 }
7000 return null;
7001 };
7002
7003 /**
7004 * Toggle pressed state.
7005 *
7006 * Press is a state that occurs when a user mouses down on an item, but
7007 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7008 * until the user releases the mouse.
7009 *
7010 * @param {boolean} pressed An option is being pressed
7011 */
7012 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7013 if ( pressed === undefined ) {
7014 pressed = !this.pressed;
7015 }
7016 if ( pressed !== this.pressed ) {
7017 this.$element
7018 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7019 // -depressed is a deprecated alias of -unpressed
7020 .toggleClass( 'oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed', !pressed );
7021 this.pressed = pressed;
7022 }
7023 };
7024
7025 /**
7026 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7027 * and any existing highlight will be removed. The highlight is mutually exclusive.
7028 *
7029 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7030 * @fires highlight
7031 * @chainable
7032 * @return {OO.ui.Widget} The widget, for chaining
7033 */
7034 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7035 var i, len, highlighted,
7036 changed = false;
7037
7038 for ( i = 0, len = this.items.length; i < len; i++ ) {
7039 highlighted = this.items[ i ] === item;
7040 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7041 this.items[ i ].setHighlighted( highlighted );
7042 changed = true;
7043 }
7044 }
7045 if ( changed ) {
7046 if ( item ) {
7047 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7048 } else {
7049 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7050 }
7051 this.emit( 'highlight', item );
7052 }
7053
7054 return this;
7055 };
7056
7057 /**
7058 * Fetch an item by its label.
7059 *
7060 * @param {string} label Label of the item to select.
7061 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7062 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7063 */
7064 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7065 var i, item, found,
7066 len = this.items.length,
7067 filter = this.getItemMatcher( label, 'exact' );
7068
7069 for ( i = 0; i < len; i++ ) {
7070 item = this.items[ i ];
7071 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7072 return item;
7073 }
7074 }
7075
7076 if ( prefix ) {
7077 found = null;
7078 filter = this.getItemMatcher( label, 'prefix' );
7079 for ( i = 0; i < len; i++ ) {
7080 item = this.items[ i ];
7081 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7082 if ( found ) {
7083 return null;
7084 }
7085 found = item;
7086 }
7087 }
7088 if ( found ) {
7089 return found;
7090 }
7091 }
7092
7093 return null;
7094 };
7095
7096 /**
7097 * Programmatically select an option by its label. If the item does not exist,
7098 * all options will be deselected.
7099 *
7100 * @param {string} [label] Label of the item to select.
7101 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7102 * @fires select
7103 * @chainable
7104 * @return {OO.ui.Widget} The widget, for chaining
7105 */
7106 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7107 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7108 if ( label === undefined || !itemFromLabel ) {
7109 return this.selectItem();
7110 }
7111 return this.selectItem( itemFromLabel );
7112 };
7113
7114 /**
7115 * Programmatically select an option by its data. If the `data` parameter is omitted,
7116 * or if the item does not exist, all options will be deselected.
7117 *
7118 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7119 * @fires select
7120 * @chainable
7121 * @return {OO.ui.Widget} The widget, for chaining
7122 */
7123 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7124 var itemFromData = this.findItemFromData( data );
7125 if ( data === undefined || !itemFromData ) {
7126 return this.selectItem();
7127 }
7128 return this.selectItem( itemFromData );
7129 };
7130
7131 /**
7132 * Programmatically unselect an option by its reference. If the widget
7133 * allows for multiple selections, there may be other items still selected;
7134 * otherwise, no items will be selected.
7135 * If no item is given, all selected items will be unselected.
7136 *
7137 * @param {OO.ui.OptionWidget} [item] Item to unselect
7138 * @fires select
7139 * @chainable
7140 * @return {OO.ui.Widget} The widget, for chaining
7141 */
7142 OO.ui.SelectWidget.prototype.unselectItem = function ( item ) {
7143 if ( item ) {
7144 item.setSelected( false );
7145 } else {
7146 this.items.forEach( function ( item ) {
7147 item.setSelected( false );
7148 } );
7149 }
7150
7151 this.emit( 'select', this.findSelectedItems() );
7152 return this;
7153 };
7154
7155 /**
7156 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7157 * all options will be deselected.
7158 *
7159 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7160 * @fires select
7161 * @chainable
7162 * @return {OO.ui.Widget} The widget, for chaining
7163 */
7164 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7165 var i, len, selected,
7166 changed = false;
7167
7168 if ( this.multiselect && item ) {
7169 // Select the item directly
7170 item.setSelected( true );
7171 } else {
7172 for ( i = 0, len = this.items.length; i < len; i++ ) {
7173 selected = this.items[ i ] === item;
7174 if ( this.items[ i ].isSelected() !== selected ) {
7175 this.items[ i ].setSelected( selected );
7176 changed = true;
7177 }
7178 }
7179 }
7180 if ( changed ) {
7181 // TODO: When should a non-highlightable element be selected?
7182 if ( item && !item.constructor.static.highlightable ) {
7183 if ( item ) {
7184 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7185 } else {
7186 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7187 }
7188 }
7189 this.emit( 'select', this.findSelectedItems() );
7190 }
7191
7192 return this;
7193 };
7194
7195 /**
7196 * Press an item.
7197 *
7198 * Press is a state that occurs when a user mouses down on an item, but has not
7199 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7200 * releases the mouse.
7201 *
7202 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7203 * @fires press
7204 * @chainable
7205 * @return {OO.ui.Widget} The widget, for chaining
7206 */
7207 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7208 var i, len, pressed,
7209 changed = false;
7210
7211 for ( i = 0, len = this.items.length; i < len; i++ ) {
7212 pressed = this.items[ i ] === item;
7213 if ( this.items[ i ].isPressed() !== pressed ) {
7214 this.items[ i ].setPressed( pressed );
7215 changed = true;
7216 }
7217 }
7218 if ( changed ) {
7219 this.emit( 'press', item );
7220 }
7221
7222 return this;
7223 };
7224
7225 /**
7226 * Choose an item.
7227 *
7228 * Note that ‘choose’ should never be modified programmatically. A user can choose
7229 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7230 * use the #selectItem method.
7231 *
7232 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7233 * when users choose an item with the keyboard or mouse.
7234 *
7235 * @param {OO.ui.OptionWidget} item Item to choose
7236 * @fires choose
7237 * @chainable
7238 * @return {OO.ui.Widget} The widget, for chaining
7239 */
7240 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7241 if ( item ) {
7242 if ( this.multiselect && item.isSelected() ) {
7243 this.unselectItem( item );
7244 } else {
7245 this.selectItem( item );
7246 }
7247
7248 this.emit( 'choose', item, item.isSelected() );
7249 }
7250
7251 return this;
7252 };
7253
7254 /**
7255 * Find an option by its position relative to the specified item (or to the start of the option
7256 * array, if item is `null`). The direction in which to search through the option array is specified
7257 * with a number: -1 for reverse (the default) or 1 for forward. The method will return an option,
7258 * or `null` if there are no options in the array.
7259 *
7260 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
7261 * the beginning of the array.
7262 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7263 * @param {Function} [filter] Only consider items for which this function returns
7264 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7265 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7266 */
7267 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7268 var currentIndex, nextIndex, i,
7269 increase = direction > 0 ? 1 : -1,
7270 len = this.items.length;
7271
7272 if ( item instanceof OO.ui.OptionWidget ) {
7273 currentIndex = this.items.indexOf( item );
7274 nextIndex = ( currentIndex + increase + len ) % len;
7275 } else {
7276 // If no item is selected and moving forward, start at the beginning.
7277 // If moving backward, start at the end.
7278 nextIndex = direction > 0 ? 0 : len - 1;
7279 }
7280
7281 for ( i = 0; i < len; i++ ) {
7282 item = this.items[ nextIndex ];
7283 if (
7284 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7285 ( !filter || filter( item ) )
7286 ) {
7287 return item;
7288 }
7289 nextIndex = ( nextIndex + increase + len ) % len;
7290 }
7291 return null;
7292 };
7293
7294 /**
7295 * Find the next selectable item or `null` if there are no selectable items.
7296 * Disabled options and menu-section markers and breaks are not selectable.
7297 *
7298 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7299 */
7300 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7301 return this.findRelativeSelectableItem( null, 1 );
7302 };
7303
7304 /**
7305 * Add an array of options to the select. Optionally, an index number can be used to
7306 * specify an insertion point.
7307 *
7308 * @param {OO.ui.OptionWidget[]} items Items to add
7309 * @param {number} [index] Index to insert items after
7310 * @fires add
7311 * @chainable
7312 * @return {OO.ui.Widget} The widget, for chaining
7313 */
7314 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7315 // Mixin method
7316 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7317
7318 // Always provide an index, even if it was omitted
7319 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7320
7321 return this;
7322 };
7323
7324 /**
7325 * Remove the specified array of options from the select. Options will be detached
7326 * from the DOM, not removed, so they can be reused later. To remove all options from
7327 * the select, you may wish to use the #clearItems method instead.
7328 *
7329 * @param {OO.ui.OptionWidget[]} items Items to remove
7330 * @fires remove
7331 * @chainable
7332 * @return {OO.ui.Widget} The widget, for chaining
7333 */
7334 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7335 var i, len, item;
7336
7337 // Deselect items being removed
7338 for ( i = 0, len = items.length; i < len; i++ ) {
7339 item = items[ i ];
7340 if ( item.isSelected() ) {
7341 this.selectItem( null );
7342 }
7343 }
7344
7345 // Mixin method
7346 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7347
7348 this.emit( 'remove', items );
7349
7350 return this;
7351 };
7352
7353 /**
7354 * Clear all options from the select. Options will be detached from the DOM, not removed,
7355 * so that they can be reused later. To remove a subset of options from the select, use
7356 * the #removeItems method.
7357 *
7358 * @fires remove
7359 * @chainable
7360 * @return {OO.ui.Widget} The widget, for chaining
7361 */
7362 OO.ui.SelectWidget.prototype.clearItems = function () {
7363 var items = this.items.slice();
7364
7365 // Mixin method
7366 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7367
7368 // Clear selection
7369 this.selectItem( null );
7370
7371 this.emit( 'remove', items );
7372
7373 return this;
7374 };
7375
7376 /**
7377 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7378 *
7379 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7380 *
7381 * @protected
7382 * @param {jQuery} $focusOwner
7383 */
7384 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7385 this.$focusOwner = $focusOwner;
7386 };
7387
7388 /**
7389 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7390 * with an {@link OO.ui.mixin.IconElement icon} and/or
7391 * {@link OO.ui.mixin.IndicatorElement indicator}.
7392 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7393 * options. For more information about options and selects, please see the
7394 * [OOUI documentation on MediaWiki][1].
7395 *
7396 * @example
7397 * // Decorated options in a select widget.
7398 * var select = new OO.ui.SelectWidget( {
7399 * items: [
7400 * new OO.ui.DecoratedOptionWidget( {
7401 * data: 'a',
7402 * label: 'Option with icon',
7403 * icon: 'help'
7404 * } ),
7405 * new OO.ui.DecoratedOptionWidget( {
7406 * data: 'b',
7407 * label: 'Option with indicator',
7408 * indicator: 'next'
7409 * } )
7410 * ]
7411 * } );
7412 * $( document.body ).append( select.$element );
7413 *
7414 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7415 *
7416 * @class
7417 * @extends OO.ui.OptionWidget
7418 * @mixins OO.ui.mixin.IconElement
7419 * @mixins OO.ui.mixin.IndicatorElement
7420 *
7421 * @constructor
7422 * @param {Object} [config] Configuration options
7423 */
7424 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7425 // Parent constructor
7426 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7427
7428 // Mixin constructors
7429 OO.ui.mixin.IconElement.call( this, config );
7430 OO.ui.mixin.IndicatorElement.call( this, config );
7431
7432 // Initialization
7433 this.$element
7434 .addClass( 'oo-ui-decoratedOptionWidget' )
7435 .prepend( this.$icon )
7436 .append( this.$indicator );
7437 };
7438
7439 /* Setup */
7440
7441 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7442 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7443 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7444
7445 /**
7446 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7447 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7448 * the [OOUI documentation on MediaWiki] [1] for more information.
7449 *
7450 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7451 *
7452 * @class
7453 * @extends OO.ui.DecoratedOptionWidget
7454 *
7455 * @constructor
7456 * @param {Object} [config] Configuration options
7457 */
7458 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7459 // Parent constructor
7460 OO.ui.MenuOptionWidget.parent.call( this, config );
7461
7462 // Properties
7463 this.checkIcon = new OO.ui.IconWidget( {
7464 icon: 'check',
7465 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7466 } );
7467
7468 // Initialization
7469 this.$element
7470 .prepend( this.checkIcon.$element )
7471 .addClass( 'oo-ui-menuOptionWidget' );
7472 };
7473
7474 /* Setup */
7475
7476 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7477
7478 /* Static Properties */
7479
7480 /**
7481 * @static
7482 * @inheritdoc
7483 */
7484 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7485
7486 /**
7487 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
7488 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
7489 * cannot be highlighted or selected.
7490 *
7491 * @example
7492 * var dropdown = new OO.ui.DropdownWidget( {
7493 * menu: {
7494 * items: [
7495 * new OO.ui.MenuSectionOptionWidget( {
7496 * label: 'Dogs'
7497 * } ),
7498 * new OO.ui.MenuOptionWidget( {
7499 * data: 'corgi',
7500 * label: 'Welsh Corgi'
7501 * } ),
7502 * new OO.ui.MenuOptionWidget( {
7503 * data: 'poodle',
7504 * label: 'Standard Poodle'
7505 * } ),
7506 * new OO.ui.MenuSectionOptionWidget( {
7507 * label: 'Cats'
7508 * } ),
7509 * new OO.ui.MenuOptionWidget( {
7510 * data: 'lion',
7511 * label: 'Lion'
7512 * } )
7513 * ]
7514 * }
7515 * } );
7516 * $( document.body ).append( dropdown.$element );
7517 *
7518 * @class
7519 * @extends OO.ui.DecoratedOptionWidget
7520 *
7521 * @constructor
7522 * @param {Object} [config] Configuration options
7523 */
7524 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7525 // Parent constructor
7526 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7527
7528 // Initialization
7529 this.$element
7530 .addClass( 'oo-ui-menuSectionOptionWidget' )
7531 .removeAttr( 'role aria-selected' );
7532 this.selected = false;
7533 };
7534
7535 /* Setup */
7536
7537 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7538
7539 /* Static Properties */
7540
7541 /**
7542 * @static
7543 * @inheritdoc
7544 */
7545 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7546
7547 /**
7548 * @static
7549 * @inheritdoc
7550 */
7551 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7552
7553 /**
7554 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7555 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7556 * See {@link OO.ui.DropdownWidget DropdownWidget},
7557 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
7558 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7559 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7560 * and customized to be opened, closed, and displayed as needed.
7561 *
7562 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7563 * mouse outside the menu.
7564 *
7565 * Menus also have support for keyboard interaction:
7566 *
7567 * - Enter/Return key: choose and select a menu option
7568 * - Up-arrow key: highlight the previous menu option
7569 * - Down-arrow key: highlight the next menu option
7570 * - Escape key: hide the menu
7571 *
7572 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7573 *
7574 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7575 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7576 *
7577 * @class
7578 * @extends OO.ui.SelectWidget
7579 * @mixins OO.ui.mixin.ClippableElement
7580 * @mixins OO.ui.mixin.FloatableElement
7581 *
7582 * @constructor
7583 * @param {Object} [config] Configuration options
7584 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu
7585 * items that match the text the user types. This config is used by
7586 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
7587 * {@link OO.ui.mixin.LookupElement LookupElement}
7588 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7589 * the text the user types. This config is used by
7590 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7591 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks
7592 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
7593 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
7594 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
7595 * in here.
7596 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7597 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7598 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7599 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7600 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7601 * @cfg {string} [filterMode='prefix'] The mode by which the menu filters the results.
7602 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
7603 * @param {number|string} [width] Width of the menu as a number of pixels or CSS string with unit
7604 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
7605 */
7606 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7607 // Configuration initialization
7608 config = config || {};
7609
7610 // Parent constructor
7611 OO.ui.MenuSelectWidget.parent.call( this, config );
7612
7613 // Mixin constructors
7614 OO.ui.mixin.ClippableElement.call( this, $.extend( { $clippable: this.$group }, config ) );
7615 OO.ui.mixin.FloatableElement.call( this, config );
7616
7617 // Initial vertical positions other than 'center' will result in
7618 // the menu being flipped if there is not enough space in the container.
7619 // Store the original position so we know what to reset to.
7620 this.originalVerticalPosition = this.verticalPosition;
7621
7622 // Properties
7623 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7624 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7625 this.filterFromInput = !!config.filterFromInput;
7626 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7627 this.$widget = config.widget ? config.widget.$element : null;
7628 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7629 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7630 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7631 this.highlightOnFilter = !!config.highlightOnFilter;
7632 this.lastHighlightedItem = null;
7633 this.width = config.width;
7634 this.filterMode = config.filterMode;
7635
7636 // Initialization
7637 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7638 if ( config.widget ) {
7639 this.setFocusOwner( config.widget.$tabIndexed );
7640 }
7641
7642 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7643 // that reference properties not initialized at that time of parent class construction
7644 // TODO: Find a better way to handle post-constructor setup
7645 this.visible = false;
7646 this.$element.addClass( 'oo-ui-element-hidden' );
7647 this.$focusOwner.attr( 'aria-expanded', 'false' );
7648 };
7649
7650 /* Setup */
7651
7652 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7653 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7654 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7655
7656 /* Events */
7657
7658 /**
7659 * @event ready
7660 *
7661 * The menu is ready: it is visible and has been positioned and clipped.
7662 */
7663
7664 /* Static properties */
7665
7666 /**
7667 * Positions to flip to if there isn't room in the container for the
7668 * menu in a specific direction.
7669 *
7670 * @property {Object.<string,string>}
7671 */
7672 OO.ui.MenuSelectWidget.static.flippedPositions = {
7673 below: 'above',
7674 above: 'below',
7675 top: 'bottom',
7676 bottom: 'top'
7677 };
7678
7679 /* Methods */
7680
7681 /**
7682 * Handles document mouse down events.
7683 *
7684 * @protected
7685 * @param {MouseEvent} e Mouse down event
7686 */
7687 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7688 if (
7689 this.isVisible() &&
7690 !OO.ui.contains(
7691 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7692 e.target,
7693 true
7694 )
7695 ) {
7696 this.toggle( false );
7697 }
7698 };
7699
7700 /**
7701 * @inheritdoc
7702 */
7703 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7704 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7705
7706 if ( !this.isDisabled() && this.isVisible() ) {
7707 switch ( e.keyCode ) {
7708 case OO.ui.Keys.LEFT:
7709 case OO.ui.Keys.RIGHT:
7710 // Do nothing if a text field is associated, arrow keys will be handled natively
7711 if ( !this.$input ) {
7712 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7713 }
7714 break;
7715 case OO.ui.Keys.ESCAPE:
7716 case OO.ui.Keys.TAB:
7717 if ( currentItem && !this.multiselect ) {
7718 currentItem.setHighlighted( false );
7719 }
7720 this.toggle( false );
7721 // Don't prevent tabbing away, prevent defocusing
7722 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7723 e.preventDefault();
7724 e.stopPropagation();
7725 }
7726 break;
7727 default:
7728 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7729 return;
7730 }
7731 }
7732 };
7733
7734 /**
7735 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7736 * or after items were added/removed (always).
7737 *
7738 * @protected
7739 */
7740 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7741 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7742 anyVisible = false,
7743 len = this.items.length,
7744 showAll = !this.isVisible(),
7745 exactMatch = false;
7746
7747 if ( this.$input && this.filterFromInput ) {
7748 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
7749 exactFilter = this.getItemMatcher( this.$input.val(), 'exact' );
7750 // Hide non-matching options, and also hide section headers if all options
7751 // in their section are hidden.
7752 for ( i = 0; i < len; i++ ) {
7753 item = this.items[ i ];
7754 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7755 if ( section ) {
7756 // If the previous section was empty, hide its header
7757 section.toggle( showAll || !sectionEmpty );
7758 }
7759 section = item;
7760 sectionEmpty = true;
7761 } else if ( item instanceof OO.ui.OptionWidget ) {
7762 visible = showAll || filter( item );
7763 exactMatch = exactMatch || exactFilter( item );
7764 anyVisible = anyVisible || visible;
7765 sectionEmpty = sectionEmpty && !visible;
7766 item.toggle( visible );
7767 }
7768 }
7769 // Process the final section
7770 if ( section ) {
7771 section.toggle( showAll || !sectionEmpty );
7772 }
7773
7774 if ( !anyVisible ) {
7775 this.highlightItem( null );
7776 }
7777
7778 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7779
7780 if (
7781 this.highlightOnFilter &&
7782 !( this.lastHighlightedItem && this.lastHighlightedItem.isVisible() )
7783 ) {
7784 // Highlight the first item on the list
7785 item = null;
7786 items = this.getItems();
7787 for ( i = 0; i < items.length; i++ ) {
7788 if ( items[ i ].isVisible() ) {
7789 item = items[ i ];
7790 break;
7791 }
7792 }
7793 this.highlightItem( item );
7794 this.lastHighlightedItem = item;
7795 }
7796
7797 }
7798
7799 // Reevaluate clipping
7800 this.clip();
7801 };
7802
7803 /**
7804 * @inheritdoc
7805 */
7806 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7807 if ( this.$input ) {
7808 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7809 } else {
7810 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7811 }
7812 };
7813
7814 /**
7815 * @inheritdoc
7816 */
7817 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7818 if ( this.$input ) {
7819 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7820 } else {
7821 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7822 }
7823 };
7824
7825 /**
7826 * @inheritdoc
7827 */
7828 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7829 if ( this.$input ) {
7830 if ( this.filterFromInput ) {
7831 this.$input.on(
7832 'keydown mouseup cut paste change input select',
7833 this.onInputEditHandler
7834 );
7835 this.updateItemVisibility();
7836 }
7837 } else {
7838 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7839 }
7840 };
7841
7842 /**
7843 * @inheritdoc
7844 */
7845 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7846 if ( this.$input ) {
7847 if ( this.filterFromInput ) {
7848 this.$input.off(
7849 'keydown mouseup cut paste change input select',
7850 this.onInputEditHandler
7851 );
7852 this.updateItemVisibility();
7853 }
7854 } else {
7855 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7856 }
7857 };
7858
7859 /**
7860 * Choose an item.
7861 *
7862 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
7863 * set to false.
7864 *
7865 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
7866 * the keyboard or mouse and it becomes selected. To select an item programmatically,
7867 * use the #selectItem method.
7868 *
7869 * @param {OO.ui.OptionWidget} item Item to choose
7870 * @chainable
7871 * @return {OO.ui.Widget} The widget, for chaining
7872 */
7873 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7874 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7875 if ( this.hideOnChoose ) {
7876 this.toggle( false );
7877 }
7878 return this;
7879 };
7880
7881 /**
7882 * @inheritdoc
7883 */
7884 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7885 // Parent method
7886 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7887
7888 this.updateItemVisibility();
7889
7890 return this;
7891 };
7892
7893 /**
7894 * @inheritdoc
7895 */
7896 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7897 // Parent method
7898 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7899
7900 this.updateItemVisibility();
7901
7902 return this;
7903 };
7904
7905 /**
7906 * @inheritdoc
7907 */
7908 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7909 // Parent method
7910 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7911
7912 this.updateItemVisibility();
7913
7914 return this;
7915 };
7916
7917 /**
7918 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7919 * `.toggle( true )` after its #$element is attached to the DOM.
7920 *
7921 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7922 * it in the right place and with the right dimensions only work correctly while it is attached.
7923 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7924 * strictly enforced, so currently it only generates a warning in the browser console.
7925 *
7926 * @fires ready
7927 * @inheritdoc
7928 */
7929 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7930 var change, originalHeight, flippedHeight, selectedItem;
7931
7932 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7933 change = visible !== this.isVisible();
7934
7935 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7936 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7937 this.warnedUnattached = true;
7938 }
7939
7940 if ( change && visible ) {
7941 // Reset position before showing the popup again. It's possible we no longer need to flip
7942 // (e.g. if the user scrolled).
7943 this.setVerticalPosition( this.originalVerticalPosition );
7944 }
7945
7946 // Parent method
7947 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7948
7949 if ( change ) {
7950 if ( visible ) {
7951
7952 if ( this.width ) {
7953 this.setIdealSize( this.width );
7954 } else if ( this.$floatableContainer ) {
7955 this.$clippable.css( 'width', 'auto' );
7956 this.setIdealSize(
7957 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7958 // Dropdown is smaller than handle so expand to width
7959 this.$floatableContainer[ 0 ].offsetWidth :
7960 // Dropdown is larger than handle so auto size
7961 'auto'
7962 );
7963 this.$clippable.css( 'width', '' );
7964 }
7965
7966 this.togglePositioning( !!this.$floatableContainer );
7967 this.toggleClipping( true );
7968
7969 this.bindDocumentKeyDownListener();
7970 this.bindDocumentKeyPressListener();
7971
7972 if (
7973 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7974 this.originalVerticalPosition !== 'center'
7975 ) {
7976 // If opening the menu in one direction causes it to be clipped, flip it
7977 originalHeight = this.$element.height();
7978 this.setVerticalPosition(
7979 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7980 );
7981 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7982 // If flipping also causes it to be clipped, open in whichever direction
7983 // we have more space
7984 flippedHeight = this.$element.height();
7985 if ( originalHeight > flippedHeight ) {
7986 this.setVerticalPosition( this.originalVerticalPosition );
7987 }
7988 }
7989 }
7990 // Note that we do not flip the menu's opening direction if the clipping changes
7991 // later (e.g. after the user scrolls), that seems like it would be annoying
7992
7993 this.$focusOwner.attr( 'aria-expanded', 'true' );
7994
7995 selectedItem = this.findSelectedItem();
7996 if ( !this.multiselect && selectedItem ) {
7997 // TODO: Verify if this is even needed; This is already done on highlight changes
7998 // in SelectWidget#highlightItem, so we should just need to highlight the item we need to
7999 // highlight here and not bother with attr or checking selections.
8000 this.$focusOwner.attr( 'aria-activedescendant', selectedItem.getElementId() );
8001 selectedItem.scrollElementIntoView( { duration: 0 } );
8002 }
8003
8004 // Auto-hide
8005 if ( this.autoHide ) {
8006 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8007 }
8008
8009 this.emit( 'ready' );
8010 } else {
8011 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8012 this.unbindDocumentKeyDownListener();
8013 this.unbindDocumentKeyPressListener();
8014 this.$focusOwner.attr( 'aria-expanded', 'false' );
8015 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8016 this.togglePositioning( false );
8017 this.toggleClipping( false );
8018 }
8019 }
8020
8021 return this;
8022 };
8023
8024 /**
8025 * Scroll to the top of the menu
8026 */
8027 OO.ui.MenuSelectWidget.prototype.scrollToTop = function () {
8028 this.$element.scrollTop( 0 );
8029 };
8030
8031 /**
8032 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8033 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8034 * users can interact with it.
8035 *
8036 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8037 * OO.ui.DropdownInputWidget instead.
8038 *
8039 * @example
8040 * // A DropdownWidget with a menu that contains three options.
8041 * var dropDown = new OO.ui.DropdownWidget( {
8042 * label: 'Dropdown menu: Select a menu option',
8043 * menu: {
8044 * items: [
8045 * new OO.ui.MenuOptionWidget( {
8046 * data: 'a',
8047 * label: 'First'
8048 * } ),
8049 * new OO.ui.MenuOptionWidget( {
8050 * data: 'b',
8051 * label: 'Second'
8052 * } ),
8053 * new OO.ui.MenuOptionWidget( {
8054 * data: 'c',
8055 * label: 'Third'
8056 * } )
8057 * ]
8058 * }
8059 * } );
8060 *
8061 * $( document.body ).append( dropDown.$element );
8062 *
8063 * dropDown.getMenu().selectItemByData( 'b' );
8064 *
8065 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8066 *
8067 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8068 *
8069 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8070 *
8071 * @class
8072 * @extends OO.ui.Widget
8073 * @mixins OO.ui.mixin.IconElement
8074 * @mixins OO.ui.mixin.IndicatorElement
8075 * @mixins OO.ui.mixin.LabelElement
8076 * @mixins OO.ui.mixin.TitledElement
8077 * @mixins OO.ui.mixin.TabIndexedElement
8078 *
8079 * @constructor
8080 * @param {Object} [config] Configuration options
8081 * @cfg {Object} [menu] Configuration options to pass to
8082 * {@link OO.ui.MenuSelectWidget menu select widget}.
8083 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
8084 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
8085 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
8086 * uses relative positioning.
8087 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8088 */
8089 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8090 // Configuration initialization
8091 config = $.extend( { indicator: 'down' }, config );
8092
8093 // Parent constructor
8094 OO.ui.DropdownWidget.parent.call( this, config );
8095
8096 // Properties (must be set before TabIndexedElement constructor call)
8097 this.$handle = $( '<button>' );
8098 this.$overlay = ( config.$overlay === true ?
8099 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8100
8101 // Mixin constructors
8102 OO.ui.mixin.IconElement.call( this, config );
8103 OO.ui.mixin.IndicatorElement.call( this, config );
8104 OO.ui.mixin.LabelElement.call( this, config );
8105 OO.ui.mixin.TitledElement.call( this, $.extend( {
8106 $titled: this.$label
8107 }, config ) );
8108 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
8109 $tabIndexed: this.$handle
8110 }, config ) );
8111
8112 // Properties
8113 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8114 widget: this,
8115 $floatableContainer: this.$element
8116 }, config.menu ) );
8117
8118 // Events
8119 this.$handle.on( {
8120 click: this.onClick.bind( this ),
8121 keydown: this.onKeyDown.bind( this ),
8122 // Hack? Handle type-to-search when menu is not expanded and not handling its own events.
8123 keypress: this.menu.onDocumentKeyPressHandler,
8124 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8125 } );
8126 this.menu.connect( this, {
8127 select: 'onMenuSelect',
8128 toggle: 'onMenuToggle'
8129 } );
8130
8131 // Initialization
8132 this.$handle
8133 .addClass( 'oo-ui-dropdownWidget-handle' )
8134 .attr( {
8135 type: 'button',
8136 'aria-owns': this.menu.getElementId(),
8137 'aria-haspopup': 'listbox'
8138 } )
8139 .append( this.$icon, this.$label, this.$indicator );
8140 this.$element
8141 .addClass( 'oo-ui-dropdownWidget' )
8142 .append( this.$handle );
8143 this.$overlay.append( this.menu.$element );
8144 };
8145
8146 /* Setup */
8147
8148 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8149 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8150 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8151 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8152 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8153 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8154
8155 /* Methods */
8156
8157 /**
8158 * Get the menu.
8159 *
8160 * @return {OO.ui.MenuSelectWidget} Menu of widget
8161 */
8162 OO.ui.DropdownWidget.prototype.getMenu = function () {
8163 return this.menu;
8164 };
8165
8166 /**
8167 * Handles menu select events.
8168 *
8169 * @private
8170 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8171 */
8172 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8173 var selectedLabel;
8174
8175 if ( !item ) {
8176 this.setLabel( null );
8177 return;
8178 }
8179
8180 selectedLabel = item.getLabel();
8181
8182 // If the label is a DOM element, clone it, because setLabel will append() it
8183 if ( selectedLabel instanceof $ ) {
8184 selectedLabel = selectedLabel.clone();
8185 }
8186
8187 this.setLabel( selectedLabel );
8188 };
8189
8190 /**
8191 * Handle menu toggle events.
8192 *
8193 * @private
8194 * @param {boolean} isVisible Open state of the menu
8195 */
8196 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8197 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8198 };
8199
8200 /**
8201 * Handle mouse click events.
8202 *
8203 * @private
8204 * @param {jQuery.Event} e Mouse click event
8205 * @return {undefined/boolean} False to prevent default if event is handled
8206 */
8207 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8208 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8209 this.menu.toggle();
8210 }
8211 return false;
8212 };
8213
8214 /**
8215 * Handle key down events.
8216 *
8217 * @private
8218 * @param {jQuery.Event} e Key down event
8219 * @return {undefined/boolean} False to prevent default if event is handled
8220 */
8221 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8222 if (
8223 !this.isDisabled() &&
8224 (
8225 e.which === OO.ui.Keys.ENTER ||
8226 (
8227 e.which === OO.ui.Keys.SPACE &&
8228 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8229 // Space only closes the menu is the user is not typing to search.
8230 this.menu.keyPressBuffer === ''
8231 ) ||
8232 (
8233 !this.menu.isVisible() &&
8234 (
8235 e.which === OO.ui.Keys.UP ||
8236 e.which === OO.ui.Keys.DOWN
8237 )
8238 )
8239 )
8240 ) {
8241 this.menu.toggle();
8242 return false;
8243 }
8244 };
8245
8246 /**
8247 * RadioOptionWidget is an option widget that looks like a radio button.
8248 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8249 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8250 *
8251 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8252 *
8253 * @class
8254 * @extends OO.ui.OptionWidget
8255 *
8256 * @constructor
8257 * @param {Object} [config] Configuration options
8258 */
8259 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8260 // Configuration initialization
8261 config = config || {};
8262
8263 // Properties (must be done before parent constructor which calls #setDisabled)
8264 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8265
8266 // Parent constructor
8267 OO.ui.RadioOptionWidget.parent.call( this, config );
8268
8269 // Initialization
8270 // Remove implicit role, we're handling it ourselves
8271 this.radio.$input.attr( 'role', 'presentation' );
8272 this.$element
8273 .addClass( 'oo-ui-radioOptionWidget' )
8274 .attr( 'role', 'radio' )
8275 .attr( 'aria-checked', 'false' )
8276 .removeAttr( 'aria-selected' )
8277 .prepend( this.radio.$element );
8278 };
8279
8280 /* Setup */
8281
8282 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8283
8284 /* Static Properties */
8285
8286 /**
8287 * @static
8288 * @inheritdoc
8289 */
8290 OO.ui.RadioOptionWidget.static.highlightable = false;
8291
8292 /**
8293 * @static
8294 * @inheritdoc
8295 */
8296 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8297
8298 /**
8299 * @static
8300 * @inheritdoc
8301 */
8302 OO.ui.RadioOptionWidget.static.pressable = false;
8303
8304 /**
8305 * @static
8306 * @inheritdoc
8307 */
8308 OO.ui.RadioOptionWidget.static.tagName = 'label';
8309
8310 /* Methods */
8311
8312 /**
8313 * @inheritdoc
8314 */
8315 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8316 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8317
8318 this.radio.setSelected( state );
8319 this.$element
8320 .attr( 'aria-checked', state.toString() )
8321 .removeAttr( 'aria-selected' );
8322
8323 return this;
8324 };
8325
8326 /**
8327 * @inheritdoc
8328 */
8329 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8330 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8331
8332 this.radio.setDisabled( this.isDisabled() );
8333
8334 return this;
8335 };
8336
8337 /**
8338 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8339 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8340 * an interface for adding, removing and selecting options.
8341 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8342 *
8343 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8344 * OO.ui.RadioSelectInputWidget instead.
8345 *
8346 * @example
8347 * // A RadioSelectWidget with RadioOptions.
8348 * var option1 = new OO.ui.RadioOptionWidget( {
8349 * data: 'a',
8350 * label: 'Selected radio option'
8351 * } ),
8352 * option2 = new OO.ui.RadioOptionWidget( {
8353 * data: 'b',
8354 * label: 'Unselected radio option'
8355 * } );
8356 * radioSelect = new OO.ui.RadioSelectWidget( {
8357 * items: [ option1, option2 ]
8358 * } );
8359 *
8360 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8361 * radioSelect.selectItem( option1 );
8362 *
8363 * $( document.body ).append( radioSelect.$element );
8364 *
8365 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8366
8367 *
8368 * @class
8369 * @extends OO.ui.SelectWidget
8370 * @mixins OO.ui.mixin.TabIndexedElement
8371 *
8372 * @constructor
8373 * @param {Object} [config] Configuration options
8374 */
8375 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8376 // Parent constructor
8377 OO.ui.RadioSelectWidget.parent.call( this, config );
8378
8379 // Mixin constructors
8380 OO.ui.mixin.TabIndexedElement.call( this, config );
8381
8382 // Events
8383 this.$element.on( {
8384 focus: this.bindDocumentKeyDownListener.bind( this ),
8385 blur: this.unbindDocumentKeyDownListener.bind( this )
8386 } );
8387
8388 // Initialization
8389 this.$element
8390 .addClass( 'oo-ui-radioSelectWidget' )
8391 .attr( 'role', 'radiogroup' );
8392 };
8393
8394 /* Setup */
8395
8396 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8397 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8398
8399 /**
8400 * MultioptionWidgets are special elements that can be selected and configured with data. The
8401 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8402 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8403 * and examples, please see the [OOUI documentation on MediaWiki][1].
8404 *
8405 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8406 *
8407 * @class
8408 * @extends OO.ui.Widget
8409 * @mixins OO.ui.mixin.ItemWidget
8410 * @mixins OO.ui.mixin.LabelElement
8411 * @mixins OO.ui.mixin.TitledElement
8412 *
8413 * @constructor
8414 * @param {Object} [config] Configuration options
8415 * @cfg {boolean} [selected=false] Whether the option is initially selected
8416 */
8417 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8418 // Configuration initialization
8419 config = config || {};
8420
8421 // Parent constructor
8422 OO.ui.MultioptionWidget.parent.call( this, config );
8423
8424 // Mixin constructors
8425 OO.ui.mixin.ItemWidget.call( this );
8426 OO.ui.mixin.LabelElement.call( this, config );
8427 OO.ui.mixin.TitledElement.call( this, config );
8428
8429 // Properties
8430 this.selected = null;
8431
8432 // Initialization
8433 this.$element
8434 .addClass( 'oo-ui-multioptionWidget' )
8435 .append( this.$label );
8436 this.setSelected( config.selected );
8437 };
8438
8439 /* Setup */
8440
8441 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8442 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8443 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8444 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8445
8446 /* Events */
8447
8448 /**
8449 * @event change
8450 *
8451 * A change event is emitted when the selected state of the option changes.
8452 *
8453 * @param {boolean} selected Whether the option is now selected
8454 */
8455
8456 /* Methods */
8457
8458 /**
8459 * Check if the option is selected.
8460 *
8461 * @return {boolean} Item is selected
8462 */
8463 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8464 return this.selected;
8465 };
8466
8467 /**
8468 * Set the option’s selected state. In general, all modifications to the selection
8469 * should be handled by the SelectWidget’s
8470 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
8471 *
8472 * @param {boolean} [state=false] Select option
8473 * @chainable
8474 * @return {OO.ui.Widget} The widget, for chaining
8475 */
8476 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8477 state = !!state;
8478 if ( this.selected !== state ) {
8479 this.selected = state;
8480 this.emit( 'change', state );
8481 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8482 }
8483 return this;
8484 };
8485
8486 /**
8487 * MultiselectWidget allows selecting multiple options from a list.
8488 *
8489 * For more information about menus and options, please see the [OOUI documentation
8490 * on MediaWiki][1].
8491 *
8492 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8493 *
8494 * @class
8495 * @abstract
8496 * @extends OO.ui.Widget
8497 * @mixins OO.ui.mixin.GroupWidget
8498 * @mixins OO.ui.mixin.TitledElement
8499 *
8500 * @constructor
8501 * @param {Object} [config] Configuration options
8502 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8503 */
8504 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8505 // Parent constructor
8506 OO.ui.MultiselectWidget.parent.call( this, config );
8507
8508 // Configuration initialization
8509 config = config || {};
8510
8511 // Mixin constructors
8512 OO.ui.mixin.GroupWidget.call( this, config );
8513 OO.ui.mixin.TitledElement.call( this, config );
8514
8515 // Events
8516 this.aggregate( {
8517 change: 'select'
8518 } );
8519 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8520 // by GroupElement only when items are added/removed
8521 this.connect( this, {
8522 select: [ 'emit', 'change' ]
8523 } );
8524
8525 // Initialization
8526 if ( config.items ) {
8527 this.addItems( config.items );
8528 }
8529 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8530 this.$element.addClass( 'oo-ui-multiselectWidget' )
8531 .append( this.$group );
8532 };
8533
8534 /* Setup */
8535
8536 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8537 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8538 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8539
8540 /* Events */
8541
8542 /**
8543 * @event change
8544 *
8545 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8546 */
8547
8548 /**
8549 * @event select
8550 *
8551 * A select event is emitted when an item is selected or deselected.
8552 */
8553
8554 /* Methods */
8555
8556 /**
8557 * Find options that are selected.
8558 *
8559 * @return {OO.ui.MultioptionWidget[]} Selected options
8560 */
8561 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8562 return this.items.filter( function ( item ) {
8563 return item.isSelected();
8564 } );
8565 };
8566
8567 /**
8568 * Find the data of options that are selected.
8569 *
8570 * @return {Object[]|string[]} Values of selected options
8571 */
8572 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8573 return this.findSelectedItems().map( function ( item ) {
8574 return item.data;
8575 } );
8576 };
8577
8578 /**
8579 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8580 *
8581 * @param {OO.ui.MultioptionWidget[]} items Items to select
8582 * @chainable
8583 * @return {OO.ui.Widget} The widget, for chaining
8584 */
8585 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8586 this.items.forEach( function ( item ) {
8587 var selected = items.indexOf( item ) !== -1;
8588 item.setSelected( selected );
8589 } );
8590 return this;
8591 };
8592
8593 /**
8594 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8595 *
8596 * @param {Object[]|string[]} datas Values of items to select
8597 * @chainable
8598 * @return {OO.ui.Widget} The widget, for chaining
8599 */
8600 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8601 var items,
8602 widget = this;
8603 items = datas.map( function ( data ) {
8604 return widget.findItemFromData( data );
8605 } );
8606 this.selectItems( items );
8607 return this;
8608 };
8609
8610 /**
8611 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8612 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8613 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8614 *
8615 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8616 *
8617 * @class
8618 * @extends OO.ui.MultioptionWidget
8619 *
8620 * @constructor
8621 * @param {Object} [config] Configuration options
8622 */
8623 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8624 // Configuration initialization
8625 config = config || {};
8626
8627 // Properties (must be done before parent constructor which calls #setDisabled)
8628 this.checkbox = new OO.ui.CheckboxInputWidget();
8629
8630 // Parent constructor
8631 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8632
8633 // Events
8634 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8635 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8636
8637 // Initialization
8638 this.$element
8639 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8640 .prepend( this.checkbox.$element );
8641 };
8642
8643 /* Setup */
8644
8645 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8646
8647 /* Static Properties */
8648
8649 /**
8650 * @static
8651 * @inheritdoc
8652 */
8653 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8654
8655 /* Methods */
8656
8657 /**
8658 * Handle checkbox selected state change.
8659 *
8660 * @private
8661 */
8662 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8663 this.setSelected( this.checkbox.isSelected() );
8664 };
8665
8666 /**
8667 * @inheritdoc
8668 */
8669 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8670 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8671 this.checkbox.setSelected( state );
8672 return this;
8673 };
8674
8675 /**
8676 * @inheritdoc
8677 */
8678 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8679 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8680 this.checkbox.setDisabled( this.isDisabled() );
8681 return this;
8682 };
8683
8684 /**
8685 * Focus the widget.
8686 */
8687 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8688 this.checkbox.focus();
8689 };
8690
8691 /**
8692 * Handle key down events.
8693 *
8694 * @protected
8695 * @param {jQuery.Event} e
8696 */
8697 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8698 var
8699 element = this.getElementGroup(),
8700 nextItem;
8701
8702 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8703 nextItem = element.getRelativeFocusableItem( this, -1 );
8704 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8705 nextItem = element.getRelativeFocusableItem( this, 1 );
8706 }
8707
8708 if ( nextItem ) {
8709 e.preventDefault();
8710 nextItem.focus();
8711 }
8712 };
8713
8714 /**
8715 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8716 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8717 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8718 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8719 *
8720 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8721 * OO.ui.CheckboxMultiselectInputWidget instead.
8722 *
8723 * @example
8724 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8725 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8726 * data: 'a',
8727 * selected: true,
8728 * label: 'Selected checkbox'
8729 * } ),
8730 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8731 * data: 'b',
8732 * label: 'Unselected checkbox'
8733 * } ),
8734 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8735 * items: [ option1, option2 ]
8736 * } );
8737 * $( document.body ).append( multiselect.$element );
8738 *
8739 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8740 *
8741 * @class
8742 * @extends OO.ui.MultiselectWidget
8743 *
8744 * @constructor
8745 * @param {Object} [config] Configuration options
8746 */
8747 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8748 // Parent constructor
8749 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8750
8751 // Properties
8752 this.$lastClicked = null;
8753
8754 // Events
8755 this.$group.on( 'click', this.onClick.bind( this ) );
8756
8757 // Initialization
8758 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
8759 };
8760
8761 /* Setup */
8762
8763 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8764
8765 /* Methods */
8766
8767 /**
8768 * Get an option by its position relative to the specified item (or to the start of the
8769 * option array, if item is `null`). The direction in which to search through the option array
8770 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
8771 * return an option, or `null` if there are no options in the array.
8772 *
8773 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
8774 * `null` to start at the beginning of the array.
8775 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8776 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
8777 * in the select.
8778 */
8779 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8780 var currentIndex, nextIndex, i,
8781 increase = direction > 0 ? 1 : -1,
8782 len = this.items.length;
8783
8784 if ( item ) {
8785 currentIndex = this.items.indexOf( item );
8786 nextIndex = ( currentIndex + increase + len ) % len;
8787 } else {
8788 // If no item is selected and moving forward, start at the beginning.
8789 // If moving backward, start at the end.
8790 nextIndex = direction > 0 ? 0 : len - 1;
8791 }
8792
8793 for ( i = 0; i < len; i++ ) {
8794 item = this.items[ nextIndex ];
8795 if ( item && !item.isDisabled() ) {
8796 return item;
8797 }
8798 nextIndex = ( nextIndex + increase + len ) % len;
8799 }
8800 return null;
8801 };
8802
8803 /**
8804 * Handle click events on checkboxes.
8805 *
8806 * @param {jQuery.Event} e
8807 */
8808 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8809 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8810 $lastClicked = this.$lastClicked,
8811 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8812 .not( '.oo-ui-widget-disabled' );
8813
8814 // Allow selecting multiple options at once by Shift-clicking them
8815 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8816 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8817 lastClickedIndex = $options.index( $lastClicked );
8818 nowClickedIndex = $options.index( $nowClicked );
8819 // If it's the same item, either the user is being silly, or it's a fake event generated
8820 // by the browser. In either case we don't need custom handling.
8821 if ( nowClickedIndex !== lastClickedIndex ) {
8822 items = this.items;
8823 wasSelected = items[ nowClickedIndex ].isSelected();
8824 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8825
8826 // This depends on the DOM order of the items and the order of the .items array being
8827 // the same.
8828 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8829 if ( !items[ i ].isDisabled() ) {
8830 items[ i ].setSelected( !wasSelected );
8831 }
8832 }
8833 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8834 // handling first, then set our value. The order in which events happen is different for
8835 // clicks on the <input> and on the <label> and there are additional fake clicks fired
8836 // for non-click actions that change the checkboxes.
8837 e.preventDefault();
8838 setTimeout( function () {
8839 if ( !items[ nowClickedIndex ].isDisabled() ) {
8840 items[ nowClickedIndex ].setSelected( !wasSelected );
8841 }
8842 } );
8843 }
8844 }
8845
8846 if ( $nowClicked.length ) {
8847 this.$lastClicked = $nowClicked;
8848 }
8849 };
8850
8851 /**
8852 * Focus the widget
8853 *
8854 * @chainable
8855 * @return {OO.ui.Widget} The widget, for chaining
8856 */
8857 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8858 var item;
8859 if ( !this.isDisabled() ) {
8860 item = this.getRelativeFocusableItem( null, 1 );
8861 if ( item ) {
8862 item.focus();
8863 }
8864 }
8865 return this;
8866 };
8867
8868 /**
8869 * @inheritdoc
8870 */
8871 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8872 this.focus();
8873 };
8874
8875 /**
8876 * Progress bars visually display the status of an operation, such as a download,
8877 * and can be either determinate or indeterminate:
8878 *
8879 * - **determinate** process bars show the percent of an operation that is complete.
8880 *
8881 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8882 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8883 * not use percentages.
8884 *
8885 * The value of the `progress` configuration determines whether the bar is determinate
8886 * or indeterminate.
8887 *
8888 * @example
8889 * // Examples of determinate and indeterminate progress bars.
8890 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8891 * progress: 33
8892 * } );
8893 * var progressBar2 = new OO.ui.ProgressBarWidget();
8894 *
8895 * // Create a FieldsetLayout to layout progress bars.
8896 * var fieldset = new OO.ui.FieldsetLayout;
8897 * fieldset.addItems( [
8898 * new OO.ui.FieldLayout( progressBar1, {
8899 * label: 'Determinate',
8900 * align: 'top'
8901 * } ),
8902 * new OO.ui.FieldLayout( progressBar2, {
8903 * label: 'Indeterminate',
8904 * align: 'top'
8905 * } )
8906 * ] );
8907 * $( document.body ).append( fieldset.$element );
8908 *
8909 * @class
8910 * @extends OO.ui.Widget
8911 *
8912 * @constructor
8913 * @param {Object} [config] Configuration options
8914 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8915 * To create a determinate progress bar, specify a number that reflects the initial
8916 * percent complete.
8917 * By default, the progress bar is indeterminate.
8918 */
8919 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8920 // Configuration initialization
8921 config = config || {};
8922
8923 // Parent constructor
8924 OO.ui.ProgressBarWidget.parent.call( this, config );
8925
8926 // Properties
8927 this.$bar = $( '<div>' );
8928 this.progress = null;
8929
8930 // Initialization
8931 this.setProgress( config.progress !== undefined ? config.progress : false );
8932 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8933 this.$element
8934 .attr( {
8935 role: 'progressbar',
8936 'aria-valuemin': 0,
8937 'aria-valuemax': 100
8938 } )
8939 .addClass( 'oo-ui-progressBarWidget' )
8940 .append( this.$bar );
8941 };
8942
8943 /* Setup */
8944
8945 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8946
8947 /* Static Properties */
8948
8949 /**
8950 * @static
8951 * @inheritdoc
8952 */
8953 OO.ui.ProgressBarWidget.static.tagName = 'div';
8954
8955 /* Methods */
8956
8957 /**
8958 * Get the percent of the progress that has been completed. Indeterminate progresses will
8959 * return `false`.
8960 *
8961 * @return {number|boolean} Progress percent
8962 */
8963 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8964 return this.progress;
8965 };
8966
8967 /**
8968 * Set the percent of the process completed or `false` for an indeterminate process.
8969 *
8970 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8971 */
8972 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8973 this.progress = progress;
8974
8975 if ( progress !== false ) {
8976 this.$bar.css( 'width', this.progress + '%' );
8977 this.$element.attr( 'aria-valuenow', this.progress );
8978 } else {
8979 this.$bar.css( 'width', '' );
8980 this.$element.removeAttr( 'aria-valuenow' );
8981 }
8982 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8983 };
8984
8985 /**
8986 * InputWidget is the base class for all input widgets, which
8987 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
8988 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
8989 * {@link OO.ui.ButtonInputWidget button inputs}.
8990 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8991 *
8992 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8993 *
8994 * @abstract
8995 * @class
8996 * @extends OO.ui.Widget
8997 * @mixins OO.ui.mixin.TabIndexedElement
8998 * @mixins OO.ui.mixin.TitledElement
8999 * @mixins OO.ui.mixin.AccessKeyedElement
9000 *
9001 * @constructor
9002 * @param {Object} [config] Configuration options
9003 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9004 * @cfg {string} [value=''] The value of the input.
9005 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
9006 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
9007 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the
9008 * value of an input before it is accepted.
9009 */
9010 OO.ui.InputWidget = function OoUiInputWidget( config ) {
9011 // Configuration initialization
9012 config = config || {};
9013
9014 // Parent constructor
9015 OO.ui.InputWidget.parent.call( this, config );
9016
9017 // Properties
9018 // See #reusePreInfuseDOM about config.$input
9019 this.$input = config.$input || this.getInputElement( config );
9020 this.value = '';
9021 this.inputFilter = config.inputFilter;
9022
9023 // Mixin constructors
9024 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
9025 $tabIndexed: this.$input
9026 }, config ) );
9027 OO.ui.mixin.TitledElement.call( this, $.extend( {
9028 $titled: this.$input
9029 }, config ) );
9030 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
9031 $accessKeyed: this.$input
9032 }, config ) );
9033
9034 // Events
9035 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9036
9037 // Initialization
9038 this.$input
9039 .addClass( 'oo-ui-inputWidget-input' )
9040 .attr( 'name', config.name )
9041 .prop( 'disabled', this.isDisabled() );
9042 this.$element
9043 .addClass( 'oo-ui-inputWidget' )
9044 .append( this.$input );
9045 this.setValue( config.value );
9046 if ( config.dir ) {
9047 this.setDir( config.dir );
9048 }
9049 if ( config.inputId !== undefined ) {
9050 this.setInputId( config.inputId );
9051 }
9052 };
9053
9054 /* Setup */
9055
9056 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9057 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9058 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9059 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9060
9061 /* Static Methods */
9062
9063 /**
9064 * @inheritdoc
9065 */
9066 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9067 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
9068 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9069 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
9070 return config;
9071 };
9072
9073 /**
9074 * @inheritdoc
9075 */
9076 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9077 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
9078 if ( config.$input && config.$input.length ) {
9079 state.value = config.$input.val();
9080 // Might be better in TabIndexedElement, but it's awkward to do there because
9081 // mixins are awkward
9082 state.focus = config.$input.is( ':focus' );
9083 }
9084 return state;
9085 };
9086
9087 /* Events */
9088
9089 /**
9090 * @event change
9091 *
9092 * A change event is emitted when the value of the input changes.
9093 *
9094 * @param {string} value
9095 */
9096
9097 /* Methods */
9098
9099 /**
9100 * Get input element.
9101 *
9102 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9103 * different circumstances. The element must have a `value` property (like form elements).
9104 *
9105 * @protected
9106 * @param {Object} config Configuration options
9107 * @return {jQuery} Input element
9108 */
9109 OO.ui.InputWidget.prototype.getInputElement = function () {
9110 return $( '<input>' );
9111 };
9112
9113 /**
9114 * Handle potentially value-changing events.
9115 *
9116 * @private
9117 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9118 */
9119 OO.ui.InputWidget.prototype.onEdit = function () {
9120 var widget = this;
9121 if ( !this.isDisabled() ) {
9122 // Allow the stack to clear so the value will be updated
9123 setTimeout( function () {
9124 widget.setValue( widget.$input.val() );
9125 } );
9126 }
9127 };
9128
9129 /**
9130 * Get the value of the input.
9131 *
9132 * @return {string} Input value
9133 */
9134 OO.ui.InputWidget.prototype.getValue = function () {
9135 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9136 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9137 var value = this.$input.val();
9138 if ( this.value !== value ) {
9139 this.setValue( value );
9140 }
9141 return this.value;
9142 };
9143
9144 /**
9145 * Set the directionality of the input.
9146 *
9147 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9148 * @chainable
9149 * @return {OO.ui.Widget} The widget, for chaining
9150 */
9151 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9152 this.$input.prop( 'dir', dir );
9153 return this;
9154 };
9155
9156 /**
9157 * Set the value of the input.
9158 *
9159 * @param {string} value New value
9160 * @fires change
9161 * @chainable
9162 * @return {OO.ui.Widget} The widget, for chaining
9163 */
9164 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9165 value = this.cleanUpValue( value );
9166 // Update the DOM if it has changed. Note that with cleanUpValue, it
9167 // is possible for the DOM value to change without this.value changing.
9168 if ( this.$input.val() !== value ) {
9169 this.$input.val( value );
9170 }
9171 if ( this.value !== value ) {
9172 this.value = value;
9173 this.emit( 'change', this.value );
9174 }
9175 // The first time that the value is set (probably while constructing the widget),
9176 // remember it in defaultValue. This property can be later used to check whether
9177 // the value of the input has been changed since it was created.
9178 if ( this.defaultValue === undefined ) {
9179 this.defaultValue = this.value;
9180 this.$input[ 0 ].defaultValue = this.defaultValue;
9181 }
9182 return this;
9183 };
9184
9185 /**
9186 * Clean up incoming value.
9187 *
9188 * Ensures value is a string, and converts undefined and null to empty string.
9189 *
9190 * @private
9191 * @param {string} value Original value
9192 * @return {string} Cleaned up value
9193 */
9194 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9195 if ( value === undefined || value === null ) {
9196 return '';
9197 } else if ( this.inputFilter ) {
9198 return this.inputFilter( String( value ) );
9199 } else {
9200 return String( value );
9201 }
9202 };
9203
9204 /**
9205 * @inheritdoc
9206 */
9207 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9208 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9209 if ( this.$input ) {
9210 this.$input.prop( 'disabled', this.isDisabled() );
9211 }
9212 return this;
9213 };
9214
9215 /**
9216 * Set the 'id' attribute of the `<input>` element.
9217 *
9218 * @param {string} id
9219 * @chainable
9220 * @return {OO.ui.Widget} The widget, for chaining
9221 */
9222 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9223 this.$input.attr( 'id', id );
9224 return this;
9225 };
9226
9227 /**
9228 * @inheritdoc
9229 */
9230 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9231 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9232 if ( state.value !== undefined && state.value !== this.getValue() ) {
9233 this.setValue( state.value );
9234 }
9235 if ( state.focus ) {
9236 this.focus();
9237 }
9238 };
9239
9240 /**
9241 * Data widget intended for creating `<input type="hidden">` inputs.
9242 *
9243 * @class
9244 * @extends OO.ui.Widget
9245 *
9246 * @constructor
9247 * @param {Object} [config] Configuration options
9248 * @cfg {string} [value=''] The value of the input.
9249 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9250 */
9251 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9252 // Configuration initialization
9253 config = $.extend( { value: '', name: '' }, config );
9254
9255 // Parent constructor
9256 OO.ui.HiddenInputWidget.parent.call( this, config );
9257
9258 // Initialization
9259 this.$element.attr( {
9260 type: 'hidden',
9261 value: config.value,
9262 name: config.name
9263 } );
9264 this.$element.removeAttr( 'aria-disabled' );
9265 };
9266
9267 /* Setup */
9268
9269 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9270
9271 /* Static Properties */
9272
9273 /**
9274 * @static
9275 * @inheritdoc
9276 */
9277 OO.ui.HiddenInputWidget.static.tagName = 'input';
9278
9279 /**
9280 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9281 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9282 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9283 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9284 * [OOUI documentation on MediaWiki] [1] for more information.
9285 *
9286 * @example
9287 * // A ButtonInputWidget rendered as an HTML button, the default.
9288 * var button = new OO.ui.ButtonInputWidget( {
9289 * label: 'Input button',
9290 * icon: 'check',
9291 * value: 'check'
9292 * } );
9293 * $( document.body ).append( button.$element );
9294 *
9295 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9296 *
9297 * @class
9298 * @extends OO.ui.InputWidget
9299 * @mixins OO.ui.mixin.ButtonElement
9300 * @mixins OO.ui.mixin.IconElement
9301 * @mixins OO.ui.mixin.IndicatorElement
9302 * @mixins OO.ui.mixin.LabelElement
9303 * @mixins OO.ui.mixin.FlaggedElement
9304 *
9305 * @constructor
9306 * @param {Object} [config] Configuration options
9307 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute:
9308 * 'button', 'submit' or 'reset'.
9309 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9310 * Widgets configured to be an `<input>` do not support {@link #icon icons} and
9311 * {@link #indicator indicators},
9312 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should
9313 * only be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9314 */
9315 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9316 // Configuration initialization
9317 config = $.extend( { type: 'button', useInputTag: false }, config );
9318
9319 // See InputWidget#reusePreInfuseDOM about config.$input
9320 if ( config.$input ) {
9321 config.$input.empty();
9322 }
9323
9324 // Properties (must be set before parent constructor, which calls #setValue)
9325 this.useInputTag = config.useInputTag;
9326
9327 // Parent constructor
9328 OO.ui.ButtonInputWidget.parent.call( this, config );
9329
9330 // Mixin constructors
9331 OO.ui.mixin.ButtonElement.call( this, $.extend( {
9332 $button: this.$input
9333 }, config ) );
9334 OO.ui.mixin.IconElement.call( this, config );
9335 OO.ui.mixin.IndicatorElement.call( this, config );
9336 OO.ui.mixin.LabelElement.call( this, config );
9337 OO.ui.mixin.FlaggedElement.call( this, config );
9338
9339 // Initialization
9340 if ( !config.useInputTag ) {
9341 this.$input.append( this.$icon, this.$label, this.$indicator );
9342 }
9343 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9344 };
9345
9346 /* Setup */
9347
9348 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9349 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9350 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9351 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9352 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9353 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.FlaggedElement );
9354
9355 /* Static Properties */
9356
9357 /**
9358 * @static
9359 * @inheritdoc
9360 */
9361 OO.ui.ButtonInputWidget.static.tagName = 'span';
9362
9363 /* Methods */
9364
9365 /**
9366 * @inheritdoc
9367 * @protected
9368 */
9369 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9370 var type;
9371 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9372 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9373 };
9374
9375 /**
9376 * Set label value.
9377 *
9378 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9379 *
9380 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9381 * text, or `null` for no label
9382 * @chainable
9383 * @return {OO.ui.Widget} The widget, for chaining
9384 */
9385 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9386 if ( typeof label === 'function' ) {
9387 label = OO.ui.resolveMsg( label );
9388 }
9389
9390 if ( this.useInputTag ) {
9391 // Discard non-plaintext labels
9392 if ( typeof label !== 'string' ) {
9393 label = '';
9394 }
9395
9396 this.$input.val( label );
9397 }
9398
9399 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9400 };
9401
9402 /**
9403 * Set the value of the input.
9404 *
9405 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9406 * they do not support {@link #value values}.
9407 *
9408 * @param {string} value New value
9409 * @chainable
9410 * @return {OO.ui.Widget} The widget, for chaining
9411 */
9412 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9413 if ( !this.useInputTag ) {
9414 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9415 }
9416 return this;
9417 };
9418
9419 /**
9420 * @inheritdoc
9421 */
9422 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9423 // Disable generating `<label>` elements for buttons. One would very rarely need additional
9424 // label for a button, and it's already a big clickable target, and it causes
9425 // unexpected rendering.
9426 return null;
9427 };
9428
9429 /**
9430 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9431 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9432 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9433 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9434 *
9435 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9436 *
9437 * @example
9438 * // An example of selected, unselected, and disabled checkbox inputs.
9439 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9440 * value: 'a',
9441 * selected: true
9442 * } ),
9443 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9444 * value: 'b'
9445 * } ),
9446 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9447 * value:'c',
9448 * disabled: true
9449 * } ),
9450 * // Create a fieldset layout with fields for each checkbox.
9451 * fieldset = new OO.ui.FieldsetLayout( {
9452 * label: 'Checkboxes'
9453 * } );
9454 * fieldset.addItems( [
9455 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9456 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9457 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9458 * ] );
9459 * $( document.body ).append( fieldset.$element );
9460 *
9461 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9462 *
9463 * @class
9464 * @extends OO.ui.InputWidget
9465 *
9466 * @constructor
9467 * @param {Object} [config] Configuration options
9468 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is
9469 * not selected.
9470 * @cfg {boolean} [indeterminate=false] Whether the checkbox is in the indeterminate state.
9471 */
9472 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9473 // Configuration initialization
9474 config = config || {};
9475
9476 // Parent constructor
9477 OO.ui.CheckboxInputWidget.parent.call( this, config );
9478
9479 // Properties
9480 this.checkIcon = new OO.ui.IconWidget( {
9481 icon: 'check',
9482 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9483 } );
9484
9485 // Initialization
9486 this.$element
9487 .addClass( 'oo-ui-checkboxInputWidget' )
9488 // Required for pretty styling in WikimediaUI theme
9489 .append( this.checkIcon.$element );
9490 this.setSelected( config.selected !== undefined ? config.selected : false );
9491 this.setIndeterminate( config.indeterminate !== undefined ? config.indeterminate : false );
9492 };
9493
9494 /* Setup */
9495
9496 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9497
9498 /* Events */
9499
9500 /**
9501 * @event change
9502 *
9503 * A change event is emitted when the state of the input changes.
9504 *
9505 * @param {boolean} selected
9506 * @param {boolean} indeterminate
9507 */
9508
9509 /* Static Properties */
9510
9511 /**
9512 * @static
9513 * @inheritdoc
9514 */
9515 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9516
9517 /* Static Methods */
9518
9519 /**
9520 * @inheritdoc
9521 */
9522 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9523 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9524 state.checked = config.$input.prop( 'checked' );
9525 return state;
9526 };
9527
9528 /* Methods */
9529
9530 /**
9531 * @inheritdoc
9532 * @protected
9533 */
9534 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9535 return $( '<input>' ).attr( 'type', 'checkbox' );
9536 };
9537
9538 /**
9539 * @inheritdoc
9540 */
9541 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9542 var widget = this;
9543 if ( !this.isDisabled() ) {
9544 // Allow the stack to clear so the value will be updated
9545 setTimeout( function () {
9546 widget.setSelected( widget.$input.prop( 'checked' ) );
9547 widget.setIndeterminate( widget.$input.prop( 'indeterminate' ) );
9548 } );
9549 }
9550 };
9551
9552 /**
9553 * Set selection state of this checkbox.
9554 *
9555 * @param {boolean} state Selected state
9556 * @param {boolean} internal Used for internal calls to suppress events
9557 * @chainable
9558 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9559 */
9560 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state, internal ) {
9561 state = !!state;
9562 if ( this.selected !== state ) {
9563 this.selected = state;
9564 this.$input.prop( 'checked', this.selected );
9565 if ( !internal ) {
9566 this.setIndeterminate( false, true );
9567 this.emit( 'change', this.selected, this.indeterminate );
9568 }
9569 }
9570 // The first time that the selection state is set (probably while constructing the widget),
9571 // remember it in defaultSelected. This property can be later used to check whether
9572 // the selection state of the input has been changed since it was created.
9573 if ( this.defaultSelected === undefined ) {
9574 this.defaultSelected = this.selected;
9575 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9576 }
9577 return this;
9578 };
9579
9580 /**
9581 * Check if this checkbox is selected.
9582 *
9583 * @return {boolean} Checkbox is selected
9584 */
9585 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9586 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9587 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9588 var selected = this.$input.prop( 'checked' );
9589 if ( this.selected !== selected ) {
9590 this.setSelected( selected );
9591 }
9592 return this.selected;
9593 };
9594
9595 /**
9596 * Set indeterminate state of this checkbox.
9597 *
9598 * @param {boolean} state Indeterminate state
9599 * @param {boolean} internal Used for internal calls to suppress events
9600 * @chainable
9601 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9602 */
9603 OO.ui.CheckboxInputWidget.prototype.setIndeterminate = function ( state, internal ) {
9604 state = !!state;
9605 if ( this.indeterminate !== state ) {
9606 this.indeterminate = state;
9607 this.$input.prop( 'indeterminate', this.indeterminate );
9608 if ( !internal ) {
9609 this.setSelected( false, true );
9610 this.emit( 'change', this.selected, this.indeterminate );
9611 }
9612 }
9613 return this;
9614 };
9615
9616 /**
9617 * Check if this checkbox is selected.
9618 *
9619 * @return {boolean} Checkbox is selected
9620 */
9621 OO.ui.CheckboxInputWidget.prototype.isIndeterminate = function () {
9622 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9623 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9624 var indeterminate = this.$input.prop( 'indeterminate' );
9625 if ( this.indeterminate !== indeterminate ) {
9626 this.setIndeterminate( indeterminate );
9627 }
9628 return this.indeterminate;
9629 };
9630
9631 /**
9632 * @inheritdoc
9633 */
9634 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9635 if ( !this.isDisabled() ) {
9636 this.$handle.trigger( 'click' );
9637 }
9638 this.focus();
9639 };
9640
9641 /**
9642 * @inheritdoc
9643 */
9644 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9645 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9646 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9647 this.setSelected( state.checked );
9648 }
9649 };
9650
9651 /**
9652 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9653 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
9654 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9655 * more information about input widgets.
9656 *
9657 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9658 * are no options. If no `value` configuration option is provided, the first option is selected.
9659 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9660 *
9661 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9662 *
9663 * @example
9664 * // A DropdownInputWidget with three options.
9665 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9666 * options: [
9667 * { data: 'a', label: 'First' },
9668 * { data: 'b', label: 'Second', disabled: true },
9669 * { optgroup: 'Group label' },
9670 * { data: 'c', label: 'First sub-item)' }
9671 * ]
9672 * } );
9673 * $( document.body ).append( dropdownInput.$element );
9674 *
9675 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9676 *
9677 * @class
9678 * @extends OO.ui.InputWidget
9679 *
9680 * @constructor
9681 * @param {Object} [config] Configuration options
9682 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9683 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9684 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
9685 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
9686 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
9687 * uses relative positioning.
9688 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9689 */
9690 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9691 // Configuration initialization
9692 config = config || {};
9693
9694 // Properties (must be done before parent constructor which calls #setDisabled)
9695 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9696 {
9697 $overlay: config.$overlay
9698 },
9699 config.dropdown
9700 ) );
9701 // Set up the options before parent constructor, which uses them to validate config.value.
9702 // Use this instead of setOptions() because this.$input is not set up yet.
9703 this.setOptionsData( config.options || [] );
9704
9705 // Parent constructor
9706 OO.ui.DropdownInputWidget.parent.call( this, config );
9707
9708 // Events
9709 this.dropdownWidget.getMenu().connect( this, {
9710 select: 'onMenuSelect'
9711 } );
9712
9713 // Initialization
9714 this.$element
9715 .addClass( 'oo-ui-dropdownInputWidget' )
9716 .append( this.dropdownWidget.$element );
9717 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9718 this.setTitledElement( this.dropdownWidget.$handle );
9719 };
9720
9721 /* Setup */
9722
9723 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9724
9725 /* Methods */
9726
9727 /**
9728 * @inheritdoc
9729 * @protected
9730 */
9731 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9732 return $( '<select>' );
9733 };
9734
9735 /**
9736 * Handles menu select events.
9737 *
9738 * @private
9739 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9740 */
9741 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9742 this.setValue( item ? item.getData() : '' );
9743 };
9744
9745 /**
9746 * @inheritdoc
9747 */
9748 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9749 var selected;
9750 value = this.cleanUpValue( value );
9751 // Only allow setting values that are actually present in the dropdown
9752 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9753 this.dropdownWidget.getMenu().findFirstSelectableItem();
9754 this.dropdownWidget.getMenu().selectItem( selected );
9755 value = selected ? selected.getData() : '';
9756 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9757 if ( this.optionsDirty ) {
9758 // We reached this from the constructor or from #setOptions.
9759 // We have to update the <select> element.
9760 this.updateOptionsInterface();
9761 }
9762 return this;
9763 };
9764
9765 /**
9766 * @inheritdoc
9767 */
9768 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9769 this.dropdownWidget.setDisabled( state );
9770 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9771 return this;
9772 };
9773
9774 /**
9775 * Set the options available for this input.
9776 *
9777 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9778 * @chainable
9779 * @return {OO.ui.Widget} The widget, for chaining
9780 */
9781 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9782 var value = this.getValue();
9783
9784 this.setOptionsData( options );
9785
9786 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9787 // In case the previous value is no longer an available option, select the first valid one.
9788 this.setValue( value );
9789
9790 return this;
9791 };
9792
9793 /**
9794 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9795 *
9796 * This method may be called before the parent constructor, so various properties may not be
9797 * initialized yet.
9798 *
9799 * @param {Object[]} options Array of menu options (see #constructor for details).
9800 * @private
9801 */
9802 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9803 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9804 widget = this;
9805
9806 this.optionsDirty = true;
9807
9808 // Go through all the supplied option configs and create either
9809 // MenuSectionOption or MenuOption widgets from each.
9810 optionWidgets = [];
9811 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9812 opt = options[ optIndex ];
9813
9814 if ( opt.optgroup !== undefined ) {
9815 // Create a <optgroup> menu item.
9816 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9817 previousOptgroup = optionWidget;
9818
9819 } else {
9820 // Create a normal <option> menu item.
9821 optValue = widget.cleanUpValue( opt.data );
9822 optionWidget = widget.createMenuOptionWidget(
9823 optValue,
9824 opt.label !== undefined ? opt.label : optValue
9825 );
9826 }
9827
9828 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9829 if (
9830 opt.disabled !== undefined ||
9831 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
9832 previousOptgroup.isDisabled()
9833 ) {
9834 optionWidget.setDisabled( true );
9835 }
9836
9837 optionWidgets.push( optionWidget );
9838 }
9839
9840 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9841 };
9842
9843 /**
9844 * Create a menu option widget.
9845 *
9846 * @protected
9847 * @param {string} data Item data
9848 * @param {string} label Item label
9849 * @return {OO.ui.MenuOptionWidget} Option widget
9850 */
9851 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9852 return new OO.ui.MenuOptionWidget( {
9853 data: data,
9854 label: label
9855 } );
9856 };
9857
9858 /**
9859 * Create a menu section option widget.
9860 *
9861 * @protected
9862 * @param {string} label Section item label
9863 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9864 */
9865 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9866 return new OO.ui.MenuSectionOptionWidget( {
9867 label: label
9868 } );
9869 };
9870
9871 /**
9872 * Update the user-visible interface to match the internal list of options and value.
9873 *
9874 * This method must only be called after the parent constructor.
9875 *
9876 * @private
9877 */
9878 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9879 var
9880 $optionsContainer = this.$input,
9881 defaultValue = this.defaultValue,
9882 widget = this;
9883
9884 this.$input.empty();
9885
9886 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9887 var $optionNode;
9888
9889 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9890 $optionNode = $( '<option>' )
9891 .attr( 'value', optionWidget.getData() )
9892 .text( optionWidget.getLabel() );
9893
9894 // Remember original selection state. This property can be later used to check whether
9895 // the selection state of the input has been changed since it was created.
9896 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9897
9898 $optionsContainer.append( $optionNode );
9899 } else {
9900 $optionNode = $( '<optgroup>' )
9901 .attr( 'label', optionWidget.getLabel() );
9902 widget.$input.append( $optionNode );
9903 $optionsContainer = $optionNode;
9904 }
9905
9906 // Disable the option or optgroup if required.
9907 if ( optionWidget.isDisabled() ) {
9908 $optionNode.prop( 'disabled', true );
9909 }
9910 } );
9911
9912 this.optionsDirty = false;
9913 };
9914
9915 /**
9916 * @inheritdoc
9917 */
9918 OO.ui.DropdownInputWidget.prototype.focus = function () {
9919 this.dropdownWidget.focus();
9920 return this;
9921 };
9922
9923 /**
9924 * @inheritdoc
9925 */
9926 OO.ui.DropdownInputWidget.prototype.blur = function () {
9927 this.dropdownWidget.blur();
9928 return this;
9929 };
9930
9931 /**
9932 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9933 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9934 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9935 * please see the [OOUI documentation on MediaWiki][1].
9936 *
9937 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9938 *
9939 * @example
9940 * // An example of selected, unselected, and disabled radio inputs
9941 * var radio1 = new OO.ui.RadioInputWidget( {
9942 * value: 'a',
9943 * selected: true
9944 * } );
9945 * var radio2 = new OO.ui.RadioInputWidget( {
9946 * value: 'b'
9947 * } );
9948 * var radio3 = new OO.ui.RadioInputWidget( {
9949 * value: 'c',
9950 * disabled: true
9951 * } );
9952 * // Create a fieldset layout with fields for each radio button.
9953 * var fieldset = new OO.ui.FieldsetLayout( {
9954 * label: 'Radio inputs'
9955 * } );
9956 * fieldset.addItems( [
9957 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9958 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9959 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9960 * ] );
9961 * $( document.body ).append( fieldset.$element );
9962 *
9963 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9964 *
9965 * @class
9966 * @extends OO.ui.InputWidget
9967 *
9968 * @constructor
9969 * @param {Object} [config] Configuration options
9970 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button
9971 * is not selected.
9972 */
9973 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9974 // Configuration initialization
9975 config = config || {};
9976
9977 // Parent constructor
9978 OO.ui.RadioInputWidget.parent.call( this, config );
9979
9980 // Initialization
9981 this.$element
9982 .addClass( 'oo-ui-radioInputWidget' )
9983 // Required for pretty styling in WikimediaUI theme
9984 .append( $( '<span>' ) );
9985 this.setSelected( config.selected !== undefined ? config.selected : false );
9986 };
9987
9988 /* Setup */
9989
9990 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9991
9992 /* Static Properties */
9993
9994 /**
9995 * @static
9996 * @inheritdoc
9997 */
9998 OO.ui.RadioInputWidget.static.tagName = 'span';
9999
10000 /* Static Methods */
10001
10002 /**
10003 * @inheritdoc
10004 */
10005 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10006 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
10007 state.checked = config.$input.prop( 'checked' );
10008 return state;
10009 };
10010
10011 /* Methods */
10012
10013 /**
10014 * @inheritdoc
10015 * @protected
10016 */
10017 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
10018 return $( '<input>' ).attr( 'type', 'radio' );
10019 };
10020
10021 /**
10022 * @inheritdoc
10023 */
10024 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10025 // RadioInputWidget doesn't track its state.
10026 };
10027
10028 /**
10029 * Set selection state of this radio button.
10030 *
10031 * @param {boolean} state `true` for selected
10032 * @chainable
10033 * @return {OO.ui.Widget} The widget, for chaining
10034 */
10035 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10036 // RadioInputWidget doesn't track its state.
10037 this.$input.prop( 'checked', state );
10038 // The first time that the selection state is set (probably while constructing the widget),
10039 // remember it in defaultSelected. This property can be later used to check whether
10040 // the selection state of the input has been changed since it was created.
10041 if ( this.defaultSelected === undefined ) {
10042 this.defaultSelected = state;
10043 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10044 }
10045 return this;
10046 };
10047
10048 /**
10049 * Check if this radio button is selected.
10050 *
10051 * @return {boolean} Radio is selected
10052 */
10053 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10054 return this.$input.prop( 'checked' );
10055 };
10056
10057 /**
10058 * @inheritdoc
10059 */
10060 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10061 if ( !this.isDisabled() ) {
10062 this.$input.trigger( 'click' );
10063 }
10064 this.focus();
10065 };
10066
10067 /**
10068 * @inheritdoc
10069 */
10070 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10071 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10072 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10073 this.setSelected( state.checked );
10074 }
10075 };
10076
10077 /**
10078 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10079 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10080 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10081 * more information about input widgets.
10082 *
10083 * This and OO.ui.DropdownInputWidget support similar configuration options.
10084 *
10085 * @example
10086 * // A RadioSelectInputWidget with three options
10087 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
10088 * options: [
10089 * { data: 'a', label: 'First' },
10090 * { data: 'b', label: 'Second'},
10091 * { data: 'c', label: 'Third' }
10092 * ]
10093 * } );
10094 * $( document.body ).append( radioSelectInput.$element );
10095 *
10096 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10097 *
10098 * @class
10099 * @extends OO.ui.InputWidget
10100 *
10101 * @constructor
10102 * @param {Object} [config] Configuration options
10103 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10104 */
10105 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
10106 // Configuration initialization
10107 config = config || {};
10108
10109 // Properties (must be done before parent constructor which calls #setDisabled)
10110 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
10111 // Set up the options before parent constructor, which uses them to validate config.value.
10112 // Use this instead of setOptions() because this.$input is not set up yet
10113 this.setOptionsData( config.options || [] );
10114
10115 // Parent constructor
10116 OO.ui.RadioSelectInputWidget.parent.call( this, config );
10117
10118 // Events
10119 this.radioSelectWidget.connect( this, {
10120 select: 'onMenuSelect'
10121 } );
10122
10123 // Initialization
10124 this.$element
10125 .addClass( 'oo-ui-radioSelectInputWidget' )
10126 .append( this.radioSelectWidget.$element );
10127 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
10128 };
10129
10130 /* Setup */
10131
10132 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
10133
10134 /* Static Methods */
10135
10136 /**
10137 * @inheritdoc
10138 */
10139 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10140 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
10141 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
10142 return state;
10143 };
10144
10145 /**
10146 * @inheritdoc
10147 */
10148 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10149 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10150 // Cannot reuse the `<input type=radio>` set
10151 delete config.$input;
10152 return config;
10153 };
10154
10155 /* Methods */
10156
10157 /**
10158 * @inheritdoc
10159 * @protected
10160 */
10161 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10162 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10163 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10164 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10165 };
10166
10167 /**
10168 * Handles menu select events.
10169 *
10170 * @private
10171 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10172 */
10173 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10174 this.setValue( item.getData() );
10175 };
10176
10177 /**
10178 * @inheritdoc
10179 */
10180 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10181 var selected;
10182 value = this.cleanUpValue( value );
10183 // Only allow setting values that are actually present in the dropdown
10184 selected = this.radioSelectWidget.findItemFromData( value ) ||
10185 this.radioSelectWidget.findFirstSelectableItem();
10186 this.radioSelectWidget.selectItem( selected );
10187 value = selected ? selected.getData() : '';
10188 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10189 return this;
10190 };
10191
10192 /**
10193 * @inheritdoc
10194 */
10195 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10196 this.radioSelectWidget.setDisabled( state );
10197 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10198 return this;
10199 };
10200
10201 /**
10202 * Set the options available for this input.
10203 *
10204 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10205 * @chainable
10206 * @return {OO.ui.Widget} The widget, for chaining
10207 */
10208 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10209 var value = this.getValue();
10210
10211 this.setOptionsData( options );
10212
10213 // Re-set the value to update the visible interface (RadioSelectWidget).
10214 // In case the previous value is no longer an available option, select the first valid one.
10215 this.setValue( value );
10216
10217 return this;
10218 };
10219
10220 /**
10221 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10222 *
10223 * This method may be called before the parent constructor, so various properties may not be
10224 * intialized yet.
10225 *
10226 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10227 * @private
10228 */
10229 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10230 var widget = this;
10231
10232 this.radioSelectWidget
10233 .clearItems()
10234 .addItems( options.map( function ( opt ) {
10235 var optValue = widget.cleanUpValue( opt.data );
10236 return new OO.ui.RadioOptionWidget( {
10237 data: optValue,
10238 label: opt.label !== undefined ? opt.label : optValue
10239 } );
10240 } ) );
10241 };
10242
10243 /**
10244 * @inheritdoc
10245 */
10246 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10247 this.radioSelectWidget.focus();
10248 return this;
10249 };
10250
10251 /**
10252 * @inheritdoc
10253 */
10254 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10255 this.radioSelectWidget.blur();
10256 return this;
10257 };
10258
10259 /**
10260 * CheckboxMultiselectInputWidget is a
10261 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10262 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10263 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10264 * more information about input widgets.
10265 *
10266 * @example
10267 * // A CheckboxMultiselectInputWidget with three options.
10268 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10269 * options: [
10270 * { data: 'a', label: 'First' },
10271 * { data: 'b', label: 'Second' },
10272 * { data: 'c', label: 'Third' }
10273 * ]
10274 * } );
10275 * $( document.body ).append( multiselectInput.$element );
10276 *
10277 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10278 *
10279 * @class
10280 * @extends OO.ui.InputWidget
10281 *
10282 * @constructor
10283 * @param {Object} [config] Configuration options
10284 * @cfg {Object[]} [options=[]] Array of menu options in the format
10285 * `{ data: …, label: …, disabled: … }`
10286 */
10287 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10288 // Configuration initialization
10289 config = config || {};
10290
10291 // Properties (must be done before parent constructor which calls #setDisabled)
10292 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10293 // Must be set before the #setOptionsData call below
10294 this.inputName = config.name;
10295 // Set up the options before parent constructor, which uses them to validate config.value.
10296 // Use this instead of setOptions() because this.$input is not set up yet
10297 this.setOptionsData( config.options || [] );
10298
10299 // Parent constructor
10300 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10301
10302 // Events
10303 this.checkboxMultiselectWidget.connect( this, {
10304 select: 'onCheckboxesSelect'
10305 } );
10306
10307 // Initialization
10308 this.$element
10309 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10310 .append( this.checkboxMultiselectWidget.$element );
10311 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10312 this.$input.detach();
10313 };
10314
10315 /* Setup */
10316
10317 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10318
10319 /* Static Methods */
10320
10321 /**
10322 * @inheritdoc
10323 */
10324 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10325 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState(
10326 node, config
10327 );
10328 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10329 .toArray().map( function ( el ) { return el.value; } );
10330 return state;
10331 };
10332
10333 /**
10334 * @inheritdoc
10335 */
10336 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10337 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10338 // Cannot reuse the `<input type=checkbox>` set
10339 delete config.$input;
10340 return config;
10341 };
10342
10343 /* Methods */
10344
10345 /**
10346 * @inheritdoc
10347 * @protected
10348 */
10349 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10350 // Actually unused
10351 return $( '<unused>' );
10352 };
10353
10354 /**
10355 * Handles CheckboxMultiselectWidget select events.
10356 *
10357 * @private
10358 */
10359 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10360 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10361 };
10362
10363 /**
10364 * @inheritdoc
10365 */
10366 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10367 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10368 .toArray().map( function ( el ) { return el.value; } );
10369 if ( this.value !== value ) {
10370 this.setValue( value );
10371 }
10372 return this.value;
10373 };
10374
10375 /**
10376 * @inheritdoc
10377 */
10378 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10379 value = this.cleanUpValue( value );
10380 this.checkboxMultiselectWidget.selectItemsByData( value );
10381 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10382 if ( this.optionsDirty ) {
10383 // We reached this from the constructor or from #setOptions.
10384 // We have to update the <select> element.
10385 this.updateOptionsInterface();
10386 }
10387 return this;
10388 };
10389
10390 /**
10391 * Clean up incoming value.
10392 *
10393 * @param {string[]} value Original value
10394 * @return {string[]} Cleaned up value
10395 */
10396 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10397 var i, singleValue,
10398 cleanValue = [];
10399 if ( !Array.isArray( value ) ) {
10400 return cleanValue;
10401 }
10402 for ( i = 0; i < value.length; i++ ) {
10403 singleValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10404 .call( this, value[ i ] );
10405 // Remove options that we don't have here
10406 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10407 continue;
10408 }
10409 cleanValue.push( singleValue );
10410 }
10411 return cleanValue;
10412 };
10413
10414 /**
10415 * @inheritdoc
10416 */
10417 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10418 this.checkboxMultiselectWidget.setDisabled( state );
10419 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10420 return this;
10421 };
10422
10423 /**
10424 * Set the options available for this input.
10425 *
10426 * @param {Object[]} options Array of menu options in the format
10427 * `{ data: …, label: …, disabled: … }`
10428 * @chainable
10429 * @return {OO.ui.Widget} The widget, for chaining
10430 */
10431 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10432 var value = this.getValue();
10433
10434 this.setOptionsData( options );
10435
10436 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10437 // This will also get rid of any stale options that we just removed.
10438 this.setValue( value );
10439
10440 return this;
10441 };
10442
10443 /**
10444 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10445 *
10446 * This method may be called before the parent constructor, so various properties may not be
10447 * intialized yet.
10448 *
10449 * @param {Object[]} options Array of menu options in the format
10450 * `{ data: …, label: … }`
10451 * @private
10452 */
10453 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10454 var widget = this;
10455
10456 this.optionsDirty = true;
10457
10458 this.checkboxMultiselectWidget
10459 .clearItems()
10460 .addItems( options.map( function ( opt ) {
10461 var optValue, item, optDisabled;
10462 optValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10463 .call( widget, opt.data );
10464 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10465 item = new OO.ui.CheckboxMultioptionWidget( {
10466 data: optValue,
10467 label: opt.label !== undefined ? opt.label : optValue,
10468 disabled: optDisabled
10469 } );
10470 // Set the 'name' and 'value' for form submission
10471 item.checkbox.$input.attr( 'name', widget.inputName );
10472 item.checkbox.setValue( optValue );
10473 return item;
10474 } ) );
10475 };
10476
10477 /**
10478 * Update the user-visible interface to match the internal list of options and value.
10479 *
10480 * This method must only be called after the parent constructor.
10481 *
10482 * @private
10483 */
10484 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10485 var defaultValue = this.defaultValue;
10486
10487 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10488 // Remember original selection state. This property can be later used to check whether
10489 // the selection state of the input has been changed since it was created.
10490 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10491 item.checkbox.defaultSelected = isDefault;
10492 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10493 } );
10494
10495 this.optionsDirty = false;
10496 };
10497
10498 /**
10499 * @inheritdoc
10500 */
10501 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10502 this.checkboxMultiselectWidget.focus();
10503 return this;
10504 };
10505
10506 /**
10507 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10508 * size of the field as well as its presentation. In addition, these widgets can be configured
10509 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
10510 * optional validation-pattern (used to determine if an input value is valid or not) and an input
10511 * filter, which modifies incoming values rather than validating them.
10512 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10513 *
10514 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10515 *
10516 * @example
10517 * // A TextInputWidget.
10518 * var textInput = new OO.ui.TextInputWidget( {
10519 * value: 'Text input'
10520 * } );
10521 * $( document.body ).append( textInput.$element );
10522 *
10523 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10524 *
10525 * @class
10526 * @extends OO.ui.InputWidget
10527 * @mixins OO.ui.mixin.IconElement
10528 * @mixins OO.ui.mixin.IndicatorElement
10529 * @mixins OO.ui.mixin.PendingElement
10530 * @mixins OO.ui.mixin.LabelElement
10531 * @mixins OO.ui.mixin.FlaggedElement
10532 *
10533 * @constructor
10534 * @param {Object} [config] Configuration options
10535 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10536 * 'email', 'url' or 'number'.
10537 * @cfg {string} [placeholder] Placeholder text
10538 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10539 * instruct the browser to focus this widget.
10540 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10541 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10542 *
10543 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10544 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10545 * many emojis) count as 2 characters each.
10546 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10547 * the value or placeholder text: `'before'` or `'after'`
10548 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator:
10549 * 'required'`. Note that `false` & setting `indicator: 'required' will result in no indicator
10550 * shown.
10551 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10552 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined`
10553 * means leaving it up to the browser).
10554 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10555 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10556 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10557 * value for it to be considered valid; when Function, a function receiving the value as parameter
10558 * that must return true, or promise resolving to true, for it to be considered valid.
10559 */
10560 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10561 // Configuration initialization
10562 config = $.extend( {
10563 type: 'text',
10564 labelPosition: 'after'
10565 }, config );
10566
10567 // Parent constructor
10568 OO.ui.TextInputWidget.parent.call( this, config );
10569
10570 // Mixin constructors
10571 OO.ui.mixin.IconElement.call( this, config );
10572 OO.ui.mixin.IndicatorElement.call( this, config );
10573 OO.ui.mixin.PendingElement.call( this, $.extend( { $pending: this.$input }, config ) );
10574 OO.ui.mixin.LabelElement.call( this, config );
10575 OO.ui.mixin.FlaggedElement.call( this, config );
10576
10577 // Properties
10578 this.type = this.getSaneType( config );
10579 this.readOnly = false;
10580 this.required = false;
10581 this.validate = null;
10582 this.scrollWidth = null;
10583
10584 this.setValidation( config.validate );
10585 this.setLabelPosition( config.labelPosition );
10586
10587 // Events
10588 this.$input.on( {
10589 keypress: this.onKeyPress.bind( this ),
10590 blur: this.onBlur.bind( this ),
10591 focus: this.onFocus.bind( this )
10592 } );
10593 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10594 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10595 this.on( 'labelChange', this.updatePosition.bind( this ) );
10596 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10597
10598 // Initialization
10599 this.$element
10600 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10601 .append( this.$icon, this.$indicator );
10602 this.setReadOnly( !!config.readOnly );
10603 this.setRequired( !!config.required );
10604 if ( config.placeholder !== undefined ) {
10605 this.$input.attr( 'placeholder', config.placeholder );
10606 }
10607 if ( config.maxLength !== undefined ) {
10608 this.$input.attr( 'maxlength', config.maxLength );
10609 }
10610 if ( config.autofocus ) {
10611 this.$input.attr( 'autofocus', 'autofocus' );
10612 }
10613 if ( config.autocomplete === false ) {
10614 this.$input.attr( 'autocomplete', 'off' );
10615 // Turning off autocompletion also disables "form caching" when the user navigates to a
10616 // different page and then clicks "Back". Re-enable it when leaving.
10617 // Borrowed from jQuery UI.
10618 $( window ).on( {
10619 beforeunload: function () {
10620 this.$input.removeAttr( 'autocomplete' );
10621 }.bind( this ),
10622 pageshow: function () {
10623 // Browsers don't seem to actually fire this event on "Back", they instead just
10624 // reload the whole page... it shouldn't hurt, though.
10625 this.$input.attr( 'autocomplete', 'off' );
10626 }.bind( this )
10627 } );
10628 }
10629 if ( config.spellcheck !== undefined ) {
10630 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10631 }
10632 if ( this.label ) {
10633 this.isWaitingToBeAttached = true;
10634 this.installParentChangeDetector();
10635 }
10636 };
10637
10638 /* Setup */
10639
10640 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10641 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10642 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10643 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10644 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10645 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.FlaggedElement );
10646
10647 /* Static Properties */
10648
10649 OO.ui.TextInputWidget.static.validationPatterns = {
10650 'non-empty': /.+/,
10651 integer: /^\d+$/
10652 };
10653
10654 /* Events */
10655
10656 /**
10657 * An `enter` event is emitted when the user presses Enter key inside the text box.
10658 *
10659 * @event enter
10660 */
10661
10662 /* Methods */
10663
10664 /**
10665 * Handle icon mouse down events.
10666 *
10667 * @private
10668 * @param {jQuery.Event} e Mouse down event
10669 * @return {undefined/boolean} False to prevent default if event is handled
10670 */
10671 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10672 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10673 this.focus();
10674 return false;
10675 }
10676 };
10677
10678 /**
10679 * Handle indicator mouse down events.
10680 *
10681 * @private
10682 * @param {jQuery.Event} e Mouse down event
10683 * @return {undefined/boolean} False to prevent default if event is handled
10684 */
10685 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10686 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10687 this.focus();
10688 return false;
10689 }
10690 };
10691
10692 /**
10693 * Handle key press events.
10694 *
10695 * @private
10696 * @param {jQuery.Event} e Key press event
10697 * @fires enter If Enter key is pressed
10698 */
10699 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10700 if ( e.which === OO.ui.Keys.ENTER ) {
10701 this.emit( 'enter', e );
10702 }
10703 };
10704
10705 /**
10706 * Handle blur events.
10707 *
10708 * @private
10709 * @param {jQuery.Event} e Blur event
10710 */
10711 OO.ui.TextInputWidget.prototype.onBlur = function () {
10712 this.setValidityFlag();
10713 };
10714
10715 /**
10716 * Handle focus events.
10717 *
10718 * @private
10719 * @param {jQuery.Event} e Focus event
10720 */
10721 OO.ui.TextInputWidget.prototype.onFocus = function () {
10722 if ( this.isWaitingToBeAttached ) {
10723 // If we've received focus, then we must be attached to the document, and if
10724 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10725 this.onElementAttach();
10726 }
10727 this.setValidityFlag( true );
10728 };
10729
10730 /**
10731 * Handle element attach events.
10732 *
10733 * @private
10734 * @param {jQuery.Event} e Element attach event
10735 */
10736 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10737 this.isWaitingToBeAttached = false;
10738 // Any previously calculated size is now probably invalid if we reattached elsewhere
10739 this.valCache = null;
10740 this.positionLabel();
10741 };
10742
10743 /**
10744 * Handle debounced change events.
10745 *
10746 * @param {string} value
10747 * @private
10748 */
10749 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10750 this.setValidityFlag();
10751 };
10752
10753 /**
10754 * Check if the input is {@link #readOnly read-only}.
10755 *
10756 * @return {boolean}
10757 */
10758 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10759 return this.readOnly;
10760 };
10761
10762 /**
10763 * Set the {@link #readOnly read-only} state of the input.
10764 *
10765 * @param {boolean} state Make input read-only
10766 * @chainable
10767 * @return {OO.ui.Widget} The widget, for chaining
10768 */
10769 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10770 this.readOnly = !!state;
10771 this.$input.prop( 'readOnly', this.readOnly );
10772 return this;
10773 };
10774
10775 /**
10776 * Check if the input is {@link #required required}.
10777 *
10778 * @return {boolean}
10779 */
10780 OO.ui.TextInputWidget.prototype.isRequired = function () {
10781 return this.required;
10782 };
10783
10784 /**
10785 * Set the {@link #required required} state of the input.
10786 *
10787 * @param {boolean} state Make input required
10788 * @chainable
10789 * @return {OO.ui.Widget} The widget, for chaining
10790 */
10791 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10792 this.required = !!state;
10793 if ( this.required ) {
10794 this.$input
10795 .prop( 'required', true )
10796 .attr( 'aria-required', 'true' );
10797 if ( this.getIndicator() === null ) {
10798 this.setIndicator( 'required' );
10799 }
10800 } else {
10801 this.$input
10802 .prop( 'required', false )
10803 .removeAttr( 'aria-required' );
10804 if ( this.getIndicator() === 'required' ) {
10805 this.setIndicator( null );
10806 }
10807 }
10808 return this;
10809 };
10810
10811 /**
10812 * Support function for making #onElementAttach work across browsers.
10813 *
10814 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10815 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10816 *
10817 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10818 * first time that the element gets attached to the documented.
10819 */
10820 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10821 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10822 MutationObserver = window.MutationObserver ||
10823 window.WebKitMutationObserver ||
10824 window.MozMutationObserver,
10825 widget = this;
10826
10827 if ( MutationObserver ) {
10828 // The new way. If only it wasn't so ugly.
10829
10830 if ( this.isElementAttached() ) {
10831 // Widget is attached already, do nothing. This breaks the functionality of this
10832 // function when the widget is detached and reattached. Alas, doing this correctly with
10833 // MutationObserver would require observation of the whole document, which would hurt
10834 // performance of other, more important code.
10835 return;
10836 }
10837
10838 // Find topmost node in the tree
10839 topmostNode = this.$element[ 0 ];
10840 while ( topmostNode.parentNode ) {
10841 topmostNode = topmostNode.parentNode;
10842 }
10843
10844 // We have no way to detect the $element being attached somewhere without observing the
10845 // entire DOM with subtree modifications, which would hurt performance. So we cheat: we hook
10846 // to the parent node of $element, and instead detect when $element is removed from it (and
10847 // thus probably attached somewhere else). If there is no parent, we create a "fake" one. If
10848 // it doesn't get attached, we end up back here and create the parent.
10849 mutationObserver = new MutationObserver( function ( mutations ) {
10850 var i, j, removedNodes;
10851 for ( i = 0; i < mutations.length; i++ ) {
10852 removedNodes = mutations[ i ].removedNodes;
10853 for ( j = 0; j < removedNodes.length; j++ ) {
10854 if ( removedNodes[ j ] === topmostNode ) {
10855 setTimeout( onRemove, 0 );
10856 return;
10857 }
10858 }
10859 }
10860 } );
10861
10862 onRemove = function () {
10863 // If the node was attached somewhere else, report it
10864 if ( widget.isElementAttached() ) {
10865 widget.onElementAttach();
10866 }
10867 mutationObserver.disconnect();
10868 widget.installParentChangeDetector();
10869 };
10870
10871 // Create a fake parent and observe it
10872 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10873 mutationObserver.observe( fakeParentNode, { childList: true } );
10874 } else {
10875 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10876 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10877 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10878 }
10879 };
10880
10881 /**
10882 * @inheritdoc
10883 * @protected
10884 */
10885 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10886 if ( this.getSaneType( config ) === 'number' ) {
10887 return $( '<input>' )
10888 .attr( 'step', 'any' )
10889 .attr( 'type', 'number' );
10890 } else {
10891 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10892 }
10893 };
10894
10895 /**
10896 * Get sanitized value for 'type' for given config.
10897 *
10898 * @param {Object} config Configuration options
10899 * @return {string|null}
10900 * @protected
10901 */
10902 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10903 var allowedTypes = [
10904 'text',
10905 'password',
10906 'email',
10907 'url',
10908 'number'
10909 ];
10910 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10911 };
10912
10913 /**
10914 * Focus the input and select a specified range within the text.
10915 *
10916 * @param {number} from Select from offset
10917 * @param {number} [to] Select to offset, defaults to from
10918 * @chainable
10919 * @return {OO.ui.Widget} The widget, for chaining
10920 */
10921 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10922 var isBackwards, start, end,
10923 input = this.$input[ 0 ];
10924
10925 to = to || from;
10926
10927 isBackwards = to < from;
10928 start = isBackwards ? to : from;
10929 end = isBackwards ? from : to;
10930
10931 this.focus();
10932
10933 try {
10934 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10935 } catch ( e ) {
10936 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10937 // Rather than expensively check if the input is attached every time, just check
10938 // if it was the cause of an error being thrown. If not, rethrow the error.
10939 if ( this.getElementDocument().body.contains( input ) ) {
10940 throw e;
10941 }
10942 }
10943 return this;
10944 };
10945
10946 /**
10947 * Get an object describing the current selection range in a directional manner
10948 *
10949 * @return {Object} Object containing 'from' and 'to' offsets
10950 */
10951 OO.ui.TextInputWidget.prototype.getRange = function () {
10952 var input = this.$input[ 0 ],
10953 start = input.selectionStart,
10954 end = input.selectionEnd,
10955 isBackwards = input.selectionDirection === 'backward';
10956
10957 return {
10958 from: isBackwards ? end : start,
10959 to: isBackwards ? start : end
10960 };
10961 };
10962
10963 /**
10964 * Get the length of the text input value.
10965 *
10966 * This could differ from the length of #getValue if the
10967 * value gets filtered
10968 *
10969 * @return {number} Input length
10970 */
10971 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10972 return this.$input[ 0 ].value.length;
10973 };
10974
10975 /**
10976 * Focus the input and select the entire text.
10977 *
10978 * @chainable
10979 * @return {OO.ui.Widget} The widget, for chaining
10980 */
10981 OO.ui.TextInputWidget.prototype.select = function () {
10982 return this.selectRange( 0, this.getInputLength() );
10983 };
10984
10985 /**
10986 * Focus the input and move the cursor to the start.
10987 *
10988 * @chainable
10989 * @return {OO.ui.Widget} The widget, for chaining
10990 */
10991 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10992 return this.selectRange( 0 );
10993 };
10994
10995 /**
10996 * Focus the input and move the cursor to the end.
10997 *
10998 * @chainable
10999 * @return {OO.ui.Widget} The widget, for chaining
11000 */
11001 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
11002 return this.selectRange( this.getInputLength() );
11003 };
11004
11005 /**
11006 * Insert new content into the input.
11007 *
11008 * @param {string} content Content to be inserted
11009 * @chainable
11010 * @return {OO.ui.Widget} The widget, for chaining
11011 */
11012 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
11013 var start, end,
11014 range = this.getRange(),
11015 value = this.getValue();
11016
11017 start = Math.min( range.from, range.to );
11018 end = Math.max( range.from, range.to );
11019
11020 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11021 this.selectRange( start + content.length );
11022 return this;
11023 };
11024
11025 /**
11026 * Insert new content either side of a selection.
11027 *
11028 * @param {string} pre Content to be inserted before the selection
11029 * @param {string} post Content to be inserted after the selection
11030 * @chainable
11031 * @return {OO.ui.Widget} The widget, for chaining
11032 */
11033 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11034 var start, end,
11035 range = this.getRange(),
11036 offset = pre.length;
11037
11038 start = Math.min( range.from, range.to );
11039 end = Math.max( range.from, range.to );
11040
11041 this.selectRange( start ).insertContent( pre );
11042 this.selectRange( offset + end ).insertContent( post );
11043
11044 this.selectRange( offset + start, offset + end );
11045 return this;
11046 };
11047
11048 /**
11049 * Set the validation pattern.
11050 *
11051 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11052 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11053 * value must contain only numbers).
11054 *
11055 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11056 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11057 */
11058 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11059 if ( validate instanceof RegExp || validate instanceof Function ) {
11060 this.validate = validate;
11061 } else {
11062 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
11063 }
11064 };
11065
11066 /**
11067 * Sets the 'invalid' flag appropriately.
11068 *
11069 * @param {boolean} [isValid] Optionally override validation result
11070 */
11071 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11072 var widget = this,
11073 setFlag = function ( valid ) {
11074 if ( !valid ) {
11075 widget.$input.attr( 'aria-invalid', 'true' );
11076 } else {
11077 widget.$input.removeAttr( 'aria-invalid' );
11078 }
11079 widget.setFlags( { invalid: !valid } );
11080 };
11081
11082 if ( isValid !== undefined ) {
11083 setFlag( isValid );
11084 } else {
11085 this.getValidity().then( function () {
11086 setFlag( true );
11087 }, function () {
11088 setFlag( false );
11089 } );
11090 }
11091 };
11092
11093 /**
11094 * Get the validity of current value.
11095 *
11096 * This method returns a promise that resolves if the value is valid and rejects if
11097 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
11098 *
11099 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11100 */
11101 OO.ui.TextInputWidget.prototype.getValidity = function () {
11102 var result;
11103
11104 function rejectOrResolve( valid ) {
11105 if ( valid ) {
11106 return $.Deferred().resolve().promise();
11107 } else {
11108 return $.Deferred().reject().promise();
11109 }
11110 }
11111
11112 // Check browser validity and reject if it is invalid
11113 if (
11114 this.$input[ 0 ].checkValidity !== undefined &&
11115 this.$input[ 0 ].checkValidity() === false
11116 ) {
11117 return rejectOrResolve( false );
11118 }
11119
11120 // Run our checks if the browser thinks the field is valid
11121 if ( this.validate instanceof Function ) {
11122 result = this.validate( this.getValue() );
11123 if ( result && typeof result.promise === 'function' ) {
11124 return result.promise().then( function ( valid ) {
11125 return rejectOrResolve( valid );
11126 } );
11127 } else {
11128 return rejectOrResolve( result );
11129 }
11130 } else {
11131 return rejectOrResolve( this.getValue().match( this.validate ) );
11132 }
11133 };
11134
11135 /**
11136 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11137 *
11138 * @param {string} labelPosition Label position, 'before' or 'after'
11139 * @chainable
11140 * @return {OO.ui.Widget} The widget, for chaining
11141 */
11142 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11143 this.labelPosition = labelPosition;
11144 if ( this.label ) {
11145 // If there is no label and we only change the position, #updatePosition is a no-op,
11146 // but it takes really a lot of work to do nothing.
11147 this.updatePosition();
11148 }
11149 return this;
11150 };
11151
11152 /**
11153 * Update the position of the inline label.
11154 *
11155 * This method is called by #setLabelPosition, and can also be called on its own if
11156 * something causes the label to be mispositioned.
11157 *
11158 * @chainable
11159 * @return {OO.ui.Widget} The widget, for chaining
11160 */
11161 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11162 var after = this.labelPosition === 'after';
11163
11164 this.$element
11165 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11166 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11167
11168 this.valCache = null;
11169 this.scrollWidth = null;
11170 this.positionLabel();
11171
11172 return this;
11173 };
11174
11175 /**
11176 * Position the label by setting the correct padding on the input.
11177 *
11178 * @private
11179 * @chainable
11180 * @return {OO.ui.Widget} The widget, for chaining
11181 */
11182 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11183 var after, rtl, property, newCss;
11184
11185 if ( this.isWaitingToBeAttached ) {
11186 // #onElementAttach will be called soon, which calls this method
11187 return this;
11188 }
11189
11190 newCss = {
11191 'padding-right': '',
11192 'padding-left': ''
11193 };
11194
11195 if ( this.label ) {
11196 this.$element.append( this.$label );
11197 } else {
11198 this.$label.detach();
11199 // Clear old values if present
11200 this.$input.css( newCss );
11201 return;
11202 }
11203
11204 after = this.labelPosition === 'after';
11205 rtl = this.$element.css( 'direction' ) === 'rtl';
11206 property = after === rtl ? 'padding-left' : 'padding-right';
11207
11208 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11209 // We have to clear the padding on the other side, in case the element direction changed
11210 this.$input.css( newCss );
11211
11212 return this;
11213 };
11214
11215 /**
11216 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11217 * {@link OO.ui.mixin.IconElement search icon} by default.
11218 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11219 *
11220 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11221 *
11222 * @class
11223 * @extends OO.ui.TextInputWidget
11224 *
11225 * @constructor
11226 * @param {Object} [config] Configuration options
11227 */
11228 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11229 config = $.extend( {
11230 icon: 'search'
11231 }, config );
11232
11233 // Parent constructor
11234 OO.ui.SearchInputWidget.parent.call( this, config );
11235
11236 // Events
11237 this.connect( this, {
11238 change: 'onChange'
11239 } );
11240 this.$indicator.on( 'click', this.onIndicatorClick.bind( this ) );
11241
11242 // Initialization
11243 this.updateSearchIndicator();
11244 this.connect( this, {
11245 disable: 'onDisable'
11246 } );
11247 };
11248
11249 /* Setup */
11250
11251 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11252
11253 /* Methods */
11254
11255 /**
11256 * @inheritdoc
11257 * @protected
11258 */
11259 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11260 return 'search';
11261 };
11262
11263 /**
11264 * Handle click events on the indicator
11265 *
11266 * @param {jQuery.Event} e Click event
11267 * @return {boolean}
11268 */
11269 OO.ui.SearchInputWidget.prototype.onIndicatorClick = function ( e ) {
11270 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11271 // Clear the text field
11272 this.setValue( '' );
11273 this.focus();
11274 return false;
11275 }
11276 };
11277
11278 /**
11279 * Update the 'clear' indicator displayed on type: 'search' text
11280 * fields, hiding it when the field is already empty or when it's not
11281 * editable.
11282 */
11283 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11284 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11285 this.setIndicator( null );
11286 } else {
11287 this.setIndicator( 'clear' );
11288 }
11289 };
11290
11291 /**
11292 * Handle change events.
11293 *
11294 * @private
11295 */
11296 OO.ui.SearchInputWidget.prototype.onChange = function () {
11297 this.updateSearchIndicator();
11298 };
11299
11300 /**
11301 * Handle disable events.
11302 *
11303 * @param {boolean} disabled Element is disabled
11304 * @private
11305 */
11306 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11307 this.updateSearchIndicator();
11308 };
11309
11310 /**
11311 * @inheritdoc
11312 */
11313 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11314 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11315 this.updateSearchIndicator();
11316 return this;
11317 };
11318
11319 /**
11320 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11321 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11322 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11323 * {@link OO.ui.mixin.IndicatorElement indicators}.
11324 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11325 *
11326 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11327 *
11328 * @example
11329 * // A MultilineTextInputWidget.
11330 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11331 * value: 'Text input on multiple lines'
11332 * } );
11333 * $( document.body ).append( multilineTextInput.$element );
11334 *
11335 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11336 *
11337 * @class
11338 * @extends OO.ui.TextInputWidget
11339 *
11340 * @constructor
11341 * @param {Object} [config] Configuration options
11342 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11343 * specifies minimum number of rows to display.
11344 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11345 * Use the #maxRows config to specify a maximum number of displayed rows.
11346 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11347 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11348 */
11349 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11350 config = $.extend( {
11351 type: 'text'
11352 }, config );
11353 // Parent constructor
11354 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11355
11356 // Properties
11357 this.autosize = !!config.autosize;
11358 this.styleHeight = null;
11359 this.minRows = config.rows !== undefined ? config.rows : '';
11360 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11361
11362 // Clone for resizing
11363 if ( this.autosize ) {
11364 this.$clone = this.$input
11365 .clone()
11366 .removeAttr( 'id' )
11367 .removeAttr( 'name' )
11368 .insertAfter( this.$input )
11369 .attr( 'aria-hidden', 'true' )
11370 .addClass( 'oo-ui-element-hidden' );
11371 }
11372
11373 // Events
11374 this.connect( this, {
11375 change: 'onChange'
11376 } );
11377
11378 // Initialization
11379 if ( config.rows ) {
11380 this.$input.attr( 'rows', config.rows );
11381 }
11382 if ( this.autosize ) {
11383 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11384 this.isWaitingToBeAttached = true;
11385 this.installParentChangeDetector();
11386 }
11387 };
11388
11389 /* Setup */
11390
11391 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11392
11393 /* Static Methods */
11394
11395 /**
11396 * @inheritdoc
11397 */
11398 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11399 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11400 state.scrollTop = config.$input.scrollTop();
11401 return state;
11402 };
11403
11404 /* Methods */
11405
11406 /**
11407 * @inheritdoc
11408 */
11409 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11410 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11411 this.adjustSize();
11412 };
11413
11414 /**
11415 * Handle change events.
11416 *
11417 * @private
11418 */
11419 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11420 this.adjustSize();
11421 };
11422
11423 /**
11424 * @inheritdoc
11425 */
11426 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11427 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11428 this.adjustSize();
11429 };
11430
11431 /**
11432 * @inheritdoc
11433 *
11434 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11435 */
11436 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11437 if (
11438 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11439 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
11440 e.which === 10
11441 ) {
11442 this.emit( 'enter', e );
11443 }
11444 };
11445
11446 /**
11447 * Automatically adjust the size of the text input.
11448 *
11449 * This only affects multiline inputs that are {@link #autosize autosized}.
11450 *
11451 * @chainable
11452 * @return {OO.ui.Widget} The widget, for chaining
11453 * @fires resize
11454 */
11455 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11456 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11457 idealHeight, newHeight, scrollWidth, property;
11458
11459 if ( this.$input.val() !== this.valCache ) {
11460 if ( this.autosize ) {
11461 this.$clone
11462 .val( this.$input.val() )
11463 .attr( 'rows', this.minRows )
11464 // Set inline height property to 0 to measure scroll height
11465 .css( 'height', 0 );
11466
11467 this.$clone.removeClass( 'oo-ui-element-hidden' );
11468
11469 this.valCache = this.$input.val();
11470
11471 scrollHeight = this.$clone[ 0 ].scrollHeight;
11472
11473 // Remove inline height property to measure natural heights
11474 this.$clone.css( 'height', '' );
11475 innerHeight = this.$clone.innerHeight();
11476 outerHeight = this.$clone.outerHeight();
11477
11478 // Measure max rows height
11479 this.$clone
11480 .attr( 'rows', this.maxRows )
11481 .css( 'height', 'auto' )
11482 .val( '' );
11483 maxInnerHeight = this.$clone.innerHeight();
11484
11485 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11486 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11487 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11488 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11489
11490 this.$clone.addClass( 'oo-ui-element-hidden' );
11491
11492 // Only apply inline height when expansion beyond natural height is needed
11493 // Use the difference between the inner and outer height as a buffer
11494 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11495 if ( newHeight !== this.styleHeight ) {
11496 this.$input.css( 'height', newHeight );
11497 this.styleHeight = newHeight;
11498 this.emit( 'resize' );
11499 }
11500 }
11501 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11502 if ( scrollWidth !== this.scrollWidth ) {
11503 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11504 // Reset
11505 this.$label.css( { right: '', left: '' } );
11506 this.$indicator.css( { right: '', left: '' } );
11507
11508 if ( scrollWidth ) {
11509 this.$indicator.css( property, scrollWidth );
11510 if ( this.labelPosition === 'after' ) {
11511 this.$label.css( property, scrollWidth );
11512 }
11513 }
11514
11515 this.scrollWidth = scrollWidth;
11516 this.positionLabel();
11517 }
11518 }
11519 return this;
11520 };
11521
11522 /**
11523 * @inheritdoc
11524 * @protected
11525 */
11526 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11527 return $( '<textarea>' );
11528 };
11529
11530 /**
11531 * Check if the input automatically adjusts its size.
11532 *
11533 * @return {boolean}
11534 */
11535 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11536 return !!this.autosize;
11537 };
11538
11539 /**
11540 * @inheritdoc
11541 */
11542 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11543 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11544 if ( state.scrollTop !== undefined ) {
11545 this.$input.scrollTop( state.scrollTop );
11546 }
11547 };
11548
11549 /**
11550 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11551 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11552 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11553 *
11554 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11555 * option, that option will appear to be selected.
11556 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11557 * input field.
11558 *
11559 * After the user chooses an option, its `data` will be used as a new value for the widget.
11560 * A `label` also can be specified for each option: if given, it will be shown instead of the
11561 * `data` in the dropdown menu.
11562 *
11563 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11564 *
11565 * For more information about menus and options, please see the
11566 * [OOUI documentation on MediaWiki][1].
11567 *
11568 * @example
11569 * // A ComboBoxInputWidget.
11570 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11571 * value: 'Option 1',
11572 * options: [
11573 * { data: 'Option 1' },
11574 * { data: 'Option 2' },
11575 * { data: 'Option 3' }
11576 * ]
11577 * } );
11578 * $( document.body ).append( comboBox.$element );
11579 *
11580 * @example
11581 * // Example: A ComboBoxInputWidget with additional option labels.
11582 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11583 * value: 'Option 1',
11584 * options: [
11585 * {
11586 * data: 'Option 1',
11587 * label: 'Option One'
11588 * },
11589 * {
11590 * data: 'Option 2',
11591 * label: 'Option Two'
11592 * },
11593 * {
11594 * data: 'Option 3',
11595 * label: 'Option Three'
11596 * }
11597 * ]
11598 * } );
11599 * $( document.body ).append( comboBox.$element );
11600 *
11601 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11602 *
11603 * @class
11604 * @extends OO.ui.TextInputWidget
11605 *
11606 * @constructor
11607 * @param {Object} [config] Configuration options
11608 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11609 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
11610 * select widget}.
11611 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
11612 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
11613 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
11614 * uses relative positioning.
11615 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11616 */
11617 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11618 // Configuration initialization
11619 config = $.extend( {
11620 autocomplete: false
11621 }, config );
11622
11623 // ComboBoxInputWidget shouldn't support `multiline`
11624 config.multiline = false;
11625
11626 // See InputWidget#reusePreInfuseDOM about `config.$input`
11627 if ( config.$input ) {
11628 config.$input.removeAttr( 'list' );
11629 }
11630
11631 // Parent constructor
11632 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11633
11634 // Properties
11635 this.$overlay = ( config.$overlay === true ?
11636 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11637 this.dropdownButton = new OO.ui.ButtonWidget( {
11638 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11639 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11640 indicator: 'down',
11641 invisibleLabel: true,
11642 disabled: this.disabled
11643 } );
11644 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11645 {
11646 widget: this,
11647 input: this,
11648 $floatableContainer: this.$element,
11649 disabled: this.isDisabled()
11650 },
11651 config.menu
11652 ) );
11653
11654 // Events
11655 this.connect( this, {
11656 change: 'onInputChange',
11657 enter: 'onInputEnter'
11658 } );
11659 this.dropdownButton.connect( this, {
11660 click: 'onDropdownButtonClick'
11661 } );
11662 this.menu.connect( this, {
11663 choose: 'onMenuChoose',
11664 add: 'onMenuItemsChange',
11665 remove: 'onMenuItemsChange',
11666 toggle: 'onMenuToggle'
11667 } );
11668
11669 // Initialization
11670 this.$input.attr( {
11671 role: 'combobox',
11672 'aria-owns': this.menu.getElementId(),
11673 'aria-autocomplete': 'list'
11674 } );
11675 this.dropdownButton.$button.attr( {
11676 'aria-controls': this.menu.getElementId()
11677 } );
11678 // Do not override options set via config.menu.items
11679 if ( config.options !== undefined ) {
11680 this.setOptions( config.options );
11681 }
11682 this.$field = $( '<div>' )
11683 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11684 .append( this.$input, this.dropdownButton.$element );
11685 this.$element
11686 .addClass( 'oo-ui-comboBoxInputWidget' )
11687 .append( this.$field );
11688 this.$overlay.append( this.menu.$element );
11689 this.onMenuItemsChange();
11690 };
11691
11692 /* Setup */
11693
11694 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11695
11696 /* Methods */
11697
11698 /**
11699 * Get the combobox's menu.
11700 *
11701 * @return {OO.ui.MenuSelectWidget} Menu widget
11702 */
11703 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11704 return this.menu;
11705 };
11706
11707 /**
11708 * Get the combobox's text input widget.
11709 *
11710 * @return {OO.ui.TextInputWidget} Text input widget
11711 */
11712 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11713 return this;
11714 };
11715
11716 /**
11717 * Handle input change events.
11718 *
11719 * @private
11720 * @param {string} value New value
11721 */
11722 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11723 var match = this.menu.findItemFromData( value );
11724
11725 this.menu.selectItem( match );
11726 if ( this.menu.findHighlightedItem() ) {
11727 this.menu.highlightItem( match );
11728 }
11729
11730 if ( !this.isDisabled() ) {
11731 this.menu.toggle( true );
11732 }
11733 };
11734
11735 /**
11736 * Handle input enter events.
11737 *
11738 * @private
11739 */
11740 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11741 if ( !this.isDisabled() ) {
11742 this.menu.toggle( false );
11743 }
11744 };
11745
11746 /**
11747 * Handle button click events.
11748 *
11749 * @private
11750 */
11751 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11752 this.menu.toggle();
11753 this.focus();
11754 };
11755
11756 /**
11757 * Handle menu choose events.
11758 *
11759 * @private
11760 * @param {OO.ui.OptionWidget} item Chosen item
11761 */
11762 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11763 this.setValue( item.getData() );
11764 };
11765
11766 /**
11767 * Handle menu item change events.
11768 *
11769 * @private
11770 */
11771 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11772 var match = this.menu.findItemFromData( this.getValue() );
11773 this.menu.selectItem( match );
11774 if ( this.menu.findHighlightedItem() ) {
11775 this.menu.highlightItem( match );
11776 }
11777 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11778 };
11779
11780 /**
11781 * Handle menu toggle events.
11782 *
11783 * @private
11784 * @param {boolean} isVisible Open state of the menu
11785 */
11786 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11787 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11788 };
11789
11790 /**
11791 * Update the disabled state of the controls
11792 *
11793 * @chainable
11794 * @protected
11795 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
11796 */
11797 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
11798 var disabled = this.isDisabled() || this.isReadOnly();
11799 if ( this.dropdownButton ) {
11800 this.dropdownButton.setDisabled( disabled );
11801 }
11802 if ( this.menu ) {
11803 this.menu.setDisabled( disabled );
11804 }
11805 return this;
11806 };
11807
11808 /**
11809 * @inheritdoc
11810 */
11811 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
11812 // Parent method
11813 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.apply( this, arguments );
11814 this.updateControlsDisabled();
11815 return this;
11816 };
11817
11818 /**
11819 * @inheritdoc
11820 */
11821 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
11822 // Parent method
11823 OO.ui.ComboBoxInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
11824 this.updateControlsDisabled();
11825 return this;
11826 };
11827
11828 /**
11829 * Set the options available for this input.
11830 *
11831 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11832 * @chainable
11833 * @return {OO.ui.Widget} The widget, for chaining
11834 */
11835 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11836 this.getMenu()
11837 .clearItems()
11838 .addItems( options.map( function ( opt ) {
11839 return new OO.ui.MenuOptionWidget( {
11840 data: opt.data,
11841 label: opt.label !== undefined ? opt.label : opt.data
11842 } );
11843 } ) );
11844
11845 return this;
11846 };
11847
11848 /**
11849 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11850 * which is a widget that is specified by reference before any optional configuration settings.
11851 *
11852 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
11853 * four ways:
11854 *
11855 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11856 * A left-alignment is used for forms with many fields.
11857 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11858 * A right-alignment is used for long but familiar forms which users tab through,
11859 * verifying the current field with a quick glance at the label.
11860 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11861 * that users fill out from top to bottom.
11862 * - **inline**: The label is placed after the field-widget and aligned to the left.
11863 * An inline-alignment is best used with checkboxes or radio buttons.
11864 *
11865 * Help text can either be:
11866 *
11867 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
11868 * or
11869 * - shown as a subtle explanation below the label.
11870 *
11871 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
11872 * If it is long or not essential, leave `helpInline` to its default, `false`.
11873 *
11874 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11875 *
11876 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11877 *
11878 * @class
11879 * @extends OO.ui.Layout
11880 * @mixins OO.ui.mixin.LabelElement
11881 * @mixins OO.ui.mixin.TitledElement
11882 *
11883 * @constructor
11884 * @param {OO.ui.Widget} fieldWidget Field widget
11885 * @param {Object} [config] Configuration options
11886 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11887 * or 'inline'
11888 * @cfg {Array} [errors] Error messages about the widget, which will be
11889 * displayed below the widget.
11890 * @cfg {Array} [warnings] Warning messages about the widget, which will be
11891 * displayed below the widget.
11892 * @cfg {Array} [successMessages] Success messages on user interactions with the widget,
11893 * which will be displayed below the widget.
11894 * The array may contain strings or OO.ui.HtmlSnippet instances.
11895 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11896 * below the widget.
11897 * The array may contain strings or OO.ui.HtmlSnippet instances.
11898 * These are more visible than `help` messages when `helpInline` is set, and so
11899 * might be good for transient messages.
11900 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11901 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11902 * corner of the rendered field; clicking it will display the text in a popup.
11903 * If `helpInline` is `true`, then a subtle description will be shown after the
11904 * label.
11905 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11906 * or shown when the "help" icon is clicked.
11907 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11908 * `help` is given.
11909 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11910 *
11911 * @throws {Error} An error is thrown if no widget is specified
11912 */
11913 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11914 // Allow passing positional parameters inside the config object
11915 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11916 config = fieldWidget;
11917 fieldWidget = config.fieldWidget;
11918 }
11919
11920 // Make sure we have required constructor arguments
11921 if ( fieldWidget === undefined ) {
11922 throw new Error( 'Widget not found' );
11923 }
11924
11925 // Configuration initialization
11926 config = $.extend( { align: 'left', helpInline: false }, config );
11927
11928 // Parent constructor
11929 OO.ui.FieldLayout.parent.call( this, config );
11930
11931 // Mixin constructors
11932 OO.ui.mixin.LabelElement.call( this, $.extend( {
11933 $label: $( '<label>' )
11934 }, config ) );
11935 OO.ui.mixin.TitledElement.call( this, $.extend( { $titled: this.$label }, config ) );
11936
11937 // Properties
11938 this.fieldWidget = fieldWidget;
11939 this.errors = [];
11940 this.warnings = [];
11941 this.successMessages = [];
11942 this.notices = [];
11943 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11944 this.$messages = $( '<ul>' );
11945 this.$header = $( '<span>' );
11946 this.$body = $( '<div>' );
11947 this.align = null;
11948 this.helpInline = config.helpInline;
11949
11950 // Events
11951 this.fieldWidget.connect( this, {
11952 disable: 'onFieldDisable'
11953 } );
11954
11955 // Initialization
11956 this.$help = config.help ?
11957 this.createHelpElement( config.help, config.$overlay ) :
11958 $( [] );
11959 if ( this.fieldWidget.getInputId() ) {
11960 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11961 if ( this.helpInline ) {
11962 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11963 }
11964 } else {
11965 this.$label.on( 'click', function () {
11966 this.fieldWidget.simulateLabelClick();
11967 }.bind( this ) );
11968 if ( this.helpInline ) {
11969 this.$help.on( 'click', function () {
11970 this.fieldWidget.simulateLabelClick();
11971 }.bind( this ) );
11972 }
11973 }
11974 this.$element
11975 .addClass( 'oo-ui-fieldLayout' )
11976 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11977 .append( this.$body );
11978 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11979 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11980 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11981 this.$field
11982 .addClass( 'oo-ui-fieldLayout-field' )
11983 .append( this.fieldWidget.$element );
11984
11985 this.setErrors( config.errors || [] );
11986 this.setWarnings( config.warnings || [] );
11987 this.setSuccess( config.successMessages || [] );
11988 this.setNotices( config.notices || [] );
11989 this.setAlignment( config.align );
11990 // Call this again to take into account the widget's accessKey
11991 this.updateTitle();
11992 };
11993
11994 /* Setup */
11995
11996 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11997 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11998 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11999
12000 /* Methods */
12001
12002 /**
12003 * Handle field disable events.
12004 *
12005 * @private
12006 * @param {boolean} value Field is disabled
12007 */
12008 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
12009 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
12010 };
12011
12012 /**
12013 * Get the widget contained by the field.
12014 *
12015 * @return {OO.ui.Widget} Field widget
12016 */
12017 OO.ui.FieldLayout.prototype.getField = function () {
12018 return this.fieldWidget;
12019 };
12020
12021 /**
12022 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12023 * #setAlignment). Return `false` if it can't or if this can't be determined.
12024 *
12025 * @return {boolean}
12026 */
12027 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12028 // This is very simplistic, but should be good enough.
12029 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12030 };
12031
12032 /**
12033 * @protected
12034 * @param {string} kind 'error' or 'notice'
12035 * @param {string|OO.ui.HtmlSnippet} text
12036 * @return {jQuery}
12037 */
12038 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12039 var $listItem, $icon, message;
12040 $listItem = $( '<li>' );
12041 if ( kind === 'error' ) {
12042 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'error' ] } ).$element;
12043 $listItem.attr( 'role', 'alert' );
12044 } else if ( kind === 'warning' ) {
12045 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
12046 $listItem.attr( 'role', 'alert' );
12047 } else if ( kind === 'success' ) {
12048 $icon = new OO.ui.IconWidget( { icon: 'check', flags: [ 'success' ] } ).$element;
12049 } else if ( kind === 'notice' ) {
12050 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
12051 } else {
12052 $icon = '';
12053 }
12054 message = new OO.ui.LabelWidget( { label: text } );
12055 $listItem
12056 .append( $icon, message.$element )
12057 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
12058 return $listItem;
12059 };
12060
12061 /**
12062 * Set the field alignment mode.
12063 *
12064 * @private
12065 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12066 * @chainable
12067 * @return {OO.ui.BookletLayout} The layout, for chaining
12068 */
12069 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12070 if ( value !== this.align ) {
12071 // Default to 'left'
12072 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12073 value = 'left';
12074 }
12075 // Validate
12076 if ( value === 'inline' && !this.isFieldInline() ) {
12077 value = 'top';
12078 }
12079 // Reorder elements
12080
12081 if ( this.helpInline ) {
12082 if ( value === 'top' ) {
12083 this.$header.append( this.$label );
12084 this.$body.append( this.$header, this.$field, this.$help );
12085 } else if ( value === 'inline' ) {
12086 this.$header.append( this.$label, this.$help );
12087 this.$body.append( this.$field, this.$header );
12088 } else {
12089 this.$header.append( this.$label, this.$help );
12090 this.$body.append( this.$header, this.$field );
12091 }
12092 } else {
12093 if ( value === 'top' ) {
12094 this.$header.append( this.$help, this.$label );
12095 this.$body.append( this.$header, this.$field );
12096 } else if ( value === 'inline' ) {
12097 this.$header.append( this.$help, this.$label );
12098 this.$body.append( this.$field, this.$header );
12099 } else {
12100 this.$header.append( this.$label );
12101 this.$body.append( this.$header, this.$help, this.$field );
12102 }
12103 }
12104 // Set classes. The following classes can be used here:
12105 // * oo-ui-fieldLayout-align-left
12106 // * oo-ui-fieldLayout-align-right
12107 // * oo-ui-fieldLayout-align-top
12108 // * oo-ui-fieldLayout-align-inline
12109 if ( this.align ) {
12110 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
12111 }
12112 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
12113 this.align = value;
12114 }
12115
12116 return this;
12117 };
12118
12119 /**
12120 * Set the list of error messages.
12121 *
12122 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
12123 * The array may contain strings or OO.ui.HtmlSnippet instances.
12124 * @chainable
12125 * @return {OO.ui.BookletLayout} The layout, for chaining
12126 */
12127 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
12128 this.errors = errors.slice();
12129 this.updateMessages();
12130 return this;
12131 };
12132
12133 /**
12134 * Set the list of warning messages.
12135 *
12136 * @param {Array} warnings Warning messages about the widget, which will be displayed below
12137 * the widget.
12138 * The array may contain strings or OO.ui.HtmlSnippet instances.
12139 * @chainable
12140 * @return {OO.ui.BookletLayout} The layout, for chaining
12141 */
12142 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
12143 this.warnings = warnings.slice();
12144 this.updateMessages();
12145 return this;
12146 };
12147
12148 /**
12149 * Set the list of success messages.
12150 *
12151 * @param {Array} successMessages Success messages about the widget, which will be displayed below
12152 * the widget.
12153 * The array may contain strings or OO.ui.HtmlSnippet instances.
12154 * @chainable
12155 * @return {OO.ui.BookletLayout} The layout, for chaining
12156 */
12157 OO.ui.FieldLayout.prototype.setSuccess = function ( successMessages ) {
12158 this.successMessages = successMessages.slice();
12159 this.updateMessages();
12160 return this;
12161 };
12162
12163 /**
12164 * Set the list of notice messages.
12165 *
12166 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
12167 * The array may contain strings or OO.ui.HtmlSnippet instances.
12168 * @chainable
12169 * @return {OO.ui.BookletLayout} The layout, for chaining
12170 */
12171 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
12172 this.notices = notices.slice();
12173 this.updateMessages();
12174 return this;
12175 };
12176
12177 /**
12178 * Update the rendering of error, warning, success and notice messages.
12179 *
12180 * @private
12181 */
12182 OO.ui.FieldLayout.prototype.updateMessages = function () {
12183 var i;
12184 this.$messages.empty();
12185
12186 if (
12187 this.errors.length ||
12188 this.warnings.length ||
12189 this.successMessages.length ||
12190 this.notices.length
12191 ) {
12192 this.$body.after( this.$messages );
12193 } else {
12194 this.$messages.remove();
12195 return;
12196 }
12197
12198 for ( i = 0; i < this.errors.length; i++ ) {
12199 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
12200 }
12201 for ( i = 0; i < this.warnings.length; i++ ) {
12202 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
12203 }
12204 for ( i = 0; i < this.successMessages.length; i++ ) {
12205 this.$messages.append( this.makeMessage( 'success', this.successMessages[ i ] ) );
12206 }
12207 for ( i = 0; i < this.notices.length; i++ ) {
12208 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
12209 }
12210 };
12211
12212 /**
12213 * Include information about the widget's accessKey in our title. TitledElement calls this method.
12214 * (This is a bit of a hack.)
12215 *
12216 * @protected
12217 * @param {string} title Tooltip label for 'title' attribute
12218 * @return {string}
12219 */
12220 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
12221 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
12222 return this.fieldWidget.formatTitleWithAccessKey( title );
12223 }
12224 return title;
12225 };
12226
12227 /**
12228 * Creates and returns the help element. Also sets the `aria-describedby`
12229 * attribute on the main element of the `fieldWidget`.
12230 *
12231 * @private
12232 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
12233 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
12234 * @return {jQuery} The element that should become `this.$help`.
12235 */
12236 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
12237 var helpId, helpWidget;
12238
12239 if ( this.helpInline ) {
12240 helpWidget = new OO.ui.LabelWidget( {
12241 label: help,
12242 classes: [ 'oo-ui-inline-help' ]
12243 } );
12244
12245 helpId = helpWidget.getElementId();
12246 } else {
12247 helpWidget = new OO.ui.PopupButtonWidget( {
12248 $overlay: $overlay,
12249 popup: {
12250 padded: true
12251 },
12252 classes: [ 'oo-ui-fieldLayout-help' ],
12253 framed: false,
12254 icon: 'info',
12255 label: OO.ui.msg( 'ooui-field-help' ),
12256 invisibleLabel: true
12257 } );
12258 if ( help instanceof OO.ui.HtmlSnippet ) {
12259 helpWidget.getPopup().$body.html( help.toString() );
12260 } else {
12261 helpWidget.getPopup().$body.text( help );
12262 }
12263
12264 helpId = helpWidget.getPopup().getBodyId();
12265 }
12266
12267 // Set the 'aria-describedby' attribute on the fieldWidget
12268 // Preference given to an input or a button
12269 (
12270 this.fieldWidget.$input ||
12271 this.fieldWidget.$button ||
12272 this.fieldWidget.$element
12273 ).attr( 'aria-describedby', helpId );
12274
12275 return helpWidget.$element;
12276 };
12277
12278 /**
12279 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
12280 * a button, and an optional label and/or help text. The field-widget (e.g., a
12281 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
12282 * configuration settings.
12283 *
12284 * Labels can be aligned in one of four ways:
12285 *
12286 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12287 * A left-alignment is used for forms with many fields.
12288 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12289 * A right-alignment is used for long but familiar forms which users tab through,
12290 * verifying the current field with a quick glance at the label.
12291 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12292 * that users fill out from top to bottom.
12293 * - **inline**: The label is placed after the field-widget and aligned to the left.
12294 * An inline-alignment is best used with checkboxes or radio buttons.
12295 *
12296 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
12297 * field layout when help text is specified.
12298 *
12299 * @example
12300 * // Example of an ActionFieldLayout
12301 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12302 * new OO.ui.TextInputWidget( {
12303 * placeholder: 'Field widget'
12304 * } ),
12305 * new OO.ui.ButtonWidget( {
12306 * label: 'Button'
12307 * } ),
12308 * {
12309 * label: 'An ActionFieldLayout. This label is aligned top',
12310 * align: 'top',
12311 * help: 'This is help text'
12312 * }
12313 * );
12314 *
12315 * $( document.body ).append( actionFieldLayout.$element );
12316 *
12317 * @class
12318 * @extends OO.ui.FieldLayout
12319 *
12320 * @constructor
12321 * @param {OO.ui.Widget} fieldWidget Field widget
12322 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12323 * @param {Object} config
12324 */
12325 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12326 // Allow passing positional parameters inside the config object
12327 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12328 config = fieldWidget;
12329 fieldWidget = config.fieldWidget;
12330 buttonWidget = config.buttonWidget;
12331 }
12332
12333 // Parent constructor
12334 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12335
12336 // Properties
12337 this.buttonWidget = buttonWidget;
12338 this.$button = $( '<span>' );
12339 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12340
12341 // Initialization
12342 this.$element.addClass( 'oo-ui-actionFieldLayout' );
12343 this.$button
12344 .addClass( 'oo-ui-actionFieldLayout-button' )
12345 .append( this.buttonWidget.$element );
12346 this.$input
12347 .addClass( 'oo-ui-actionFieldLayout-input' )
12348 .append( this.fieldWidget.$element );
12349 this.$field.append( this.$input, this.$button );
12350 };
12351
12352 /* Setup */
12353
12354 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12355
12356 /**
12357 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12358 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12359 * configured with a label as well. For more information and examples,
12360 * please see the [OOUI documentation on MediaWiki][1].
12361 *
12362 * @example
12363 * // Example of a fieldset layout
12364 * var input1 = new OO.ui.TextInputWidget( {
12365 * placeholder: 'A text input field'
12366 * } );
12367 *
12368 * var input2 = new OO.ui.TextInputWidget( {
12369 * placeholder: 'A text input field'
12370 * } );
12371 *
12372 * var fieldset = new OO.ui.FieldsetLayout( {
12373 * label: 'Example of a fieldset layout'
12374 * } );
12375 *
12376 * fieldset.addItems( [
12377 * new OO.ui.FieldLayout( input1, {
12378 * label: 'Field One'
12379 * } ),
12380 * new OO.ui.FieldLayout( input2, {
12381 * label: 'Field Two'
12382 * } )
12383 * ] );
12384 * $( document.body ).append( fieldset.$element );
12385 *
12386 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12387 *
12388 * @class
12389 * @extends OO.ui.Layout
12390 * @mixins OO.ui.mixin.IconElement
12391 * @mixins OO.ui.mixin.LabelElement
12392 * @mixins OO.ui.mixin.GroupElement
12393 *
12394 * @constructor
12395 * @param {Object} [config] Configuration options
12396 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset.
12397 * See OO.ui.FieldLayout for more information about fields.
12398 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon
12399 * will appear in the upper-right corner of the rendered field; clicking it will display the text
12400 * in a popup. For important messages, you are advised to use `notices`, as they are always shown.
12401 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12402 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12403 */
12404 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12405 // Configuration initialization
12406 config = config || {};
12407
12408 // Parent constructor
12409 OO.ui.FieldsetLayout.parent.call( this, config );
12410
12411 // Mixin constructors
12412 OO.ui.mixin.IconElement.call( this, config );
12413 OO.ui.mixin.LabelElement.call( this, config );
12414 OO.ui.mixin.GroupElement.call( this, config );
12415
12416 // Properties
12417 this.$header = $( '<legend>' );
12418 if ( config.help ) {
12419 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12420 $overlay: config.$overlay,
12421 popup: {
12422 padded: true
12423 },
12424 classes: [ 'oo-ui-fieldsetLayout-help' ],
12425 framed: false,
12426 icon: 'info',
12427 label: OO.ui.msg( 'ooui-field-help' ),
12428 invisibleLabel: true
12429 } );
12430 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12431 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12432 } else {
12433 this.popupButtonWidget.getPopup().$body.text( config.help );
12434 }
12435 this.$help = this.popupButtonWidget.$element;
12436 } else {
12437 this.$help = $( [] );
12438 }
12439
12440 // Initialization
12441 this.$header
12442 .addClass( 'oo-ui-fieldsetLayout-header' )
12443 .append( this.$icon, this.$label, this.$help );
12444 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12445 this.$element
12446 .addClass( 'oo-ui-fieldsetLayout' )
12447 .prepend( this.$header, this.$group );
12448 if ( Array.isArray( config.items ) ) {
12449 this.addItems( config.items );
12450 }
12451 };
12452
12453 /* Setup */
12454
12455 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12456 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12457 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12458 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12459
12460 /* Static Properties */
12461
12462 /**
12463 * @static
12464 * @inheritdoc
12465 */
12466 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12467
12468 /**
12469 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
12470 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
12471 * can be configured with an HTML form action, an encoding type, and a method using the #action,
12472 * #enctype, and #method configs, respectively.
12473 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12474 *
12475 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12476 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12477 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12478 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12479 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12480 * often have simplified APIs to match the capabilities of HTML forms.
12481 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12482 *
12483 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12484 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12485 *
12486 * @example
12487 * // Example of a form layout that wraps a fieldset layout.
12488 * var input1 = new OO.ui.TextInputWidget( {
12489 * placeholder: 'Username'
12490 * } ),
12491 * input2 = new OO.ui.TextInputWidget( {
12492 * placeholder: 'Password',
12493 * type: 'password'
12494 * } ),
12495 * submit = new OO.ui.ButtonInputWidget( {
12496 * label: 'Submit'
12497 * } ),
12498 * fieldset = new OO.ui.FieldsetLayout( {
12499 * label: 'A form layout'
12500 * } );
12501 *
12502 * fieldset.addItems( [
12503 * new OO.ui.FieldLayout( input1, {
12504 * label: 'Username',
12505 * align: 'top'
12506 * } ),
12507 * new OO.ui.FieldLayout( input2, {
12508 * label: 'Password',
12509 * align: 'top'
12510 * } ),
12511 * new OO.ui.FieldLayout( submit )
12512 * ] );
12513 * var form = new OO.ui.FormLayout( {
12514 * items: [ fieldset ],
12515 * action: '/api/formhandler',
12516 * method: 'get'
12517 * } )
12518 * $( document.body ).append( form.$element );
12519 *
12520 * @class
12521 * @extends OO.ui.Layout
12522 * @mixins OO.ui.mixin.GroupElement
12523 *
12524 * @constructor
12525 * @param {Object} [config] Configuration options
12526 * @cfg {string} [method] HTML form `method` attribute
12527 * @cfg {string} [action] HTML form `action` attribute
12528 * @cfg {string} [enctype] HTML form `enctype` attribute
12529 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12530 */
12531 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12532 var action;
12533
12534 // Configuration initialization
12535 config = config || {};
12536
12537 // Parent constructor
12538 OO.ui.FormLayout.parent.call( this, config );
12539
12540 // Mixin constructors
12541 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12542
12543 // Events
12544 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12545
12546 // Make sure the action is safe
12547 action = config.action;
12548 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12549 action = './' + action;
12550 }
12551
12552 // Initialization
12553 this.$element
12554 .addClass( 'oo-ui-formLayout' )
12555 .attr( {
12556 method: config.method,
12557 action: action,
12558 enctype: config.enctype
12559 } );
12560 if ( Array.isArray( config.items ) ) {
12561 this.addItems( config.items );
12562 }
12563 };
12564
12565 /* Setup */
12566
12567 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12568 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12569
12570 /* Events */
12571
12572 /**
12573 * A 'submit' event is emitted when the form is submitted.
12574 *
12575 * @event submit
12576 */
12577
12578 /* Static Properties */
12579
12580 /**
12581 * @static
12582 * @inheritdoc
12583 */
12584 OO.ui.FormLayout.static.tagName = 'form';
12585
12586 /* Methods */
12587
12588 /**
12589 * Handle form submit events.
12590 *
12591 * @private
12592 * @param {jQuery.Event} e Submit event
12593 * @fires submit
12594 * @return {OO.ui.FormLayout} The layout, for chaining
12595 */
12596 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12597 if ( this.emit( 'submit' ) ) {
12598 return false;
12599 }
12600 };
12601
12602 /**
12603 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
12604 * scrolling, padding, and a frame, and are often used together with
12605 * {@link OO.ui.StackLayout StackLayouts}.
12606 *
12607 * @example
12608 * // Example of a panel layout
12609 * var panel = new OO.ui.PanelLayout( {
12610 * expanded: false,
12611 * framed: true,
12612 * padded: true,
12613 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12614 * } );
12615 * $( document.body ).append( panel.$element );
12616 *
12617 * @class
12618 * @extends OO.ui.Layout
12619 *
12620 * @constructor
12621 * @param {Object} [config] Configuration options
12622 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12623 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12624 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12625 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside
12626 * content.
12627 */
12628 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12629 // Configuration initialization
12630 config = $.extend( {
12631 scrollable: false,
12632 padded: false,
12633 expanded: true,
12634 framed: false
12635 }, config );
12636
12637 // Parent constructor
12638 OO.ui.PanelLayout.parent.call( this, config );
12639
12640 // Initialization
12641 this.$element.addClass( 'oo-ui-panelLayout' );
12642 if ( config.scrollable ) {
12643 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12644 }
12645 if ( config.padded ) {
12646 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12647 }
12648 if ( config.expanded ) {
12649 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12650 }
12651 if ( config.framed ) {
12652 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12653 }
12654 };
12655
12656 /* Setup */
12657
12658 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12659
12660 /* Static Methods */
12661
12662 /**
12663 * @inheritdoc
12664 */
12665 OO.ui.PanelLayout.static.reusePreInfuseDOM = function ( node, config ) {
12666 config = OO.ui.PanelLayout.parent.static.reusePreInfuseDOM( node, config );
12667 if ( config.preserveContent !== false ) {
12668 config.$content = $( node ).contents();
12669 }
12670 return config;
12671 };
12672
12673 /* Methods */
12674
12675 /**
12676 * Focus the panel layout
12677 *
12678 * The default implementation just focuses the first focusable element in the panel
12679 */
12680 OO.ui.PanelLayout.prototype.focus = function () {
12681 OO.ui.findFocusable( this.$element ).focus();
12682 };
12683
12684 /**
12685 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12686 * items), with small margins between them. Convenient when you need to put a number of block-level
12687 * widgets on a single line next to each other.
12688 *
12689 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12690 *
12691 * @example
12692 * // HorizontalLayout with a text input and a label.
12693 * var layout = new OO.ui.HorizontalLayout( {
12694 * items: [
12695 * new OO.ui.LabelWidget( { label: 'Label' } ),
12696 * new OO.ui.TextInputWidget( { value: 'Text' } )
12697 * ]
12698 * } );
12699 * $( document.body ).append( layout.$element );
12700 *
12701 * @class
12702 * @extends OO.ui.Layout
12703 * @mixins OO.ui.mixin.GroupElement
12704 *
12705 * @constructor
12706 * @param {Object} [config] Configuration options
12707 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12708 */
12709 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12710 // Configuration initialization
12711 config = config || {};
12712
12713 // Parent constructor
12714 OO.ui.HorizontalLayout.parent.call( this, config );
12715
12716 // Mixin constructors
12717 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12718
12719 // Initialization
12720 this.$element.addClass( 'oo-ui-horizontalLayout' );
12721 if ( Array.isArray( config.items ) ) {
12722 this.addItems( config.items );
12723 }
12724 };
12725
12726 /* Setup */
12727
12728 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12729 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12730
12731 /**
12732 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12733 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12734 * (to adjust the value in increments) to allow the user to enter a number.
12735 *
12736 * @example
12737 * // A NumberInputWidget.
12738 * var numberInput = new OO.ui.NumberInputWidget( {
12739 * label: 'NumberInputWidget',
12740 * input: { value: 5 },
12741 * min: 1,
12742 * max: 10
12743 * } );
12744 * $( document.body ).append( numberInput.$element );
12745 *
12746 * @class
12747 * @extends OO.ui.TextInputWidget
12748 *
12749 * @constructor
12750 * @param {Object} [config] Configuration options
12751 * @cfg {Object} [minusButton] Configuration options to pass to the
12752 * {@link OO.ui.ButtonWidget decrementing button widget}.
12753 * @cfg {Object} [plusButton] Configuration options to pass to the
12754 * {@link OO.ui.ButtonWidget incrementing button widget}.
12755 * @cfg {number} [min=-Infinity] Minimum allowed value
12756 * @cfg {number} [max=Infinity] Maximum allowed value
12757 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12758 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
12759 * Defaults to `step` if specified, otherwise `1`.
12760 * @cfg {number} [pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
12761 * Defaults to 10 times `buttonStep`.
12762 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12763 */
12764 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12765 var $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
12766
12767 // Configuration initialization
12768 config = $.extend( {
12769 min: -Infinity,
12770 max: Infinity,
12771 showButtons: true
12772 }, config );
12773
12774 // For backward compatibility
12775 $.extend( config, config.input );
12776 this.input = this;
12777
12778 // Parent constructor
12779 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12780 type: 'number'
12781 } ) );
12782
12783 if ( config.showButtons ) {
12784 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12785 {
12786 disabled: this.isDisabled(),
12787 tabIndex: -1,
12788 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12789 icon: 'subtract'
12790 },
12791 config.minusButton
12792 ) );
12793 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12794 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12795 {
12796 disabled: this.isDisabled(),
12797 tabIndex: -1,
12798 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12799 icon: 'add'
12800 },
12801 config.plusButton
12802 ) );
12803 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12804 }
12805
12806 // Events
12807 this.$input.on( {
12808 keydown: this.onKeyDown.bind( this ),
12809 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12810 } );
12811 if ( config.showButtons ) {
12812 this.plusButton.connect( this, {
12813 click: [ 'onButtonClick', +1 ]
12814 } );
12815 this.minusButton.connect( this, {
12816 click: [ 'onButtonClick', -1 ]
12817 } );
12818 }
12819
12820 // Build the field
12821 $field.append( this.$input );
12822 if ( config.showButtons ) {
12823 $field
12824 .prepend( this.minusButton.$element )
12825 .append( this.plusButton.$element );
12826 }
12827
12828 // Initialization
12829 if ( config.allowInteger || config.isInteger ) {
12830 // Backward compatibility
12831 config.step = 1;
12832 }
12833 this.setRange( config.min, config.max );
12834 this.setStep( config.buttonStep, config.pageStep, config.step );
12835 // Set the validation method after we set step and range
12836 // so that it doesn't immediately call setValidityFlag
12837 this.setValidation( this.validateNumber.bind( this ) );
12838
12839 this.$element
12840 .addClass( 'oo-ui-numberInputWidget' )
12841 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12842 .append( $field );
12843 };
12844
12845 /* Setup */
12846
12847 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12848
12849 /* Methods */
12850
12851 // Backward compatibility
12852 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12853 this.setStep( flag ? 1 : null );
12854 };
12855 // Backward compatibility
12856 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12857
12858 // Backward compatibility
12859 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12860 return this.step === 1;
12861 };
12862 // Backward compatibility
12863 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12864
12865 /**
12866 * Set the range of allowed values
12867 *
12868 * @param {number} min Minimum allowed value
12869 * @param {number} max Maximum allowed value
12870 */
12871 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12872 if ( min > max ) {
12873 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12874 }
12875 this.min = min;
12876 this.max = max;
12877 this.$input.attr( 'min', this.min );
12878 this.$input.attr( 'max', this.max );
12879 this.setValidityFlag();
12880 };
12881
12882 /**
12883 * Get the current range
12884 *
12885 * @return {number[]} Minimum and maximum values
12886 */
12887 OO.ui.NumberInputWidget.prototype.getRange = function () {
12888 return [ this.min, this.max ];
12889 };
12890
12891 /**
12892 * Set the stepping deltas
12893 *
12894 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12895 * Defaults to `step` if specified, otherwise `1`.
12896 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12897 * Defaults to 10 times `buttonStep`.
12898 * @param {number|null} [step] If specified, the field only accepts values that are multiples
12899 * of this.
12900 */
12901 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12902 if ( buttonStep === undefined ) {
12903 buttonStep = step || 1;
12904 }
12905 if ( pageStep === undefined ) {
12906 pageStep = 10 * buttonStep;
12907 }
12908 if ( step !== null && step <= 0 ) {
12909 throw new Error( 'Step value, if given, must be positive' );
12910 }
12911 if ( buttonStep <= 0 ) {
12912 throw new Error( 'Button step value must be positive' );
12913 }
12914 if ( pageStep <= 0 ) {
12915 throw new Error( 'Page step value must be positive' );
12916 }
12917 this.step = step;
12918 this.buttonStep = buttonStep;
12919 this.pageStep = pageStep;
12920 this.$input.attr( 'step', this.step || 'any' );
12921 this.setValidityFlag();
12922 };
12923
12924 /**
12925 * @inheritdoc
12926 */
12927 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12928 if ( value === '' ) {
12929 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12930 // so here we make sure an 'empty' value is actually displayed as such.
12931 this.$input.val( '' );
12932 }
12933 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12934 };
12935
12936 /**
12937 * Get the current stepping values
12938 *
12939 * @return {number[]} Button step, page step, and validity step
12940 */
12941 OO.ui.NumberInputWidget.prototype.getStep = function () {
12942 return [ this.buttonStep, this.pageStep, this.step ];
12943 };
12944
12945 /**
12946 * Get the current value of the widget as a number
12947 *
12948 * @return {number} May be NaN, or an invalid number
12949 */
12950 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12951 return +this.getValue();
12952 };
12953
12954 /**
12955 * Adjust the value of the widget
12956 *
12957 * @param {number} delta Adjustment amount
12958 */
12959 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12960 var n, v = this.getNumericValue();
12961
12962 delta = +delta;
12963 if ( isNaN( delta ) || !isFinite( delta ) ) {
12964 throw new Error( 'Delta must be a finite number' );
12965 }
12966
12967 if ( isNaN( v ) ) {
12968 n = 0;
12969 } else {
12970 n = v + delta;
12971 n = Math.max( Math.min( n, this.max ), this.min );
12972 if ( this.step ) {
12973 n = Math.round( n / this.step ) * this.step;
12974 }
12975 }
12976
12977 if ( n !== v ) {
12978 this.setValue( n );
12979 }
12980 };
12981 /**
12982 * Validate input
12983 *
12984 * @private
12985 * @param {string} value Field value
12986 * @return {boolean}
12987 */
12988 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12989 var n = +value;
12990 if ( value === '' ) {
12991 return !this.isRequired();
12992 }
12993
12994 if ( isNaN( n ) || !isFinite( n ) ) {
12995 return false;
12996 }
12997
12998 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12999 return false;
13000 }
13001
13002 if ( n < this.min || n > this.max ) {
13003 return false;
13004 }
13005
13006 return true;
13007 };
13008
13009 /**
13010 * Handle mouse click events.
13011 *
13012 * @private
13013 * @param {number} dir +1 or -1
13014 */
13015 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
13016 this.adjustValue( dir * this.buttonStep );
13017 };
13018
13019 /**
13020 * Handle mouse wheel events.
13021 *
13022 * @private
13023 * @param {jQuery.Event} event
13024 * @return {undefined/boolean} False to prevent default if event is handled
13025 */
13026 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13027 var delta = 0;
13028
13029 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
13030 // Standard 'wheel' event
13031 if ( event.originalEvent.deltaMode !== undefined ) {
13032 this.sawWheelEvent = true;
13033 }
13034 if ( event.originalEvent.deltaY ) {
13035 delta = -event.originalEvent.deltaY;
13036 } else if ( event.originalEvent.deltaX ) {
13037 delta = event.originalEvent.deltaX;
13038 }
13039
13040 // Non-standard events
13041 if ( !this.sawWheelEvent ) {
13042 if ( event.originalEvent.wheelDeltaX ) {
13043 delta = -event.originalEvent.wheelDeltaX;
13044 } else if ( event.originalEvent.wheelDeltaY ) {
13045 delta = event.originalEvent.wheelDeltaY;
13046 } else if ( event.originalEvent.wheelDelta ) {
13047 delta = event.originalEvent.wheelDelta;
13048 } else if ( event.originalEvent.detail ) {
13049 delta = -event.originalEvent.detail;
13050 }
13051 }
13052
13053 if ( delta ) {
13054 delta = delta < 0 ? -1 : 1;
13055 this.adjustValue( delta * this.buttonStep );
13056 }
13057
13058 return false;
13059 }
13060 };
13061
13062 /**
13063 * Handle key down events.
13064 *
13065 * @private
13066 * @param {jQuery.Event} e Key down event
13067 * @return {undefined/boolean} False to prevent default if event is handled
13068 */
13069 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
13070 if ( !this.isDisabled() ) {
13071 switch ( e.which ) {
13072 case OO.ui.Keys.UP:
13073 this.adjustValue( this.buttonStep );
13074 return false;
13075 case OO.ui.Keys.DOWN:
13076 this.adjustValue( -this.buttonStep );
13077 return false;
13078 case OO.ui.Keys.PAGEUP:
13079 this.adjustValue( this.pageStep );
13080 return false;
13081 case OO.ui.Keys.PAGEDOWN:
13082 this.adjustValue( -this.pageStep );
13083 return false;
13084 }
13085 }
13086 };
13087
13088 /**
13089 * @inheritdoc
13090 */
13091 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
13092 // Parent method
13093 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
13094
13095 if ( this.minusButton ) {
13096 this.minusButton.setDisabled( this.isDisabled() );
13097 }
13098 if ( this.plusButton ) {
13099 this.plusButton.setDisabled( this.isDisabled() );
13100 }
13101
13102 return this;
13103 };
13104
13105 /**
13106 * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
13107 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
13108 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
13109 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
13110 *
13111 * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
13112 *
13113 * @example
13114 * // A file select input widget.
13115 * var selectFile = new OO.ui.SelectFileInputWidget();
13116 * $( document.body ).append( selectFile.$element );
13117 *
13118 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
13119 *
13120 * @class
13121 * @extends OO.ui.InputWidget
13122 *
13123 * @constructor
13124 * @param {Object} [config] Configuration options
13125 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
13126 * @cfg {string} [placeholder] Text to display when no file is selected.
13127 * @cfg {Object} [button] Config to pass to select file button.
13128 * @cfg {string} [icon] Icon to show next to file info
13129 */
13130 OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
13131 config = config || {};
13132
13133 // Construct buttons before parent method is called (calling setDisabled)
13134 this.selectButton = new OO.ui.ButtonWidget( $.extend( {
13135 $element: $( '<label>' ),
13136 classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
13137 label: OO.ui.msg( 'ooui-selectfile-button-select' )
13138 }, config.button ) );
13139
13140 // Configuration initialization
13141 config = $.extend( {
13142 accept: null,
13143 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
13144 $tabIndexed: this.selectButton.$tabIndexed
13145 }, config );
13146
13147 this.info = new OO.ui.SearchInputWidget( {
13148 classes: [ 'oo-ui-selectFileInputWidget-info' ],
13149 placeholder: config.placeholder,
13150 // Pass an empty collection so that .focus() always does nothing
13151 $tabIndexed: $( [] )
13152 } ).setIcon( config.icon );
13153 // Set tabindex manually on $input as $tabIndexed has been overridden
13154 this.info.$input.attr( 'tabindex', -1 );
13155
13156 // Parent constructor
13157 OO.ui.SelectFileInputWidget.parent.call( this, config );
13158
13159 // Properties
13160 this.currentFile = null;
13161 if ( Array.isArray( config.accept ) ) {
13162 this.accept = config.accept;
13163 } else {
13164 this.accept = null;
13165 }
13166 this.onFileSelectedHandler = this.onFileSelected.bind( this );
13167
13168 // Events
13169 this.info.connect( this, { change: 'onInfoChange' } );
13170 this.selectButton.$button.on( {
13171 keypress: this.onKeyPress.bind( this )
13172 } );
13173 this.connect( this, { change: 'updateUI' } );
13174
13175 // Initialization
13176 this.setupInput();
13177
13178 this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
13179
13180 this.$element
13181 .addClass( 'oo-ui-selectFileInputWidget' )
13182 .append( this.fieldLayout.$element );
13183
13184 this.updateUI();
13185 };
13186
13187 /* Setup */
13188
13189 OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
13190
13191 /* Methods */
13192
13193 /**
13194 * Get the filename of the currently selected file.
13195 *
13196 * @return {string} Filename
13197 */
13198 OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
13199 if ( this.currentFile ) {
13200 return this.currentFile.name;
13201 } else {
13202 // Try to strip leading fakepath.
13203 return this.getValue().split( '\\' ).pop();
13204 }
13205 };
13206
13207 /**
13208 * @inheritdoc
13209 */
13210 OO.ui.SelectFileInputWidget.prototype.setValue = function ( value ) {
13211 if ( value === undefined ) {
13212 // Called during init, don't replace value if just infusing.
13213 return;
13214 }
13215 if ( value ) {
13216 // We need to update this.value, but without trying to modify
13217 // the DOM value, which would throw an exception.
13218 if ( this.value !== value ) {
13219 this.value = value;
13220 this.emit( 'change', this.value );
13221 }
13222 } else {
13223 this.currentFile = null;
13224 // Parent method
13225 OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
13226 }
13227 };
13228
13229 /**
13230 * Handle file selection from the input.
13231 *
13232 * @protected
13233 * @param {jQuery.Event} e
13234 */
13235 OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
13236 var file = OO.getProp( e.target, 'files', 0 ) || null;
13237
13238 if ( file && !this.isAllowedType( file.type ) ) {
13239 file = null;
13240 }
13241
13242 this.currentFile = file;
13243 };
13244
13245 /**
13246 * Update the user interface when a file is selected or unselected.
13247 *
13248 * @protected
13249 */
13250 OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
13251 this.info.setValue( this.getFilename() );
13252 };
13253
13254 /**
13255 * Setup the input element.
13256 *
13257 * @protected
13258 */
13259 OO.ui.SelectFileInputWidget.prototype.setupInput = function () {
13260 var widget = this;
13261 this.$input
13262 .attr( {
13263 type: 'file',
13264 // this.selectButton is tabindexed
13265 tabindex: -1,
13266 // Infused input may have previously by
13267 // TabIndexed, so remove aria-disabled attr.
13268 'aria-disabled': null
13269 } )
13270 .on( 'change', this.onFileSelectedHandler )
13271 // Support: IE11
13272 // In IE 11, focussing a file input (by clicking on it) displays a text cursor and scrolls
13273 // the cursor into view (in this case, it scrolls the button, which has 'overflow: hidden').
13274 // Since this messes with our custom styling (the file input has large dimensions and this
13275 // causes the label to scroll out of view), scroll the button back to top. (T192131)
13276 .on( 'focus', function () {
13277 widget.$input.parent().prop( 'scrollTop', 0 );
13278 } );
13279
13280 if ( this.accept ) {
13281 this.$input.attr( 'accept', this.accept.join( ', ' ) );
13282 }
13283 this.selectButton.$button.append( this.$input );
13284 };
13285
13286 /**
13287 * Determine if we should accept this file.
13288 *
13289 * @private
13290 * @param {string} mimeType File MIME type
13291 * @return {boolean}
13292 */
13293 OO.ui.SelectFileInputWidget.prototype.isAllowedType = function ( mimeType ) {
13294 var i, mimeTest;
13295
13296 if ( !this.accept || !mimeType ) {
13297 return true;
13298 }
13299
13300 for ( i = 0; i < this.accept.length; i++ ) {
13301 mimeTest = this.accept[ i ];
13302 if ( mimeTest === mimeType ) {
13303 return true;
13304 } else if ( mimeTest.substr( -2 ) === '/*' ) {
13305 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
13306 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
13307 return true;
13308 }
13309 }
13310 }
13311
13312 return false;
13313 };
13314
13315 /**
13316 * Handle info input change events
13317 *
13318 * The info widget can only be changed by the user
13319 * with the clear button.
13320 *
13321 * @private
13322 * @param {string} value
13323 */
13324 OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
13325 if ( value === '' ) {
13326 this.setValue( null );
13327 }
13328 };
13329
13330 /**
13331 * Handle key press events.
13332 *
13333 * @private
13334 * @param {jQuery.Event} e Key press event
13335 * @return {undefined/boolean} False to prevent default if event is handled
13336 */
13337 OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
13338 if ( !this.isDisabled() && this.$input &&
13339 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
13340 ) {
13341 // Emit a click to open the file selector.
13342 this.$input.trigger( 'click' );
13343 // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
13344 this.selectButton.onDocumentKeyUp( e );
13345 return false;
13346 }
13347 };
13348
13349 /**
13350 * @inheritdoc
13351 */
13352 OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
13353 // Parent method
13354 OO.ui.SelectFileInputWidget.parent.prototype.setDisabled.call( this, disabled );
13355
13356 this.selectButton.setDisabled( disabled );
13357 this.info.setDisabled( disabled );
13358
13359 return this;
13360 };
13361
13362 }( OO ) );
13363
13364 //# sourceMappingURL=oojs-ui-core.js.map.json