Merge "Selenium: replace UserLoginPage with BlankPage where possible"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.33.1
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-07-03T21:05:08Z
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 = Date.now() - wait,
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 = Math.max( wait - ( Date.now() - previous ), 0 );
308 context = this;
309 args = arguments;
310 if ( !timeout ) {
311 // If time is up, do setTimeout( run, 0 ) so the function
312 // always runs asynchronously, just like Promise#then .
313 timeout = setTimeout( run, remaining );
314 }
315 };
316 };
317
318 /**
319 * Reconstitute a JavaScript object corresponding to a widget created by
320 * the PHP implementation.
321 *
322 * This is an alias for `OO.ui.Element.static.infuse()`.
323 *
324 * @param {string|HTMLElement|jQuery} idOrNode
325 * A DOM id (if a string) or node for the widget to infuse.
326 * @param {Object} [config] Configuration options
327 * @return {OO.ui.Element}
328 * The `OO.ui.Element` corresponding to this (infusable) document node.
329 */
330 OO.ui.infuse = function ( idOrNode, config ) {
331 return OO.ui.Element.static.infuse( idOrNode, config );
332 };
333
334 /**
335 * Get a localized message.
336 *
337 * After the message key, message parameters may optionally be passed. In the default
338 * implementation, any occurrences of $1 are replaced with the first parameter, $2 with the
339 * second parameter, etc.
340 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long
341 * as they support unnamed, ordered message parameters.
342 *
343 * In environments that provide a localization system, this function should be overridden to
344 * return the message translated in the user's language. The default implementation always
345 * returns English messages. An example of doing this with
346 * [jQuery.i18n](https://github.com/wikimedia/jquery.i18n) follows.
347 *
348 * @example
349 * var i, iLen, button,
350 * messagePath = 'oojs-ui/dist/i18n/',
351 * languages = [ $.i18n().locale, 'ur', 'en' ],
352 * languageMap = {};
353 *
354 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
355 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
356 * }
357 *
358 * $.i18n().load( languageMap ).done( function() {
359 * // Replace the built-in `msg` only once we've loaded the internationalization.
360 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
361 * // you put off creating any widgets until this promise is complete, no English
362 * // will be displayed.
363 * OO.ui.msg = $.i18n;
364 *
365 * // A button displaying "OK" in the default locale
366 * button = new OO.ui.ButtonWidget( {
367 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
368 * icon: 'check'
369 * } );
370 * $( document.body ).append( button.$element );
371 *
372 * // A button displaying "OK" in Urdu
373 * $.i18n().locale = 'ur';
374 * button = new OO.ui.ButtonWidget( {
375 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
376 * icon: 'check'
377 * } );
378 * $( document.body ).append( button.$element );
379 * } );
380 *
381 * @param {string} key Message key
382 * @param {...Mixed} [params] Message parameters
383 * @return {string} Translated message with parameters substituted
384 */
385 OO.ui.msg = function ( key ) {
386 // `OO.ui.msg.messages` is defined in code generated during the build process
387 var messages = OO.ui.msg.messages,
388 message = messages[ key ],
389 params = Array.prototype.slice.call( arguments, 1 );
390 if ( typeof message === 'string' ) {
391 // Perform $1 substitution
392 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
393 var i = parseInt( n, 10 );
394 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
395 } );
396 } else {
397 // Return placeholder if message not found
398 message = '[' + key + ']';
399 }
400 return message;
401 };
402
403 /**
404 * Package a message and arguments for deferred resolution.
405 *
406 * Use this when you are statically specifying a message and the message may not yet be present.
407 *
408 * @param {string} key Message key
409 * @param {...Mixed} [params] Message parameters
410 * @return {Function} Function that returns the resolved message when executed
411 */
412 OO.ui.deferMsg = function () {
413 var args = arguments;
414 return function () {
415 return OO.ui.msg.apply( OO.ui, args );
416 };
417 };
418
419 /**
420 * Resolve a message.
421 *
422 * If the message is a function it will be executed, otherwise it will pass through directly.
423 *
424 * @param {Function|string} msg Deferred message, or message text
425 * @return {string} Resolved message
426 */
427 OO.ui.resolveMsg = function ( msg ) {
428 if ( typeof msg === 'function' ) {
429 return msg();
430 }
431 return msg;
432 };
433
434 /**
435 * @param {string} url
436 * @return {boolean}
437 */
438 OO.ui.isSafeUrl = function ( url ) {
439 // Keep this function in sync with php/Tag.php
440 var i, protocolWhitelist;
441
442 function stringStartsWith( haystack, needle ) {
443 return haystack.substr( 0, needle.length ) === needle;
444 }
445
446 protocolWhitelist = [
447 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
448 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
449 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
450 ];
451
452 if ( url === '' ) {
453 return true;
454 }
455
456 for ( i = 0; i < protocolWhitelist.length; i++ ) {
457 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
458 return true;
459 }
460 }
461
462 // This matches '//' too
463 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
464 return true;
465 }
466 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
467 return true;
468 }
469
470 return false;
471 };
472
473 /**
474 * Check if the user has a 'mobile' device.
475 *
476 * For our purposes this means the user is primarily using an
477 * on-screen keyboard, touch input instead of a mouse and may
478 * have a physically small display.
479 *
480 * It is left up to implementors to decide how to compute this
481 * so the default implementation always returns false.
482 *
483 * @return {boolean} User is on a mobile device
484 */
485 OO.ui.isMobile = function () {
486 return false;
487 };
488
489 /**
490 * Get the additional spacing that should be taken into account when displaying elements that are
491 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
492 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
493 *
494 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
495 * the extra spacing from that edge of viewport (in pixels)
496 */
497 OO.ui.getViewportSpacing = function () {
498 return {
499 top: 0,
500 right: 0,
501 bottom: 0,
502 left: 0
503 };
504 };
505
506 /**
507 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
508 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
509 *
510 * @return {jQuery} Default overlay node
511 */
512 OO.ui.getDefaultOverlay = function () {
513 if ( !OO.ui.$defaultOverlay ) {
514 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
515 $( document.body ).append( OO.ui.$defaultOverlay );
516 }
517 return OO.ui.$defaultOverlay;
518 };
519
520 /**
521 * Message store for the default implementation of OO.ui.msg.
522 *
523 * Environments that provide a localization system should not use this, but should override
524 * OO.ui.msg altogether.
525 *
526 * @private
527 */
528 OO.ui.msg.messages = {
529 "ooui-outline-control-move-down": "Move item down",
530 "ooui-outline-control-move-up": "Move item up",
531 "ooui-outline-control-remove": "Remove item",
532 "ooui-toolbar-more": "More",
533 "ooui-toolgroup-expand": "More",
534 "ooui-toolgroup-collapse": "Fewer",
535 "ooui-item-remove": "Remove",
536 "ooui-dialog-message-accept": "OK",
537 "ooui-dialog-message-reject": "Cancel",
538 "ooui-dialog-process-error": "Something went wrong",
539 "ooui-dialog-process-dismiss": "Dismiss",
540 "ooui-dialog-process-retry": "Try again",
541 "ooui-dialog-process-continue": "Continue",
542 "ooui-combobox-button-label": "Dropdown for combobox",
543 "ooui-selectfile-button-select": "Select a file",
544 "ooui-selectfile-not-supported": "File selection is not supported",
545 "ooui-selectfile-placeholder": "No file is selected",
546 "ooui-selectfile-dragdrop-placeholder": "Drop file here",
547 "ooui-field-help": "Help"
548 };
549
550 /*!
551 * Mixin namespace.
552 */
553
554 /**
555 * Namespace for OOUI mixins.
556 *
557 * Mixins are named according to the type of object they are intended to
558 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
559 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
560 * is intended to be mixed in to an instance of OO.ui.Widget.
561 *
562 * @class
563 * @singleton
564 */
565 OO.ui.mixin = {};
566
567 /**
568 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
569 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not
570 * have events connected to them and can't be interacted with.
571 *
572 * @abstract
573 * @class
574 *
575 * @constructor
576 * @param {Object} [config] Configuration options
577 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are
578 * added to the top level (e.g., the outermost div) of the element. See the
579 * [OOUI documentation on MediaWiki][2] for an example.
580 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
581 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
582 * @cfg {string} [text] Text to insert
583 * @cfg {Array} [content] An array of content elements to append (after #text).
584 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
585 * Instances of OO.ui.Element will have their $element appended.
586 * @cfg {jQuery} [$content] Content elements to append (after #text).
587 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
588 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number,
589 * array, object).
590 * Data can also be specified with the #setData method.
591 */
592 OO.ui.Element = function OoUiElement( config ) {
593 if ( OO.ui.isDemo ) {
594 this.initialConfig = config;
595 }
596 // Configuration initialization
597 config = config || {};
598
599 // Properties
600 this.elementId = null;
601 this.visible = true;
602 this.data = config.data;
603 this.$element = config.$element ||
604 $( document.createElement( this.getTagName() ) );
605 this.elementGroup = null;
606
607 // Initialization
608 if ( Array.isArray( config.classes ) ) {
609 this.$element.addClass( config.classes );
610 }
611 if ( config.id ) {
612 this.setElementId( config.id );
613 }
614 if ( config.text ) {
615 this.$element.text( config.text );
616 }
617 if ( config.content ) {
618 // The `content` property treats plain strings as text; use an
619 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
620 // appropriate $element appended.
621 this.$element.append( config.content.map( function ( v ) {
622 if ( typeof v === 'string' ) {
623 // Escape string so it is properly represented in HTML.
624 // Don't create empty text nodes for empty strings.
625 return v ? document.createTextNode( v ) : undefined;
626 } else if ( v instanceof OO.ui.HtmlSnippet ) {
627 // Bypass escaping.
628 return v.toString();
629 } else if ( v instanceof OO.ui.Element ) {
630 return v.$element;
631 }
632 return v;
633 } ) );
634 }
635 if ( config.$content ) {
636 // The `$content` property treats plain strings as HTML.
637 this.$element.append( config.$content );
638 }
639 };
640
641 /* Setup */
642
643 OO.initClass( OO.ui.Element );
644
645 /* Static Properties */
646
647 /**
648 * The name of the HTML tag used by the element.
649 *
650 * The static value may be ignored if the #getTagName method is overridden.
651 *
652 * @static
653 * @inheritable
654 * @property {string}
655 */
656 OO.ui.Element.static.tagName = 'div';
657
658 /* Static Methods */
659
660 /**
661 * Reconstitute a JavaScript object corresponding to a widget created
662 * by the PHP implementation.
663 *
664 * @param {string|HTMLElement|jQuery} idOrNode
665 * A DOM id (if a string) or node for the widget to infuse.
666 * @param {Object} [config] Configuration options
667 * @return {OO.ui.Element}
668 * The `OO.ui.Element` corresponding to this (infusable) document node.
669 * For `Tag` objects emitted on the HTML side (used occasionally for content)
670 * the value returned is a newly-created Element wrapping around the existing
671 * DOM node.
672 */
673 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
674 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
675
676 if ( typeof idOrNode === 'string' ) {
677 // IDs deprecated since 0.29.7
678 OO.ui.warnDeprecation(
679 'Passing a string ID to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
680 );
681 }
682 // Verify that the type matches up.
683 // FIXME: uncomment after T89721 is fixed, see T90929.
684 /*
685 if ( !( obj instanceof this['class'] ) ) {
686 throw new Error( 'Infusion type mismatch!' );
687 }
688 */
689 return obj;
690 };
691
692 /**
693 * Implementation helper for `infuse`; skips the type check and has an
694 * extra property so that only the top-level invocation touches the DOM.
695 *
696 * @private
697 * @param {string|HTMLElement|jQuery} idOrNode
698 * @param {Object} [config] Configuration options
699 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
700 * when the top-level widget of this infusion is inserted into DOM,
701 * replacing the original node; only used internally.
702 * @return {OO.ui.Element}
703 */
704 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
705 // look for a cached result of a previous infusion.
706 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
707 if ( typeof idOrNode === 'string' ) {
708 id = idOrNode;
709 $elem = $( document.getElementById( id ) );
710 } else {
711 $elem = $( idOrNode );
712 id = $elem.attr( 'id' );
713 }
714 if ( !$elem.length ) {
715 if ( typeof idOrNode === 'string' ) {
716 error = 'Widget not found: ' + idOrNode;
717 } else if ( idOrNode && idOrNode.selector ) {
718 error = 'Widget not found: ' + idOrNode.selector;
719 } else {
720 error = 'Widget not found';
721 }
722 throw new Error( error );
723 }
724 if ( $elem[ 0 ].oouiInfused ) {
725 $elem = $elem[ 0 ].oouiInfused;
726 }
727 data = $elem.data( 'ooui-infused' );
728 if ( data ) {
729 // cached!
730 if ( data === true ) {
731 throw new Error( 'Circular dependency! ' + id );
732 }
733 if ( domPromise ) {
734 // Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
735 state = data.constructor.static.gatherPreInfuseState( $elem, data );
736 // Restore dynamic state after the new element is re-inserted into DOM under
737 // infused parent.
738 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
739 infusedChildren = $elem.data( 'ooui-infused-children' );
740 if ( infusedChildren && infusedChildren.length ) {
741 infusedChildren.forEach( function ( data ) {
742 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
743 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
744 } );
745 }
746 }
747 return data;
748 }
749 data = $elem.attr( 'data-ooui' );
750 if ( !data ) {
751 throw new Error( 'No infusion data found: ' + id );
752 }
753 try {
754 data = JSON.parse( data );
755 } catch ( _ ) {
756 data = null;
757 }
758 if ( !( data && data._ ) ) {
759 throw new Error( 'No valid infusion data found: ' + id );
760 }
761 if ( data._ === 'Tag' ) {
762 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
763 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
764 }
765 parts = data._.split( '.' );
766 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
767 if ( cls === undefined ) {
768 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
769 }
770
771 // Verify that we're creating an OO.ui.Element instance
772 parent = cls.parent;
773
774 while ( parent !== undefined ) {
775 if ( parent === OO.ui.Element ) {
776 // Safe
777 break;
778 }
779
780 parent = parent.parent;
781 }
782
783 if ( parent !== OO.ui.Element ) {
784 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
785 }
786
787 if ( !domPromise ) {
788 top = $.Deferred();
789 domPromise = top.promise();
790 }
791 $elem.data( 'ooui-infused', true ); // prevent loops
792 data.id = id; // implicit
793 infusedChildren = [];
794 data = OO.copy( data, null, function deserialize( value ) {
795 var infused;
796 if ( OO.isPlainObject( value ) ) {
797 if ( value.tag ) {
798 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
799 infusedChildren.push( infused );
800 // Flatten the structure
801 infusedChildren.push.apply(
802 infusedChildren,
803 infused.$element.data( 'ooui-infused-children' ) || []
804 );
805 infused.$element.removeData( 'ooui-infused-children' );
806 return infused;
807 }
808 if ( value.html !== undefined ) {
809 return new OO.ui.HtmlSnippet( value.html );
810 }
811 }
812 } );
813 // allow widgets to reuse parts of the DOM
814 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
815 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
816 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
817 // rebuild widget
818 // eslint-disable-next-line new-cap
819 obj = new cls( $.extend( {}, config, data ) );
820 // If anyone is holding a reference to the old DOM element,
821 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
822 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
823 $elem[ 0 ].oouiInfused = obj.$element;
824 // now replace old DOM with this new DOM.
825 if ( top ) {
826 // An efficient constructor might be able to reuse the entire DOM tree of the original
827 // element, so only mutate the DOM if we need to.
828 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
829 $elem.replaceWith( obj.$element );
830 }
831 top.resolve();
832 }
833 obj.$element.data( 'ooui-infused', obj );
834 obj.$element.data( 'ooui-infused-children', infusedChildren );
835 // set the 'data-ooui' attribute so we can identify infused widgets
836 obj.$element.attr( 'data-ooui', '' );
837 // restore dynamic state after the new element is inserted into DOM
838 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
839 return obj;
840 };
841
842 /**
843 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
844 *
845 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
846 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
847 * constructor, which will be given the enhanced config.
848 *
849 * @protected
850 * @param {HTMLElement} node
851 * @param {Object} config
852 * @return {Object}
853 */
854 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
855 return config;
856 };
857
858 /**
859 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
860 * node (and its children) that represent an Element of the same class and the given configuration,
861 * generated by the PHP implementation.
862 *
863 * This method is called just before `node` is detached from the DOM. The return value of this
864 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
865 * is inserted into DOM to replace `node`.
866 *
867 * @protected
868 * @param {HTMLElement} node
869 * @param {Object} config
870 * @return {Object}
871 */
872 OO.ui.Element.static.gatherPreInfuseState = function () {
873 return {};
874 };
875
876 /**
877 * Get the document of an element.
878 *
879 * @static
880 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
881 * @return {HTMLDocument|null} Document object
882 */
883 OO.ui.Element.static.getDocument = function ( obj ) {
884 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
885 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
886 // Empty jQuery selections might have a context
887 obj.context ||
888 // HTMLElement
889 obj.ownerDocument ||
890 // Window
891 obj.document ||
892 // HTMLDocument
893 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
894 null;
895 };
896
897 /**
898 * Get the window of an element or document.
899 *
900 * @static
901 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
902 * @return {Window} Window object
903 */
904 OO.ui.Element.static.getWindow = function ( obj ) {
905 var doc = this.getDocument( obj );
906 return doc.defaultView;
907 };
908
909 /**
910 * Get the direction of an element or document.
911 *
912 * @static
913 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
914 * @return {string} Text direction, either 'ltr' or 'rtl'
915 */
916 OO.ui.Element.static.getDir = function ( obj ) {
917 var isDoc, isWin;
918
919 if ( obj instanceof $ ) {
920 obj = obj[ 0 ];
921 }
922 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
923 isWin = obj.document !== undefined;
924 if ( isDoc || isWin ) {
925 if ( isWin ) {
926 obj = obj.document;
927 }
928 obj = obj.body;
929 }
930 return $( obj ).css( 'direction' );
931 };
932
933 /**
934 * Get the offset between two frames.
935 *
936 * TODO: Make this function not use recursion.
937 *
938 * @static
939 * @param {Window} from Window of the child frame
940 * @param {Window} [to=window] Window of the parent frame
941 * @param {Object} [offset] Offset to start with, used internally
942 * @return {Object} Offset object, containing left and top properties
943 */
944 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
945 var i, len, frames, frame, rect;
946
947 if ( !to ) {
948 to = window;
949 }
950 if ( !offset ) {
951 offset = { top: 0, left: 0 };
952 }
953 if ( from.parent === from ) {
954 return offset;
955 }
956
957 // Get iframe element
958 frames = from.parent.document.getElementsByTagName( 'iframe' );
959 for ( i = 0, len = frames.length; i < len; i++ ) {
960 if ( frames[ i ].contentWindow === from ) {
961 frame = frames[ i ];
962 break;
963 }
964 }
965
966 // Recursively accumulate offset values
967 if ( frame ) {
968 rect = frame.getBoundingClientRect();
969 offset.left += rect.left;
970 offset.top += rect.top;
971 if ( from !== to ) {
972 this.getFrameOffset( from.parent, offset );
973 }
974 }
975 return offset;
976 };
977
978 /**
979 * Get the offset between two elements.
980 *
981 * The two elements may be in a different frame, but in that case the frame $element is in must
982 * be contained in the frame $anchor is in.
983 *
984 * @static
985 * @param {jQuery} $element Element whose position to get
986 * @param {jQuery} $anchor Element to get $element's position relative to
987 * @return {Object} Translated position coordinates, containing top and left properties
988 */
989 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
990 var iframe, iframePos,
991 pos = $element.offset(),
992 anchorPos = $anchor.offset(),
993 elementDocument = this.getDocument( $element ),
994 anchorDocument = this.getDocument( $anchor );
995
996 // If $element isn't in the same document as $anchor, traverse up
997 while ( elementDocument !== anchorDocument ) {
998 iframe = elementDocument.defaultView.frameElement;
999 if ( !iframe ) {
1000 throw new Error( '$element frame is not contained in $anchor frame' );
1001 }
1002 iframePos = $( iframe ).offset();
1003 pos.left += iframePos.left;
1004 pos.top += iframePos.top;
1005 elementDocument = iframe.ownerDocument;
1006 }
1007 pos.left -= anchorPos.left;
1008 pos.top -= anchorPos.top;
1009 return pos;
1010 };
1011
1012 /**
1013 * Get element border sizes.
1014 *
1015 * @static
1016 * @param {HTMLElement} el Element to measure
1017 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1018 */
1019 OO.ui.Element.static.getBorders = function ( el ) {
1020 var doc = el.ownerDocument,
1021 win = doc.defaultView,
1022 style = win.getComputedStyle( el, null ),
1023 $el = $( el ),
1024 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1025 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1026 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1027 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1028
1029 return {
1030 top: top,
1031 left: left,
1032 bottom: bottom,
1033 right: right
1034 };
1035 };
1036
1037 /**
1038 * Get dimensions of an element or window.
1039 *
1040 * @static
1041 * @param {HTMLElement|Window} el Element to measure
1042 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1043 */
1044 OO.ui.Element.static.getDimensions = function ( el ) {
1045 var $el, $win,
1046 doc = el.ownerDocument || el.document,
1047 win = doc.defaultView;
1048
1049 if ( win === el || el === doc.documentElement ) {
1050 $win = $( win );
1051 return {
1052 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1053 scroll: {
1054 top: $win.scrollTop(),
1055 left: $win.scrollLeft()
1056 },
1057 scrollbar: { right: 0, bottom: 0 },
1058 rect: {
1059 top: 0,
1060 left: 0,
1061 bottom: $win.innerHeight(),
1062 right: $win.innerWidth()
1063 }
1064 };
1065 } else {
1066 $el = $( el );
1067 return {
1068 borders: this.getBorders( el ),
1069 scroll: {
1070 top: $el.scrollTop(),
1071 left: $el.scrollLeft()
1072 },
1073 scrollbar: {
1074 right: $el.innerWidth() - el.clientWidth,
1075 bottom: $el.innerHeight() - el.clientHeight
1076 },
1077 rect: el.getBoundingClientRect()
1078 };
1079 }
1080 };
1081
1082 /**
1083 * Get the number of pixels that an element's content is scrolled to the left.
1084 *
1085 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1086 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1087 *
1088 * This function smooths out browser inconsistencies (nicely described in the README at
1089 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1090 * with Firefox's 'scrollLeft', which seems the sanest.
1091 *
1092 * @static
1093 * @method
1094 * @param {HTMLElement|Window} el Element to measure
1095 * @return {number} Scroll position from the left.
1096 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1097 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1098 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1099 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1100 */
1101 OO.ui.Element.static.getScrollLeft = ( function () {
1102 var rtlScrollType = null;
1103
1104 function test() {
1105 var $definer = $( '<div>' ).attr( {
1106 dir: 'rtl',
1107 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1108 } ).text( 'ABCD' ),
1109 definer = $definer[ 0 ];
1110
1111 $definer.appendTo( 'body' );
1112 if ( definer.scrollLeft > 0 ) {
1113 // Safari, Chrome
1114 rtlScrollType = 'default';
1115 } else {
1116 definer.scrollLeft = 1;
1117 if ( definer.scrollLeft === 0 ) {
1118 // Firefox, old Opera
1119 rtlScrollType = 'negative';
1120 } else {
1121 // Internet Explorer, Edge
1122 rtlScrollType = 'reverse';
1123 }
1124 }
1125 $definer.remove();
1126 }
1127
1128 return function getScrollLeft( el ) {
1129 var isRoot = el.window === el ||
1130 el === el.ownerDocument.body ||
1131 el === el.ownerDocument.documentElement,
1132 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1133 // All browsers use the correct scroll type ('negative') on the root, so don't
1134 // do any fixups when looking at the root element
1135 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1136
1137 if ( direction === 'rtl' ) {
1138 if ( rtlScrollType === null ) {
1139 test();
1140 }
1141 if ( rtlScrollType === 'reverse' ) {
1142 scrollLeft = -scrollLeft;
1143 } else if ( rtlScrollType === 'default' ) {
1144 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1145 }
1146 }
1147
1148 return scrollLeft;
1149 };
1150 }() );
1151
1152 /**
1153 * Get the root scrollable element of given element's document.
1154 *
1155 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1156 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1157 * lets us use 'body' or 'documentElement' based on what is working.
1158 *
1159 * https://code.google.com/p/chromium/issues/detail?id=303131
1160 *
1161 * @static
1162 * @param {HTMLElement} el Element to find root scrollable parent for
1163 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1164 * depending on browser
1165 */
1166 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1167 var scrollTop, body;
1168
1169 if ( OO.ui.scrollableElement === undefined ) {
1170 body = el.ownerDocument.body;
1171 scrollTop = body.scrollTop;
1172 body.scrollTop = 1;
1173
1174 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1175 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1176 if ( Math.round( body.scrollTop ) === 1 ) {
1177 body.scrollTop = scrollTop;
1178 OO.ui.scrollableElement = 'body';
1179 } else {
1180 OO.ui.scrollableElement = 'documentElement';
1181 }
1182 }
1183
1184 return el.ownerDocument[ OO.ui.scrollableElement ];
1185 };
1186
1187 /**
1188 * Get closest scrollable container.
1189 *
1190 * Traverses up until either a scrollable element or the root is reached, in which case the root
1191 * scrollable element will be returned (see #getRootScrollableElement).
1192 *
1193 * @static
1194 * @param {HTMLElement} el Element to find scrollable container for
1195 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1196 * @return {HTMLElement} Closest scrollable container
1197 */
1198 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1199 var i, val,
1200 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1201 // 'overflow-y' have different values, so we need to check the separate properties.
1202 props = [ 'overflow-x', 'overflow-y' ],
1203 $parent = $( el ).parent();
1204
1205 if ( dimension === 'x' || dimension === 'y' ) {
1206 props = [ 'overflow-' + dimension ];
1207 }
1208
1209 // Special case for the document root (which doesn't really have any scrollable container,
1210 // since it is the ultimate scrollable container, but this is probably saner than null or
1211 // exception).
1212 if ( $( el ).is( 'html, body' ) ) {
1213 return this.getRootScrollableElement( el );
1214 }
1215
1216 while ( $parent.length ) {
1217 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1218 return $parent[ 0 ];
1219 }
1220 i = props.length;
1221 while ( i-- ) {
1222 val = $parent.css( props[ i ] );
1223 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1224 // never be scrolled in that direction, but they can actually be scrolled
1225 // programatically. The user can unintentionally perform a scroll in such case even if
1226 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1227 // when using built-in find functionality.
1228 // This could cause funny issues...
1229 if ( val === 'auto' || val === 'scroll' ) {
1230 return $parent[ 0 ];
1231 }
1232 }
1233 $parent = $parent.parent();
1234 }
1235 // The element is unattached... return something mostly sane
1236 return this.getRootScrollableElement( el );
1237 };
1238
1239 /**
1240 * Scroll element into view.
1241 *
1242 * @static
1243 * @param {HTMLElement|Object} elOrPosition Element to scroll into view
1244 * @param {Object} [config] Configuration options
1245 * @param {string} [config.animate=true] Animate to the new scroll offset.
1246 * @param {string} [config.duration='fast'] jQuery animation duration value
1247 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1248 * to scroll in both directions
1249 * @param {Object} [config.padding] Additional padding on the container to scroll past.
1250 * Object containing any of 'top', 'bottom', 'left', or 'right' as numbers.
1251 * @param {Object} [config.scrollContainer] Scroll container. Defaults to
1252 * getClosestScrollableContainer of the element.
1253 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1254 */
1255 OO.ui.Element.static.scrollIntoView = function ( elOrPosition, config ) {
1256 var position, animations, container, $container, elementPosition, containerDimensions,
1257 $window, padding, animate, method,
1258 deferred = $.Deferred();
1259
1260 // Configuration initialization
1261 config = config || {};
1262
1263 padding = $.extend( {
1264 top: 0,
1265 bottom: 0,
1266 left: 0,
1267 right: 0
1268 }, config.padding );
1269
1270 animate = config.animate !== false;
1271
1272 animations = {};
1273 elementPosition = elOrPosition instanceof HTMLElement ?
1274 this.getDimensions( elOrPosition ).rect :
1275 elOrPosition;
1276 container = config.scrollContainer || (
1277 elOrPosition instanceof HTMLElement ?
1278 this.getClosestScrollableContainer( elOrPosition, config.direction ) :
1279 // No scrollContainer or element
1280 this.getClosestScrollableContainer( document.body )
1281 );
1282 $container = $( container );
1283 containerDimensions = this.getDimensions( container );
1284 $window = $( this.getWindow( container ) );
1285
1286 // Compute the element's position relative to the container
1287 if ( $container.is( 'html, body' ) ) {
1288 // If the scrollable container is the root, this is easy
1289 position = {
1290 top: elementPosition.top,
1291 bottom: $window.innerHeight() - elementPosition.bottom,
1292 left: elementPosition.left,
1293 right: $window.innerWidth() - elementPosition.right
1294 };
1295 } else {
1296 // Otherwise, we have to subtract el's coordinates from container's coordinates
1297 position = {
1298 top: elementPosition.top -
1299 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1300 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1301 containerDimensions.scrollbar.bottom - elementPosition.bottom,
1302 left: elementPosition.left -
1303 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1304 right: containerDimensions.rect.right - containerDimensions.borders.right -
1305 containerDimensions.scrollbar.right - elementPosition.right
1306 };
1307 }
1308
1309 if ( !config.direction || config.direction === 'y' ) {
1310 if ( position.top < padding.top ) {
1311 animations.scrollTop = containerDimensions.scroll.top + position.top - padding.top;
1312 } else if ( position.bottom < padding.bottom ) {
1313 animations.scrollTop = containerDimensions.scroll.top +
1314 // Scroll the bottom into view, but not at the expense
1315 // of scrolling the top out of view
1316 Math.min( position.top - padding.top, -position.bottom + padding.bottom );
1317 }
1318 }
1319 if ( !config.direction || config.direction === 'x' ) {
1320 if ( position.left < padding.left ) {
1321 animations.scrollLeft = containerDimensions.scroll.left + position.left - padding.left;
1322 } else if ( position.right < padding.right ) {
1323 animations.scrollLeft = containerDimensions.scroll.left +
1324 // Scroll the right into view, but not at the expense
1325 // of scrolling the left out of view
1326 Math.min( position.left - padding.left, -position.right + padding.right );
1327 }
1328 }
1329 if ( !$.isEmptyObject( animations ) ) {
1330 if ( animate ) {
1331 // eslint-disable-next-line no-jquery/no-animate
1332 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1333 $container.queue( function ( next ) {
1334 deferred.resolve();
1335 next();
1336 } );
1337 } else {
1338 $container.stop( true );
1339 for ( method in animations ) {
1340 $container[ method ]( animations[ method ] );
1341 }
1342 deferred.resolve();
1343 }
1344 } else {
1345 deferred.resolve();
1346 }
1347 return deferred.promise();
1348 };
1349
1350 /**
1351 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1352 * and reserve space for them, because it probably doesn't.
1353 *
1354 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1355 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1356 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1357 * reflow, and then reattach (or show) them back.
1358 *
1359 * @static
1360 * @param {HTMLElement} el Element to reconsider the scrollbars on
1361 */
1362 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1363 var i, len, scrollLeft, scrollTop, nodes = [];
1364 // Save scroll position
1365 scrollLeft = el.scrollLeft;
1366 scrollTop = el.scrollTop;
1367 // Detach all children
1368 while ( el.firstChild ) {
1369 nodes.push( el.firstChild );
1370 el.removeChild( el.firstChild );
1371 }
1372 // Force reflow
1373 // eslint-disable-next-line no-void
1374 void el.offsetHeight;
1375 // Reattach all children
1376 for ( i = 0, len = nodes.length; i < len; i++ ) {
1377 el.appendChild( nodes[ i ] );
1378 }
1379 // Restore scroll position (no-op if scrollbars disappeared)
1380 el.scrollLeft = scrollLeft;
1381 el.scrollTop = scrollTop;
1382 };
1383
1384 /* Methods */
1385
1386 /**
1387 * Toggle visibility of an element.
1388 *
1389 * @param {boolean} [show] Make element visible, omit to toggle visibility
1390 * @fires visible
1391 * @chainable
1392 * @return {OO.ui.Element} The element, for chaining
1393 */
1394 OO.ui.Element.prototype.toggle = function ( show ) {
1395 show = show === undefined ? !this.visible : !!show;
1396
1397 if ( show !== this.isVisible() ) {
1398 this.visible = show;
1399 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1400 this.emit( 'toggle', show );
1401 }
1402
1403 return this;
1404 };
1405
1406 /**
1407 * Check if element is visible.
1408 *
1409 * @return {boolean} element is visible
1410 */
1411 OO.ui.Element.prototype.isVisible = function () {
1412 return this.visible;
1413 };
1414
1415 /**
1416 * Get element data.
1417 *
1418 * @return {Mixed} Element data
1419 */
1420 OO.ui.Element.prototype.getData = function () {
1421 return this.data;
1422 };
1423
1424 /**
1425 * Set element data.
1426 *
1427 * @param {Mixed} data Element data
1428 * @chainable
1429 * @return {OO.ui.Element} The element, for chaining
1430 */
1431 OO.ui.Element.prototype.setData = function ( data ) {
1432 this.data = data;
1433 return this;
1434 };
1435
1436 /**
1437 * Set the element has an 'id' attribute.
1438 *
1439 * @param {string} id
1440 * @chainable
1441 * @return {OO.ui.Element} The element, for chaining
1442 */
1443 OO.ui.Element.prototype.setElementId = function ( id ) {
1444 this.elementId = id;
1445 this.$element.attr( 'id', id );
1446 return this;
1447 };
1448
1449 /**
1450 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1451 * and return its value.
1452 *
1453 * @return {string}
1454 */
1455 OO.ui.Element.prototype.getElementId = function () {
1456 if ( this.elementId === null ) {
1457 this.setElementId( OO.ui.generateElementId() );
1458 }
1459 return this.elementId;
1460 };
1461
1462 /**
1463 * Check if element supports one or more methods.
1464 *
1465 * @param {string|string[]} methods Method or list of methods to check
1466 * @return {boolean} All methods are supported
1467 */
1468 OO.ui.Element.prototype.supports = function ( methods ) {
1469 var i, len,
1470 support = 0;
1471
1472 methods = Array.isArray( methods ) ? methods : [ methods ];
1473 for ( i = 0, len = methods.length; i < len; i++ ) {
1474 if ( typeof this[ methods[ i ] ] === 'function' ) {
1475 support++;
1476 }
1477 }
1478
1479 return methods.length === support;
1480 };
1481
1482 /**
1483 * Update the theme-provided classes.
1484 *
1485 * @localdoc This is called in element mixins and widget classes any time state changes.
1486 * Updating is debounced, minimizing overhead of changing multiple attributes and
1487 * guaranteeing that theme updates do not occur within an element's constructor
1488 */
1489 OO.ui.Element.prototype.updateThemeClasses = function () {
1490 OO.ui.theme.queueUpdateElementClasses( this );
1491 };
1492
1493 /**
1494 * Get the HTML tag name.
1495 *
1496 * Override this method to base the result on instance information.
1497 *
1498 * @return {string} HTML tag name
1499 */
1500 OO.ui.Element.prototype.getTagName = function () {
1501 return this.constructor.static.tagName;
1502 };
1503
1504 /**
1505 * Check if the element is attached to the DOM
1506 *
1507 * @return {boolean} The element is attached to the DOM
1508 */
1509 OO.ui.Element.prototype.isElementAttached = function () {
1510 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1511 };
1512
1513 /**
1514 * Get the DOM document.
1515 *
1516 * @return {HTMLDocument} Document object
1517 */
1518 OO.ui.Element.prototype.getElementDocument = function () {
1519 // Don't cache this in other ways either because subclasses could can change this.$element
1520 return OO.ui.Element.static.getDocument( this.$element );
1521 };
1522
1523 /**
1524 * Get the DOM window.
1525 *
1526 * @return {Window} Window object
1527 */
1528 OO.ui.Element.prototype.getElementWindow = function () {
1529 return OO.ui.Element.static.getWindow( this.$element );
1530 };
1531
1532 /**
1533 * Get closest scrollable container.
1534 *
1535 * @return {HTMLElement} Closest scrollable container
1536 */
1537 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1538 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1539 };
1540
1541 /**
1542 * Get group element is in.
1543 *
1544 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1545 */
1546 OO.ui.Element.prototype.getElementGroup = function () {
1547 return this.elementGroup;
1548 };
1549
1550 /**
1551 * Set group element is in.
1552 *
1553 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1554 * @chainable
1555 * @return {OO.ui.Element} The element, for chaining
1556 */
1557 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1558 this.elementGroup = group;
1559 return this;
1560 };
1561
1562 /**
1563 * Scroll element into view.
1564 *
1565 * @param {Object} [config] Configuration options
1566 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1567 */
1568 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1569 if (
1570 !this.isElementAttached() ||
1571 !this.isVisible() ||
1572 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1573 ) {
1574 return $.Deferred().resolve();
1575 }
1576 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1577 };
1578
1579 /**
1580 * Restore the pre-infusion dynamic state for this widget.
1581 *
1582 * This method is called after #$element has been inserted into DOM. The parameter is the return
1583 * value of #gatherPreInfuseState.
1584 *
1585 * @protected
1586 * @param {Object} state
1587 */
1588 OO.ui.Element.prototype.restorePreInfuseState = function () {
1589 };
1590
1591 /**
1592 * Wraps an HTML snippet for use with configuration values which default
1593 * to strings. This bypasses the default html-escaping done to string
1594 * values.
1595 *
1596 * @class
1597 *
1598 * @constructor
1599 * @param {string} [content] HTML content
1600 */
1601 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1602 // Properties
1603 this.content = content;
1604 };
1605
1606 /* Setup */
1607
1608 OO.initClass( OO.ui.HtmlSnippet );
1609
1610 /* Methods */
1611
1612 /**
1613 * Render into HTML.
1614 *
1615 * @return {string} Unchanged HTML snippet.
1616 */
1617 OO.ui.HtmlSnippet.prototype.toString = function () {
1618 return this.content;
1619 };
1620
1621 /**
1622 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1623 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1624 * are, combined.
1625 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1626 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1627 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1628 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1629 * for more information and examples.
1630 *
1631 * @abstract
1632 * @class
1633 * @extends OO.ui.Element
1634 * @mixins OO.EventEmitter
1635 *
1636 * @constructor
1637 * @param {Object} [config] Configuration options
1638 */
1639 OO.ui.Layout = function OoUiLayout( config ) {
1640 // Configuration initialization
1641 config = config || {};
1642
1643 // Parent constructor
1644 OO.ui.Layout.parent.call( this, config );
1645
1646 // Mixin constructors
1647 OO.EventEmitter.call( this );
1648
1649 // Initialization
1650 this.$element.addClass( 'oo-ui-layout' );
1651 };
1652
1653 /* Setup */
1654
1655 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1656 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1657
1658 /* Methods */
1659
1660 /**
1661 * Reset scroll offsets
1662 *
1663 * @chainable
1664 * @return {OO.ui.Layout} The layout, for chaining
1665 */
1666 OO.ui.Layout.prototype.resetScroll = function () {
1667 this.$element[ 0 ].scrollTop = 0;
1668 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1669
1670 return this;
1671 };
1672
1673 /**
1674 * Widgets are compositions of one or more OOUI elements that users can both view
1675 * and interact with. All widgets can be configured and modified via a standard API,
1676 * and their state can change dynamically according to a model.
1677 *
1678 * @abstract
1679 * @class
1680 * @extends OO.ui.Element
1681 * @mixins OO.EventEmitter
1682 *
1683 * @constructor
1684 * @param {Object} [config] Configuration options
1685 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1686 * appearance reflects this state.
1687 */
1688 OO.ui.Widget = function OoUiWidget( config ) {
1689 // Initialize config
1690 config = $.extend( { disabled: false }, config );
1691
1692 // Parent constructor
1693 OO.ui.Widget.parent.call( this, config );
1694
1695 // Mixin constructors
1696 OO.EventEmitter.call( this );
1697
1698 // Properties
1699 this.disabled = null;
1700 this.wasDisabled = null;
1701
1702 // Initialization
1703 this.$element.addClass( 'oo-ui-widget' );
1704 this.setDisabled( !!config.disabled );
1705 };
1706
1707 /* Setup */
1708
1709 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1710 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1711
1712 /* Events */
1713
1714 /**
1715 * @event disable
1716 *
1717 * A 'disable' event is emitted when the disabled state of the widget changes
1718 * (i.e. on disable **and** enable).
1719 *
1720 * @param {boolean} disabled Widget is disabled
1721 */
1722
1723 /**
1724 * @event toggle
1725 *
1726 * A 'toggle' event is emitted when the visibility of the widget changes.
1727 *
1728 * @param {boolean} visible Widget is visible
1729 */
1730
1731 /* Methods */
1732
1733 /**
1734 * Check if the widget is disabled.
1735 *
1736 * @return {boolean} Widget is disabled
1737 */
1738 OO.ui.Widget.prototype.isDisabled = function () {
1739 return this.disabled;
1740 };
1741
1742 /**
1743 * Set the 'disabled' state of the widget.
1744 *
1745 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1746 *
1747 * @param {boolean} disabled Disable widget
1748 * @chainable
1749 * @return {OO.ui.Widget} The widget, for chaining
1750 */
1751 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1752 var isDisabled;
1753
1754 this.disabled = !!disabled;
1755 isDisabled = this.isDisabled();
1756 if ( isDisabled !== this.wasDisabled ) {
1757 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1758 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1759 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1760 this.emit( 'disable', isDisabled );
1761 this.updateThemeClasses();
1762 }
1763 this.wasDisabled = isDisabled;
1764
1765 return this;
1766 };
1767
1768 /**
1769 * Update the disabled state, in case of changes in parent widget.
1770 *
1771 * @chainable
1772 * @return {OO.ui.Widget} The widget, for chaining
1773 */
1774 OO.ui.Widget.prototype.updateDisabled = function () {
1775 this.setDisabled( this.disabled );
1776 return this;
1777 };
1778
1779 /**
1780 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1781 * value.
1782 *
1783 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1784 * instead.
1785 *
1786 * @return {string|null} The ID of the labelable element
1787 */
1788 OO.ui.Widget.prototype.getInputId = function () {
1789 return null;
1790 };
1791
1792 /**
1793 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1794 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1795 * override this method to provide intuitive, accessible behavior.
1796 *
1797 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1798 * Individual widgets may override it too.
1799 *
1800 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1801 * directly.
1802 */
1803 OO.ui.Widget.prototype.simulateLabelClick = function () {
1804 };
1805
1806 /**
1807 * Theme logic.
1808 *
1809 * @abstract
1810 * @class
1811 *
1812 * @constructor
1813 */
1814 OO.ui.Theme = function OoUiTheme() {
1815 this.elementClassesQueue = [];
1816 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1817 };
1818
1819 /* Setup */
1820
1821 OO.initClass( OO.ui.Theme );
1822
1823 /* Methods */
1824
1825 /**
1826 * Get a list of classes to be applied to a widget.
1827 *
1828 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1829 * otherwise state transitions will not work properly.
1830 *
1831 * @param {OO.ui.Element} element Element for which to get classes
1832 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1833 */
1834 OO.ui.Theme.prototype.getElementClasses = function () {
1835 return { on: [], off: [] };
1836 };
1837
1838 /**
1839 * Update CSS classes provided by the theme.
1840 *
1841 * For elements with theme logic hooks, this should be called any time there's a state change.
1842 *
1843 * @param {OO.ui.Element} element Element for which to update classes
1844 */
1845 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1846 var $elements = $( [] ),
1847 classes = this.getElementClasses( element );
1848
1849 if ( element.$icon ) {
1850 $elements = $elements.add( element.$icon );
1851 }
1852 if ( element.$indicator ) {
1853 $elements = $elements.add( element.$indicator );
1854 }
1855
1856 $elements
1857 .removeClass( classes.off )
1858 .addClass( classes.on );
1859 };
1860
1861 /**
1862 * @private
1863 */
1864 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1865 var i;
1866 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1867 this.updateElementClasses( this.elementClassesQueue[ i ] );
1868 }
1869 // Clear the queue
1870 this.elementClassesQueue = [];
1871 };
1872
1873 /**
1874 * Queue #updateElementClasses to be called for this element.
1875 *
1876 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1877 * to make them synchronous.
1878 *
1879 * @param {OO.ui.Element} element Element for which to update classes
1880 */
1881 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1882 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1883 // the most common case (this method is often called repeatedly for the same element).
1884 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1885 return;
1886 }
1887 this.elementClassesQueue.push( element );
1888 this.debouncedUpdateQueuedElementClasses();
1889 };
1890
1891 /**
1892 * Get the transition duration in milliseconds for dialogs opening/closing
1893 *
1894 * The dialog should be fully rendered this many milliseconds after the
1895 * ready process has executed.
1896 *
1897 * @return {number} Transition duration in milliseconds
1898 */
1899 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1900 return 0;
1901 };
1902
1903 /**
1904 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1905 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1906 * order in which users will navigate through the focusable elements via the Tab key.
1907 *
1908 * @example
1909 * // TabIndexedElement is mixed into the ButtonWidget class
1910 * // to provide a tabIndex property.
1911 * var button1 = new OO.ui.ButtonWidget( {
1912 * label: 'fourth',
1913 * tabIndex: 4
1914 * } ),
1915 * button2 = new OO.ui.ButtonWidget( {
1916 * label: 'second',
1917 * tabIndex: 2
1918 * } ),
1919 * button3 = new OO.ui.ButtonWidget( {
1920 * label: 'third',
1921 * tabIndex: 3
1922 * } ),
1923 * button4 = new OO.ui.ButtonWidget( {
1924 * label: 'first',
1925 * tabIndex: 1
1926 * } );
1927 * $( document.body ).append(
1928 * button1.$element,
1929 * button2.$element,
1930 * button3.$element,
1931 * button4.$element
1932 * );
1933 *
1934 * @abstract
1935 * @class
1936 *
1937 * @constructor
1938 * @param {Object} [config] Configuration options
1939 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1940 * the functionality is applied to the element created by the class ($element). If a different
1941 * element is specified, the tabindex functionality will be applied to it instead.
1942 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the
1943 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
1944 * navigation order; use -1 to remove the element from the tab-navigation flow.
1945 */
1946 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1947 // Configuration initialization
1948 config = $.extend( { tabIndex: 0 }, config );
1949
1950 // Properties
1951 this.$tabIndexed = null;
1952 this.tabIndex = null;
1953
1954 // Events
1955 this.connect( this, {
1956 disable: 'onTabIndexedElementDisable'
1957 } );
1958
1959 // Initialization
1960 this.setTabIndex( config.tabIndex );
1961 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1962 };
1963
1964 /* Setup */
1965
1966 OO.initClass( OO.ui.mixin.TabIndexedElement );
1967
1968 /* Methods */
1969
1970 /**
1971 * Set the element that should use the tabindex functionality.
1972 *
1973 * This method is used to retarget a tabindex mixin so that its functionality applies
1974 * to the specified element. If an element is currently using the functionality, the mixin’s
1975 * effect on that element is removed before the new element is set up.
1976 *
1977 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1978 * @chainable
1979 * @return {OO.ui.Element} The element, for chaining
1980 */
1981 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1982 var tabIndex = this.tabIndex;
1983 // Remove attributes from old $tabIndexed
1984 this.setTabIndex( null );
1985 // Force update of new $tabIndexed
1986 this.$tabIndexed = $tabIndexed;
1987 this.tabIndex = tabIndex;
1988 return this.updateTabIndex();
1989 };
1990
1991 /**
1992 * Set the value of the tabindex.
1993 *
1994 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1995 * @chainable
1996 * @return {OO.ui.Element} The element, for chaining
1997 */
1998 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1999 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2000
2001 if ( this.tabIndex !== tabIndex ) {
2002 this.tabIndex = tabIndex;
2003 this.updateTabIndex();
2004 }
2005
2006 return this;
2007 };
2008
2009 /**
2010 * Update the `tabindex` attribute, in case of changes to tab index or
2011 * disabled state.
2012 *
2013 * @private
2014 * @chainable
2015 * @return {OO.ui.Element} The element, for chaining
2016 */
2017 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2018 if ( this.$tabIndexed ) {
2019 if ( this.tabIndex !== null ) {
2020 // Do not index over disabled elements
2021 this.$tabIndexed.attr( {
2022 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2023 // Support: ChromeVox and NVDA
2024 // These do not seem to inherit aria-disabled from parent elements
2025 'aria-disabled': this.isDisabled().toString()
2026 } );
2027 } else {
2028 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2029 }
2030 }
2031 return this;
2032 };
2033
2034 /**
2035 * Handle disable events.
2036 *
2037 * @private
2038 * @param {boolean} disabled Element is disabled
2039 */
2040 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2041 this.updateTabIndex();
2042 };
2043
2044 /**
2045 * Get the value of the tabindex.
2046 *
2047 * @return {number|null} Tabindex value
2048 */
2049 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2050 return this.tabIndex;
2051 };
2052
2053 /**
2054 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2055 *
2056 * If the element already has an ID then that is returned, otherwise unique ID is
2057 * generated, set on the element, and returned.
2058 *
2059 * @return {string|null} The ID of the focusable element
2060 */
2061 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2062 var id;
2063
2064 if ( !this.$tabIndexed ) {
2065 return null;
2066 }
2067 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2068 return null;
2069 }
2070
2071 id = this.$tabIndexed.attr( 'id' );
2072 if ( id === undefined ) {
2073 id = OO.ui.generateElementId();
2074 this.$tabIndexed.attr( 'id', id );
2075 }
2076
2077 return id;
2078 };
2079
2080 /**
2081 * Whether the node is 'labelable' according to the HTML spec
2082 * (i.e., whether it can be interacted with through a `<label for="…">`).
2083 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2084 *
2085 * @private
2086 * @param {jQuery} $node
2087 * @return {boolean}
2088 */
2089 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2090 var
2091 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2092 tagName = ( $node.prop( 'tagName' ) || '' ).toLowerCase();
2093
2094 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2095 return true;
2096 }
2097 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2098 return true;
2099 }
2100 return false;
2101 };
2102
2103 /**
2104 * Focus this element.
2105 *
2106 * @chainable
2107 * @return {OO.ui.Element} The element, for chaining
2108 */
2109 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2110 if ( !this.isDisabled() ) {
2111 this.$tabIndexed.trigger( 'focus' );
2112 }
2113 return this;
2114 };
2115
2116 /**
2117 * Blur this element.
2118 *
2119 * @chainable
2120 * @return {OO.ui.Element} The element, for chaining
2121 */
2122 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2123 this.$tabIndexed.trigger( 'blur' );
2124 return this;
2125 };
2126
2127 /**
2128 * @inheritdoc OO.ui.Widget
2129 */
2130 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2131 this.focus();
2132 };
2133
2134 /**
2135 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2136 * interface element that can be configured with access keys for keyboard interaction.
2137 * See the [OOUI documentation on MediaWiki] [1] for examples.
2138 *
2139 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2140 *
2141 * @abstract
2142 * @class
2143 *
2144 * @constructor
2145 * @param {Object} [config] Configuration options
2146 * @cfg {jQuery} [$button] The button element created by the class.
2147 * If this configuration is omitted, the button element will use a generated `<a>`.
2148 * @cfg {boolean} [framed=true] Render the button with a frame
2149 */
2150 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2151 // Configuration initialization
2152 config = config || {};
2153
2154 // Properties
2155 this.$button = null;
2156 this.framed = null;
2157 this.active = config.active !== undefined && config.active;
2158 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2159 this.onMouseDownHandler = this.onMouseDown.bind( this );
2160 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2161 this.onKeyDownHandler = this.onKeyDown.bind( this );
2162 this.onClickHandler = this.onClick.bind( this );
2163 this.onKeyPressHandler = this.onKeyPress.bind( this );
2164
2165 // Initialization
2166 this.$element.addClass( 'oo-ui-buttonElement' );
2167 this.toggleFramed( config.framed === undefined || config.framed );
2168 this.setButtonElement( config.$button || $( '<a>' ) );
2169 };
2170
2171 /* Setup */
2172
2173 OO.initClass( OO.ui.mixin.ButtonElement );
2174
2175 /* Static Properties */
2176
2177 /**
2178 * Cancel mouse down events.
2179 *
2180 * This property is usually set to `true` to prevent the focus from changing when the button is
2181 * clicked.
2182 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2183 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2184 * behavior is possible and mousedown events can be handled by a parent widget.
2185 *
2186 * @static
2187 * @inheritable
2188 * @property {boolean}
2189 */
2190 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2191
2192 /* Events */
2193
2194 /**
2195 * A 'click' event is emitted when the button element is clicked.
2196 *
2197 * @event click
2198 */
2199
2200 /* Methods */
2201
2202 /**
2203 * Set the button element.
2204 *
2205 * This method is used to retarget a button mixin so that its functionality applies to
2206 * the specified button element instead of the one created by the class. If a button element
2207 * is already set, the method will remove the mixin’s effect on that element.
2208 *
2209 * @param {jQuery} $button Element to use as button
2210 */
2211 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2212 if ( this.$button ) {
2213 this.$button
2214 .removeClass( 'oo-ui-buttonElement-button' )
2215 .removeAttr( 'role accesskey' )
2216 .off( {
2217 mousedown: this.onMouseDownHandler,
2218 keydown: this.onKeyDownHandler,
2219 click: this.onClickHandler,
2220 keypress: this.onKeyPressHandler
2221 } );
2222 }
2223
2224 this.$button = $button
2225 .addClass( 'oo-ui-buttonElement-button' )
2226 .on( {
2227 mousedown: this.onMouseDownHandler,
2228 keydown: this.onKeyDownHandler,
2229 click: this.onClickHandler,
2230 keypress: this.onKeyPressHandler
2231 } );
2232
2233 // Add `role="button"` on `<a>` elements, where it's needed
2234 // `toUpperCase()` is added for XHTML documents
2235 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2236 this.$button.attr( 'role', 'button' );
2237 }
2238 };
2239
2240 /**
2241 * Handles mouse down events.
2242 *
2243 * @protected
2244 * @param {jQuery.Event} e Mouse down event
2245 * @return {undefined|boolean} False to prevent default if event is handled
2246 */
2247 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2248 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2249 return;
2250 }
2251 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2252 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2253 // reliably remove the pressed class
2254 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2255 // Prevent change of focus unless specifically configured otherwise
2256 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2257 return false;
2258 }
2259 };
2260
2261 /**
2262 * Handles document mouse up events.
2263 *
2264 * @protected
2265 * @param {MouseEvent} e Mouse up event
2266 */
2267 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2268 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2269 return;
2270 }
2271 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2272 // Stop listening for mouseup, since we only needed this once
2273 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2274 };
2275
2276 /**
2277 * Handles mouse click events.
2278 *
2279 * @protected
2280 * @param {jQuery.Event} e Mouse click event
2281 * @fires click
2282 * @return {undefined|boolean} False to prevent default if event is handled
2283 */
2284 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2285 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2286 if ( this.emit( 'click' ) ) {
2287 return false;
2288 }
2289 }
2290 };
2291
2292 /**
2293 * Handles key down events.
2294 *
2295 * @protected
2296 * @param {jQuery.Event} e Key down event
2297 */
2298 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2299 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2300 return;
2301 }
2302 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2303 // Run the keyup handler no matter where the key is when the button is let go, so we can
2304 // reliably remove the pressed class
2305 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2306 };
2307
2308 /**
2309 * Handles document key up events.
2310 *
2311 * @protected
2312 * @param {KeyboardEvent} e Key up event
2313 */
2314 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2315 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2316 return;
2317 }
2318 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2319 // Stop listening for keyup, since we only needed this once
2320 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2321 };
2322
2323 /**
2324 * Handles key press events.
2325 *
2326 * @protected
2327 * @param {jQuery.Event} e Key press event
2328 * @fires click
2329 * @return {undefined|boolean} False to prevent default if event is handled
2330 */
2331 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2332 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2333 if ( this.emit( 'click' ) ) {
2334 return false;
2335 }
2336 }
2337 };
2338
2339 /**
2340 * Check if button has a frame.
2341 *
2342 * @return {boolean} Button is framed
2343 */
2344 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2345 return this.framed;
2346 };
2347
2348 /**
2349 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2350 * on and off.
2351 *
2352 * @param {boolean} [framed] Make button framed, omit to toggle
2353 * @chainable
2354 * @return {OO.ui.Element} The element, for chaining
2355 */
2356 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2357 framed = framed === undefined ? !this.framed : !!framed;
2358 if ( framed !== this.framed ) {
2359 this.framed = framed;
2360 this.$element
2361 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2362 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2363 this.updateThemeClasses();
2364 }
2365
2366 return this;
2367 };
2368
2369 /**
2370 * Set the button's active state.
2371 *
2372 * The active state can be set on:
2373 *
2374 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2375 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2376 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2377 *
2378 * @protected
2379 * @param {boolean} value Make button active
2380 * @chainable
2381 * @return {OO.ui.Element} The element, for chaining
2382 */
2383 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2384 this.active = !!value;
2385 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2386 this.updateThemeClasses();
2387 return this;
2388 };
2389
2390 /**
2391 * Check if the button is active
2392 *
2393 * @protected
2394 * @return {boolean} The button is active
2395 */
2396 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2397 return this.active;
2398 };
2399
2400 /**
2401 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2402 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2403 * items from the group is done through the interface the class provides.
2404 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2405 *
2406 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2407 *
2408 * @abstract
2409 * @mixins OO.EmitterList
2410 * @class
2411 *
2412 * @constructor
2413 * @param {Object} [config] Configuration options
2414 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2415 * is omitted, the group element will use a generated `<div>`.
2416 */
2417 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2418 // Configuration initialization
2419 config = config || {};
2420
2421 // Mixin constructors
2422 OO.EmitterList.call( this, config );
2423
2424 // Properties
2425 this.$group = null;
2426
2427 // Initialization
2428 this.setGroupElement( config.$group || $( '<div>' ) );
2429 };
2430
2431 /* Setup */
2432
2433 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2434
2435 /* Events */
2436
2437 /**
2438 * @event change
2439 *
2440 * A change event is emitted when the set of selected items changes.
2441 *
2442 * @param {OO.ui.Element[]} items Items currently in the group
2443 */
2444
2445 /* Methods */
2446
2447 /**
2448 * Set the group element.
2449 *
2450 * If an element is already set, items will be moved to the new element.
2451 *
2452 * @param {jQuery} $group Element to use as group
2453 */
2454 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2455 var i, len;
2456
2457 this.$group = $group;
2458 for ( i = 0, len = this.items.length; i < len; i++ ) {
2459 this.$group.append( this.items[ i ].$element );
2460 }
2461 };
2462
2463 /**
2464 * Find an item by its data.
2465 *
2466 * Only the first item with matching data will be returned. To return all matching items,
2467 * use the #findItemsFromData method.
2468 *
2469 * @param {Object} data Item data to search for
2470 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2471 */
2472 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2473 var i, len, item,
2474 hash = OO.getHash( data );
2475
2476 for ( i = 0, len = this.items.length; i < len; i++ ) {
2477 item = this.items[ i ];
2478 if ( hash === OO.getHash( item.getData() ) ) {
2479 return item;
2480 }
2481 }
2482
2483 return null;
2484 };
2485
2486 /**
2487 * Find items by their data.
2488 *
2489 * All items with matching data will be returned. To return only the first match, use the
2490 * #findItemFromData method instead.
2491 *
2492 * @param {Object} data Item data to search for
2493 * @return {OO.ui.Element[]} Items with equivalent data
2494 */
2495 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2496 var i, len, item,
2497 hash = OO.getHash( data ),
2498 items = [];
2499
2500 for ( i = 0, len = this.items.length; i < len; i++ ) {
2501 item = this.items[ i ];
2502 if ( hash === OO.getHash( item.getData() ) ) {
2503 items.push( item );
2504 }
2505 }
2506
2507 return items;
2508 };
2509
2510 /**
2511 * Add items to the group.
2512 *
2513 * Items will be added to the end of the group array unless the optional `index` parameter
2514 * specifies a different insertion point. Adding an existing item will move it to the end of the
2515 * array or the point specified by the `index`.
2516 *
2517 * @param {OO.ui.Element[]} items An array of items to add to the group
2518 * @param {number} [index] Index of the insertion point
2519 * @chainable
2520 * @return {OO.ui.Element} The element, for chaining
2521 */
2522 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2523
2524 if ( items.length === 0 ) {
2525 return this;
2526 }
2527
2528 // Mixin method
2529 OO.EmitterList.prototype.addItems.call( this, items, index );
2530
2531 this.emit( 'change', this.getItems() );
2532 return this;
2533 };
2534
2535 /**
2536 * @inheritdoc
2537 */
2538 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2539 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2540 this.insertItemElements( items, newIndex );
2541
2542 // Mixin method
2543 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2544
2545 return newIndex;
2546 };
2547
2548 /**
2549 * @inheritdoc
2550 */
2551 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2552 item.setElementGroup( this );
2553 this.insertItemElements( item, index );
2554
2555 // Mixin method
2556 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2557
2558 return index;
2559 };
2560
2561 /**
2562 * Insert elements into the group
2563 *
2564 * @private
2565 * @param {OO.ui.Element} itemWidget Item to insert
2566 * @param {number} index Insertion index
2567 */
2568 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2569 if ( index === undefined || index < 0 || index >= this.items.length ) {
2570 this.$group.append( itemWidget.$element );
2571 } else if ( index === 0 ) {
2572 this.$group.prepend( itemWidget.$element );
2573 } else {
2574 this.items[ index ].$element.before( itemWidget.$element );
2575 }
2576 };
2577
2578 /**
2579 * Remove the specified items from a group.
2580 *
2581 * Removed items are detached (not removed) from the DOM so that they may be reused.
2582 * To remove all items from a group, you may wish to use the #clearItems method instead.
2583 *
2584 * @param {OO.ui.Element[]} items An array of items to remove
2585 * @chainable
2586 * @return {OO.ui.Element} The element, for chaining
2587 */
2588 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2589 var i, len, item, index;
2590
2591 if ( items.length === 0 ) {
2592 return this;
2593 }
2594
2595 // Remove specific items elements
2596 for ( i = 0, len = items.length; i < len; i++ ) {
2597 item = items[ i ];
2598 index = this.items.indexOf( item );
2599 if ( index !== -1 ) {
2600 item.setElementGroup( null );
2601 item.$element.detach();
2602 }
2603 }
2604
2605 // Mixin method
2606 OO.EmitterList.prototype.removeItems.call( this, items );
2607
2608 this.emit( 'change', this.getItems() );
2609 return this;
2610 };
2611
2612 /**
2613 * Clear all items from the group.
2614 *
2615 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2616 * To remove only a subset of items from a group, use the #removeItems method.
2617 *
2618 * @chainable
2619 * @return {OO.ui.Element} The element, for chaining
2620 */
2621 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2622 var i, len;
2623
2624 // Remove all item elements
2625 for ( i = 0, len = this.items.length; i < len; i++ ) {
2626 this.items[ i ].setElementGroup( null );
2627 this.items[ i ].$element.detach();
2628 }
2629
2630 // Mixin method
2631 OO.EmitterList.prototype.clearItems.call( this );
2632
2633 this.emit( 'change', this.getItems() );
2634 return this;
2635 };
2636
2637 /**
2638 * LabelElement is often mixed into other classes to generate a label, which
2639 * helps identify the function of an interface element.
2640 * See the [OOUI documentation on MediaWiki] [1] for more information.
2641 *
2642 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2643 *
2644 * @abstract
2645 * @class
2646 *
2647 * @constructor
2648 * @param {Object} [config] Configuration options
2649 * @cfg {jQuery} [$label] The label element created by the class. If this
2650 * configuration is omitted, the label element will use a generated `<span>`.
2651 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be
2652 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2653 * produce a string in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2654 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2655 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still
2656 * accessible to screen-readers).
2657 */
2658 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2659 // Configuration initialization
2660 config = config || {};
2661
2662 // Properties
2663 this.$label = null;
2664 this.label = null;
2665 this.invisibleLabel = null;
2666
2667 // Initialization
2668 this.setLabel( config.label || this.constructor.static.label );
2669 this.setLabelElement( config.$label || $( '<span>' ) );
2670 this.setInvisibleLabel( config.invisibleLabel );
2671 };
2672
2673 /* Setup */
2674
2675 OO.initClass( OO.ui.mixin.LabelElement );
2676
2677 /* Events */
2678
2679 /**
2680 * @event labelChange
2681 * @param {string} value
2682 */
2683
2684 /* Static Properties */
2685
2686 /**
2687 * The label text. The label can be specified as a plaintext string, a function that will
2688 * produce a string in the future, or `null` for no label. The static value will
2689 * be overridden if a label is specified with the #label config option.
2690 *
2691 * @static
2692 * @inheritable
2693 * @property {string|Function|null}
2694 */
2695 OO.ui.mixin.LabelElement.static.label = null;
2696
2697 /* Static methods */
2698
2699 /**
2700 * Highlight the first occurrence of the query in the given text
2701 *
2702 * @param {string} text Text
2703 * @param {string} query Query to find
2704 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2705 * @return {jQuery} Text with the first match of the query
2706 * sub-string wrapped in highlighted span
2707 */
2708 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2709 var i, tLen, qLen,
2710 offset = -1,
2711 $result = $( '<span>' );
2712
2713 if ( compare ) {
2714 tLen = text.length;
2715 qLen = query.length;
2716 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2717 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2718 offset = i;
2719 }
2720 }
2721 } else {
2722 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2723 }
2724
2725 if ( !query.length || offset === -1 ) {
2726 $result.text( text );
2727 } else {
2728 $result.append(
2729 document.createTextNode( text.slice( 0, offset ) ),
2730 $( '<span>' )
2731 .addClass( 'oo-ui-labelElement-label-highlight' )
2732 .text( text.slice( offset, offset + query.length ) ),
2733 document.createTextNode( text.slice( offset + query.length ) )
2734 );
2735 }
2736 return $result.contents();
2737 };
2738
2739 /* Methods */
2740
2741 /**
2742 * Set the label element.
2743 *
2744 * If an element is already set, it will be cleaned up before setting up the new element.
2745 *
2746 * @param {jQuery} $label Element to use as label
2747 */
2748 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2749 if ( this.$label ) {
2750 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2751 }
2752
2753 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2754 this.setLabelContent( this.label );
2755 };
2756
2757 /**
2758 * Set the label.
2759 *
2760 * An empty string will result in the label being hidden. A string containing only whitespace will
2761 * be converted to a single `&nbsp;`.
2762 *
2763 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2764 * returns nodes or text; or null for no label
2765 * @chainable
2766 * @return {OO.ui.Element} The element, for chaining
2767 */
2768 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2769 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2770 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2771
2772 if ( this.label !== label ) {
2773 if ( this.$label ) {
2774 this.setLabelContent( label );
2775 }
2776 this.label = label;
2777 this.emit( 'labelChange' );
2778 }
2779
2780 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2781
2782 return this;
2783 };
2784
2785 /**
2786 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2787 *
2788 * @param {boolean} invisibleLabel
2789 * @chainable
2790 * @return {OO.ui.Element} The element, for chaining
2791 */
2792 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2793 invisibleLabel = !!invisibleLabel;
2794
2795 if ( this.invisibleLabel !== invisibleLabel ) {
2796 this.invisibleLabel = invisibleLabel;
2797 this.emit( 'labelChange' );
2798 }
2799
2800 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2801 // Pretend that there is no label, a lot of CSS has been written with this assumption
2802 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2803
2804 return this;
2805 };
2806
2807 /**
2808 * Set the label as plain text with a highlighted query
2809 *
2810 * @param {string} text Text label to set
2811 * @param {string} query Substring of text to highlight
2812 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2813 * @chainable
2814 * @return {OO.ui.Element} The element, for chaining
2815 */
2816 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2817 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2818 };
2819
2820 /**
2821 * Get the label.
2822 *
2823 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2824 * text; or null for no label
2825 */
2826 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2827 return this.label;
2828 };
2829
2830 /**
2831 * Set the content of the label.
2832 *
2833 * Do not call this method until after the label element has been set by #setLabelElement.
2834 *
2835 * @private
2836 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2837 * text; or null for no label
2838 */
2839 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2840 if ( typeof label === 'string' ) {
2841 if ( label.match( /^\s*$/ ) ) {
2842 // Convert whitespace only string to a single non-breaking space
2843 this.$label.html( '&nbsp;' );
2844 } else {
2845 this.$label.text( label );
2846 }
2847 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2848 this.$label.html( label.toString() );
2849 } else if ( label instanceof $ ) {
2850 this.$label.empty().append( label );
2851 } else {
2852 this.$label.empty();
2853 }
2854 };
2855
2856 /**
2857 * IconElement is often mixed into other classes to generate an icon.
2858 * Icons are graphics, about the size of normal text. They are used to aid the user
2859 * in locating a control or to convey information in a space-efficient way. See the
2860 * [OOUI documentation on MediaWiki] [1] for a list of icons
2861 * included in the library.
2862 *
2863 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2864 *
2865 * @abstract
2866 * @class
2867 *
2868 * @constructor
2869 * @param {Object} [config] Configuration options
2870 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2871 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2872 * the icon element be set to an existing icon instead of the one generated by this class, set a
2873 * value using a jQuery selection. For example:
2874 *
2875 * // Use a <div> tag instead of a <span>
2876 * $icon: $( '<div>' )
2877 * // Use an existing icon element instead of the one generated by the class
2878 * $icon: this.$element
2879 * // Use an icon element from a child widget
2880 * $icon: this.childwidget.$element
2881 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
2882 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
2883 * name and additional names keyed by language code. The `default` name is used when no icon is
2884 * keyed by the user's language.
2885 *
2886 * Example of an i18n map:
2887 *
2888 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2889 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2890 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2891 */
2892 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2893 // Configuration initialization
2894 config = config || {};
2895
2896 // Properties
2897 this.$icon = null;
2898 this.icon = null;
2899
2900 // Initialization
2901 this.setIcon( config.icon || this.constructor.static.icon );
2902 this.setIconElement( config.$icon || $( '<span>' ) );
2903 };
2904
2905 /* Setup */
2906
2907 OO.initClass( OO.ui.mixin.IconElement );
2908
2909 /* Static Properties */
2910
2911 /**
2912 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
2913 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
2914 * language code. The `default` name is used when no icon is keyed by the user's language.
2915 *
2916 * Example of an i18n map:
2917 *
2918 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2919 *
2920 * Note: the static property will be overridden if the #icon configuration is used.
2921 *
2922 * @static
2923 * @inheritable
2924 * @property {Object|string}
2925 */
2926 OO.ui.mixin.IconElement.static.icon = null;
2927
2928 /**
2929 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2930 * function that returns title text, or `null` for no title.
2931 *
2932 * The static property will be overridden if the #iconTitle configuration is used.
2933 *
2934 * @static
2935 * @inheritable
2936 * @property {string|Function|null}
2937 */
2938 OO.ui.mixin.IconElement.static.iconTitle = null;
2939
2940 /* Methods */
2941
2942 /**
2943 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2944 * applies to the specified icon element instead of the one created by the class. If an icon
2945 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2946 * and mixin methods will no longer affect the element.
2947 *
2948 * @param {jQuery} $icon Element to use as icon
2949 */
2950 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2951 if ( this.$icon ) {
2952 this.$icon
2953 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2954 .removeAttr( 'title' );
2955 }
2956
2957 this.$icon = $icon
2958 .addClass( 'oo-ui-iconElement-icon' )
2959 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2960 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2961 if ( this.iconTitle !== null ) {
2962 this.$icon.attr( 'title', this.iconTitle );
2963 }
2964
2965 this.updateThemeClasses();
2966 };
2967
2968 /**
2969 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2970 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2971 * for an example.
2972 *
2973 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2974 * by language code, or `null` to remove the icon.
2975 * @chainable
2976 * @return {OO.ui.Element} The element, for chaining
2977 */
2978 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2979 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2980 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2981
2982 if ( this.icon !== icon ) {
2983 if ( this.$icon ) {
2984 if ( this.icon !== null ) {
2985 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2986 }
2987 if ( icon !== null ) {
2988 this.$icon.addClass( 'oo-ui-icon-' + icon );
2989 }
2990 }
2991 this.icon = icon;
2992 }
2993
2994 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2995 if ( this.$icon ) {
2996 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2997 }
2998 this.updateThemeClasses();
2999
3000 return this;
3001 };
3002
3003 /**
3004 * Get the symbolic name of the icon.
3005 *
3006 * @return {string} Icon name
3007 */
3008 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3009 return this.icon;
3010 };
3011
3012 /**
3013 * IndicatorElement is often mixed into other classes to generate an indicator.
3014 * Indicators are small graphics that are generally used in two ways:
3015 *
3016 * - To draw attention to the status of an item. For example, an indicator might be
3017 * used to show that an item in a list has errors that need to be resolved.
3018 * - To clarify the function of a control that acts in an exceptional way (a button
3019 * that opens a menu instead of performing an action directly, for example).
3020 *
3021 * For a list of indicators included in the library, please see the
3022 * [OOUI documentation on MediaWiki] [1].
3023 *
3024 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3025 *
3026 * @abstract
3027 * @class
3028 *
3029 * @constructor
3030 * @param {Object} [config] Configuration options
3031 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3032 * configuration is omitted, the indicator element will use a generated `<span>`.
3033 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3034 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3035 * in the library.
3036 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3037 */
3038 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3039 // Configuration initialization
3040 config = config || {};
3041
3042 // Properties
3043 this.$indicator = null;
3044 this.indicator = null;
3045
3046 // Initialization
3047 this.setIndicator( config.indicator || this.constructor.static.indicator );
3048 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3049 };
3050
3051 /* Setup */
3052
3053 OO.initClass( OO.ui.mixin.IndicatorElement );
3054
3055 /* Static Properties */
3056
3057 /**
3058 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3059 * The static property will be overridden if the #indicator configuration is used.
3060 *
3061 * @static
3062 * @inheritable
3063 * @property {string|null}
3064 */
3065 OO.ui.mixin.IndicatorElement.static.indicator = null;
3066
3067 /**
3068 * A text string used as the indicator title, a function that returns title text, or `null`
3069 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3070 * used.
3071 *
3072 * @static
3073 * @inheritable
3074 * @property {string|Function|null}
3075 */
3076 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3077
3078 /* Methods */
3079
3080 /**
3081 * Set the indicator element.
3082 *
3083 * If an element is already set, it will be cleaned up before setting up the new element.
3084 *
3085 * @param {jQuery} $indicator Element to use as indicator
3086 */
3087 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3088 if ( this.$indicator ) {
3089 this.$indicator
3090 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3091 .removeAttr( 'title' );
3092 }
3093
3094 this.$indicator = $indicator
3095 .addClass( 'oo-ui-indicatorElement-indicator' )
3096 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3097 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3098 if ( this.indicatorTitle !== null ) {
3099 this.$indicator.attr( 'title', this.indicatorTitle );
3100 }
3101
3102 this.updateThemeClasses();
3103 };
3104
3105 /**
3106 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null`
3107 * to remove the indicator.
3108 *
3109 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3110 * @chainable
3111 * @return {OO.ui.Element} The element, for chaining
3112 */
3113 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3114 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3115
3116 if ( this.indicator !== indicator ) {
3117 if ( this.$indicator ) {
3118 if ( this.indicator !== null ) {
3119 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3120 }
3121 if ( indicator !== null ) {
3122 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3123 }
3124 }
3125 this.indicator = indicator;
3126 }
3127
3128 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3129 if ( this.$indicator ) {
3130 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3131 }
3132 this.updateThemeClasses();
3133
3134 return this;
3135 };
3136
3137 /**
3138 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3139 *
3140 * @return {string} Symbolic name of indicator
3141 */
3142 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3143 return this.indicator;
3144 };
3145
3146 /**
3147 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3148 * additional functionality to an element created by another class. The class provides
3149 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3150 * which are used to customize the look and feel of a widget to better describe its
3151 * importance and functionality.
3152 *
3153 * The library currently contains the following styling flags for general use:
3154 *
3155 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3156 * forward in a process.
3157 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3158 * something.
3159 *
3160 * The flags affect the appearance of the buttons:
3161 *
3162 * @example
3163 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3164 * var button1 = new OO.ui.ButtonWidget( {
3165 * label: 'Progressive',
3166 * flags: 'progressive'
3167 * } ),
3168 * button2 = new OO.ui.ButtonWidget( {
3169 * label: 'Destructive',
3170 * flags: 'destructive'
3171 * } );
3172 * $( document.body ).append( button1.$element, button2.$element );
3173 *
3174 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3175 * action, use these flags: **primary** and **safe**.
3176 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3177 *
3178 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3179 *
3180 * @abstract
3181 * @class
3182 *
3183 * @constructor
3184 * @param {Object} [config] Configuration options
3185 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3186 * to apply.
3187 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3188 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3189 * @cfg {jQuery} [$flagged] The flagged element. By default,
3190 * the flagged functionality is applied to the element created by the class ($element).
3191 * If a different element is specified, the flagged functionality will be applied to it instead.
3192 */
3193 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3194 // Configuration initialization
3195 config = config || {};
3196
3197 // Properties
3198 this.flags = {};
3199 this.$flagged = null;
3200
3201 // Initialization
3202 this.setFlags( config.flags || this.constructor.static.flags );
3203 this.setFlaggedElement( config.$flagged || this.$element );
3204 };
3205
3206 /* Setup */
3207
3208 OO.initClass( OO.ui.mixin.FlaggedElement );
3209
3210 /* Events */
3211
3212 /**
3213 * @event flag
3214 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3215 * parameter contains the name of each modified flag and indicates whether it was
3216 * added or removed.
3217 *
3218 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3219 * that the flag was added, `false` that the flag was removed.
3220 */
3221
3222 /* Static Properties */
3223
3224 /**
3225 * Initial value to pass to setFlags if no value is provided in config.
3226 *
3227 * @static
3228 * @inheritable
3229 * @property {string|string[]|Object.<string, boolean>}
3230 */
3231 OO.ui.mixin.FlaggedElement.static.flags = null;
3232
3233 /* Methods */
3234
3235 /**
3236 * Set the flagged element.
3237 *
3238 * This method is used to retarget a flagged mixin so that its functionality applies to the
3239 * specified element.
3240 * If an element is already set, the method will remove the mixin’s effect on that element.
3241 *
3242 * @param {jQuery} $flagged Element that should be flagged
3243 */
3244 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3245 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3246 return 'oo-ui-flaggedElement-' + flag;
3247 } );
3248
3249 if ( this.$flagged ) {
3250 this.$flagged.removeClass( classNames );
3251 }
3252
3253 this.$flagged = $flagged.addClass( classNames );
3254 };
3255
3256 /**
3257 * Check if the specified flag is set.
3258 *
3259 * @param {string} flag Name of flag
3260 * @return {boolean} The flag is set
3261 */
3262 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3263 // This may be called before the constructor, thus before this.flags is set
3264 return this.flags && ( flag in this.flags );
3265 };
3266
3267 /**
3268 * Get the names of all flags set.
3269 *
3270 * @return {string[]} Flag names
3271 */
3272 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3273 // This may be called before the constructor, thus before this.flags is set
3274 return Object.keys( this.flags || {} );
3275 };
3276
3277 /**
3278 * Clear all flags.
3279 *
3280 * @chainable
3281 * @return {OO.ui.Element} The element, for chaining
3282 * @fires flag
3283 */
3284 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3285 var flag, className,
3286 changes = {},
3287 remove = [],
3288 classPrefix = 'oo-ui-flaggedElement-';
3289
3290 for ( flag in this.flags ) {
3291 className = classPrefix + flag;
3292 changes[ flag ] = false;
3293 delete this.flags[ flag ];
3294 remove.push( className );
3295 }
3296
3297 if ( this.$flagged ) {
3298 this.$flagged.removeClass( remove );
3299 }
3300
3301 this.updateThemeClasses();
3302 this.emit( 'flag', changes );
3303
3304 return this;
3305 };
3306
3307 /**
3308 * Add one or more flags.
3309 *
3310 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3311 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3312 * be added (`true`) or removed (`false`).
3313 * @chainable
3314 * @return {OO.ui.Element} The element, for chaining
3315 * @fires flag
3316 */
3317 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3318 var i, len, flag, className,
3319 changes = {},
3320 add = [],
3321 remove = [],
3322 classPrefix = 'oo-ui-flaggedElement-';
3323
3324 if ( typeof flags === 'string' ) {
3325 className = classPrefix + flags;
3326 // Set
3327 if ( !this.flags[ flags ] ) {
3328 this.flags[ flags ] = true;
3329 add.push( className );
3330 }
3331 } else if ( Array.isArray( flags ) ) {
3332 for ( i = 0, len = flags.length; i < len; i++ ) {
3333 flag = flags[ i ];
3334 className = classPrefix + flag;
3335 // Set
3336 if ( !this.flags[ flag ] ) {
3337 changes[ flag ] = true;
3338 this.flags[ flag ] = true;
3339 add.push( className );
3340 }
3341 }
3342 } else if ( OO.isPlainObject( flags ) ) {
3343 for ( flag in flags ) {
3344 className = classPrefix + flag;
3345 if ( flags[ flag ] ) {
3346 // Set
3347 if ( !this.flags[ flag ] ) {
3348 changes[ flag ] = true;
3349 this.flags[ flag ] = true;
3350 add.push( className );
3351 }
3352 } else {
3353 // Remove
3354 if ( this.flags[ flag ] ) {
3355 changes[ flag ] = false;
3356 delete this.flags[ flag ];
3357 remove.push( className );
3358 }
3359 }
3360 }
3361 }
3362
3363 if ( this.$flagged ) {
3364 this.$flagged
3365 .addClass( add )
3366 .removeClass( remove );
3367 }
3368
3369 this.updateThemeClasses();
3370 this.emit( 'flag', changes );
3371
3372 return this;
3373 };
3374
3375 /**
3376 * TitledElement is mixed into other classes to provide a `title` attribute.
3377 * Titles are rendered by the browser and are made visible when the user moves
3378 * the mouse over the element. Titles are not visible on touch devices.
3379 *
3380 * @example
3381 * // TitledElement provides a `title` attribute to the
3382 * // ButtonWidget class.
3383 * var button = new OO.ui.ButtonWidget( {
3384 * label: 'Button with Title',
3385 * title: 'I am a button'
3386 * } );
3387 * $( document.body ).append( button.$element );
3388 *
3389 * @abstract
3390 * @class
3391 *
3392 * @constructor
3393 * @param {Object} [config] Configuration options
3394 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3395 * If this config is omitted, the title functionality is applied to $element, the
3396 * element created by the class.
3397 * @cfg {string|Function} [title] The title text or a function that returns text. If
3398 * this config is omitted, the value of the {@link #static-title static title} property is used.
3399 */
3400 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3401 // Configuration initialization
3402 config = config || {};
3403
3404 // Properties
3405 this.$titled = null;
3406 this.title = null;
3407
3408 // Initialization
3409 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3410 this.setTitledElement( config.$titled || this.$element );
3411 };
3412
3413 /* Setup */
3414
3415 OO.initClass( OO.ui.mixin.TitledElement );
3416
3417 /* Static Properties */
3418
3419 /**
3420 * The title text, a function that returns text, or `null` for no title. The value of the static
3421 * property is overridden if the #title config option is used.
3422 *
3423 * If the element has a default title (e.g. `<input type=file>`), `null` will allow that title to be
3424 * shown. Use empty string to suppress it.
3425 *
3426 * @static
3427 * @inheritable
3428 * @property {string|Function|null}
3429 */
3430 OO.ui.mixin.TitledElement.static.title = null;
3431
3432 /* Methods */
3433
3434 /**
3435 * Set the titled element.
3436 *
3437 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3438 * specified element.
3439 * If an element is already set, the mixin’s effect on that element is removed before the new
3440 * element is set up.
3441 *
3442 * @param {jQuery} $titled Element that should use the 'titled' functionality
3443 */
3444 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3445 if ( this.$titled ) {
3446 this.$titled.removeAttr( 'title' );
3447 }
3448
3449 this.$titled = $titled;
3450 this.updateTitle();
3451 };
3452
3453 /**
3454 * Set title.
3455 *
3456 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3457 * for no title
3458 * @chainable
3459 * @return {OO.ui.Element} The element, for chaining
3460 */
3461 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3462 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3463 title = typeof title === 'string' ? title : null;
3464
3465 if ( this.title !== title ) {
3466 this.title = title;
3467 this.updateTitle();
3468 }
3469
3470 return this;
3471 };
3472
3473 /**
3474 * Update the title attribute, in case of changes to title or accessKey.
3475 *
3476 * @protected
3477 * @chainable
3478 * @return {OO.ui.Element} The element, for chaining
3479 */
3480 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3481 var title = this.getTitle();
3482 if ( this.$titled ) {
3483 if ( title !== null ) {
3484 // Only if this is an AccessKeyedElement
3485 if ( this.formatTitleWithAccessKey ) {
3486 title = this.formatTitleWithAccessKey( title );
3487 }
3488 this.$titled.attr( 'title', title );
3489 } else {
3490 this.$titled.removeAttr( 'title' );
3491 }
3492 }
3493 return this;
3494 };
3495
3496 /**
3497 * Get title.
3498 *
3499 * @return {string} Title string
3500 */
3501 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3502 return this.title;
3503 };
3504
3505 /**
3506 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3507 * Access keys allow an user to go to a specific element by using
3508 * a shortcut combination of a browser specific keys + the key
3509 * set to the field.
3510 *
3511 * @example
3512 * // AccessKeyedElement provides an `accesskey` attribute to the
3513 * // ButtonWidget class.
3514 * var button = new OO.ui.ButtonWidget( {
3515 * label: 'Button with access key',
3516 * accessKey: 'k'
3517 * } );
3518 * $( document.body ).append( button.$element );
3519 *
3520 * @abstract
3521 * @class
3522 *
3523 * @constructor
3524 * @param {Object} [config] Configuration options
3525 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3526 * If this config is omitted, the access key functionality is applied to $element, the
3527 * element created by the class.
3528 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3529 * this config is omitted, no access key will be added.
3530 */
3531 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3532 // Configuration initialization
3533 config = config || {};
3534
3535 // Properties
3536 this.$accessKeyed = null;
3537 this.accessKey = null;
3538
3539 // Initialization
3540 this.setAccessKey( config.accessKey || null );
3541 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3542
3543 // If this is also a TitledElement and it initialized before we did, we may have
3544 // to update the title with the access key
3545 if ( this.updateTitle ) {
3546 this.updateTitle();
3547 }
3548 };
3549
3550 /* Setup */
3551
3552 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3553
3554 /* Static Properties */
3555
3556 /**
3557 * The access key, a function that returns a key, or `null` for no access key.
3558 *
3559 * @static
3560 * @inheritable
3561 * @property {string|Function|null}
3562 */
3563 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3564
3565 /* Methods */
3566
3567 /**
3568 * Set the access keyed element.
3569 *
3570 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3571 * the specified element.
3572 * If an element is already set, the mixin's effect on that element is removed before the new
3573 * element is set up.
3574 *
3575 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3576 */
3577 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3578 if ( this.$accessKeyed ) {
3579 this.$accessKeyed.removeAttr( 'accesskey' );
3580 }
3581
3582 this.$accessKeyed = $accessKeyed;
3583 if ( this.accessKey ) {
3584 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3585 }
3586 };
3587
3588 /**
3589 * Set access key.
3590 *
3591 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3592 * access key
3593 * @chainable
3594 * @return {OO.ui.Element} The element, for chaining
3595 */
3596 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3597 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3598
3599 if ( this.accessKey !== accessKey ) {
3600 if ( this.$accessKeyed ) {
3601 if ( accessKey !== null ) {
3602 this.$accessKeyed.attr( 'accesskey', accessKey );
3603 } else {
3604 this.$accessKeyed.removeAttr( 'accesskey' );
3605 }
3606 }
3607 this.accessKey = accessKey;
3608
3609 // Only if this is a TitledElement
3610 if ( this.updateTitle ) {
3611 this.updateTitle();
3612 }
3613 }
3614
3615 return this;
3616 };
3617
3618 /**
3619 * Get access key.
3620 *
3621 * @return {string} accessKey string
3622 */
3623 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3624 return this.accessKey;
3625 };
3626
3627 /**
3628 * Add information about the access key to the element's tooltip label.
3629 * (This is only public for hacky usage in FieldLayout.)
3630 *
3631 * @param {string} title Tooltip label for `title` attribute
3632 * @return {string}
3633 */
3634 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3635 var accessKey;
3636
3637 if ( !this.$accessKeyed ) {
3638 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3639 // function.
3640 return title;
3641 }
3642 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3643 // single key.
3644 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3645 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3646 } else {
3647 accessKey = this.getAccessKey();
3648 }
3649 if ( accessKey ) {
3650 title += ' [' + accessKey + ']';
3651 }
3652 return title;
3653 };
3654
3655 /**
3656 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3657 * feels, and functionality can be customized via the class’s configuration options
3658 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3659 * and examples.
3660 *
3661 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3662 *
3663 * @example
3664 * // A button widget.
3665 * var button = new OO.ui.ButtonWidget( {
3666 * label: 'Button with Icon',
3667 * icon: 'trash',
3668 * title: 'Remove'
3669 * } );
3670 * $( document.body ).append( button.$element );
3671 *
3672 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3673 *
3674 * @class
3675 * @extends OO.ui.Widget
3676 * @mixins OO.ui.mixin.ButtonElement
3677 * @mixins OO.ui.mixin.IconElement
3678 * @mixins OO.ui.mixin.IndicatorElement
3679 * @mixins OO.ui.mixin.LabelElement
3680 * @mixins OO.ui.mixin.TitledElement
3681 * @mixins OO.ui.mixin.FlaggedElement
3682 * @mixins OO.ui.mixin.TabIndexedElement
3683 * @mixins OO.ui.mixin.AccessKeyedElement
3684 *
3685 * @constructor
3686 * @param {Object} [config] Configuration options
3687 * @cfg {boolean} [active=false] Whether button should be shown as active
3688 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3689 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3690 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3691 */
3692 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3693 // Configuration initialization
3694 config = config || {};
3695
3696 // Parent constructor
3697 OO.ui.ButtonWidget.parent.call( this, config );
3698
3699 // Mixin constructors
3700 OO.ui.mixin.ButtonElement.call( this, config );
3701 OO.ui.mixin.IconElement.call( this, config );
3702 OO.ui.mixin.IndicatorElement.call( this, config );
3703 OO.ui.mixin.LabelElement.call( this, config );
3704 OO.ui.mixin.TitledElement.call( this, $.extend( {
3705 $titled: this.$button
3706 }, config ) );
3707 OO.ui.mixin.FlaggedElement.call( this, config );
3708 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
3709 $tabIndexed: this.$button
3710 }, config ) );
3711 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
3712 $accessKeyed: this.$button
3713 }, config ) );
3714
3715 // Properties
3716 this.href = null;
3717 this.target = null;
3718 this.noFollow = false;
3719
3720 // Events
3721 this.connect( this, {
3722 disable: 'onDisable'
3723 } );
3724
3725 // Initialization
3726 this.$button.append( this.$icon, this.$label, this.$indicator );
3727 this.$element
3728 .addClass( 'oo-ui-buttonWidget' )
3729 .append( this.$button );
3730 this.setActive( config.active );
3731 this.setHref( config.href );
3732 this.setTarget( config.target );
3733 this.setNoFollow( config.noFollow );
3734 };
3735
3736 /* Setup */
3737
3738 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3739 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3740 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3741 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3742 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3743 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3744 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3745 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3746 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3747
3748 /* Static Properties */
3749
3750 /**
3751 * @static
3752 * @inheritdoc
3753 */
3754 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3755
3756 /**
3757 * @static
3758 * @inheritdoc
3759 */
3760 OO.ui.ButtonWidget.static.tagName = 'span';
3761
3762 /* Methods */
3763
3764 /**
3765 * Get hyperlink location.
3766 *
3767 * @return {string} Hyperlink location
3768 */
3769 OO.ui.ButtonWidget.prototype.getHref = function () {
3770 return this.href;
3771 };
3772
3773 /**
3774 * Get hyperlink target.
3775 *
3776 * @return {string} Hyperlink target
3777 */
3778 OO.ui.ButtonWidget.prototype.getTarget = function () {
3779 return this.target;
3780 };
3781
3782 /**
3783 * Get search engine traversal hint.
3784 *
3785 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3786 */
3787 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3788 return this.noFollow;
3789 };
3790
3791 /**
3792 * Set hyperlink location.
3793 *
3794 * @param {string|null} href Hyperlink location, null to remove
3795 * @chainable
3796 * @return {OO.ui.Widget} The widget, for chaining
3797 */
3798 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3799 href = typeof href === 'string' ? href : null;
3800 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3801 href = './' + href;
3802 }
3803
3804 if ( href !== this.href ) {
3805 this.href = href;
3806 this.updateHref();
3807 }
3808
3809 return this;
3810 };
3811
3812 /**
3813 * Update the `href` attribute, in case of changes to href or
3814 * disabled state.
3815 *
3816 * @private
3817 * @chainable
3818 * @return {OO.ui.Widget} The widget, for chaining
3819 */
3820 OO.ui.ButtonWidget.prototype.updateHref = function () {
3821 if ( this.href !== null && !this.isDisabled() ) {
3822 this.$button.attr( 'href', this.href );
3823 } else {
3824 this.$button.removeAttr( 'href' );
3825 }
3826
3827 return this;
3828 };
3829
3830 /**
3831 * Handle disable events.
3832 *
3833 * @private
3834 * @param {boolean} disabled Element is disabled
3835 */
3836 OO.ui.ButtonWidget.prototype.onDisable = function () {
3837 this.updateHref();
3838 };
3839
3840 /**
3841 * Set hyperlink target.
3842 *
3843 * @param {string|null} target Hyperlink target, null to remove
3844 * @return {OO.ui.Widget} The widget, for chaining
3845 */
3846 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3847 target = typeof target === 'string' ? target : null;
3848
3849 if ( target !== this.target ) {
3850 this.target = target;
3851 if ( target !== null ) {
3852 this.$button.attr( 'target', target );
3853 } else {
3854 this.$button.removeAttr( 'target' );
3855 }
3856 }
3857
3858 return this;
3859 };
3860
3861 /**
3862 * Set search engine traversal hint.
3863 *
3864 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3865 * @return {OO.ui.Widget} The widget, for chaining
3866 */
3867 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3868 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3869
3870 if ( noFollow !== this.noFollow ) {
3871 this.noFollow = noFollow;
3872 if ( noFollow ) {
3873 this.$button.attr( 'rel', 'nofollow' );
3874 } else {
3875 this.$button.removeAttr( 'rel' );
3876 }
3877 }
3878
3879 return this;
3880 };
3881
3882 // Override method visibility hints from ButtonElement
3883 /**
3884 * @method setActive
3885 * @inheritdoc
3886 */
3887 /**
3888 * @method isActive
3889 * @inheritdoc
3890 */
3891
3892 /**
3893 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3894 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3895 * removed, and cleared from the group.
3896 *
3897 * @example
3898 * // A ButtonGroupWidget with two buttons.
3899 * var button1 = new OO.ui.PopupButtonWidget( {
3900 * label: 'Select a category',
3901 * icon: 'menu',
3902 * popup: {
3903 * $content: $( '<p>List of categories…</p>' ),
3904 * padded: true,
3905 * align: 'left'
3906 * }
3907 * } ),
3908 * button2 = new OO.ui.ButtonWidget( {
3909 * label: 'Add item'
3910 * } ),
3911 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3912 * items: [ button1, button2 ]
3913 * } );
3914 * $( document.body ).append( buttonGroup.$element );
3915 *
3916 * @class
3917 * @extends OO.ui.Widget
3918 * @mixins OO.ui.mixin.GroupElement
3919 * @mixins OO.ui.mixin.TitledElement
3920 *
3921 * @constructor
3922 * @param {Object} [config] Configuration options
3923 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3924 */
3925 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3926 // Configuration initialization
3927 config = config || {};
3928
3929 // Parent constructor
3930 OO.ui.ButtonGroupWidget.parent.call( this, config );
3931
3932 // Mixin constructors
3933 OO.ui.mixin.GroupElement.call( this, $.extend( {
3934 $group: this.$element
3935 }, config ) );
3936 OO.ui.mixin.TitledElement.call( this, config );
3937
3938 // Initialization
3939 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3940 if ( Array.isArray( config.items ) ) {
3941 this.addItems( config.items );
3942 }
3943 };
3944
3945 /* Setup */
3946
3947 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3948 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3949 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
3950
3951 /* Static Properties */
3952
3953 /**
3954 * @static
3955 * @inheritdoc
3956 */
3957 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3958
3959 /* Methods */
3960
3961 /**
3962 * Focus the widget
3963 *
3964 * @chainable
3965 * @return {OO.ui.Widget} The widget, for chaining
3966 */
3967 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3968 if ( !this.isDisabled() ) {
3969 if ( this.items[ 0 ] ) {
3970 this.items[ 0 ].focus();
3971 }
3972 }
3973 return this;
3974 };
3975
3976 /**
3977 * @inheritdoc
3978 */
3979 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3980 this.focus();
3981 };
3982
3983 /**
3984 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
3985 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
3986 * identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
3987 * for a list of icons included in the library.
3988 *
3989 * @example
3990 * // An IconWidget with a label via LabelWidget.
3991 * var myIcon = new OO.ui.IconWidget( {
3992 * icon: 'help',
3993 * title: 'Help'
3994 * } ),
3995 * // Create a label.
3996 * iconLabel = new OO.ui.LabelWidget( {
3997 * label: 'Help'
3998 * } );
3999 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4000 *
4001 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4002 *
4003 * @class
4004 * @extends OO.ui.Widget
4005 * @mixins OO.ui.mixin.IconElement
4006 * @mixins OO.ui.mixin.TitledElement
4007 * @mixins OO.ui.mixin.LabelElement
4008 * @mixins OO.ui.mixin.FlaggedElement
4009 *
4010 * @constructor
4011 * @param {Object} [config] Configuration options
4012 */
4013 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4014 // Configuration initialization
4015 config = config || {};
4016
4017 // Parent constructor
4018 OO.ui.IconWidget.parent.call( this, config );
4019
4020 // Mixin constructors
4021 OO.ui.mixin.IconElement.call( this, $.extend( {
4022 $icon: this.$element
4023 }, config ) );
4024 OO.ui.mixin.TitledElement.call( this, $.extend( {
4025 $titled: this.$element
4026 }, config ) );
4027 OO.ui.mixin.LabelElement.call( this, $.extend( {
4028 $label: this.$element,
4029 invisibleLabel: true
4030 }, config ) );
4031 OO.ui.mixin.FlaggedElement.call( this, $.extend( {
4032 $flagged: this.$element
4033 }, config ) );
4034
4035 // Initialization
4036 this.$element.addClass( 'oo-ui-iconWidget' );
4037 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4038 // nested in other widgets, because this widget used to not mix in LabelElement.
4039 this.$element.removeClass( 'oo-ui-labelElement-label' );
4040 };
4041
4042 /* Setup */
4043
4044 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4045 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4046 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4047 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4048 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4049
4050 /* Static Properties */
4051
4052 /**
4053 * @static
4054 * @inheritdoc
4055 */
4056 OO.ui.IconWidget.static.tagName = 'span';
4057
4058 /**
4059 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4060 * attention to the status of an item or to clarify the function within a control. For a list of
4061 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4062 *
4063 * @example
4064 * // An indicator widget.
4065 * var indicator1 = new OO.ui.IndicatorWidget( {
4066 * indicator: 'required'
4067 * } ),
4068 * // Create a fieldset layout to add a label.
4069 * fieldset = new OO.ui.FieldsetLayout();
4070 * fieldset.addItems( [
4071 * new OO.ui.FieldLayout( indicator1, {
4072 * label: 'A required indicator:'
4073 * } )
4074 * ] );
4075 * $( document.body ).append( fieldset.$element );
4076 *
4077 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4078 *
4079 * @class
4080 * @extends OO.ui.Widget
4081 * @mixins OO.ui.mixin.IndicatorElement
4082 * @mixins OO.ui.mixin.TitledElement
4083 * @mixins OO.ui.mixin.LabelElement
4084 *
4085 * @constructor
4086 * @param {Object} [config] Configuration options
4087 */
4088 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4089 // Configuration initialization
4090 config = config || {};
4091
4092 // Parent constructor
4093 OO.ui.IndicatorWidget.parent.call( this, config );
4094
4095 // Mixin constructors
4096 OO.ui.mixin.IndicatorElement.call( this, $.extend( {
4097 $indicator: this.$element
4098 }, config ) );
4099 OO.ui.mixin.TitledElement.call( this, $.extend( {
4100 $titled: this.$element
4101 }, config ) );
4102 OO.ui.mixin.LabelElement.call( this, $.extend( {
4103 $label: this.$element,
4104 invisibleLabel: true
4105 }, config ) );
4106
4107 // Initialization
4108 this.$element.addClass( 'oo-ui-indicatorWidget' );
4109 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4110 // nested in other widgets, because this widget used to not mix in LabelElement.
4111 this.$element.removeClass( 'oo-ui-labelElement-label' );
4112 };
4113
4114 /* Setup */
4115
4116 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4117 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4118 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4119 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4120
4121 /* Static Properties */
4122
4123 /**
4124 * @static
4125 * @inheritdoc
4126 */
4127 OO.ui.IndicatorWidget.static.tagName = 'span';
4128
4129 /**
4130 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4131 * be configured with a `label` option that is set to a string, a label node, or a function:
4132 *
4133 * - String: a plaintext string
4134 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4135 * label that includes a link or special styling, such as a gray color or additional
4136 * graphical elements.
4137 * - Function: a function that will produce a string in the future. Functions are used
4138 * in cases where the value of the label is not currently defined.
4139 *
4140 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4141 * which will come into focus when the label is clicked.
4142 *
4143 * @example
4144 * // Two LabelWidgets.
4145 * var label1 = new OO.ui.LabelWidget( {
4146 * label: 'plaintext label'
4147 * } ),
4148 * label2 = new OO.ui.LabelWidget( {
4149 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4150 * } ),
4151 * // Create a fieldset layout with fields for each example.
4152 * fieldset = new OO.ui.FieldsetLayout();
4153 * fieldset.addItems( [
4154 * new OO.ui.FieldLayout( label1 ),
4155 * new OO.ui.FieldLayout( label2 )
4156 * ] );
4157 * $( document.body ).append( fieldset.$element );
4158 *
4159 * @class
4160 * @extends OO.ui.Widget
4161 * @mixins OO.ui.mixin.LabelElement
4162 * @mixins OO.ui.mixin.TitledElement
4163 *
4164 * @constructor
4165 * @param {Object} [config] Configuration options
4166 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4167 * Clicking the label will focus the specified input field.
4168 */
4169 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4170 // Configuration initialization
4171 config = config || {};
4172
4173 // Parent constructor
4174 OO.ui.LabelWidget.parent.call( this, config );
4175
4176 // Mixin constructors
4177 OO.ui.mixin.LabelElement.call( this, $.extend( {
4178 $label: this.$element
4179 }, config ) );
4180 OO.ui.mixin.TitledElement.call( this, config );
4181
4182 // Properties
4183 this.input = config.input;
4184
4185 // Initialization
4186 if ( this.input ) {
4187 if ( this.input.getInputId() ) {
4188 this.$element.attr( 'for', this.input.getInputId() );
4189 } else {
4190 this.$label.on( 'click', function () {
4191 this.input.simulateLabelClick();
4192 }.bind( this ) );
4193 }
4194 }
4195 this.$element.addClass( 'oo-ui-labelWidget' );
4196 };
4197
4198 /* Setup */
4199
4200 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4201 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4202 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4203
4204 /* Static Properties */
4205
4206 /**
4207 * @static
4208 * @inheritdoc
4209 */
4210 OO.ui.LabelWidget.static.tagName = 'label';
4211
4212 /**
4213 * MessageWidget produces a visual component for sending a notice to the user
4214 * with an icon and distinct design noting its purpose. The MessageWidget changes
4215 * its visual presentation based on the type chosen, which also denotes its UX
4216 * purpose.
4217 *
4218 * @class
4219 * @extends OO.ui.Widget
4220 * @mixins OO.ui.mixin.IconElement
4221 * @mixins OO.ui.mixin.LabelElement
4222 * @mixins OO.ui.mixin.TitledElement
4223 * @mixins OO.ui.mixin.FlaggedElement
4224 *
4225 * @constructor
4226 * @param {Object} [config] Configuration options
4227 * @cfg {string} [type='notice'] The type of the notice widget. This will also
4228 * impact the flags that the widget receives (and hence its CSS design) as well
4229 * as the icon that appears. Available types:
4230 * 'notice', 'error', 'warning', 'success'
4231 * @cfg {boolean} [inline] Set the notice as an inline notice. The default
4232 * is not inline, or 'boxed' style.
4233 */
4234 OO.ui.MessageWidget = function OoUiMessageWidget( config ) {
4235 // Configuration initialization
4236 config = config || {};
4237
4238 // Parent constructor
4239 OO.ui.MessageWidget.parent.call( this, config );
4240
4241 // Mixin constructors
4242 OO.ui.mixin.IconElement.call( this, config );
4243 OO.ui.mixin.LabelElement.call( this, config );
4244 OO.ui.mixin.TitledElement.call( this, config );
4245 OO.ui.mixin.FlaggedElement.call( this, config );
4246
4247 // Set type
4248 this.setType( config.type );
4249 this.setInline( config.inline );
4250
4251 // Build the widget
4252 this.$element
4253 .append( this.$icon, this.$label )
4254 .addClass( 'oo-ui-messageWidget' );
4255 };
4256
4257 /* Setup */
4258
4259 OO.inheritClass( OO.ui.MessageWidget, OO.ui.Widget );
4260 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.IconElement );
4261 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.LabelElement );
4262 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.TitledElement );
4263 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.FlaggedElement );
4264
4265 /* Static Properties */
4266
4267 /**
4268 * An object defining the icon name per defined type.
4269 *
4270 * @static
4271 * @property {Object}
4272 */
4273 OO.ui.MessageWidget.static.iconMap = {
4274 notice: 'infoFilled',
4275 error: 'error',
4276 warning: 'alert',
4277 success: 'check'
4278 };
4279
4280 /* Methods */
4281
4282 /**
4283 * Set the inline state of the widget.
4284 *
4285 * @param {boolean} inline Widget is inline
4286 */
4287 OO.ui.MessageWidget.prototype.setInline = function ( inline ) {
4288 inline = !!inline;
4289
4290 if ( this.inline !== inline ) {
4291 this.inline = inline;
4292 this.$element
4293 .toggleClass( 'oo-ui-messageWidget-block', !this.inline );
4294 }
4295 };
4296 /**
4297 * Set the widget type. The given type must belong to the list of
4298 * legal types set by OO.ui.MessageWidget.static.iconMap
4299 *
4300 * @param {string} [type] Given type. Defaults to 'notice'
4301 */
4302 OO.ui.MessageWidget.prototype.setType = function ( type ) {
4303 // Validate type
4304 if ( Object.keys( this.constructor.static.iconMap ).indexOf( type ) === -1 ) {
4305 type = 'notice'; // Default
4306 }
4307
4308 if ( this.type !== type ) {
4309
4310 // Flags
4311 this.clearFlags();
4312 this.setFlags( type );
4313
4314 // Set the icon and its variant
4315 this.setIcon( this.constructor.static.iconMap[ type ] );
4316 this.$icon.removeClass( 'oo-ui-image-' + this.type );
4317 this.$icon.addClass( 'oo-ui-image-' + type );
4318
4319 if ( type === 'error' ) {
4320 this.$element.attr( 'role', 'alert' );
4321 this.$element.removeAttr( 'aria-live' );
4322 } else {
4323 this.$element.removeAttr( 'role' );
4324 this.$element.attr( 'aria-live', 'polite' );
4325 }
4326
4327 this.type = type;
4328 }
4329 };
4330
4331 /**
4332 * PendingElement is a mixin that is used to create elements that notify users that something is
4333 * happening and that they should wait before proceeding. The pending state is visually represented
4334 * with a pending texture that appears in the head of a pending
4335 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4336 * {@link OO.ui.TextInputWidget text input widget}.
4337 *
4338 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4339 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4340 * not currently supported for action widgets used in process dialogs.
4341 *
4342 * @example
4343 * function MessageDialog( config ) {
4344 * MessageDialog.parent.call( this, config );
4345 * }
4346 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4347 *
4348 * MessageDialog.static.name = 'myMessageDialog';
4349 * MessageDialog.static.actions = [
4350 * { action: 'save', label: 'Done', flags: 'primary' },
4351 * { label: 'Cancel', flags: 'safe' }
4352 * ];
4353 *
4354 * MessageDialog.prototype.initialize = function () {
4355 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4356 * this.content = new OO.ui.PanelLayout( { padded: true } );
4357 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4358 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4359 * 'process dialogs.</p>' );
4360 * this.$body.append( this.content.$element );
4361 * };
4362 * MessageDialog.prototype.getBodyHeight = function () {
4363 * return 100;
4364 * }
4365 * MessageDialog.prototype.getActionProcess = function ( action ) {
4366 * var dialog = this;
4367 * if ( action === 'save' ) {
4368 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4369 * return new OO.ui.Process()
4370 * .next( 1000 )
4371 * .next( function () {
4372 * dialog.getActions().get({actions: 'save'})[0].popPending();
4373 * } );
4374 * }
4375 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4376 * };
4377 *
4378 * var windowManager = new OO.ui.WindowManager();
4379 * $( document.body ).append( windowManager.$element );
4380 *
4381 * var dialog = new MessageDialog();
4382 * windowManager.addWindows( [ dialog ] );
4383 * windowManager.openWindow( dialog );
4384 *
4385 * @abstract
4386 * @class
4387 *
4388 * @constructor
4389 * @param {Object} [config] Configuration options
4390 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4391 */
4392 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4393 // Configuration initialization
4394 config = config || {};
4395
4396 // Properties
4397 this.pending = 0;
4398 this.$pending = null;
4399
4400 // Initialisation
4401 this.setPendingElement( config.$pending || this.$element );
4402 };
4403
4404 /* Setup */
4405
4406 OO.initClass( OO.ui.mixin.PendingElement );
4407
4408 /* Methods */
4409
4410 /**
4411 * Set the pending element (and clean up any existing one).
4412 *
4413 * @param {jQuery} $pending The element to set to pending.
4414 */
4415 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4416 if ( this.$pending ) {
4417 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4418 }
4419
4420 this.$pending = $pending;
4421 if ( this.pending > 0 ) {
4422 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4423 }
4424 };
4425
4426 /**
4427 * Check if an element is pending.
4428 *
4429 * @return {boolean} Element is pending
4430 */
4431 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4432 return !!this.pending;
4433 };
4434
4435 /**
4436 * Increase the pending counter. The pending state will remain active until the counter is zero
4437 * (i.e., the number of calls to #pushPending and #popPending is the same).
4438 *
4439 * @chainable
4440 * @return {OO.ui.Element} The element, for chaining
4441 */
4442 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4443 if ( this.pending === 0 ) {
4444 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4445 this.updateThemeClasses();
4446 }
4447 this.pending++;
4448
4449 return this;
4450 };
4451
4452 /**
4453 * Decrease the pending counter. The pending state will remain active until the counter is zero
4454 * (i.e., the number of calls to #pushPending and #popPending is the same).
4455 *
4456 * @chainable
4457 * @return {OO.ui.Element} The element, for chaining
4458 */
4459 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4460 if ( this.pending === 1 ) {
4461 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4462 this.updateThemeClasses();
4463 }
4464 this.pending = Math.max( 0, this.pending - 1 );
4465
4466 return this;
4467 };
4468
4469 /**
4470 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4471 * in the document (for example, in an OO.ui.Window's $overlay).
4472 *
4473 * The elements's position is automatically calculated and maintained when window is resized or the
4474 * page is scrolled. If you reposition the container manually, you have to call #position to make
4475 * sure the element is still placed correctly.
4476 *
4477 * As positioning is only possible when both the element and the container are attached to the DOM
4478 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4479 * the #toggle method to display a floating popup, for example.
4480 *
4481 * @abstract
4482 * @class
4483 *
4484 * @constructor
4485 * @param {Object} [config] Configuration options
4486 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4487 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4488 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4489 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4490 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4491 * 'top': Align the top edge with $floatableContainer's top edge
4492 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4493 * 'center': Vertically align the center with $floatableContainer's center
4494 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4495 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4496 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4497 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4498 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4499 * 'center': Horizontally align the center with $floatableContainer's center
4500 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4501 * is out of view
4502 */
4503 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4504 // Configuration initialization
4505 config = config || {};
4506
4507 // Properties
4508 this.$floatable = null;
4509 this.$floatableContainer = null;
4510 this.$floatableWindow = null;
4511 this.$floatableClosestScrollable = null;
4512 this.floatableOutOfView = false;
4513 this.onFloatableScrollHandler = this.position.bind( this );
4514 this.onFloatableWindowResizeHandler = this.position.bind( this );
4515
4516 // Initialization
4517 this.setFloatableContainer( config.$floatableContainer );
4518 this.setFloatableElement( config.$floatable || this.$element );
4519 this.setVerticalPosition( config.verticalPosition || 'below' );
4520 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4521 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
4522 true : !!config.hideWhenOutOfView;
4523 };
4524
4525 /* Methods */
4526
4527 /**
4528 * Set floatable element.
4529 *
4530 * If an element is already set, it will be cleaned up before setting up the new element.
4531 *
4532 * @param {jQuery} $floatable Element to make floatable
4533 */
4534 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4535 if ( this.$floatable ) {
4536 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4537 this.$floatable.css( { left: '', top: '' } );
4538 }
4539
4540 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4541 this.position();
4542 };
4543
4544 /**
4545 * Set floatable container.
4546 *
4547 * The element will be positioned relative to the specified container.
4548 *
4549 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4550 */
4551 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4552 this.$floatableContainer = $floatableContainer;
4553 if ( this.$floatable ) {
4554 this.position();
4555 }
4556 };
4557
4558 /**
4559 * Change how the element is positioned vertically.
4560 *
4561 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4562 */
4563 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4564 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4565 throw new Error( 'Invalid value for vertical position: ' + position );
4566 }
4567 if ( this.verticalPosition !== position ) {
4568 this.verticalPosition = position;
4569 if ( this.$floatable ) {
4570 this.position();
4571 }
4572 }
4573 };
4574
4575 /**
4576 * Change how the element is positioned horizontally.
4577 *
4578 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4579 */
4580 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4581 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4582 throw new Error( 'Invalid value for horizontal position: ' + position );
4583 }
4584 if ( this.horizontalPosition !== position ) {
4585 this.horizontalPosition = position;
4586 if ( this.$floatable ) {
4587 this.position();
4588 }
4589 }
4590 };
4591
4592 /**
4593 * Toggle positioning.
4594 *
4595 * Do not turn positioning on until after the element is attached to the DOM and visible.
4596 *
4597 * @param {boolean} [positioning] Enable positioning, omit to toggle
4598 * @chainable
4599 * @return {OO.ui.Element} The element, for chaining
4600 */
4601 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4602 var closestScrollableOfContainer;
4603
4604 if ( !this.$floatable || !this.$floatableContainer ) {
4605 return this;
4606 }
4607
4608 positioning = positioning === undefined ? !this.positioning : !!positioning;
4609
4610 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4611 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4612 this.warnedUnattached = true;
4613 }
4614
4615 if ( this.positioning !== positioning ) {
4616 this.positioning = positioning;
4617
4618 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
4619 this.$floatableContainer[ 0 ]
4620 );
4621 // If the scrollable is the root, we have to listen to scroll events
4622 // on the window because of browser inconsistencies.
4623 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4624 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
4625 closestScrollableOfContainer
4626 );
4627 }
4628
4629 if ( positioning ) {
4630 this.$floatableWindow = $( this.getElementWindow() );
4631 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4632
4633 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4634 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4635
4636 // Initial position after visible
4637 this.position();
4638 } else {
4639 if ( this.$floatableWindow ) {
4640 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4641 this.$floatableWindow = null;
4642 }
4643
4644 if ( this.$floatableClosestScrollable ) {
4645 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4646 this.$floatableClosestScrollable = null;
4647 }
4648
4649 this.$floatable.css( { left: '', right: '', top: '' } );
4650 }
4651 }
4652
4653 return this;
4654 };
4655
4656 /**
4657 * Check whether the bottom edge of the given element is within the viewport of the given
4658 * container.
4659 *
4660 * @private
4661 * @param {jQuery} $element
4662 * @param {jQuery} $container
4663 * @return {boolean}
4664 */
4665 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4666 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds,
4667 rightEdgeInBounds, startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4668 direction = $element.css( 'direction' );
4669
4670 elemRect = $element[ 0 ].getBoundingClientRect();
4671 if ( $container[ 0 ] === window ) {
4672 viewportSpacing = OO.ui.getViewportSpacing();
4673 contRect = {
4674 top: 0,
4675 left: 0,
4676 right: document.documentElement.clientWidth,
4677 bottom: document.documentElement.clientHeight
4678 };
4679 contRect.top += viewportSpacing.top;
4680 contRect.left += viewportSpacing.left;
4681 contRect.right -= viewportSpacing.right;
4682 contRect.bottom -= viewportSpacing.bottom;
4683 } else {
4684 contRect = $container[ 0 ].getBoundingClientRect();
4685 }
4686
4687 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4688 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4689 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4690 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4691 if ( direction === 'rtl' ) {
4692 startEdgeInBounds = rightEdgeInBounds;
4693 endEdgeInBounds = leftEdgeInBounds;
4694 } else {
4695 startEdgeInBounds = leftEdgeInBounds;
4696 endEdgeInBounds = rightEdgeInBounds;
4697 }
4698
4699 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4700 return false;
4701 }
4702 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4703 return false;
4704 }
4705 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4706 return false;
4707 }
4708 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4709 return false;
4710 }
4711
4712 // The other positioning values are all about being inside the container,
4713 // so in those cases all we care about is that any part of the container is visible.
4714 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4715 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4716 };
4717
4718 /**
4719 * Check if the floatable is hidden to the user because it was offscreen.
4720 *
4721 * @return {boolean} Floatable is out of view
4722 */
4723 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4724 return this.floatableOutOfView;
4725 };
4726
4727 /**
4728 * Position the floatable below its container.
4729 *
4730 * This should only be done when both of them are attached to the DOM and visible.
4731 *
4732 * @chainable
4733 * @return {OO.ui.Element} The element, for chaining
4734 */
4735 OO.ui.mixin.FloatableElement.prototype.position = function () {
4736 if ( !this.positioning ) {
4737 return this;
4738 }
4739
4740 if ( !(
4741 // To continue, some things need to be true:
4742 // The element must actually be in the DOM
4743 this.isElementAttached() && (
4744 // The closest scrollable is the current window
4745 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4746 // OR is an element in the element's DOM
4747 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4748 )
4749 ) ) {
4750 // Abort early if important parts of the widget are no longer attached to the DOM
4751 return this;
4752 }
4753
4754 this.floatableOutOfView = this.hideWhenOutOfView &&
4755 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4756 if ( this.floatableOutOfView ) {
4757 this.$floatable.addClass( 'oo-ui-element-hidden' );
4758 return this;
4759 } else {
4760 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4761 }
4762
4763 this.$floatable.css( this.computePosition() );
4764
4765 // We updated the position, so re-evaluate the clipping state.
4766 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4767 // will not notice the need to update itself.)
4768 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
4769 // Why does it not listen to the right events in the right places?
4770 if ( this.clip ) {
4771 this.clip();
4772 }
4773
4774 return this;
4775 };
4776
4777 /**
4778 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4779 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4780 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4781 *
4782 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4783 */
4784 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4785 var isBody, scrollableX, scrollableY, containerPos,
4786 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4787 newPos = { top: '', left: '', bottom: '', right: '' },
4788 direction = this.$floatableContainer.css( 'direction' ),
4789 $offsetParent = this.$floatable.offsetParent();
4790
4791 if ( $offsetParent.is( 'html' ) ) {
4792 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4793 // <html> element, but they do work on the <body>
4794 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4795 }
4796 isBody = $offsetParent.is( 'body' );
4797 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
4798 $offsetParent.css( 'overflow-x' ) === 'auto';
4799 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
4800 $offsetParent.css( 'overflow-y' ) === 'auto';
4801
4802 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4803 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4804 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
4805 // is the body, or if it isn't scrollable
4806 scrollTop = scrollableY && !isBody ?
4807 $offsetParent.scrollTop() : 0;
4808 scrollLeft = scrollableX && !isBody ?
4809 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4810
4811 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4812 // if the <body> has a margin
4813 containerPos = isBody ?
4814 this.$floatableContainer.offset() :
4815 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4816 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4817 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4818 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4819 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4820
4821 if ( this.verticalPosition === 'below' ) {
4822 newPos.top = containerPos.bottom;
4823 } else if ( this.verticalPosition === 'above' ) {
4824 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4825 } else if ( this.verticalPosition === 'top' ) {
4826 newPos.top = containerPos.top;
4827 } else if ( this.verticalPosition === 'bottom' ) {
4828 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4829 } else if ( this.verticalPosition === 'center' ) {
4830 newPos.top = containerPos.top +
4831 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4832 }
4833
4834 if ( this.horizontalPosition === 'before' ) {
4835 newPos.end = containerPos.start;
4836 } else if ( this.horizontalPosition === 'after' ) {
4837 newPos.start = containerPos.end;
4838 } else if ( this.horizontalPosition === 'start' ) {
4839 newPos.start = containerPos.start;
4840 } else if ( this.horizontalPosition === 'end' ) {
4841 newPos.end = containerPos.end;
4842 } else if ( this.horizontalPosition === 'center' ) {
4843 newPos.left = containerPos.left +
4844 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4845 }
4846
4847 if ( newPos.start !== undefined ) {
4848 if ( direction === 'rtl' ) {
4849 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4850 $offsetParent ).outerWidth() - newPos.start;
4851 } else {
4852 newPos.left = newPos.start;
4853 }
4854 delete newPos.start;
4855 }
4856 if ( newPos.end !== undefined ) {
4857 if ( direction === 'rtl' ) {
4858 newPos.left = newPos.end;
4859 } else {
4860 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4861 $offsetParent ).outerWidth() - newPos.end;
4862 }
4863 delete newPos.end;
4864 }
4865
4866 // Account for scroll position
4867 if ( newPos.top !== '' ) {
4868 newPos.top += scrollTop;
4869 }
4870 if ( newPos.bottom !== '' ) {
4871 newPos.bottom -= scrollTop;
4872 }
4873 if ( newPos.left !== '' ) {
4874 newPos.left += scrollLeft;
4875 }
4876 if ( newPos.right !== '' ) {
4877 newPos.right -= scrollLeft;
4878 }
4879
4880 // Account for scrollbar gutter
4881 if ( newPos.bottom !== '' ) {
4882 newPos.bottom -= horizScrollbarHeight;
4883 }
4884 if ( direction === 'rtl' ) {
4885 if ( newPos.left !== '' ) {
4886 newPos.left -= vertScrollbarWidth;
4887 }
4888 } else {
4889 if ( newPos.right !== '' ) {
4890 newPos.right -= vertScrollbarWidth;
4891 }
4892 }
4893
4894 return newPos;
4895 };
4896
4897 /**
4898 * Element that can be automatically clipped to visible boundaries.
4899 *
4900 * Whenever the element's natural height changes, you have to call
4901 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4902 * clipping correctly.
4903 *
4904 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4905 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4906 * then #$clippable will be given a fixed reduced height and/or width and will be made
4907 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4908 * but you can build a static footer by setting #$clippableContainer to an element that contains
4909 * #$clippable and the footer.
4910 *
4911 * @abstract
4912 * @class
4913 *
4914 * @constructor
4915 * @param {Object} [config] Configuration options
4916 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4917 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4918 * omit to use #$clippable
4919 */
4920 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4921 // Configuration initialization
4922 config = config || {};
4923
4924 // Properties
4925 this.$clippable = null;
4926 this.$clippableContainer = null;
4927 this.clipping = false;
4928 this.clippedHorizontally = false;
4929 this.clippedVertically = false;
4930 this.$clippableScrollableContainer = null;
4931 this.$clippableScroller = null;
4932 this.$clippableWindow = null;
4933 this.idealWidth = null;
4934 this.idealHeight = null;
4935 this.onClippableScrollHandler = this.clip.bind( this );
4936 this.onClippableWindowResizeHandler = this.clip.bind( this );
4937
4938 // Initialization
4939 if ( config.$clippableContainer ) {
4940 this.setClippableContainer( config.$clippableContainer );
4941 }
4942 this.setClippableElement( config.$clippable || this.$element );
4943 };
4944
4945 /* Methods */
4946
4947 /**
4948 * Set clippable element.
4949 *
4950 * If an element is already set, it will be cleaned up before setting up the new element.
4951 *
4952 * @param {jQuery} $clippable Element to make clippable
4953 */
4954 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4955 if ( this.$clippable ) {
4956 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4957 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4958 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4959 }
4960
4961 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4962 this.clip();
4963 };
4964
4965 /**
4966 * Set clippable container.
4967 *
4968 * This is the container that will be measured when deciding whether to clip. When clipping,
4969 * #$clippable will be resized in order to keep the clippable container fully visible.
4970 *
4971 * If the clippable container is unset, #$clippable will be used.
4972 *
4973 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4974 */
4975 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4976 this.$clippableContainer = $clippableContainer;
4977 if ( this.$clippable ) {
4978 this.clip();
4979 }
4980 };
4981
4982 /**
4983 * Toggle clipping.
4984 *
4985 * Do not turn clipping on until after the element is attached to the DOM and visible.
4986 *
4987 * @param {boolean} [clipping] Enable clipping, omit to toggle
4988 * @chainable
4989 * @return {OO.ui.Element} The element, for chaining
4990 */
4991 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4992 clipping = clipping === undefined ? !this.clipping : !!clipping;
4993
4994 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4995 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4996 this.warnedUnattached = true;
4997 }
4998
4999 if ( this.clipping !== clipping ) {
5000 this.clipping = clipping;
5001 if ( clipping ) {
5002 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
5003 // If the clippable container is the root, we have to listen to scroll events and check
5004 // jQuery.scrollTop on the window because of browser inconsistencies
5005 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
5006 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
5007 this.$clippableScrollableContainer;
5008 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
5009 this.$clippableWindow = $( this.getElementWindow() )
5010 .on( 'resize', this.onClippableWindowResizeHandler );
5011 // Initial clip after visible
5012 this.clip();
5013 } else {
5014 this.$clippable.css( {
5015 width: '',
5016 height: '',
5017 maxWidth: '',
5018 maxHeight: '',
5019 overflowX: '',
5020 overflowY: ''
5021 } );
5022 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5023
5024 this.$clippableScrollableContainer = null;
5025 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
5026 this.$clippableScroller = null;
5027 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
5028 this.$clippableWindow = null;
5029 }
5030 }
5031
5032 return this;
5033 };
5034
5035 /**
5036 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
5037 *
5038 * @return {boolean} Element will be clipped to the visible area
5039 */
5040 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
5041 return this.clipping;
5042 };
5043
5044 /**
5045 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
5046 *
5047 * @return {boolean} Part of the element is being clipped
5048 */
5049 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
5050 return this.clippedHorizontally || this.clippedVertically;
5051 };
5052
5053 /**
5054 * Check if the right of the element is being clipped by the nearest scrollable container.
5055 *
5056 * @return {boolean} Part of the element is being clipped
5057 */
5058 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
5059 return this.clippedHorizontally;
5060 };
5061
5062 /**
5063 * Check if the bottom of the element is being clipped by the nearest scrollable container.
5064 *
5065 * @return {boolean} Part of the element is being clipped
5066 */
5067 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
5068 return this.clippedVertically;
5069 };
5070
5071 /**
5072 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
5073 *
5074 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
5075 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
5076 */
5077 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
5078 this.idealWidth = width;
5079 this.idealHeight = height;
5080
5081 if ( !this.clipping ) {
5082 // Update dimensions
5083 this.$clippable.css( { width: width, height: height } );
5084 }
5085 // While clipping, idealWidth and idealHeight are not considered
5086 };
5087
5088 /**
5089 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5090 * ClippableElement will clip the opposite side when reducing element's width.
5091 *
5092 * Classes that mix in ClippableElement should override this to return 'right' if their
5093 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5094 * If your class also mixes in FloatableElement, this is handled automatically.
5095 *
5096 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5097 * always in pixels, even if they were unset or set to 'auto'.)
5098 *
5099 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5100 *
5101 * @return {string} 'left' or 'right'
5102 */
5103 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5104 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5105 return 'right';
5106 }
5107 return 'left';
5108 };
5109
5110 /**
5111 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5112 * ClippableElement will clip the opposite side when reducing element's width.
5113 *
5114 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5115 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5116 * If your class also mixes in FloatableElement, this is handled automatically.
5117 *
5118 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5119 * always in pixels, even if they were unset or set to 'auto'.)
5120 *
5121 * When in doubt, 'top' is a sane fallback.
5122 *
5123 * @return {string} 'top' or 'bottom'
5124 */
5125 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5126 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5127 return 'bottom';
5128 }
5129 return 'top';
5130 };
5131
5132 /**
5133 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5134 * when the element's natural height changes.
5135 *
5136 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5137 * overlapped by, the visible area of the nearest scrollable container.
5138 *
5139 * Because calling clip() when the natural height changes isn't always possible, we also set
5140 * max-height when the element isn't being clipped. This means that if the element tries to grow
5141 * beyond the edge, something reasonable will happen before clip() is called.
5142 *
5143 * @chainable
5144 * @return {OO.ui.Element} The element, for chaining
5145 */
5146 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5147 var extraHeight, extraWidth, viewportSpacing,
5148 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5149 naturalWidth, naturalHeight, clipWidth, clipHeight,
5150 $item, itemRect, $viewport, viewportRect, availableRect,
5151 direction, vertScrollbarWidth, horizScrollbarHeight,
5152 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5153 // by one or two pixels. (And also so that we have space to display drop shadows.)
5154 // Chosen by fair dice roll.
5155 buffer = 7;
5156
5157 if ( !this.clipping ) {
5158 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5159 // will fail
5160 return this;
5161 }
5162
5163 function rectIntersection( a, b ) {
5164 var out = {};
5165 out.top = Math.max( a.top, b.top );
5166 out.left = Math.max( a.left, b.left );
5167 out.bottom = Math.min( a.bottom, b.bottom );
5168 out.right = Math.min( a.right, b.right );
5169 return out;
5170 }
5171
5172 viewportSpacing = OO.ui.getViewportSpacing();
5173
5174 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5175 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5176 // Dimensions of the browser window, rather than the element!
5177 viewportRect = {
5178 top: 0,
5179 left: 0,
5180 right: document.documentElement.clientWidth,
5181 bottom: document.documentElement.clientHeight
5182 };
5183 viewportRect.top += viewportSpacing.top;
5184 viewportRect.left += viewportSpacing.left;
5185 viewportRect.right -= viewportSpacing.right;
5186 viewportRect.bottom -= viewportSpacing.bottom;
5187 } else {
5188 $viewport = this.$clippableScrollableContainer;
5189 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5190 // Convert into a plain object
5191 viewportRect = $.extend( {}, viewportRect );
5192 }
5193
5194 // Account for scrollbar gutter
5195 direction = $viewport.css( 'direction' );
5196 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5197 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5198 viewportRect.bottom -= horizScrollbarHeight;
5199 if ( direction === 'rtl' ) {
5200 viewportRect.left += vertScrollbarWidth;
5201 } else {
5202 viewportRect.right -= vertScrollbarWidth;
5203 }
5204
5205 // Add arbitrary tolerance
5206 viewportRect.top += buffer;
5207 viewportRect.left += buffer;
5208 viewportRect.right -= buffer;
5209 viewportRect.bottom -= buffer;
5210
5211 $item = this.$clippableContainer || this.$clippable;
5212
5213 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5214 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5215
5216 itemRect = $item[ 0 ].getBoundingClientRect();
5217 // Convert into a plain object
5218 itemRect = $.extend( {}, itemRect );
5219
5220 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5221 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5222 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5223 itemRect.left = viewportRect.left;
5224 } else {
5225 itemRect.right = viewportRect.right;
5226 }
5227 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5228 itemRect.top = viewportRect.top;
5229 } else {
5230 itemRect.bottom = viewportRect.bottom;
5231 }
5232
5233 availableRect = rectIntersection( viewportRect, itemRect );
5234
5235 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5236 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5237 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5238 desiredWidth = Math.min( desiredWidth,
5239 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5240 desiredHeight = Math.min( desiredHeight,
5241 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5242 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5243 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5244 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5245 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5246 clipWidth = allotedWidth < naturalWidth;
5247 clipHeight = allotedHeight < naturalHeight;
5248
5249 if ( clipWidth ) {
5250 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5251 // See T157672.
5252 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5253 // this case.
5254 this.$clippable.css( 'overflowX', 'scroll' );
5255 // eslint-disable-next-line no-void
5256 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5257 this.$clippable.css( {
5258 width: Math.max( 0, allotedWidth ),
5259 maxWidth: ''
5260 } );
5261 } else {
5262 this.$clippable.css( {
5263 overflowX: '',
5264 width: this.idealWidth || '',
5265 maxWidth: Math.max( 0, allotedWidth )
5266 } );
5267 }
5268 if ( clipHeight ) {
5269 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5270 // See T157672.
5271 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5272 // this case.
5273 this.$clippable.css( 'overflowY', 'scroll' );
5274 // eslint-disable-next-line no-void
5275 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5276 this.$clippable.css( {
5277 height: Math.max( 0, allotedHeight ),
5278 maxHeight: ''
5279 } );
5280 } else {
5281 this.$clippable.css( {
5282 overflowY: '',
5283 height: this.idealHeight || '',
5284 maxHeight: Math.max( 0, allotedHeight )
5285 } );
5286 }
5287
5288 // If we stopped clipping in at least one of the dimensions
5289 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5290 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5291 }
5292
5293 this.clippedHorizontally = clipWidth;
5294 this.clippedVertically = clipHeight;
5295
5296 return this;
5297 };
5298
5299 /**
5300 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5301 * By default, each popup has an anchor that points toward its origin.
5302 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5303 *
5304 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5305 *
5306 * @example
5307 * // A PopupWidget.
5308 * var popup = new OO.ui.PopupWidget( {
5309 * $content: $( '<p>Hi there!</p>' ),
5310 * padded: true,
5311 * width: 300
5312 * } );
5313 *
5314 * $( document.body ).append( popup.$element );
5315 * // To display the popup, toggle the visibility to 'true'.
5316 * popup.toggle( true );
5317 *
5318 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5319 *
5320 * @class
5321 * @extends OO.ui.Widget
5322 * @mixins OO.ui.mixin.LabelElement
5323 * @mixins OO.ui.mixin.ClippableElement
5324 * @mixins OO.ui.mixin.FloatableElement
5325 *
5326 * @constructor
5327 * @param {Object} [config] Configuration options
5328 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5329 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5330 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5331 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5332 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5333 * of $floatableContainer
5334 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5335 * of $floatableContainer
5336 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5337 * endwards (right/left) to the vertical center of $floatableContainer
5338 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5339 * startwards (left/right) to the vertical center of $floatableContainer
5340 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5341 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5342 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5343 * move the popup as far downwards as possible.
5344 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5345 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5346 * move the popup as far upwards as possible.
5347 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5348 * center of the popup with the center of $floatableContainer.
5349 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5350 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5351 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5352 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5353 * desired direction to display the popup without clipping
5354 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5355 * See the [OOUI docs on MediaWiki][3] for an example.
5356 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5357 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a
5358 * number of pixels.
5359 * @cfg {jQuery} [$content] Content to append to the popup's body
5360 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5361 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5362 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5363 * This config option is only relevant if #autoClose is set to `true`. See the
5364 * [OOUI documentation on MediaWiki][2] for an example.
5365 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5366 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5367 * button.
5368 * @cfg {boolean} [padded=false] Add padding to the popup's body
5369 */
5370 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5371 // Configuration initialization
5372 config = config || {};
5373
5374 // Parent constructor
5375 OO.ui.PopupWidget.parent.call( this, config );
5376
5377 // Properties (must be set before ClippableElement constructor call)
5378 this.$body = $( '<div>' );
5379 this.$popup = $( '<div>' );
5380
5381 // Mixin constructors
5382 OO.ui.mixin.LabelElement.call( this, config );
5383 OO.ui.mixin.ClippableElement.call( this, $.extend( {
5384 $clippable: this.$body,
5385 $clippableContainer: this.$popup
5386 }, config ) );
5387 OO.ui.mixin.FloatableElement.call( this, config );
5388
5389 // Properties
5390 this.$anchor = $( '<div>' );
5391 // If undefined, will be computed lazily in computePosition()
5392 this.$container = config.$container;
5393 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5394 this.autoClose = !!config.autoClose;
5395 this.transitionTimeout = null;
5396 this.anchored = false;
5397 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5398 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5399
5400 // Initialization
5401 this.setSize( config.width, config.height );
5402 this.toggleAnchor( config.anchor === undefined || config.anchor );
5403 this.setAlignment( config.align || 'center' );
5404 this.setPosition( config.position || 'below' );
5405 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5406 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5407 this.$body.addClass( 'oo-ui-popupWidget-body' );
5408 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5409 this.$popup
5410 .addClass( 'oo-ui-popupWidget-popup' )
5411 .append( this.$body );
5412 this.$element
5413 .addClass( 'oo-ui-popupWidget' )
5414 .append( this.$popup, this.$anchor );
5415 // Move content, which was added to #$element by OO.ui.Widget, to the body
5416 // FIXME This is gross, we should use '$body' or something for the config
5417 if ( config.$content instanceof $ ) {
5418 this.$body.append( config.$content );
5419 }
5420
5421 if ( config.padded ) {
5422 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5423 }
5424
5425 if ( config.head ) {
5426 this.closeButton = new OO.ui.ButtonWidget( {
5427 framed: false,
5428 icon: 'close'
5429 } );
5430 this.closeButton.connect( this, {
5431 click: 'onCloseButtonClick'
5432 } );
5433 this.$head = $( '<div>' )
5434 .addClass( 'oo-ui-popupWidget-head' )
5435 .append( this.$label, this.closeButton.$element );
5436 this.$popup.prepend( this.$head );
5437 }
5438
5439 if ( config.$footer ) {
5440 this.$footer = $( '<div>' )
5441 .addClass( 'oo-ui-popupWidget-footer' )
5442 .append( config.$footer );
5443 this.$popup.append( this.$footer );
5444 }
5445
5446 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5447 // that reference properties not initialized at that time of parent class construction
5448 // TODO: Find a better way to handle post-constructor setup
5449 this.visible = false;
5450 this.$element.addClass( 'oo-ui-element-hidden' );
5451 };
5452
5453 /* Setup */
5454
5455 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5456 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5457 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5458 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5459
5460 /* Events */
5461
5462 /**
5463 * @event ready
5464 *
5465 * The popup is ready: it is visible and has been positioned and clipped.
5466 */
5467
5468 /* Methods */
5469
5470 /**
5471 * Handles document mouse down events.
5472 *
5473 * @private
5474 * @param {MouseEvent} e Mouse down event
5475 */
5476 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5477 if (
5478 this.isVisible() &&
5479 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5480 ) {
5481 this.toggle( false );
5482 }
5483 };
5484
5485 /**
5486 * Bind document mouse down listener.
5487 *
5488 * @private
5489 */
5490 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5491 // Capture clicks outside popup
5492 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5493 // We add 'click' event because iOS safari needs to respond to this event.
5494 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5495 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5496 // of occasionally not emitting 'click' properly, that event seems to be the standard
5497 // that it should be emitting, so we add it to this and will operate the event handler
5498 // on whichever of these events was triggered first
5499 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5500 };
5501
5502 /**
5503 * Handles close button click events.
5504 *
5505 * @private
5506 */
5507 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5508 if ( this.isVisible() ) {
5509 this.toggle( false );
5510 }
5511 };
5512
5513 /**
5514 * Unbind document mouse down listener.
5515 *
5516 * @private
5517 */
5518 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5519 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5520 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5521 };
5522
5523 /**
5524 * Handles document key down events.
5525 *
5526 * @private
5527 * @param {KeyboardEvent} e Key down event
5528 */
5529 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5530 if (
5531 e.which === OO.ui.Keys.ESCAPE &&
5532 this.isVisible()
5533 ) {
5534 this.toggle( false );
5535 e.preventDefault();
5536 e.stopPropagation();
5537 }
5538 };
5539
5540 /**
5541 * Bind document key down listener.
5542 *
5543 * @private
5544 */
5545 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5546 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5547 };
5548
5549 /**
5550 * Unbind document key down listener.
5551 *
5552 * @private
5553 */
5554 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5555 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5556 };
5557
5558 /**
5559 * Show, hide, or toggle the visibility of the anchor.
5560 *
5561 * @param {boolean} [show] Show anchor, omit to toggle
5562 */
5563 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5564 show = show === undefined ? !this.anchored : !!show;
5565
5566 if ( this.anchored !== show ) {
5567 if ( show ) {
5568 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5569 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5570 } else {
5571 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5572 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5573 }
5574 this.anchored = show;
5575 }
5576 };
5577
5578 /**
5579 * Change which edge the anchor appears on.
5580 *
5581 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5582 */
5583 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5584 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5585 throw new Error( 'Invalid value for edge: ' + edge );
5586 }
5587 if ( this.anchorEdge !== null ) {
5588 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5589 }
5590 this.anchorEdge = edge;
5591 if ( this.anchored ) {
5592 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5593 }
5594 };
5595
5596 /**
5597 * Check if the anchor is visible.
5598 *
5599 * @return {boolean} Anchor is visible
5600 */
5601 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5602 return this.anchored;
5603 };
5604
5605 /**
5606 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5607 * `.toggle( true )` after its #$element is attached to the DOM.
5608 *
5609 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5610 * it in the right place and with the right dimensions only work correctly while it is attached.
5611 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5612 * strictly enforced, so currently it only generates a warning in the browser console.
5613 *
5614 * @fires ready
5615 * @inheritdoc
5616 */
5617 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5618 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5619 show = show === undefined ? !this.isVisible() : !!show;
5620
5621 change = show !== this.isVisible();
5622
5623 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5624 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5625 this.warnedUnattached = true;
5626 }
5627 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5628 // Fall back to the parent node if the floatableContainer is not set
5629 this.setFloatableContainer( this.$element.parent() );
5630 }
5631
5632 if ( change && show && this.autoFlip ) {
5633 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
5634 // flip (e.g. if the user scrolled).
5635 this.isAutoFlipped = false;
5636 }
5637
5638 // Parent method
5639 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5640
5641 if ( change ) {
5642 this.togglePositioning( show && !!this.$floatableContainer );
5643
5644 if ( show ) {
5645 if ( this.autoClose ) {
5646 this.bindDocumentMouseDownListener();
5647 this.bindDocumentKeyDownListener();
5648 }
5649 this.updateDimensions();
5650 this.toggleClipping( true );
5651
5652 if ( this.autoFlip ) {
5653 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5654 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5655 // If opening the popup in the normal direction causes it to be clipped,
5656 // open in the opposite one instead
5657 normalHeight = this.$element.height();
5658 this.isAutoFlipped = !this.isAutoFlipped;
5659 this.position();
5660 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5661 // If that also causes it to be clipped, open in whichever direction
5662 // we have more space
5663 oppositeHeight = this.$element.height();
5664 if ( oppositeHeight < normalHeight ) {
5665 this.isAutoFlipped = !this.isAutoFlipped;
5666 this.position();
5667 }
5668 }
5669 }
5670 }
5671 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5672 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5673 // If opening the popup in the normal direction causes it to be clipped,
5674 // open in the opposite one instead
5675 normalWidth = this.$element.width();
5676 this.isAutoFlipped = !this.isAutoFlipped;
5677 // Due to T180173 horizontally clipped PopupWidgets have messed up
5678 // dimensions, which causes positioning to be off. Toggle clipping back and
5679 // forth to work around.
5680 this.toggleClipping( false );
5681 this.position();
5682 this.toggleClipping( true );
5683 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5684 // If that also causes it to be clipped, open in whichever direction
5685 // we have more space
5686 oppositeWidth = this.$element.width();
5687 if ( oppositeWidth < normalWidth ) {
5688 this.isAutoFlipped = !this.isAutoFlipped;
5689 // Due to T180173, horizontally clipped PopupWidgets have messed up
5690 // dimensions, which causes positioning to be off. Toggle clipping
5691 // back and forth to work around.
5692 this.toggleClipping( false );
5693 this.position();
5694 this.toggleClipping( true );
5695 }
5696 }
5697 }
5698 }
5699 }
5700
5701 this.emit( 'ready' );
5702 } else {
5703 this.toggleClipping( false );
5704 if ( this.autoClose ) {
5705 this.unbindDocumentMouseDownListener();
5706 this.unbindDocumentKeyDownListener();
5707 }
5708 }
5709 }
5710
5711 return this;
5712 };
5713
5714 /**
5715 * Set the size of the popup.
5716 *
5717 * Changing the size may also change the popup's position depending on the alignment.
5718 *
5719 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5720 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5721 * @param {boolean} [transition=false] Use a smooth transition
5722 * @chainable
5723 */
5724 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5725 this.width = width !== undefined ? width : 320;
5726 this.height = height !== undefined ? height : null;
5727 if ( this.isVisible() ) {
5728 this.updateDimensions( transition );
5729 }
5730 };
5731
5732 /**
5733 * Update the size and position.
5734 *
5735 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5736 * be called automatically.
5737 *
5738 * @param {boolean} [transition=false] Use a smooth transition
5739 * @chainable
5740 */
5741 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5742 var widget = this;
5743
5744 // Prevent transition from being interrupted
5745 clearTimeout( this.transitionTimeout );
5746 if ( transition ) {
5747 // Enable transition
5748 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5749 }
5750
5751 this.position();
5752
5753 if ( transition ) {
5754 // Prevent transitioning after transition is complete
5755 this.transitionTimeout = setTimeout( function () {
5756 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5757 }, 200 );
5758 } else {
5759 // Prevent transitioning immediately
5760 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5761 }
5762 };
5763
5764 /**
5765 * @inheritdoc
5766 */
5767 OO.ui.PopupWidget.prototype.computePosition = function () {
5768 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize,
5769 anchorPos, anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment,
5770 floatablePos, offsetParentPos, containerPos, popupPosition, viewportSpacing,
5771 popupPos = {},
5772 anchorCss = { left: '', right: '', top: '', bottom: '' },
5773 popupPositionOppositeMap = {
5774 above: 'below',
5775 below: 'above',
5776 before: 'after',
5777 after: 'before'
5778 },
5779 alignMap = {
5780 ltr: {
5781 'force-left': 'backwards',
5782 'force-right': 'forwards'
5783 },
5784 rtl: {
5785 'force-left': 'forwards',
5786 'force-right': 'backwards'
5787 }
5788 },
5789 anchorEdgeMap = {
5790 above: 'bottom',
5791 below: 'top',
5792 before: 'end',
5793 after: 'start'
5794 },
5795 hPosMap = {
5796 forwards: 'start',
5797 center: 'center',
5798 backwards: this.anchored ? 'before' : 'end'
5799 },
5800 vPosMap = {
5801 forwards: 'top',
5802 center: 'center',
5803 backwards: 'bottom'
5804 };
5805
5806 if ( !this.$container ) {
5807 // Lazy-initialize $container if not specified in constructor
5808 this.$container = $( this.getClosestScrollableElementContainer() );
5809 }
5810 direction = this.$container.css( 'direction' );
5811
5812 // Set height and width before we do anything else, since it might cause our measurements
5813 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5814 this.$popup.css( {
5815 width: this.width !== null ? this.width : 'auto',
5816 height: this.height !== null ? this.height : 'auto'
5817 } );
5818
5819 align = alignMap[ direction ][ this.align ] || this.align;
5820 popupPosition = this.popupPosition;
5821 if ( this.isAutoFlipped ) {
5822 popupPosition = popupPositionOppositeMap[ popupPosition ];
5823 }
5824
5825 // If the popup is positioned before or after, then the anchor positioning is vertical,
5826 // otherwise horizontal
5827 vertical = popupPosition === 'before' || popupPosition === 'after';
5828 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5829 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5830 near = vertical ? 'top' : 'left';
5831 far = vertical ? 'bottom' : 'right';
5832 sizeProp = vertical ? 'Height' : 'Width';
5833 popupSize = vertical ?
5834 ( this.height || this.$popup.height() ) :
5835 ( this.width || this.$popup.width() );
5836
5837 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5838 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5839 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5840
5841 // Parent method
5842 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5843 // Find out which property FloatableElement used for positioning, and adjust that value
5844 positionProp = vertical ?
5845 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5846 ( parentPosition.left !== '' ? 'left' : 'right' );
5847
5848 // Figure out where the near and far edges of the popup and $floatableContainer are
5849 floatablePos = this.$floatableContainer.offset();
5850 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5851 // Measure where the offsetParent is and compute our position based on that and parentPosition
5852 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5853 { top: 0, left: 0 } :
5854 this.$element.offsetParent().offset();
5855
5856 if ( positionProp === near ) {
5857 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5858 popupPos[ far ] = popupPos[ near ] + popupSize;
5859 } else {
5860 popupPos[ far ] = offsetParentPos[ near ] +
5861 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5862 popupPos[ near ] = popupPos[ far ] - popupSize;
5863 }
5864
5865 if ( this.anchored ) {
5866 // Position the anchor (which is positioned relative to the popup) to point to
5867 // $floatableContainer
5868 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5869 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5870
5871 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
5872 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
5873 // scrollWidth/Height
5874 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5875 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5876 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5877 // Not enough space for the anchor on the start side; pull the popup startwards
5878 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5879 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5880 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5881 // Not enough space for the anchor on the end side; pull the popup endwards
5882 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5883 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5884 } else {
5885 positionAdjustment = 0;
5886 }
5887 } else {
5888 positionAdjustment = 0;
5889 }
5890
5891 // Check if the popup will go beyond the edge of this.$container
5892 containerPos = this.$container[ 0 ] === document.documentElement ?
5893 { top: 0, left: 0 } :
5894 this.$container.offset();
5895 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5896 if ( this.$container[ 0 ] === document.documentElement ) {
5897 viewportSpacing = OO.ui.getViewportSpacing();
5898 containerPos[ near ] += viewportSpacing[ near ];
5899 containerPos[ far ] -= viewportSpacing[ far ];
5900 }
5901 // Take into account how much the popup will move because of the adjustments we're going to make
5902 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5903 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5904 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5905 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5906 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5907 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5908 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5909 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5910 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5911 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5912 }
5913
5914 if ( this.anchored ) {
5915 // Adjust anchorOffset for positionAdjustment
5916 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5917
5918 // Position the anchor
5919 anchorCss[ start ] = anchorOffset;
5920 this.$anchor.css( anchorCss );
5921 }
5922
5923 // Move the popup if needed
5924 parentPosition[ positionProp ] += positionAdjustment;
5925
5926 return parentPosition;
5927 };
5928
5929 /**
5930 * Set popup alignment
5931 *
5932 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5933 * `backwards` or `forwards`.
5934 */
5935 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5936 // Validate alignment
5937 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5938 this.align = align;
5939 } else {
5940 this.align = 'center';
5941 }
5942 this.position();
5943 };
5944
5945 /**
5946 * Get popup alignment
5947 *
5948 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5949 * `backwards` or `forwards`.
5950 */
5951 OO.ui.PopupWidget.prototype.getAlignment = function () {
5952 return this.align;
5953 };
5954
5955 /**
5956 * Change the positioning of the popup.
5957 *
5958 * @param {string} position 'above', 'below', 'before' or 'after'
5959 */
5960 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5961 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5962 position = 'below';
5963 }
5964 this.popupPosition = position;
5965 this.position();
5966 };
5967
5968 /**
5969 * Get popup positioning.
5970 *
5971 * @return {string} 'above', 'below', 'before' or 'after'
5972 */
5973 OO.ui.PopupWidget.prototype.getPosition = function () {
5974 return this.popupPosition;
5975 };
5976
5977 /**
5978 * Set popup auto-flipping.
5979 *
5980 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5981 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5982 * desired direction to display the popup without clipping
5983 */
5984 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5985 autoFlip = !!autoFlip;
5986
5987 if ( this.autoFlip !== autoFlip ) {
5988 this.autoFlip = autoFlip;
5989 }
5990 };
5991
5992 /**
5993 * Set which elements will not close the popup when clicked.
5994 *
5995 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5996 *
5997 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5998 */
5999 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
6000 this.$autoCloseIgnore = $autoCloseIgnore;
6001 };
6002
6003 /**
6004 * Get an ID of the body element, this can be used as the
6005 * `aria-describedby` attribute for an input field.
6006 *
6007 * @return {string} The ID of the body element
6008 */
6009 OO.ui.PopupWidget.prototype.getBodyId = function () {
6010 var id = this.$body.attr( 'id' );
6011 if ( id === undefined ) {
6012 id = OO.ui.generateElementId();
6013 this.$body.attr( 'id', id );
6014 }
6015 return id;
6016 };
6017
6018 /**
6019 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
6020 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
6021 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
6022 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
6023 *
6024 * @abstract
6025 * @class
6026 *
6027 * @constructor
6028 * @param {Object} [config] Configuration options
6029 * @cfg {Object} [popup] Configuration to pass to popup
6030 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
6031 */
6032 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
6033 // Configuration initialization
6034 config = config || {};
6035
6036 // Properties
6037 this.popup = new OO.ui.PopupWidget( $.extend(
6038 {
6039 autoClose: true,
6040 $floatableContainer: this.$element
6041 },
6042 config.popup,
6043 {
6044 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
6045 }
6046 ) );
6047 };
6048
6049 /* Methods */
6050
6051 /**
6052 * Get popup.
6053 *
6054 * @return {OO.ui.PopupWidget} Popup widget
6055 */
6056 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
6057 return this.popup;
6058 };
6059
6060 /**
6061 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
6062 * which is used to display additional information or options.
6063 *
6064 * @example
6065 * // A PopupButtonWidget.
6066 * var popupButton = new OO.ui.PopupButtonWidget( {
6067 * label: 'Popup button with options',
6068 * icon: 'menu',
6069 * popup: {
6070 * $content: $( '<p>Additional options here.</p>' ),
6071 * padded: true,
6072 * align: 'force-left'
6073 * }
6074 * } );
6075 * // Append the button to the DOM.
6076 * $( document.body ).append( popupButton.$element );
6077 *
6078 * @class
6079 * @extends OO.ui.ButtonWidget
6080 * @mixins OO.ui.mixin.PopupElement
6081 *
6082 * @constructor
6083 * @param {Object} [config] Configuration options
6084 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful
6085 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
6086 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
6087 * uses relative positioning.
6088 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6089 */
6090 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6091 // Configuration initialization
6092 config = config || {};
6093
6094 // Parent constructor
6095 OO.ui.PopupButtonWidget.parent.call( this, config );
6096
6097 // Mixin constructors
6098 OO.ui.mixin.PopupElement.call( this, config );
6099
6100 // Properties
6101 this.$overlay = ( config.$overlay === true ?
6102 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6103
6104 // Events
6105 this.connect( this, {
6106 click: 'onAction'
6107 } );
6108
6109 // Initialization
6110 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6111 this.popup.$element
6112 .addClass( 'oo-ui-popupButtonWidget-popup' )
6113 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6114 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6115 this.$overlay.append( this.popup.$element );
6116 };
6117
6118 /* Setup */
6119
6120 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6121 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6122
6123 /* Methods */
6124
6125 /**
6126 * Handle the button action being triggered.
6127 *
6128 * @private
6129 */
6130 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6131 this.popup.toggle();
6132 };
6133
6134 /**
6135 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6136 *
6137 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6138 *
6139 * @private
6140 * @abstract
6141 * @class
6142 * @mixins OO.ui.mixin.GroupElement
6143 *
6144 * @constructor
6145 * @param {Object} [config] Configuration options
6146 */
6147 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6148 // Mixin constructors
6149 OO.ui.mixin.GroupElement.call( this, config );
6150 };
6151
6152 /* Setup */
6153
6154 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6155
6156 /* Methods */
6157
6158 /**
6159 * Set the disabled state of the widget.
6160 *
6161 * This will also update the disabled state of child widgets.
6162 *
6163 * @param {boolean} disabled Disable widget
6164 * @chainable
6165 * @return {OO.ui.Widget} The widget, for chaining
6166 */
6167 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6168 var i, len;
6169
6170 // Parent method
6171 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6172 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6173
6174 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6175 if ( this.items ) {
6176 for ( i = 0, len = this.items.length; i < len; i++ ) {
6177 this.items[ i ].updateDisabled();
6178 }
6179 }
6180
6181 return this;
6182 };
6183
6184 /**
6185 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6186 *
6187 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6188 * This allows bidirectional communication.
6189 *
6190 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6191 *
6192 * @private
6193 * @abstract
6194 * @class
6195 *
6196 * @constructor
6197 */
6198 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6199 //
6200 };
6201
6202 /* Methods */
6203
6204 /**
6205 * Check if widget is disabled.
6206 *
6207 * Checks parent if present, making disabled state inheritable.
6208 *
6209 * @return {boolean} Widget is disabled
6210 */
6211 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6212 return this.disabled ||
6213 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6214 };
6215
6216 /**
6217 * Set group element is in.
6218 *
6219 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6220 * @chainable
6221 * @return {OO.ui.Widget} The widget, for chaining
6222 */
6223 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6224 // Parent method
6225 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6226 OO.ui.Element.prototype.setElementGroup.call( this, group );
6227
6228 // Initialize item disabled states
6229 this.updateDisabled();
6230
6231 return this;
6232 };
6233
6234 /**
6235 * OptionWidgets are special elements that can be selected and configured with data. The
6236 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6237 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6238 * and examples, please see the [OOUI documentation on MediaWiki][1].
6239 *
6240 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6241 *
6242 * @class
6243 * @extends OO.ui.Widget
6244 * @mixins OO.ui.mixin.ItemWidget
6245 * @mixins OO.ui.mixin.LabelElement
6246 * @mixins OO.ui.mixin.FlaggedElement
6247 * @mixins OO.ui.mixin.AccessKeyedElement
6248 * @mixins OO.ui.mixin.TitledElement
6249 *
6250 * @constructor
6251 * @param {Object} [config] Configuration options
6252 */
6253 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6254 // Configuration initialization
6255 config = config || {};
6256
6257 // Parent constructor
6258 OO.ui.OptionWidget.parent.call( this, config );
6259
6260 // Mixin constructors
6261 OO.ui.mixin.ItemWidget.call( this );
6262 OO.ui.mixin.LabelElement.call( this, config );
6263 OO.ui.mixin.FlaggedElement.call( this, config );
6264 OO.ui.mixin.AccessKeyedElement.call( this, config );
6265 OO.ui.mixin.TitledElement.call( this, config );
6266
6267 // Properties
6268 this.highlighted = false;
6269 this.pressed = false;
6270 this.setSelected( !!config.selected );
6271
6272 // Initialization
6273 this.$element
6274 .data( 'oo-ui-optionWidget', this )
6275 // Allow programmatic focussing (and by access key), but not tabbing
6276 .attr( 'tabindex', '-1' )
6277 .attr( 'role', 'option' )
6278 .addClass( 'oo-ui-optionWidget' )
6279 .append( this.$label );
6280 };
6281
6282 /* Setup */
6283
6284 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6285 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6286 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6287 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6288 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6289 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6290
6291 /* Static Properties */
6292
6293 /**
6294 * Whether this option can be selected. See #setSelected.
6295 *
6296 * @static
6297 * @inheritable
6298 * @property {boolean}
6299 */
6300 OO.ui.OptionWidget.static.selectable = true;
6301
6302 /**
6303 * Whether this option can be highlighted. See #setHighlighted.
6304 *
6305 * @static
6306 * @inheritable
6307 * @property {boolean}
6308 */
6309 OO.ui.OptionWidget.static.highlightable = true;
6310
6311 /**
6312 * Whether this option can be pressed. See #setPressed.
6313 *
6314 * @static
6315 * @inheritable
6316 * @property {boolean}
6317 */
6318 OO.ui.OptionWidget.static.pressable = true;
6319
6320 /**
6321 * Whether this option will be scrolled into view when it is selected.
6322 *
6323 * @static
6324 * @inheritable
6325 * @property {boolean}
6326 */
6327 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6328
6329 /* Methods */
6330
6331 /**
6332 * Check if the option can be selected.
6333 *
6334 * @return {boolean} Item is selectable
6335 */
6336 OO.ui.OptionWidget.prototype.isSelectable = function () {
6337 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6338 };
6339
6340 /**
6341 * Check if the option can be highlighted. A highlight indicates that the option
6342 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6343 * be highlighted.
6344 *
6345 * @return {boolean} Item is highlightable
6346 */
6347 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6348 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6349 };
6350
6351 /**
6352 * Check if the option can be pressed. The pressed state occurs when a user mouses
6353 * down on an item, but has not yet let go of the mouse.
6354 *
6355 * @return {boolean} Item is pressable
6356 */
6357 OO.ui.OptionWidget.prototype.isPressable = function () {
6358 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6359 };
6360
6361 /**
6362 * Check if the option is selected.
6363 *
6364 * @return {boolean} Item is selected
6365 */
6366 OO.ui.OptionWidget.prototype.isSelected = function () {
6367 return this.selected;
6368 };
6369
6370 /**
6371 * Check if the option is highlighted. A highlight indicates that the
6372 * item may be selected when a user presses Enter key or clicks.
6373 *
6374 * @return {boolean} Item is highlighted
6375 */
6376 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6377 return this.highlighted;
6378 };
6379
6380 /**
6381 * Check if the option is pressed. The pressed state occurs when a user mouses
6382 * down on an item, but has not yet let go of the mouse. The item may appear
6383 * selected, but it will not be selected until the user releases the mouse.
6384 *
6385 * @return {boolean} Item is pressed
6386 */
6387 OO.ui.OptionWidget.prototype.isPressed = function () {
6388 return this.pressed;
6389 };
6390
6391 /**
6392 * Set the option’s selected state. In general, all modifications to the selection
6393 * should be handled by the SelectWidget’s
6394 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
6395 *
6396 * @param {boolean} [state=false] Select option
6397 * @chainable
6398 * @return {OO.ui.Widget} The widget, for chaining
6399 */
6400 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6401 if ( this.constructor.static.selectable ) {
6402 this.selected = !!state;
6403 this.$element
6404 .toggleClass( 'oo-ui-optionWidget-selected', state )
6405 .attr( 'aria-selected', state.toString() );
6406 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6407 this.scrollElementIntoView();
6408 }
6409 this.updateThemeClasses();
6410 }
6411 return this;
6412 };
6413
6414 /**
6415 * Set the option’s highlighted state. In general, all programmatic
6416 * modifications to the highlight should be handled by the
6417 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6418 * method instead of this method.
6419 *
6420 * @param {boolean} [state=false] Highlight option
6421 * @chainable
6422 * @return {OO.ui.Widget} The widget, for chaining
6423 */
6424 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6425 if ( this.constructor.static.highlightable ) {
6426 this.highlighted = !!state;
6427 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6428 this.updateThemeClasses();
6429 }
6430 return this;
6431 };
6432
6433 /**
6434 * Set the option’s pressed state. In general, all
6435 * programmatic modifications to the pressed state should be handled by the
6436 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6437 * method instead of this method.
6438 *
6439 * @param {boolean} [state=false] Press option
6440 * @chainable
6441 * @return {OO.ui.Widget} The widget, for chaining
6442 */
6443 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6444 if ( this.constructor.static.pressable ) {
6445 this.pressed = !!state;
6446 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6447 this.updateThemeClasses();
6448 }
6449 return this;
6450 };
6451
6452 /**
6453 * Get text to match search strings against.
6454 *
6455 * The default implementation returns the label text, but subclasses
6456 * can override this to provide more complex behavior.
6457 *
6458 * @return {string|boolean} String to match search string against
6459 */
6460 OO.ui.OptionWidget.prototype.getMatchText = function () {
6461 var label = this.getLabel();
6462 return typeof label === 'string' ? label : this.$label.text();
6463 };
6464
6465 /**
6466 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6467 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6468 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6469 * menu selects}.
6470 *
6471 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
6472 * more information, please see the [OOUI documentation on MediaWiki][1].
6473 *
6474 * @example
6475 * // A select widget with three options.
6476 * var select = new OO.ui.SelectWidget( {
6477 * items: [
6478 * new OO.ui.OptionWidget( {
6479 * data: 'a',
6480 * label: 'Option One',
6481 * } ),
6482 * new OO.ui.OptionWidget( {
6483 * data: 'b',
6484 * label: 'Option Two',
6485 * } ),
6486 * new OO.ui.OptionWidget( {
6487 * data: 'c',
6488 * label: 'Option Three',
6489 * } )
6490 * ]
6491 * } );
6492 * $( document.body ).append( select.$element );
6493 *
6494 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6495 *
6496 * @abstract
6497 * @class
6498 * @extends OO.ui.Widget
6499 * @mixins OO.ui.mixin.GroupWidget
6500 *
6501 * @constructor
6502 * @param {Object} [config] Configuration options
6503 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6504 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6505 * the [OOUI documentation on MediaWiki] [2] for examples.
6506 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6507 * @cfg {boolean} [multiselect] Allow for multiple selections
6508 */
6509 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6510 // Configuration initialization
6511 config = config || {};
6512
6513 // Parent constructor
6514 OO.ui.SelectWidget.parent.call( this, config );
6515
6516 // Mixin constructors
6517 OO.ui.mixin.GroupWidget.call( this, $.extend( {
6518 $group: this.$element
6519 }, config ) );
6520
6521 // Properties
6522 this.pressed = false;
6523 this.selecting = null;
6524 this.multiselect = !!config.multiselect;
6525 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6526 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6527 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6528 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6529 this.keyPressBuffer = '';
6530 this.keyPressBufferTimer = null;
6531 this.blockMouseOverEvents = 0;
6532
6533 // Events
6534 this.connect( this, {
6535 toggle: 'onToggle'
6536 } );
6537 this.$element.on( {
6538 focusin: this.onFocus.bind( this ),
6539 mousedown: this.onMouseDown.bind( this ),
6540 mouseover: this.onMouseOver.bind( this ),
6541 mouseleave: this.onMouseLeave.bind( this )
6542 } );
6543
6544 // Initialization
6545 this.$element
6546 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed' )
6547 .attr( 'role', 'listbox' );
6548 this.setFocusOwner( this.$element );
6549 if ( Array.isArray( config.items ) ) {
6550 this.addItems( config.items );
6551 }
6552 };
6553
6554 /* Setup */
6555
6556 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6557 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6558
6559 /* Events */
6560
6561 /**
6562 * @event highlight
6563 *
6564 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6565 *
6566 * @param {OO.ui.OptionWidget|null} item Highlighted item
6567 */
6568
6569 /**
6570 * @event press
6571 *
6572 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6573 * pressed state of an option.
6574 *
6575 * @param {OO.ui.OptionWidget|null} item Pressed item
6576 */
6577
6578 /**
6579 * @event select
6580 *
6581 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
6582 * method.
6583 *
6584 * @param {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} items Currently selected items
6585 */
6586
6587 /**
6588 * @event choose
6589 *
6590 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6591 *
6592 * @param {OO.ui.OptionWidget} item Chosen item
6593 * @param {boolean} selected Item is selected
6594 */
6595
6596 /**
6597 * @event add
6598 *
6599 * An `add` event is emitted when options are added to the select with the #addItems method.
6600 *
6601 * @param {OO.ui.OptionWidget[]} items Added items
6602 * @param {number} index Index of insertion point
6603 */
6604
6605 /**
6606 * @event remove
6607 *
6608 * A `remove` event is emitted when options are removed from the select with the #clearItems
6609 * or #removeItems methods.
6610 *
6611 * @param {OO.ui.OptionWidget[]} items Removed items
6612 */
6613
6614 /* Static methods */
6615
6616 /**
6617 * Normalize text for filter matching
6618 *
6619 * @param {string} text Text
6620 * @return {string} Normalized text
6621 */
6622 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
6623 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
6624 var normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
6625
6626 // Normalize Unicode
6627 // eslint-disable-next-line no-restricted-properties
6628 if ( normalized.normalize ) {
6629 // eslint-disable-next-line no-restricted-properties
6630 normalized = normalized.normalize();
6631 }
6632 return normalized;
6633 };
6634
6635 /* Methods */
6636
6637 /**
6638 * Handle focus events
6639 *
6640 * @private
6641 * @param {jQuery.Event} event
6642 */
6643 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6644 var item;
6645 if ( event.target === this.$element[ 0 ] ) {
6646 // This widget was focussed, e.g. by the user tabbing to it.
6647 // The styles for focus state depend on one of the items being selected.
6648 if ( !this.findSelectedItem() ) {
6649 item = this.findFirstSelectableItem();
6650 }
6651 } else {
6652 if ( event.target.tabIndex === -1 ) {
6653 // One of the options got focussed (and the event bubbled up here).
6654 // They can't be tabbed to, but they can be activated using access keys.
6655 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6656 item = this.findTargetItem( event );
6657 } else {
6658 // There is something actually user-focusable in one of the labels of the options, and
6659 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
6660 // the focus).
6661 return;
6662 }
6663 }
6664
6665 if ( item ) {
6666 if ( item.constructor.static.highlightable ) {
6667 this.highlightItem( item );
6668 } else {
6669 this.selectItem( item );
6670 }
6671 }
6672
6673 if ( event.target !== this.$element[ 0 ] ) {
6674 this.$focusOwner.trigger( 'focus' );
6675 }
6676 };
6677
6678 /**
6679 * Handle mouse down events.
6680 *
6681 * @private
6682 * @param {jQuery.Event} e Mouse down event
6683 * @return {undefined|boolean} False to prevent default if event is handled
6684 */
6685 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6686 var item;
6687
6688 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6689 this.togglePressed( true );
6690 item = this.findTargetItem( e );
6691 if ( item && item.isSelectable() ) {
6692 this.pressItem( item );
6693 this.selecting = item;
6694 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6695 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6696 }
6697 }
6698 return false;
6699 };
6700
6701 /**
6702 * Handle document mouse up events.
6703 *
6704 * @private
6705 * @param {MouseEvent} e Mouse up event
6706 * @return {undefined|boolean} False to prevent default if event is handled
6707 */
6708 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6709 var item;
6710
6711 this.togglePressed( false );
6712 if ( !this.selecting ) {
6713 item = this.findTargetItem( e );
6714 if ( item && item.isSelectable() ) {
6715 this.selecting = item;
6716 }
6717 }
6718 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6719 this.pressItem( null );
6720 this.chooseItem( this.selecting );
6721 this.selecting = null;
6722 }
6723
6724 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6725 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6726
6727 return false;
6728 };
6729
6730 /**
6731 * Handle document mouse move events.
6732 *
6733 * @private
6734 * @param {MouseEvent} e Mouse move event
6735 */
6736 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6737 var item;
6738
6739 if ( !this.isDisabled() && this.pressed ) {
6740 item = this.findTargetItem( e );
6741 if ( item && item !== this.selecting && item.isSelectable() ) {
6742 this.pressItem( item );
6743 this.selecting = item;
6744 }
6745 }
6746 };
6747
6748 /**
6749 * Handle mouse over events.
6750 *
6751 * @private
6752 * @param {jQuery.Event} e Mouse over event
6753 * @return {undefined|boolean} False to prevent default if event is handled
6754 */
6755 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6756 var item;
6757 if ( this.blockMouseOverEvents ) {
6758 return;
6759 }
6760 if ( !this.isDisabled() ) {
6761 item = this.findTargetItem( e );
6762 this.highlightItem( item && item.isHighlightable() ? item : null );
6763 }
6764 return false;
6765 };
6766
6767 /**
6768 * Handle mouse leave events.
6769 *
6770 * @private
6771 * @param {jQuery.Event} e Mouse over event
6772 * @return {undefined|boolean} False to prevent default if event is handled
6773 */
6774 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6775 if ( !this.isDisabled() ) {
6776 this.highlightItem( null );
6777 }
6778 return false;
6779 };
6780
6781 /**
6782 * Handle document key down events.
6783 *
6784 * @protected
6785 * @param {KeyboardEvent} e Key down event
6786 */
6787 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6788 var nextItem,
6789 handled = false,
6790 selected = this.findSelectedItems(),
6791 currentItem = this.findHighlightedItem() || (
6792 Array.isArray( selected ) ? selected[ 0 ] : selected
6793 ),
6794 firstItem = this.getItems()[ 0 ];
6795
6796 if ( !this.isDisabled() && this.isVisible() ) {
6797 switch ( e.keyCode ) {
6798 case OO.ui.Keys.ENTER:
6799 if ( currentItem ) {
6800 // Was only highlighted, now let's select it. No-op if already selected.
6801 this.chooseItem( currentItem );
6802 handled = true;
6803 }
6804 break;
6805 case OO.ui.Keys.UP:
6806 case OO.ui.Keys.LEFT:
6807 this.clearKeyPressBuffer();
6808 nextItem = currentItem ?
6809 this.findRelativeSelectableItem( currentItem, -1 ) : firstItem;
6810 handled = true;
6811 break;
6812 case OO.ui.Keys.DOWN:
6813 case OO.ui.Keys.RIGHT:
6814 this.clearKeyPressBuffer();
6815 nextItem = currentItem ?
6816 this.findRelativeSelectableItem( currentItem, 1 ) : firstItem;
6817 handled = true;
6818 break;
6819 case OO.ui.Keys.ESCAPE:
6820 case OO.ui.Keys.TAB:
6821 if ( currentItem ) {
6822 currentItem.setHighlighted( false );
6823 }
6824 this.unbindDocumentKeyDownListener();
6825 this.unbindDocumentKeyPressListener();
6826 // Don't prevent tabbing away / defocusing
6827 handled = false;
6828 break;
6829 }
6830
6831 if ( nextItem ) {
6832 if ( nextItem.constructor.static.highlightable ) {
6833 this.highlightItem( nextItem );
6834 } else {
6835 this.chooseItem( nextItem );
6836 }
6837 this.scrollItemIntoView( nextItem );
6838 }
6839
6840 if ( handled ) {
6841 e.preventDefault();
6842 e.stopPropagation();
6843 }
6844 }
6845 };
6846
6847 /**
6848 * Bind document key down listener.
6849 *
6850 * @protected
6851 */
6852 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6853 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6854 };
6855
6856 /**
6857 * Unbind document key down listener.
6858 *
6859 * @protected
6860 */
6861 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6862 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6863 };
6864
6865 /**
6866 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6867 *
6868 * @param {OO.ui.OptionWidget} item Item to scroll into view
6869 */
6870 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6871 var widget = this;
6872 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
6873 // scrolling and around 100-150 ms after it is finished.
6874 this.blockMouseOverEvents++;
6875 item.scrollElementIntoView().done( function () {
6876 setTimeout( function () {
6877 widget.blockMouseOverEvents--;
6878 }, 200 );
6879 } );
6880 };
6881
6882 /**
6883 * Clear the key-press buffer
6884 *
6885 * @protected
6886 */
6887 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6888 if ( this.keyPressBufferTimer ) {
6889 clearTimeout( this.keyPressBufferTimer );
6890 this.keyPressBufferTimer = null;
6891 }
6892 this.keyPressBuffer = '';
6893 };
6894
6895 /**
6896 * Handle key press events.
6897 *
6898 * @protected
6899 * @param {KeyboardEvent} e Key press event
6900 * @return {undefined|boolean} False to prevent default if event is handled
6901 */
6902 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6903 var c, filter, item, selected;
6904
6905 if ( !e.charCode ) {
6906 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6907 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6908 return false;
6909 }
6910 return;
6911 }
6912 // eslint-disable-next-line no-restricted-properties
6913 if ( String.fromCodePoint ) {
6914 // eslint-disable-next-line no-restricted-properties
6915 c = String.fromCodePoint( e.charCode );
6916 } else {
6917 c = String.fromCharCode( e.charCode );
6918 }
6919
6920 if ( this.keyPressBufferTimer ) {
6921 clearTimeout( this.keyPressBufferTimer );
6922 }
6923 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6924
6925 selected = this.findSelectedItems();
6926 item = this.findHighlightedItem() || (
6927 Array.isArray( selected ) ? selected[ 0 ] : selected
6928 );
6929
6930 if ( this.keyPressBuffer === c ) {
6931 // Common (if weird) special case: typing "xxxx" will cycle through all
6932 // the items beginning with "x".
6933 if ( item ) {
6934 item = this.findRelativeSelectableItem( item, 1 );
6935 }
6936 } else {
6937 this.keyPressBuffer += c;
6938 }
6939
6940 filter = this.getItemMatcher( this.keyPressBuffer, false );
6941 if ( !item || !filter( item ) ) {
6942 item = this.findRelativeSelectableItem( item, 1, filter );
6943 }
6944 if ( item ) {
6945 if ( this.isVisible() && item.constructor.static.highlightable ) {
6946 this.highlightItem( item );
6947 } else {
6948 this.chooseItem( item );
6949 }
6950 this.scrollItemIntoView( item );
6951 }
6952
6953 e.preventDefault();
6954 e.stopPropagation();
6955 };
6956
6957 /**
6958 * Get a matcher for the specific string
6959 *
6960 * @protected
6961 * @param {string} query String to match against items
6962 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
6963 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6964 */
6965 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
6966 var normalizeForMatching = this.constructor.static.normalizeForMatching,
6967 normalizedQuery = normalizeForMatching( query );
6968
6969 // Support deprecated exact=true argument
6970 if ( mode === true ) {
6971 mode = 'exact';
6972 }
6973
6974 return function ( item ) {
6975 var matchText = normalizeForMatching( item.getMatchText() );
6976
6977 if ( normalizedQuery === '' ) {
6978 // Empty string matches all, except if we are in 'exact'
6979 // mode, where it doesn't match at all
6980 return mode !== 'exact';
6981 }
6982
6983 switch ( mode ) {
6984 case 'exact':
6985 return matchText === normalizedQuery;
6986 case 'substring':
6987 return matchText.indexOf( normalizedQuery ) !== -1;
6988 // 'prefix'
6989 default:
6990 return matchText.indexOf( normalizedQuery ) === 0;
6991 }
6992 };
6993 };
6994
6995 /**
6996 * Bind document key press listener.
6997 *
6998 * @protected
6999 */
7000 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
7001 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7002 };
7003
7004 /**
7005 * Unbind document key down listener.
7006 *
7007 * If you override this, be sure to call this.clearKeyPressBuffer() from your
7008 * implementation.
7009 *
7010 * @protected
7011 */
7012 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7013 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7014 this.clearKeyPressBuffer();
7015 };
7016
7017 /**
7018 * Visibility change handler
7019 *
7020 * @protected
7021 * @param {boolean} visible
7022 */
7023 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
7024 if ( !visible ) {
7025 this.clearKeyPressBuffer();
7026 }
7027 };
7028
7029 /**
7030 * Get the closest item to a jQuery.Event.
7031 *
7032 * @private
7033 * @param {jQuery.Event} e
7034 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
7035 */
7036 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
7037 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
7038 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
7039 return null;
7040 }
7041 return $option.data( 'oo-ui-optionWidget' ) || null;
7042 };
7043
7044 /**
7045 * Find all selected items, if there are any. If the widget allows for multiselect
7046 * it will return an array of selected options. If the widget doesn't allow for
7047 * multiselect, it will return the selected option or null if no item is selected.
7048 *
7049 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7050 * then return an array of selected items (or empty array),
7051 * if the widget is not multiselect, return a single selected item, or `null`
7052 * if no item is selected
7053 */
7054 OO.ui.SelectWidget.prototype.findSelectedItems = function () {
7055 var selected = this.items.filter( function ( item ) {
7056 return item.isSelected();
7057 } );
7058
7059 return this.multiselect ?
7060 selected :
7061 selected[ 0 ] || null;
7062 };
7063
7064 /**
7065 * Find selected item.
7066 *
7067 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7068 * then return an array of selected items (or empty array),
7069 * if the widget is not multiselect, return a single selected item, or `null`
7070 * if no item is selected
7071 */
7072 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
7073 return this.findSelectedItems();
7074 };
7075
7076 /**
7077 * Find highlighted item.
7078 *
7079 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
7080 */
7081 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
7082 var i, len;
7083
7084 for ( i = 0, len = this.items.length; i < len; i++ ) {
7085 if ( this.items[ i ].isHighlighted() ) {
7086 return this.items[ i ];
7087 }
7088 }
7089 return null;
7090 };
7091
7092 /**
7093 * Toggle pressed state.
7094 *
7095 * Press is a state that occurs when a user mouses down on an item, but
7096 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7097 * until the user releases the mouse.
7098 *
7099 * @param {boolean} pressed An option is being pressed
7100 */
7101 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7102 if ( pressed === undefined ) {
7103 pressed = !this.pressed;
7104 }
7105 if ( pressed !== this.pressed ) {
7106 this.$element
7107 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7108 .toggleClass( 'oo-ui-selectWidget-unpressed', !pressed );
7109 this.pressed = pressed;
7110 }
7111 };
7112
7113 /**
7114 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7115 * and any existing highlight will be removed. The highlight is mutually exclusive.
7116 *
7117 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7118 * @fires highlight
7119 * @chainable
7120 * @return {OO.ui.Widget} The widget, for chaining
7121 */
7122 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7123 var i, len, highlighted,
7124 changed = false;
7125
7126 for ( i = 0, len = this.items.length; i < len; i++ ) {
7127 highlighted = this.items[ i ] === item;
7128 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7129 this.items[ i ].setHighlighted( highlighted );
7130 changed = true;
7131 }
7132 }
7133 if ( changed ) {
7134 if ( item ) {
7135 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7136 } else {
7137 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7138 }
7139 this.emit( 'highlight', item );
7140 }
7141
7142 return this;
7143 };
7144
7145 /**
7146 * Fetch an item by its label.
7147 *
7148 * @param {string} label Label of the item to select.
7149 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7150 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7151 */
7152 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7153 var i, item, found,
7154 len = this.items.length,
7155 filter = this.getItemMatcher( label, 'exact' );
7156
7157 for ( i = 0; i < len; i++ ) {
7158 item = this.items[ i ];
7159 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7160 return item;
7161 }
7162 }
7163
7164 if ( prefix ) {
7165 found = null;
7166 filter = this.getItemMatcher( label, 'prefix' );
7167 for ( i = 0; i < len; i++ ) {
7168 item = this.items[ i ];
7169 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7170 if ( found ) {
7171 return null;
7172 }
7173 found = item;
7174 }
7175 }
7176 if ( found ) {
7177 return found;
7178 }
7179 }
7180
7181 return null;
7182 };
7183
7184 /**
7185 * Programmatically select an option by its label. If the item does not exist,
7186 * all options will be deselected.
7187 *
7188 * @param {string} [label] Label of the item to select.
7189 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7190 * @fires select
7191 * @chainable
7192 * @return {OO.ui.Widget} The widget, for chaining
7193 */
7194 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7195 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7196 if ( label === undefined || !itemFromLabel ) {
7197 return this.selectItem();
7198 }
7199 return this.selectItem( itemFromLabel );
7200 };
7201
7202 /**
7203 * Programmatically select an option by its data. If the `data` parameter is omitted,
7204 * or if the item does not exist, all options will be deselected.
7205 *
7206 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7207 * @fires select
7208 * @chainable
7209 * @return {OO.ui.Widget} The widget, for chaining
7210 */
7211 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7212 var itemFromData = this.findItemFromData( data );
7213 if ( data === undefined || !itemFromData ) {
7214 return this.selectItem();
7215 }
7216 return this.selectItem( itemFromData );
7217 };
7218
7219 /**
7220 * Programmatically unselect an option by its reference. If the widget
7221 * allows for multiple selections, there may be other items still selected;
7222 * otherwise, no items will be selected.
7223 * If no item is given, all selected items will be unselected.
7224 *
7225 * @param {OO.ui.OptionWidget} [item] Item to unselect
7226 * @fires select
7227 * @chainable
7228 * @return {OO.ui.Widget} The widget, for chaining
7229 */
7230 OO.ui.SelectWidget.prototype.unselectItem = function ( item ) {
7231 if ( item ) {
7232 item.setSelected( false );
7233 } else {
7234 this.items.forEach( function ( item ) {
7235 item.setSelected( false );
7236 } );
7237 }
7238
7239 this.emit( 'select', this.findSelectedItems() );
7240 return this;
7241 };
7242
7243 /**
7244 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7245 * all options will be deselected.
7246 *
7247 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7248 * @fires select
7249 * @chainable
7250 * @return {OO.ui.Widget} The widget, for chaining
7251 */
7252 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7253 var i, len, selected,
7254 changed = false;
7255
7256 if ( this.multiselect && item ) {
7257 // Select the item directly
7258 item.setSelected( true );
7259 } else {
7260 for ( i = 0, len = this.items.length; i < len; i++ ) {
7261 selected = this.items[ i ] === item;
7262 if ( this.items[ i ].isSelected() !== selected ) {
7263 this.items[ i ].setSelected( selected );
7264 changed = true;
7265 }
7266 }
7267 }
7268 if ( changed ) {
7269 // TODO: When should a non-highlightable element be selected?
7270 if ( item && !item.constructor.static.highlightable ) {
7271 if ( item ) {
7272 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7273 } else {
7274 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7275 }
7276 }
7277 this.emit( 'select', this.findSelectedItems() );
7278 }
7279
7280 return this;
7281 };
7282
7283 /**
7284 * Press an item.
7285 *
7286 * Press is a state that occurs when a user mouses down on an item, but has not
7287 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7288 * releases the mouse.
7289 *
7290 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7291 * @fires press
7292 * @chainable
7293 * @return {OO.ui.Widget} The widget, for chaining
7294 */
7295 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7296 var i, len, pressed,
7297 changed = false;
7298
7299 for ( i = 0, len = this.items.length; i < len; i++ ) {
7300 pressed = this.items[ i ] === item;
7301 if ( this.items[ i ].isPressed() !== pressed ) {
7302 this.items[ i ].setPressed( pressed );
7303 changed = true;
7304 }
7305 }
7306 if ( changed ) {
7307 this.emit( 'press', item );
7308 }
7309
7310 return this;
7311 };
7312
7313 /**
7314 * Choose an item.
7315 *
7316 * Note that ‘choose’ should never be modified programmatically. A user can choose
7317 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7318 * use the #selectItem method.
7319 *
7320 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7321 * when users choose an item with the keyboard or mouse.
7322 *
7323 * @param {OO.ui.OptionWidget} item Item to choose
7324 * @fires choose
7325 * @chainable
7326 * @return {OO.ui.Widget} The widget, for chaining
7327 */
7328 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7329 if ( item ) {
7330 if ( this.multiselect && item.isSelected() ) {
7331 this.unselectItem( item );
7332 } else {
7333 this.selectItem( item );
7334 }
7335
7336 this.emit( 'choose', item, item.isSelected() );
7337 }
7338
7339 return this;
7340 };
7341
7342 /**
7343 * Find an option by its position relative to the specified item (or to the start of the option
7344 * array, if item is `null`). The direction in which to search through the option array is specified
7345 * with a number: -1 for reverse (the default) or 1 for forward. The method will return an option,
7346 * or `null` if there are no options in the array.
7347 *
7348 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
7349 * the beginning of the array.
7350 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7351 * @param {Function} [filter] Only consider items for which this function returns
7352 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7353 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7354 */
7355 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7356 var currentIndex, nextIndex, i,
7357 increase = direction > 0 ? 1 : -1,
7358 len = this.items.length;
7359
7360 if ( item instanceof OO.ui.OptionWidget ) {
7361 currentIndex = this.items.indexOf( item );
7362 nextIndex = ( currentIndex + increase + len ) % len;
7363 } else {
7364 // If no item is selected and moving forward, start at the beginning.
7365 // If moving backward, start at the end.
7366 nextIndex = direction > 0 ? 0 : len - 1;
7367 }
7368
7369 for ( i = 0; i < len; i++ ) {
7370 item = this.items[ nextIndex ];
7371 if (
7372 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7373 ( !filter || filter( item ) )
7374 ) {
7375 return item;
7376 }
7377 nextIndex = ( nextIndex + increase + len ) % len;
7378 }
7379 return null;
7380 };
7381
7382 /**
7383 * Find the next selectable item or `null` if there are no selectable items.
7384 * Disabled options and menu-section markers and breaks are not selectable.
7385 *
7386 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7387 */
7388 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7389 return this.findRelativeSelectableItem( null, 1 );
7390 };
7391
7392 /**
7393 * Add an array of options to the select. Optionally, an index number can be used to
7394 * specify an insertion point.
7395 *
7396 * @param {OO.ui.OptionWidget[]} items Items to add
7397 * @param {number} [index] Index to insert items after
7398 * @fires add
7399 * @chainable
7400 * @return {OO.ui.Widget} The widget, for chaining
7401 */
7402 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7403 // Mixin method
7404 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7405
7406 // Always provide an index, even if it was omitted
7407 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7408
7409 return this;
7410 };
7411
7412 /**
7413 * Remove the specified array of options from the select. Options will be detached
7414 * from the DOM, not removed, so they can be reused later. To remove all options from
7415 * the select, you may wish to use the #clearItems method instead.
7416 *
7417 * @param {OO.ui.OptionWidget[]} items Items to remove
7418 * @fires remove
7419 * @chainable
7420 * @return {OO.ui.Widget} The widget, for chaining
7421 */
7422 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7423 var i, len, item;
7424
7425 // Deselect items being removed
7426 for ( i = 0, len = items.length; i < len; i++ ) {
7427 item = items[ i ];
7428 if ( item.isSelected() ) {
7429 this.selectItem( null );
7430 }
7431 }
7432
7433 // Mixin method
7434 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7435
7436 this.emit( 'remove', items );
7437
7438 return this;
7439 };
7440
7441 /**
7442 * Clear all options from the select. Options will be detached from the DOM, not removed,
7443 * so that they can be reused later. To remove a subset of options from the select, use
7444 * the #removeItems method.
7445 *
7446 * @fires remove
7447 * @chainable
7448 * @return {OO.ui.Widget} The widget, for chaining
7449 */
7450 OO.ui.SelectWidget.prototype.clearItems = function () {
7451 var items = this.items.slice();
7452
7453 // Mixin method
7454 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7455
7456 // Clear selection
7457 this.selectItem( null );
7458
7459 this.emit( 'remove', items );
7460
7461 return this;
7462 };
7463
7464 /**
7465 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7466 *
7467 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7468 *
7469 * @protected
7470 * @param {jQuery} $focusOwner
7471 */
7472 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7473 this.$focusOwner = $focusOwner;
7474 };
7475
7476 /**
7477 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7478 * with an {@link OO.ui.mixin.IconElement icon} and/or
7479 * {@link OO.ui.mixin.IndicatorElement indicator}.
7480 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7481 * options. For more information about options and selects, please see the
7482 * [OOUI documentation on MediaWiki][1].
7483 *
7484 * @example
7485 * // Decorated options in a select widget.
7486 * var select = new OO.ui.SelectWidget( {
7487 * items: [
7488 * new OO.ui.DecoratedOptionWidget( {
7489 * data: 'a',
7490 * label: 'Option with icon',
7491 * icon: 'help'
7492 * } ),
7493 * new OO.ui.DecoratedOptionWidget( {
7494 * data: 'b',
7495 * label: 'Option with indicator',
7496 * indicator: 'next'
7497 * } )
7498 * ]
7499 * } );
7500 * $( document.body ).append( select.$element );
7501 *
7502 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7503 *
7504 * @class
7505 * @extends OO.ui.OptionWidget
7506 * @mixins OO.ui.mixin.IconElement
7507 * @mixins OO.ui.mixin.IndicatorElement
7508 *
7509 * @constructor
7510 * @param {Object} [config] Configuration options
7511 */
7512 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7513 // Parent constructor
7514 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7515
7516 // Mixin constructors
7517 OO.ui.mixin.IconElement.call( this, config );
7518 OO.ui.mixin.IndicatorElement.call( this, config );
7519
7520 // Initialization
7521 this.$element
7522 .addClass( 'oo-ui-decoratedOptionWidget' )
7523 .prepend( this.$icon )
7524 .append( this.$indicator );
7525 };
7526
7527 /* Setup */
7528
7529 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7530 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7531 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7532
7533 /**
7534 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7535 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7536 * the [OOUI documentation on MediaWiki] [1] for more information.
7537 *
7538 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7539 *
7540 * @class
7541 * @extends OO.ui.DecoratedOptionWidget
7542 *
7543 * @constructor
7544 * @param {Object} [config] Configuration options
7545 */
7546 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7547 // Parent constructor
7548 OO.ui.MenuOptionWidget.parent.call( this, config );
7549
7550 // Properties
7551 this.checkIcon = new OO.ui.IconWidget( {
7552 icon: 'check',
7553 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7554 } );
7555
7556 // Initialization
7557 this.$element
7558 .prepend( this.checkIcon.$element )
7559 .addClass( 'oo-ui-menuOptionWidget' );
7560 };
7561
7562 /* Setup */
7563
7564 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7565
7566 /* Static Properties */
7567
7568 /**
7569 * @static
7570 * @inheritdoc
7571 */
7572 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7573
7574 /**
7575 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
7576 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
7577 * cannot be highlighted or selected.
7578 *
7579 * @example
7580 * var dropdown = new OO.ui.DropdownWidget( {
7581 * menu: {
7582 * items: [
7583 * new OO.ui.MenuSectionOptionWidget( {
7584 * label: 'Dogs'
7585 * } ),
7586 * new OO.ui.MenuOptionWidget( {
7587 * data: 'corgi',
7588 * label: 'Welsh Corgi'
7589 * } ),
7590 * new OO.ui.MenuOptionWidget( {
7591 * data: 'poodle',
7592 * label: 'Standard Poodle'
7593 * } ),
7594 * new OO.ui.MenuSectionOptionWidget( {
7595 * label: 'Cats'
7596 * } ),
7597 * new OO.ui.MenuOptionWidget( {
7598 * data: 'lion',
7599 * label: 'Lion'
7600 * } )
7601 * ]
7602 * }
7603 * } );
7604 * $( document.body ).append( dropdown.$element );
7605 *
7606 * @class
7607 * @extends OO.ui.DecoratedOptionWidget
7608 *
7609 * @constructor
7610 * @param {Object} [config] Configuration options
7611 */
7612 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7613 // Parent constructor
7614 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7615
7616 // Initialization
7617 this.$element
7618 .addClass( 'oo-ui-menuSectionOptionWidget' )
7619 .removeAttr( 'role aria-selected' );
7620 this.selected = false;
7621 };
7622
7623 /* Setup */
7624
7625 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7626
7627 /* Static Properties */
7628
7629 /**
7630 * @static
7631 * @inheritdoc
7632 */
7633 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7634
7635 /**
7636 * @static
7637 * @inheritdoc
7638 */
7639 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7640
7641 /**
7642 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7643 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7644 * See {@link OO.ui.DropdownWidget DropdownWidget},
7645 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
7646 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7647 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7648 * and customized to be opened, closed, and displayed as needed.
7649 *
7650 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7651 * mouse outside the menu.
7652 *
7653 * Menus also have support for keyboard interaction:
7654 *
7655 * - Enter/Return key: choose and select a menu option
7656 * - Up-arrow key: highlight the previous menu option
7657 * - Down-arrow key: highlight the next menu option
7658 * - Escape key: hide the menu
7659 *
7660 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7661 *
7662 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7663 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7664 *
7665 * @class
7666 * @extends OO.ui.SelectWidget
7667 * @mixins OO.ui.mixin.ClippableElement
7668 * @mixins OO.ui.mixin.FloatableElement
7669 *
7670 * @constructor
7671 * @param {Object} [config] Configuration options
7672 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu
7673 * items that match the text the user types. This config is used by
7674 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
7675 * {@link OO.ui.mixin.LookupElement LookupElement}
7676 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7677 * the text the user types. This config is used by
7678 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7679 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks
7680 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
7681 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
7682 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
7683 * in here.
7684 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7685 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7686 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7687 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7688 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7689 * @cfg {string} [filterMode='prefix'] The mode by which the menu filters the results.
7690 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
7691 * @param {number|string} [width] Width of the menu as a number of pixels or CSS string with unit
7692 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
7693 */
7694 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7695 // Configuration initialization
7696 config = config || {};
7697
7698 // Parent constructor
7699 OO.ui.MenuSelectWidget.parent.call( this, config );
7700
7701 // Mixin constructors
7702 OO.ui.mixin.ClippableElement.call( this, $.extend( { $clippable: this.$group }, config ) );
7703 OO.ui.mixin.FloatableElement.call( this, config );
7704
7705 // Initial vertical positions other than 'center' will result in
7706 // the menu being flipped if there is not enough space in the container.
7707 // Store the original position so we know what to reset to.
7708 this.originalVerticalPosition = this.verticalPosition;
7709
7710 // Properties
7711 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7712 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7713 this.filterFromInput = !!config.filterFromInput;
7714 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7715 this.$widget = config.widget ? config.widget.$element : null;
7716 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7717 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7718 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7719 this.highlightOnFilter = !!config.highlightOnFilter;
7720 this.lastHighlightedItem = null;
7721 this.width = config.width;
7722 this.filterMode = config.filterMode;
7723
7724 // Initialization
7725 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7726 if ( config.widget ) {
7727 this.setFocusOwner( config.widget.$tabIndexed );
7728 }
7729
7730 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7731 // that reference properties not initialized at that time of parent class construction
7732 // TODO: Find a better way to handle post-constructor setup
7733 this.visible = false;
7734 this.$element.addClass( 'oo-ui-element-hidden' );
7735 this.$focusOwner.attr( 'aria-expanded', 'false' );
7736 };
7737
7738 /* Setup */
7739
7740 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7741 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7742 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7743
7744 /* Events */
7745
7746 /**
7747 * @event ready
7748 *
7749 * The menu is ready: it is visible and has been positioned and clipped.
7750 */
7751
7752 /* Static properties */
7753
7754 /**
7755 * Positions to flip to if there isn't room in the container for the
7756 * menu in a specific direction.
7757 *
7758 * @property {Object.<string,string>}
7759 */
7760 OO.ui.MenuSelectWidget.static.flippedPositions = {
7761 below: 'above',
7762 above: 'below',
7763 top: 'bottom',
7764 bottom: 'top'
7765 };
7766
7767 /* Methods */
7768
7769 /**
7770 * Handles document mouse down events.
7771 *
7772 * @protected
7773 * @param {MouseEvent} e Mouse down event
7774 */
7775 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7776 if (
7777 this.isVisible() &&
7778 !OO.ui.contains(
7779 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7780 e.target,
7781 true
7782 )
7783 ) {
7784 this.toggle( false );
7785 }
7786 };
7787
7788 /**
7789 * @inheritdoc
7790 */
7791 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7792 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7793
7794 if ( !this.isDisabled() && this.isVisible() ) {
7795 switch ( e.keyCode ) {
7796 case OO.ui.Keys.LEFT:
7797 case OO.ui.Keys.RIGHT:
7798 // Do nothing if a text field is associated, arrow keys will be handled natively
7799 if ( !this.$input ) {
7800 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7801 }
7802 break;
7803 case OO.ui.Keys.ESCAPE:
7804 case OO.ui.Keys.TAB:
7805 if ( currentItem && !this.multiselect ) {
7806 currentItem.setHighlighted( false );
7807 }
7808 this.toggle( false );
7809 // Don't prevent tabbing away, prevent defocusing
7810 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7811 e.preventDefault();
7812 e.stopPropagation();
7813 }
7814 break;
7815 default:
7816 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7817 return;
7818 }
7819 }
7820 };
7821
7822 /**
7823 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7824 * or after items were added/removed (always).
7825 *
7826 * @protected
7827 */
7828 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7829 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7830 anyVisible = false,
7831 len = this.items.length,
7832 showAll = !this.isVisible(),
7833 exactMatch = false;
7834
7835 if ( this.$input && this.filterFromInput ) {
7836 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
7837 exactFilter = this.getItemMatcher( this.$input.val(), 'exact' );
7838 // Hide non-matching options, and also hide section headers if all options
7839 // in their section are hidden.
7840 for ( i = 0; i < len; i++ ) {
7841 item = this.items[ i ];
7842 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7843 if ( section ) {
7844 // If the previous section was empty, hide its header
7845 section.toggle( showAll || !sectionEmpty );
7846 }
7847 section = item;
7848 sectionEmpty = true;
7849 } else if ( item instanceof OO.ui.OptionWidget ) {
7850 visible = showAll || filter( item );
7851 exactMatch = exactMatch || exactFilter( item );
7852 anyVisible = anyVisible || visible;
7853 sectionEmpty = sectionEmpty && !visible;
7854 item.toggle( visible );
7855 }
7856 }
7857 // Process the final section
7858 if ( section ) {
7859 section.toggle( showAll || !sectionEmpty );
7860 }
7861
7862 if ( !anyVisible ) {
7863 this.highlightItem( null );
7864 }
7865
7866 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7867
7868 if (
7869 this.highlightOnFilter &&
7870 !( this.lastHighlightedItem && this.lastHighlightedItem.isVisible() )
7871 ) {
7872 // Highlight the first item on the list
7873 item = null;
7874 items = this.getItems();
7875 for ( i = 0; i < items.length; i++ ) {
7876 if ( items[ i ].isVisible() ) {
7877 item = items[ i ];
7878 break;
7879 }
7880 }
7881 this.highlightItem( item );
7882 this.lastHighlightedItem = item;
7883 }
7884
7885 }
7886
7887 // Reevaluate clipping
7888 this.clip();
7889 };
7890
7891 /**
7892 * @inheritdoc
7893 */
7894 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7895 if ( this.$input ) {
7896 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7897 } else {
7898 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7899 }
7900 };
7901
7902 /**
7903 * @inheritdoc
7904 */
7905 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7906 if ( this.$input ) {
7907 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7908 } else {
7909 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7910 }
7911 };
7912
7913 /**
7914 * @inheritdoc
7915 */
7916 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7917 if ( this.$input ) {
7918 if ( this.filterFromInput ) {
7919 this.$input.on(
7920 'keydown mouseup cut paste change input select',
7921 this.onInputEditHandler
7922 );
7923 this.updateItemVisibility();
7924 }
7925 } else {
7926 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7927 }
7928 };
7929
7930 /**
7931 * @inheritdoc
7932 */
7933 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7934 if ( this.$input ) {
7935 if ( this.filterFromInput ) {
7936 this.$input.off(
7937 'keydown mouseup cut paste change input select',
7938 this.onInputEditHandler
7939 );
7940 this.updateItemVisibility();
7941 }
7942 } else {
7943 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7944 }
7945 };
7946
7947 /**
7948 * Choose an item.
7949 *
7950 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
7951 * set to false.
7952 *
7953 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
7954 * the keyboard or mouse and it becomes selected. To select an item programmatically,
7955 * use the #selectItem method.
7956 *
7957 * @param {OO.ui.OptionWidget} item Item to choose
7958 * @chainable
7959 * @return {OO.ui.Widget} The widget, for chaining
7960 */
7961 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7962 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7963 if ( this.hideOnChoose ) {
7964 this.toggle( false );
7965 }
7966 return this;
7967 };
7968
7969 /**
7970 * @inheritdoc
7971 */
7972 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7973 // Parent method
7974 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7975
7976 this.updateItemVisibility();
7977
7978 return this;
7979 };
7980
7981 /**
7982 * @inheritdoc
7983 */
7984 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7985 // Parent method
7986 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7987
7988 this.updateItemVisibility();
7989
7990 return this;
7991 };
7992
7993 /**
7994 * @inheritdoc
7995 */
7996 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7997 // Parent method
7998 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7999
8000 this.updateItemVisibility();
8001
8002 return this;
8003 };
8004
8005 /**
8006 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
8007 * `.toggle( true )` after its #$element is attached to the DOM.
8008 *
8009 * Do not show the menu while it is not attached to the DOM. The calculations required to display
8010 * it in the right place and with the right dimensions only work correctly while it is attached.
8011 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
8012 * strictly enforced, so currently it only generates a warning in the browser console.
8013 *
8014 * @fires ready
8015 * @inheritdoc
8016 */
8017 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
8018 var change, originalHeight, flippedHeight, selectedItem;
8019
8020 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
8021 change = visible !== this.isVisible();
8022
8023 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
8024 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
8025 this.warnedUnattached = true;
8026 }
8027
8028 if ( change && visible ) {
8029 // Reset position before showing the popup again. It's possible we no longer need to flip
8030 // (e.g. if the user scrolled).
8031 this.setVerticalPosition( this.originalVerticalPosition );
8032 }
8033
8034 // Parent method
8035 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
8036
8037 if ( change ) {
8038 if ( visible ) {
8039
8040 if ( this.width ) {
8041 this.setIdealSize( this.width );
8042 } else if ( this.$floatableContainer ) {
8043 this.$clippable.css( 'width', 'auto' );
8044 this.setIdealSize(
8045 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
8046 // Dropdown is smaller than handle so expand to width
8047 this.$floatableContainer[ 0 ].offsetWidth :
8048 // Dropdown is larger than handle so auto size
8049 'auto'
8050 );
8051 this.$clippable.css( 'width', '' );
8052 }
8053
8054 this.togglePositioning( !!this.$floatableContainer );
8055 this.toggleClipping( true );
8056
8057 this.bindDocumentKeyDownListener();
8058 this.bindDocumentKeyPressListener();
8059
8060 if (
8061 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
8062 this.originalVerticalPosition !== 'center'
8063 ) {
8064 // If opening the menu in one direction causes it to be clipped, flip it
8065 originalHeight = this.$element.height();
8066 this.setVerticalPosition(
8067 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
8068 );
8069 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
8070 // If flipping also causes it to be clipped, open in whichever direction
8071 // we have more space
8072 flippedHeight = this.$element.height();
8073 if ( originalHeight > flippedHeight ) {
8074 this.setVerticalPosition( this.originalVerticalPosition );
8075 }
8076 }
8077 }
8078 // Note that we do not flip the menu's opening direction if the clipping changes
8079 // later (e.g. after the user scrolls), that seems like it would be annoying
8080
8081 this.$focusOwner.attr( 'aria-expanded', 'true' );
8082
8083 selectedItem = this.findSelectedItem();
8084 if ( !this.multiselect && selectedItem ) {
8085 // TODO: Verify if this is even needed; This is already done on highlight changes
8086 // in SelectWidget#highlightItem, so we should just need to highlight the item
8087 // we need to highlight here and not bother with attr or checking selections.
8088 this.$focusOwner.attr( 'aria-activedescendant', selectedItem.getElementId() );
8089 selectedItem.scrollElementIntoView( { duration: 0 } );
8090 }
8091
8092 // Auto-hide
8093 if ( this.autoHide ) {
8094 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8095 }
8096
8097 this.emit( 'ready' );
8098 } else {
8099 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8100 this.unbindDocumentKeyDownListener();
8101 this.unbindDocumentKeyPressListener();
8102 this.$focusOwner.attr( 'aria-expanded', 'false' );
8103 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8104 this.togglePositioning( false );
8105 this.toggleClipping( false );
8106 this.lastHighlightedItem = null;
8107 }
8108 }
8109
8110 return this;
8111 };
8112
8113 /**
8114 * Scroll to the top of the menu
8115 */
8116 OO.ui.MenuSelectWidget.prototype.scrollToTop = function () {
8117 this.$element.scrollTop( 0 );
8118 };
8119
8120 /**
8121 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8122 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8123 * users can interact with it.
8124 *
8125 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8126 * OO.ui.DropdownInputWidget instead.
8127 *
8128 * @example
8129 * // A DropdownWidget with a menu that contains three options.
8130 * var dropDown = new OO.ui.DropdownWidget( {
8131 * label: 'Dropdown menu: Select a menu option',
8132 * menu: {
8133 * items: [
8134 * new OO.ui.MenuOptionWidget( {
8135 * data: 'a',
8136 * label: 'First'
8137 * } ),
8138 * new OO.ui.MenuOptionWidget( {
8139 * data: 'b',
8140 * label: 'Second'
8141 * } ),
8142 * new OO.ui.MenuOptionWidget( {
8143 * data: 'c',
8144 * label: 'Third'
8145 * } )
8146 * ]
8147 * }
8148 * } );
8149 *
8150 * $( document.body ).append( dropDown.$element );
8151 *
8152 * dropDown.getMenu().selectItemByData( 'b' );
8153 *
8154 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8155 *
8156 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8157 *
8158 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8159 *
8160 * @class
8161 * @extends OO.ui.Widget
8162 * @mixins OO.ui.mixin.IconElement
8163 * @mixins OO.ui.mixin.IndicatorElement
8164 * @mixins OO.ui.mixin.LabelElement
8165 * @mixins OO.ui.mixin.TitledElement
8166 * @mixins OO.ui.mixin.TabIndexedElement
8167 *
8168 * @constructor
8169 * @param {Object} [config] Configuration options
8170 * @cfg {Object} [menu] Configuration options to pass to
8171 * {@link OO.ui.MenuSelectWidget menu select widget}.
8172 * @cfg {jQuery|boolean} [$overlay] Render the menu into a separate layer. This configuration is
8173 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
8174 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
8175 * the menu uses relative positioning. Pass 'true' to use the default overlay.
8176 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8177 */
8178 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8179 // Configuration initialization
8180 config = $.extend( { indicator: 'down' }, config );
8181
8182 // Parent constructor
8183 OO.ui.DropdownWidget.parent.call( this, config );
8184
8185 // Properties (must be set before TabIndexedElement constructor call)
8186 this.$handle = $( '<span>' );
8187 this.$overlay = ( config.$overlay === true ?
8188 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8189
8190 // Mixin constructors
8191 OO.ui.mixin.IconElement.call( this, config );
8192 OO.ui.mixin.IndicatorElement.call( this, config );
8193 OO.ui.mixin.LabelElement.call( this, config );
8194 OO.ui.mixin.TitledElement.call( this, $.extend( {
8195 $titled: this.$label
8196 }, config ) );
8197 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
8198 $tabIndexed: this.$handle
8199 }, config ) );
8200
8201 // Properties
8202 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8203 widget: this,
8204 $floatableContainer: this.$element
8205 }, config.menu ) );
8206
8207 // Events
8208 this.$handle.on( {
8209 click: this.onClick.bind( this ),
8210 keydown: this.onKeyDown.bind( this ),
8211 // Hack? Handle type-to-search when menu is not expanded and not handling its own events.
8212 keypress: this.menu.onDocumentKeyPressHandler,
8213 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8214 } );
8215 this.menu.connect( this, {
8216 select: 'onMenuSelect',
8217 toggle: 'onMenuToggle'
8218 } );
8219
8220 // Initialization
8221 this.$label
8222 .attr( {
8223 role: 'textbox',
8224 'aria-readonly': 'true'
8225 } );
8226 this.$handle
8227 .addClass( 'oo-ui-dropdownWidget-handle' )
8228 .append( this.$icon, this.$label, this.$indicator )
8229 .attr( {
8230 role: 'combobox',
8231 'aria-autocomplete': 'list',
8232 'aria-expanded': 'false',
8233 'aria-haspopup': 'true',
8234 'aria-owns': this.menu.getElementId()
8235 } );
8236 this.$element
8237 .addClass( 'oo-ui-dropdownWidget' )
8238 .append( this.$handle );
8239 this.$overlay.append( this.menu.$element );
8240 };
8241
8242 /* Setup */
8243
8244 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8245 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8246 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8247 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8248 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8249 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8250
8251 /* Methods */
8252
8253 /**
8254 * Get the menu.
8255 *
8256 * @return {OO.ui.MenuSelectWidget} Menu of widget
8257 */
8258 OO.ui.DropdownWidget.prototype.getMenu = function () {
8259 return this.menu;
8260 };
8261
8262 /**
8263 * Handles menu select events.
8264 *
8265 * @private
8266 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8267 */
8268 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8269 var selectedLabel;
8270
8271 if ( !item ) {
8272 this.setLabel( null );
8273 return;
8274 }
8275
8276 selectedLabel = item.getLabel();
8277
8278 // If the label is a DOM element, clone it, because setLabel will append() it
8279 if ( selectedLabel instanceof $ ) {
8280 selectedLabel = selectedLabel.clone();
8281 }
8282
8283 this.setLabel( selectedLabel );
8284 };
8285
8286 /**
8287 * Handle menu toggle events.
8288 *
8289 * @private
8290 * @param {boolean} isVisible Open state of the menu
8291 */
8292 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8293 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8294 };
8295
8296 /**
8297 * Handle mouse click events.
8298 *
8299 * @private
8300 * @param {jQuery.Event} e Mouse click event
8301 * @return {undefined|boolean} False to prevent default if event is handled
8302 */
8303 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8304 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8305 this.menu.toggle();
8306 }
8307 return false;
8308 };
8309
8310 /**
8311 * Handle key down events.
8312 *
8313 * @private
8314 * @param {jQuery.Event} e Key down event
8315 * @return {undefined|boolean} False to prevent default if event is handled
8316 */
8317 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8318 if (
8319 !this.isDisabled() &&
8320 (
8321 e.which === OO.ui.Keys.ENTER ||
8322 (
8323 e.which === OO.ui.Keys.SPACE &&
8324 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8325 // Space only closes the menu is the user is not typing to search.
8326 this.menu.keyPressBuffer === ''
8327 ) ||
8328 (
8329 !this.menu.isVisible() &&
8330 (
8331 e.which === OO.ui.Keys.UP ||
8332 e.which === OO.ui.Keys.DOWN
8333 )
8334 )
8335 )
8336 ) {
8337 this.menu.toggle();
8338 return false;
8339 }
8340 };
8341
8342 /**
8343 * RadioOptionWidget is an option widget that looks like a radio button.
8344 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8345 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8346 *
8347 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8348 *
8349 * @class
8350 * @extends OO.ui.OptionWidget
8351 *
8352 * @constructor
8353 * @param {Object} [config] Configuration options
8354 */
8355 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8356 // Configuration initialization
8357 config = config || {};
8358
8359 // Properties (must be done before parent constructor which calls #setDisabled)
8360 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8361
8362 // Parent constructor
8363 OO.ui.RadioOptionWidget.parent.call( this, config );
8364
8365 // Initialization
8366 // Remove implicit role, we're handling it ourselves
8367 this.radio.$input.attr( 'role', 'presentation' );
8368 this.$element
8369 .addClass( 'oo-ui-radioOptionWidget' )
8370 .attr( 'role', 'radio' )
8371 .attr( 'aria-checked', 'false' )
8372 .removeAttr( 'aria-selected' )
8373 .prepend( this.radio.$element );
8374 };
8375
8376 /* Setup */
8377
8378 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8379
8380 /* Static Properties */
8381
8382 /**
8383 * @static
8384 * @inheritdoc
8385 */
8386 OO.ui.RadioOptionWidget.static.highlightable = false;
8387
8388 /**
8389 * @static
8390 * @inheritdoc
8391 */
8392 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8393
8394 /**
8395 * @static
8396 * @inheritdoc
8397 */
8398 OO.ui.RadioOptionWidget.static.pressable = false;
8399
8400 /**
8401 * @static
8402 * @inheritdoc
8403 */
8404 OO.ui.RadioOptionWidget.static.tagName = 'label';
8405
8406 /* Methods */
8407
8408 /**
8409 * @inheritdoc
8410 */
8411 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8412 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8413
8414 this.radio.setSelected( state );
8415 this.$element
8416 .attr( 'aria-checked', state.toString() )
8417 .removeAttr( 'aria-selected' );
8418
8419 return this;
8420 };
8421
8422 /**
8423 * @inheritdoc
8424 */
8425 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8426 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8427
8428 this.radio.setDisabled( this.isDisabled() );
8429
8430 return this;
8431 };
8432
8433 /**
8434 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8435 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8436 * an interface for adding, removing and selecting options.
8437 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8438 *
8439 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8440 * OO.ui.RadioSelectInputWidget instead.
8441 *
8442 * @example
8443 * // A RadioSelectWidget with RadioOptions.
8444 * var option1 = new OO.ui.RadioOptionWidget( {
8445 * data: 'a',
8446 * label: 'Selected radio option'
8447 * } ),
8448 * option2 = new OO.ui.RadioOptionWidget( {
8449 * data: 'b',
8450 * label: 'Unselected radio option'
8451 * } );
8452 * radioSelect = new OO.ui.RadioSelectWidget( {
8453 * items: [ option1, option2 ]
8454 * } );
8455 *
8456 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8457 * radioSelect.selectItem( option1 );
8458 *
8459 * $( document.body ).append( radioSelect.$element );
8460 *
8461 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8462
8463 *
8464 * @class
8465 * @extends OO.ui.SelectWidget
8466 * @mixins OO.ui.mixin.TabIndexedElement
8467 *
8468 * @constructor
8469 * @param {Object} [config] Configuration options
8470 */
8471 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8472 // Parent constructor
8473 OO.ui.RadioSelectWidget.parent.call( this, config );
8474
8475 // Mixin constructors
8476 OO.ui.mixin.TabIndexedElement.call( this, config );
8477
8478 // Events
8479 this.$element.on( {
8480 focus: this.bindDocumentKeyDownListener.bind( this ),
8481 blur: this.unbindDocumentKeyDownListener.bind( this )
8482 } );
8483
8484 // Initialization
8485 this.$element
8486 .addClass( 'oo-ui-radioSelectWidget' )
8487 .attr( 'role', 'radiogroup' );
8488 };
8489
8490 /* Setup */
8491
8492 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8493 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8494
8495 /**
8496 * MultioptionWidgets are special elements that can be selected and configured with data. The
8497 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8498 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8499 * and examples, please see the [OOUI documentation on MediaWiki][1].
8500 *
8501 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8502 *
8503 * @class
8504 * @extends OO.ui.Widget
8505 * @mixins OO.ui.mixin.ItemWidget
8506 * @mixins OO.ui.mixin.LabelElement
8507 * @mixins OO.ui.mixin.TitledElement
8508 *
8509 * @constructor
8510 * @param {Object} [config] Configuration options
8511 * @cfg {boolean} [selected=false] Whether the option is initially selected
8512 */
8513 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8514 // Configuration initialization
8515 config = config || {};
8516
8517 // Parent constructor
8518 OO.ui.MultioptionWidget.parent.call( this, config );
8519
8520 // Mixin constructors
8521 OO.ui.mixin.ItemWidget.call( this );
8522 OO.ui.mixin.LabelElement.call( this, config );
8523 OO.ui.mixin.TitledElement.call( this, config );
8524
8525 // Properties
8526 this.selected = null;
8527
8528 // Initialization
8529 this.$element
8530 .addClass( 'oo-ui-multioptionWidget' )
8531 .append( this.$label );
8532 this.setSelected( config.selected );
8533 };
8534
8535 /* Setup */
8536
8537 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8538 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8539 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8540 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8541
8542 /* Events */
8543
8544 /**
8545 * @event change
8546 *
8547 * A change event is emitted when the selected state of the option changes.
8548 *
8549 * @param {boolean} selected Whether the option is now selected
8550 */
8551
8552 /* Methods */
8553
8554 /**
8555 * Check if the option is selected.
8556 *
8557 * @return {boolean} Item is selected
8558 */
8559 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8560 return this.selected;
8561 };
8562
8563 /**
8564 * Set the option’s selected state. In general, all modifications to the selection
8565 * should be handled by the SelectWidget’s
8566 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
8567 *
8568 * @param {boolean} [state=false] Select option
8569 * @chainable
8570 * @return {OO.ui.Widget} The widget, for chaining
8571 */
8572 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8573 state = !!state;
8574 if ( this.selected !== state ) {
8575 this.selected = state;
8576 this.emit( 'change', state );
8577 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8578 }
8579 return this;
8580 };
8581
8582 /**
8583 * MultiselectWidget allows selecting multiple options from a list.
8584 *
8585 * For more information about menus and options, please see the [OOUI documentation
8586 * on MediaWiki][1].
8587 *
8588 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8589 *
8590 * @class
8591 * @abstract
8592 * @extends OO.ui.Widget
8593 * @mixins OO.ui.mixin.GroupWidget
8594 * @mixins OO.ui.mixin.TitledElement
8595 *
8596 * @constructor
8597 * @param {Object} [config] Configuration options
8598 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8599 */
8600 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8601 // Parent constructor
8602 OO.ui.MultiselectWidget.parent.call( this, config );
8603
8604 // Configuration initialization
8605 config = config || {};
8606
8607 // Mixin constructors
8608 OO.ui.mixin.GroupWidget.call( this, config );
8609 OO.ui.mixin.TitledElement.call( this, config );
8610
8611 // Events
8612 this.aggregate( {
8613 change: 'select'
8614 } );
8615 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8616 // by GroupElement only when items are added/removed
8617 this.connect( this, {
8618 select: [ 'emit', 'change' ]
8619 } );
8620
8621 // Initialization
8622 if ( config.items ) {
8623 this.addItems( config.items );
8624 }
8625 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8626 this.$element.addClass( 'oo-ui-multiselectWidget' )
8627 .append( this.$group );
8628 };
8629
8630 /* Setup */
8631
8632 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8633 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8634 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8635
8636 /* Events */
8637
8638 /**
8639 * @event change
8640 *
8641 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8642 */
8643
8644 /**
8645 * @event select
8646 *
8647 * A select event is emitted when an item is selected or deselected.
8648 */
8649
8650 /* Methods */
8651
8652 /**
8653 * Find options that are selected.
8654 *
8655 * @return {OO.ui.MultioptionWidget[]} Selected options
8656 */
8657 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8658 return this.items.filter( function ( item ) {
8659 return item.isSelected();
8660 } );
8661 };
8662
8663 /**
8664 * Find the data of options that are selected.
8665 *
8666 * @return {Object[]|string[]} Values of selected options
8667 */
8668 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8669 return this.findSelectedItems().map( function ( item ) {
8670 return item.data;
8671 } );
8672 };
8673
8674 /**
8675 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8676 *
8677 * @param {OO.ui.MultioptionWidget[]} items Items to select
8678 * @chainable
8679 * @return {OO.ui.Widget} The widget, for chaining
8680 */
8681 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8682 this.items.forEach( function ( item ) {
8683 var selected = items.indexOf( item ) !== -1;
8684 item.setSelected( selected );
8685 } );
8686 return this;
8687 };
8688
8689 /**
8690 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8691 *
8692 * @param {Object[]|string[]} datas Values of items to select
8693 * @chainable
8694 * @return {OO.ui.Widget} The widget, for chaining
8695 */
8696 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8697 var items,
8698 widget = this;
8699 items = datas.map( function ( data ) {
8700 return widget.findItemFromData( data );
8701 } );
8702 this.selectItems( items );
8703 return this;
8704 };
8705
8706 /**
8707 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8708 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8709 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8710 *
8711 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8712 *
8713 * @class
8714 * @extends OO.ui.MultioptionWidget
8715 *
8716 * @constructor
8717 * @param {Object} [config] Configuration options
8718 */
8719 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8720 // Configuration initialization
8721 config = config || {};
8722
8723 // Properties (must be done before parent constructor which calls #setDisabled)
8724 this.checkbox = new OO.ui.CheckboxInputWidget();
8725
8726 // Parent constructor
8727 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8728
8729 // Events
8730 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8731 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8732
8733 // Initialization
8734 this.$element
8735 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8736 .prepend( this.checkbox.$element );
8737 };
8738
8739 /* Setup */
8740
8741 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8742
8743 /* Static Properties */
8744
8745 /**
8746 * @static
8747 * @inheritdoc
8748 */
8749 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8750
8751 /* Methods */
8752
8753 /**
8754 * Handle checkbox selected state change.
8755 *
8756 * @private
8757 */
8758 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8759 this.setSelected( this.checkbox.isSelected() );
8760 };
8761
8762 /**
8763 * @inheritdoc
8764 */
8765 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8766 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8767 this.checkbox.setSelected( state );
8768 return this;
8769 };
8770
8771 /**
8772 * @inheritdoc
8773 */
8774 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8775 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8776 this.checkbox.setDisabled( this.isDisabled() );
8777 return this;
8778 };
8779
8780 /**
8781 * Focus the widget.
8782 */
8783 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8784 this.checkbox.focus();
8785 };
8786
8787 /**
8788 * Handle key down events.
8789 *
8790 * @protected
8791 * @param {jQuery.Event} e
8792 */
8793 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8794 var
8795 element = this.getElementGroup(),
8796 nextItem;
8797
8798 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8799 nextItem = element.getRelativeFocusableItem( this, -1 );
8800 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8801 nextItem = element.getRelativeFocusableItem( this, 1 );
8802 }
8803
8804 if ( nextItem ) {
8805 e.preventDefault();
8806 nextItem.focus();
8807 }
8808 };
8809
8810 /**
8811 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8812 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8813 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8814 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8815 *
8816 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8817 * OO.ui.CheckboxMultiselectInputWidget instead.
8818 *
8819 * @example
8820 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8821 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8822 * data: 'a',
8823 * selected: true,
8824 * label: 'Selected checkbox'
8825 * } ),
8826 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8827 * data: 'b',
8828 * label: 'Unselected checkbox'
8829 * } ),
8830 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8831 * items: [ option1, option2 ]
8832 * } );
8833 * $( document.body ).append( multiselect.$element );
8834 *
8835 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8836 *
8837 * @class
8838 * @extends OO.ui.MultiselectWidget
8839 *
8840 * @constructor
8841 * @param {Object} [config] Configuration options
8842 */
8843 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8844 // Parent constructor
8845 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8846
8847 // Properties
8848 this.$lastClicked = null;
8849
8850 // Events
8851 this.$group.on( 'click', this.onClick.bind( this ) );
8852
8853 // Initialization
8854 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
8855 };
8856
8857 /* Setup */
8858
8859 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8860
8861 /* Methods */
8862
8863 /**
8864 * Get an option by its position relative to the specified item (or to the start of the
8865 * option array, if item is `null`). The direction in which to search through the option array
8866 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
8867 * return an option, or `null` if there are no options in the array.
8868 *
8869 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
8870 * `null` to start at the beginning of the array.
8871 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8872 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
8873 * in the select.
8874 */
8875 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8876 var currentIndex, nextIndex, i,
8877 increase = direction > 0 ? 1 : -1,
8878 len = this.items.length;
8879
8880 if ( item ) {
8881 currentIndex = this.items.indexOf( item );
8882 nextIndex = ( currentIndex + increase + len ) % len;
8883 } else {
8884 // If no item is selected and moving forward, start at the beginning.
8885 // If moving backward, start at the end.
8886 nextIndex = direction > 0 ? 0 : len - 1;
8887 }
8888
8889 for ( i = 0; i < len; i++ ) {
8890 item = this.items[ nextIndex ];
8891 if ( item && !item.isDisabled() ) {
8892 return item;
8893 }
8894 nextIndex = ( nextIndex + increase + len ) % len;
8895 }
8896 return null;
8897 };
8898
8899 /**
8900 * Handle click events on checkboxes.
8901 *
8902 * @param {jQuery.Event} e
8903 */
8904 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8905 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8906 $lastClicked = this.$lastClicked,
8907 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8908 .not( '.oo-ui-widget-disabled' );
8909
8910 // Allow selecting multiple options at once by Shift-clicking them
8911 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8912 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8913 lastClickedIndex = $options.index( $lastClicked );
8914 nowClickedIndex = $options.index( $nowClicked );
8915 // If it's the same item, either the user is being silly, or it's a fake event generated
8916 // by the browser. In either case we don't need custom handling.
8917 if ( nowClickedIndex !== lastClickedIndex ) {
8918 items = this.items;
8919 wasSelected = items[ nowClickedIndex ].isSelected();
8920 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8921
8922 // This depends on the DOM order of the items and the order of the .items array being
8923 // the same.
8924 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8925 if ( !items[ i ].isDisabled() ) {
8926 items[ i ].setSelected( !wasSelected );
8927 }
8928 }
8929 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8930 // handling first, then set our value. The order in which events happen is different for
8931 // clicks on the <input> and on the <label> and there are additional fake clicks fired
8932 // for non-click actions that change the checkboxes.
8933 e.preventDefault();
8934 setTimeout( function () {
8935 if ( !items[ nowClickedIndex ].isDisabled() ) {
8936 items[ nowClickedIndex ].setSelected( !wasSelected );
8937 }
8938 } );
8939 }
8940 }
8941
8942 if ( $nowClicked.length ) {
8943 this.$lastClicked = $nowClicked;
8944 }
8945 };
8946
8947 /**
8948 * Focus the widget
8949 *
8950 * @chainable
8951 * @return {OO.ui.Widget} The widget, for chaining
8952 */
8953 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8954 var item;
8955 if ( !this.isDisabled() ) {
8956 item = this.getRelativeFocusableItem( null, 1 );
8957 if ( item ) {
8958 item.focus();
8959 }
8960 }
8961 return this;
8962 };
8963
8964 /**
8965 * @inheritdoc
8966 */
8967 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8968 this.focus();
8969 };
8970
8971 /**
8972 * Progress bars visually display the status of an operation, such as a download,
8973 * and can be either determinate or indeterminate:
8974 *
8975 * - **determinate** process bars show the percent of an operation that is complete.
8976 *
8977 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8978 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8979 * not use percentages.
8980 *
8981 * The value of the `progress` configuration determines whether the bar is determinate
8982 * or indeterminate.
8983 *
8984 * @example
8985 * // Examples of determinate and indeterminate progress bars.
8986 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8987 * progress: 33
8988 * } );
8989 * var progressBar2 = new OO.ui.ProgressBarWidget();
8990 *
8991 * // Create a FieldsetLayout to layout progress bars.
8992 * var fieldset = new OO.ui.FieldsetLayout;
8993 * fieldset.addItems( [
8994 * new OO.ui.FieldLayout( progressBar1, {
8995 * label: 'Determinate',
8996 * align: 'top'
8997 * } ),
8998 * new OO.ui.FieldLayout( progressBar2, {
8999 * label: 'Indeterminate',
9000 * align: 'top'
9001 * } )
9002 * ] );
9003 * $( document.body ).append( fieldset.$element );
9004 *
9005 * @class
9006 * @extends OO.ui.Widget
9007 *
9008 * @constructor
9009 * @param {Object} [config] Configuration options
9010 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
9011 * To create a determinate progress bar, specify a number that reflects the initial
9012 * percent complete.
9013 * By default, the progress bar is indeterminate.
9014 */
9015 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
9016 // Configuration initialization
9017 config = config || {};
9018
9019 // Parent constructor
9020 OO.ui.ProgressBarWidget.parent.call( this, config );
9021
9022 // Properties
9023 this.$bar = $( '<div>' );
9024 this.progress = null;
9025
9026 // Initialization
9027 this.setProgress( config.progress !== undefined ? config.progress : false );
9028 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
9029 this.$element
9030 .attr( {
9031 role: 'progressbar',
9032 'aria-valuemin': 0,
9033 'aria-valuemax': 100
9034 } )
9035 .addClass( 'oo-ui-progressBarWidget' )
9036 .append( this.$bar );
9037 };
9038
9039 /* Setup */
9040
9041 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
9042
9043 /* Static Properties */
9044
9045 /**
9046 * @static
9047 * @inheritdoc
9048 */
9049 OO.ui.ProgressBarWidget.static.tagName = 'div';
9050
9051 /* Methods */
9052
9053 /**
9054 * Get the percent of the progress that has been completed. Indeterminate progresses will
9055 * return `false`.
9056 *
9057 * @return {number|boolean} Progress percent
9058 */
9059 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
9060 return this.progress;
9061 };
9062
9063 /**
9064 * Set the percent of the process completed or `false` for an indeterminate process.
9065 *
9066 * @param {number|boolean} progress Progress percent or `false` for indeterminate
9067 */
9068 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
9069 this.progress = progress;
9070
9071 if ( progress !== false ) {
9072 this.$bar.css( 'width', this.progress + '%' );
9073 this.$element.attr( 'aria-valuenow', this.progress );
9074 } else {
9075 this.$bar.css( 'width', '' );
9076 this.$element.removeAttr( 'aria-valuenow' );
9077 }
9078 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
9079 };
9080
9081 /**
9082 * InputWidget is the base class for all input widgets, which
9083 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
9084 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
9085 * {@link OO.ui.ButtonInputWidget button inputs}.
9086 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
9087 *
9088 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9089 *
9090 * @abstract
9091 * @class
9092 * @extends OO.ui.Widget
9093 * @mixins OO.ui.mixin.TabIndexedElement
9094 * @mixins OO.ui.mixin.TitledElement
9095 * @mixins OO.ui.mixin.AccessKeyedElement
9096 *
9097 * @constructor
9098 * @param {Object} [config] Configuration options
9099 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9100 * @cfg {string} [value=''] The value of the input.
9101 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
9102 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
9103 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the
9104 * value of an input before it is accepted.
9105 */
9106 OO.ui.InputWidget = function OoUiInputWidget( config ) {
9107 // Configuration initialization
9108 config = config || {};
9109
9110 // Parent constructor
9111 OO.ui.InputWidget.parent.call( this, config );
9112
9113 // Properties
9114 // See #reusePreInfuseDOM about config.$input
9115 this.$input = config.$input || this.getInputElement( config );
9116 this.value = '';
9117 this.inputFilter = config.inputFilter;
9118
9119 // Mixin constructors
9120 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
9121 $tabIndexed: this.$input
9122 }, config ) );
9123 OO.ui.mixin.TitledElement.call( this, $.extend( {
9124 $titled: this.$input
9125 }, config ) );
9126 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
9127 $accessKeyed: this.$input
9128 }, config ) );
9129
9130 // Events
9131 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9132
9133 // Initialization
9134 this.$input
9135 .addClass( 'oo-ui-inputWidget-input' )
9136 .attr( 'name', config.name )
9137 .prop( 'disabled', this.isDisabled() );
9138 this.$element
9139 .addClass( 'oo-ui-inputWidget' )
9140 .append( this.$input );
9141 this.setValue( config.value );
9142 if ( config.dir ) {
9143 this.setDir( config.dir );
9144 }
9145 if ( config.inputId !== undefined ) {
9146 this.setInputId( config.inputId );
9147 }
9148 };
9149
9150 /* Setup */
9151
9152 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9153 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9154 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9155 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9156
9157 /* Static Methods */
9158
9159 /**
9160 * @inheritdoc
9161 */
9162 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9163 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
9164 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9165 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
9166 return config;
9167 };
9168
9169 /**
9170 * @inheritdoc
9171 */
9172 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9173 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
9174 if ( config.$input && config.$input.length ) {
9175 state.value = config.$input.val();
9176 // Might be better in TabIndexedElement, but it's awkward to do there because
9177 // mixins are awkward
9178 state.focus = config.$input.is( ':focus' );
9179 }
9180 return state;
9181 };
9182
9183 /* Events */
9184
9185 /**
9186 * @event change
9187 *
9188 * A change event is emitted when the value of the input changes.
9189 *
9190 * @param {string} value
9191 */
9192
9193 /* Methods */
9194
9195 /**
9196 * Get input element.
9197 *
9198 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9199 * different circumstances. The element must have a `value` property (like form elements).
9200 *
9201 * @protected
9202 * @param {Object} config Configuration options
9203 * @return {jQuery} Input element
9204 */
9205 OO.ui.InputWidget.prototype.getInputElement = function () {
9206 return $( '<input>' );
9207 };
9208
9209 /**
9210 * Handle potentially value-changing events.
9211 *
9212 * @private
9213 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9214 */
9215 OO.ui.InputWidget.prototype.onEdit = function () {
9216 var widget = this;
9217 if ( !this.isDisabled() ) {
9218 // Allow the stack to clear so the value will be updated
9219 setTimeout( function () {
9220 widget.setValue( widget.$input.val() );
9221 } );
9222 }
9223 };
9224
9225 /**
9226 * Get the value of the input.
9227 *
9228 * @return {string} Input value
9229 */
9230 OO.ui.InputWidget.prototype.getValue = function () {
9231 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9232 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9233 var value = this.$input.val();
9234 if ( this.value !== value ) {
9235 this.setValue( value );
9236 }
9237 return this.value;
9238 };
9239
9240 /**
9241 * Set the directionality of the input.
9242 *
9243 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9244 * @chainable
9245 * @return {OO.ui.Widget} The widget, for chaining
9246 */
9247 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9248 this.$input.prop( 'dir', dir );
9249 return this;
9250 };
9251
9252 /**
9253 * Set the value of the input.
9254 *
9255 * @param {string} value New value
9256 * @fires change
9257 * @chainable
9258 * @return {OO.ui.Widget} The widget, for chaining
9259 */
9260 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9261 value = this.cleanUpValue( value );
9262 // Update the DOM if it has changed. Note that with cleanUpValue, it
9263 // is possible for the DOM value to change without this.value changing.
9264 if ( this.$input.val() !== value ) {
9265 this.$input.val( value );
9266 }
9267 if ( this.value !== value ) {
9268 this.value = value;
9269 this.emit( 'change', this.value );
9270 }
9271 // The first time that the value is set (probably while constructing the widget),
9272 // remember it in defaultValue. This property can be later used to check whether
9273 // the value of the input has been changed since it was created.
9274 if ( this.defaultValue === undefined ) {
9275 this.defaultValue = this.value;
9276 this.$input[ 0 ].defaultValue = this.defaultValue;
9277 }
9278 return this;
9279 };
9280
9281 /**
9282 * Clean up incoming value.
9283 *
9284 * Ensures value is a string, and converts undefined and null to empty string.
9285 *
9286 * @private
9287 * @param {string} value Original value
9288 * @return {string} Cleaned up value
9289 */
9290 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9291 if ( value === undefined || value === null ) {
9292 return '';
9293 } else if ( this.inputFilter ) {
9294 return this.inputFilter( String( value ) );
9295 } else {
9296 return String( value );
9297 }
9298 };
9299
9300 /**
9301 * @inheritdoc
9302 */
9303 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9304 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9305 if ( this.$input ) {
9306 this.$input.prop( 'disabled', this.isDisabled() );
9307 }
9308 return this;
9309 };
9310
9311 /**
9312 * Set the 'id' attribute of the `<input>` element.
9313 *
9314 * @param {string} id
9315 * @chainable
9316 * @return {OO.ui.Widget} The widget, for chaining
9317 */
9318 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9319 this.$input.attr( 'id', id );
9320 return this;
9321 };
9322
9323 /**
9324 * @inheritdoc
9325 */
9326 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9327 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9328 if ( state.value !== undefined && state.value !== this.getValue() ) {
9329 this.setValue( state.value );
9330 }
9331 if ( state.focus ) {
9332 this.focus();
9333 }
9334 };
9335
9336 /**
9337 * Data widget intended for creating `<input type="hidden">` inputs.
9338 *
9339 * @class
9340 * @extends OO.ui.Widget
9341 *
9342 * @constructor
9343 * @param {Object} [config] Configuration options
9344 * @cfg {string} [value=''] The value of the input.
9345 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9346 */
9347 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9348 // Configuration initialization
9349 config = $.extend( { value: '', name: '' }, config );
9350
9351 // Parent constructor
9352 OO.ui.HiddenInputWidget.parent.call( this, config );
9353
9354 // Initialization
9355 this.$element.attr( {
9356 type: 'hidden',
9357 value: config.value,
9358 name: config.name
9359 } );
9360 this.$element.removeAttr( 'aria-disabled' );
9361 };
9362
9363 /* Setup */
9364
9365 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9366
9367 /* Static Properties */
9368
9369 /**
9370 * @static
9371 * @inheritdoc
9372 */
9373 OO.ui.HiddenInputWidget.static.tagName = 'input';
9374
9375 /**
9376 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9377 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9378 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9379 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9380 * [OOUI documentation on MediaWiki] [1] for more information.
9381 *
9382 * @example
9383 * // A ButtonInputWidget rendered as an HTML button, the default.
9384 * var button = new OO.ui.ButtonInputWidget( {
9385 * label: 'Input button',
9386 * icon: 'check',
9387 * value: 'check'
9388 * } );
9389 * $( document.body ).append( button.$element );
9390 *
9391 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9392 *
9393 * @class
9394 * @extends OO.ui.InputWidget
9395 * @mixins OO.ui.mixin.ButtonElement
9396 * @mixins OO.ui.mixin.IconElement
9397 * @mixins OO.ui.mixin.IndicatorElement
9398 * @mixins OO.ui.mixin.LabelElement
9399 * @mixins OO.ui.mixin.FlaggedElement
9400 *
9401 * @constructor
9402 * @param {Object} [config] Configuration options
9403 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute:
9404 * 'button', 'submit' or 'reset'.
9405 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9406 * Widgets configured to be an `<input>` do not support {@link #icon icons} and
9407 * {@link #indicator indicators},
9408 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should
9409 * only be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9410 */
9411 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9412 // Configuration initialization
9413 config = $.extend( { type: 'button', useInputTag: false }, config );
9414
9415 // See InputWidget#reusePreInfuseDOM about config.$input
9416 if ( config.$input ) {
9417 config.$input.empty();
9418 }
9419
9420 // Properties (must be set before parent constructor, which calls #setValue)
9421 this.useInputTag = config.useInputTag;
9422
9423 // Parent constructor
9424 OO.ui.ButtonInputWidget.parent.call( this, config );
9425
9426 // Mixin constructors
9427 OO.ui.mixin.ButtonElement.call( this, $.extend( {
9428 $button: this.$input
9429 }, config ) );
9430 OO.ui.mixin.IconElement.call( this, config );
9431 OO.ui.mixin.IndicatorElement.call( this, config );
9432 OO.ui.mixin.LabelElement.call( this, config );
9433 OO.ui.mixin.FlaggedElement.call( this, config );
9434
9435 // Initialization
9436 if ( !config.useInputTag ) {
9437 this.$input.append( this.$icon, this.$label, this.$indicator );
9438 }
9439 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9440 };
9441
9442 /* Setup */
9443
9444 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9445 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9446 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9447 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9448 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9449 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.FlaggedElement );
9450
9451 /* Static Properties */
9452
9453 /**
9454 * @static
9455 * @inheritdoc
9456 */
9457 OO.ui.ButtonInputWidget.static.tagName = 'span';
9458
9459 /* Methods */
9460
9461 /**
9462 * @inheritdoc
9463 * @protected
9464 */
9465 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9466 var type;
9467 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9468 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9469 };
9470
9471 /**
9472 * Set label value.
9473 *
9474 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9475 *
9476 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9477 * text, or `null` for no label
9478 * @chainable
9479 * @return {OO.ui.Widget} The widget, for chaining
9480 */
9481 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9482 if ( typeof label === 'function' ) {
9483 label = OO.ui.resolveMsg( label );
9484 }
9485
9486 if ( this.useInputTag ) {
9487 // Discard non-plaintext labels
9488 if ( typeof label !== 'string' ) {
9489 label = '';
9490 }
9491
9492 this.$input.val( label );
9493 }
9494
9495 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9496 };
9497
9498 /**
9499 * Set the value of the input.
9500 *
9501 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9502 * they do not support {@link #value values}.
9503 *
9504 * @param {string} value New value
9505 * @chainable
9506 * @return {OO.ui.Widget} The widget, for chaining
9507 */
9508 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9509 if ( !this.useInputTag ) {
9510 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9511 }
9512 return this;
9513 };
9514
9515 /**
9516 * @inheritdoc
9517 */
9518 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9519 // Disable generating `<label>` elements for buttons. One would very rarely need additional
9520 // label for a button, and it's already a big clickable target, and it causes
9521 // unexpected rendering.
9522 return null;
9523 };
9524
9525 /**
9526 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9527 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9528 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9529 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9530 *
9531 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9532 *
9533 * @example
9534 * // An example of selected, unselected, and disabled checkbox inputs.
9535 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9536 * value: 'a',
9537 * selected: true
9538 * } ),
9539 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9540 * value: 'b'
9541 * } ),
9542 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9543 * value:'c',
9544 * disabled: true
9545 * } ),
9546 * // Create a fieldset layout with fields for each checkbox.
9547 * fieldset = new OO.ui.FieldsetLayout( {
9548 * label: 'Checkboxes'
9549 * } );
9550 * fieldset.addItems( [
9551 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9552 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9553 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9554 * ] );
9555 * $( document.body ).append( fieldset.$element );
9556 *
9557 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9558 *
9559 * @class
9560 * @extends OO.ui.InputWidget
9561 *
9562 * @constructor
9563 * @param {Object} [config] Configuration options
9564 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is
9565 * not selected.
9566 * @cfg {boolean} [indeterminate=false] Whether the checkbox is in the indeterminate state.
9567 */
9568 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9569 // Configuration initialization
9570 config = config || {};
9571
9572 // Parent constructor
9573 OO.ui.CheckboxInputWidget.parent.call( this, config );
9574
9575 // Properties
9576 this.checkIcon = new OO.ui.IconWidget( {
9577 icon: 'check',
9578 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9579 } );
9580
9581 // Initialization
9582 this.$element
9583 .addClass( 'oo-ui-checkboxInputWidget' )
9584 // Required for pretty styling in WikimediaUI theme
9585 .append( this.checkIcon.$element );
9586 this.setSelected( config.selected !== undefined ? config.selected : false );
9587 this.setIndeterminate( config.indeterminate !== undefined ? config.indeterminate : false );
9588 };
9589
9590 /* Setup */
9591
9592 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9593
9594 /* Events */
9595
9596 /**
9597 * @event change
9598 *
9599 * A change event is emitted when the state of the input changes.
9600 *
9601 * @param {boolean} selected
9602 * @param {boolean} indeterminate
9603 */
9604
9605 /* Static Properties */
9606
9607 /**
9608 * @static
9609 * @inheritdoc
9610 */
9611 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9612
9613 /* Static Methods */
9614
9615 /**
9616 * @inheritdoc
9617 */
9618 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9619 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9620 state.checked = config.$input.prop( 'checked' );
9621 return state;
9622 };
9623
9624 /* Methods */
9625
9626 /**
9627 * @inheritdoc
9628 * @protected
9629 */
9630 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9631 return $( '<input>' ).attr( 'type', 'checkbox' );
9632 };
9633
9634 /**
9635 * @inheritdoc
9636 */
9637 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9638 var widget = this;
9639 if ( !this.isDisabled() ) {
9640 // Allow the stack to clear so the value will be updated
9641 setTimeout( function () {
9642 widget.setSelected( widget.$input.prop( 'checked' ) );
9643 widget.setIndeterminate( widget.$input.prop( 'indeterminate' ) );
9644 } );
9645 }
9646 };
9647
9648 /**
9649 * Set selection state of this checkbox.
9650 *
9651 * @param {boolean} state Selected state
9652 * @param {boolean} internal Used for internal calls to suppress events
9653 * @chainable
9654 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9655 */
9656 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state, internal ) {
9657 state = !!state;
9658 if ( this.selected !== state ) {
9659 this.selected = state;
9660 this.$input.prop( 'checked', this.selected );
9661 if ( !internal ) {
9662 this.setIndeterminate( false, true );
9663 this.emit( 'change', this.selected, this.indeterminate );
9664 }
9665 }
9666 // The first time that the selection state is set (probably while constructing the widget),
9667 // remember it in defaultSelected. This property can be later used to check whether
9668 // the selection state of the input has been changed since it was created.
9669 if ( this.defaultSelected === undefined ) {
9670 this.defaultSelected = this.selected;
9671 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9672 }
9673 return this;
9674 };
9675
9676 /**
9677 * Check if this checkbox is selected.
9678 *
9679 * @return {boolean} Checkbox is selected
9680 */
9681 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9682 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9683 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9684 var selected = this.$input.prop( 'checked' );
9685 if ( this.selected !== selected ) {
9686 this.setSelected( selected );
9687 }
9688 return this.selected;
9689 };
9690
9691 /**
9692 * Set indeterminate state of this checkbox.
9693 *
9694 * @param {boolean} state Indeterminate state
9695 * @param {boolean} internal Used for internal calls to suppress events
9696 * @chainable
9697 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9698 */
9699 OO.ui.CheckboxInputWidget.prototype.setIndeterminate = function ( state, internal ) {
9700 state = !!state;
9701 if ( this.indeterminate !== state ) {
9702 this.indeterminate = state;
9703 this.$input.prop( 'indeterminate', this.indeterminate );
9704 if ( !internal ) {
9705 this.setSelected( false, true );
9706 this.emit( 'change', this.selected, this.indeterminate );
9707 }
9708 }
9709 return this;
9710 };
9711
9712 /**
9713 * Check if this checkbox is selected.
9714 *
9715 * @return {boolean} Checkbox is selected
9716 */
9717 OO.ui.CheckboxInputWidget.prototype.isIndeterminate = function () {
9718 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9719 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9720 var indeterminate = this.$input.prop( 'indeterminate' );
9721 if ( this.indeterminate !== indeterminate ) {
9722 this.setIndeterminate( indeterminate );
9723 }
9724 return this.indeterminate;
9725 };
9726
9727 /**
9728 * @inheritdoc
9729 */
9730 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9731 if ( !this.isDisabled() ) {
9732 this.$handle.trigger( 'click' );
9733 }
9734 this.focus();
9735 };
9736
9737 /**
9738 * @inheritdoc
9739 */
9740 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9741 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9742 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9743 this.setSelected( state.checked );
9744 }
9745 };
9746
9747 /**
9748 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9749 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
9750 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9751 * more information about input widgets.
9752 *
9753 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9754 * are no options. If no `value` configuration option is provided, the first option is selected.
9755 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9756 *
9757 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9758 *
9759 * @example
9760 * // A DropdownInputWidget with three options.
9761 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9762 * options: [
9763 * { data: 'a', label: 'First' },
9764 * { data: 'b', label: 'Second', disabled: true },
9765 * { optgroup: 'Group label' },
9766 * { data: 'c', label: 'First sub-item)' }
9767 * ]
9768 * } );
9769 * $( document.body ).append( dropdownInput.$element );
9770 *
9771 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9772 *
9773 * @class
9774 * @extends OO.ui.InputWidget
9775 *
9776 * @constructor
9777 * @param {Object} [config] Configuration options
9778 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9779 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9780 * @cfg {jQuery|boolean} [$overlay] Render the menu into a separate layer. This configuration is
9781 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
9782 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
9783 * the menu uses relative positioning. Pass 'true' to use the default overlay.
9784 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9785 */
9786 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9787 // Configuration initialization
9788 config = config || {};
9789
9790 // Properties (must be done before parent constructor which calls #setDisabled)
9791 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9792 {
9793 $overlay: config.$overlay
9794 },
9795 config.dropdown
9796 ) );
9797 // Set up the options before parent constructor, which uses them to validate config.value.
9798 // Use this instead of setOptions() because this.$input is not set up yet.
9799 this.setOptionsData( config.options || [] );
9800
9801 // Parent constructor
9802 OO.ui.DropdownInputWidget.parent.call( this, config );
9803
9804 // Events
9805 this.dropdownWidget.getMenu().connect( this, {
9806 select: 'onMenuSelect'
9807 } );
9808
9809 // Initialization
9810 this.$element
9811 .addClass( 'oo-ui-dropdownInputWidget' )
9812 .append( this.dropdownWidget.$element );
9813 if ( OO.ui.isMobile() ) {
9814 this.$element.addClass( 'oo-ui-isMobile' );
9815 }
9816 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9817 this.setTitledElement( this.dropdownWidget.$handle );
9818 };
9819
9820 /* Setup */
9821
9822 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9823
9824 /* Methods */
9825
9826 /**
9827 * @inheritdoc
9828 * @protected
9829 */
9830 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9831 return $( '<select>' ).addClass( 'oo-ui-indicator-down' );
9832 };
9833
9834 /**
9835 * Handles menu select events.
9836 *
9837 * @private
9838 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9839 */
9840 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9841 this.setValue( item ? item.getData() : '' );
9842 };
9843
9844 /**
9845 * @inheritdoc
9846 */
9847 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9848 var selected;
9849 value = this.cleanUpValue( value );
9850 // Only allow setting values that are actually present in the dropdown
9851 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9852 this.dropdownWidget.getMenu().findFirstSelectableItem();
9853 this.dropdownWidget.getMenu().selectItem( selected );
9854 value = selected ? selected.getData() : '';
9855 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9856 if ( this.optionsDirty ) {
9857 // We reached this from the constructor or from #setOptions.
9858 // We have to update the <select> element.
9859 this.updateOptionsInterface();
9860 }
9861 return this;
9862 };
9863
9864 /**
9865 * @inheritdoc
9866 */
9867 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9868 this.dropdownWidget.setDisabled( state );
9869 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9870 return this;
9871 };
9872
9873 /**
9874 * Set the options available for this input.
9875 *
9876 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9877 * @chainable
9878 * @return {OO.ui.Widget} The widget, for chaining
9879 */
9880 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9881 var value = this.getValue();
9882
9883 this.setOptionsData( options );
9884
9885 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9886 // In case the previous value is no longer an available option, select the first valid one.
9887 this.setValue( value );
9888
9889 return this;
9890 };
9891
9892 /**
9893 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9894 *
9895 * This method may be called before the parent constructor, so various properties may not be
9896 * initialized yet.
9897 *
9898 * @param {Object[]} options Array of menu options (see #constructor for details).
9899 * @private
9900 */
9901 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9902 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9903 widget = this;
9904
9905 this.optionsDirty = true;
9906
9907 // Go through all the supplied option configs and create either
9908 // MenuSectionOption or MenuOption widgets from each.
9909 optionWidgets = [];
9910 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9911 opt = options[ optIndex ];
9912
9913 if ( opt.optgroup !== undefined ) {
9914 // Create a <optgroup> menu item.
9915 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9916 previousOptgroup = optionWidget;
9917
9918 } else {
9919 // Create a normal <option> menu item.
9920 optValue = widget.cleanUpValue( opt.data );
9921 optionWidget = widget.createMenuOptionWidget(
9922 optValue,
9923 opt.label !== undefined ? opt.label : optValue
9924 );
9925 }
9926
9927 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9928 if (
9929 opt.disabled !== undefined ||
9930 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
9931 previousOptgroup.isDisabled()
9932 ) {
9933 optionWidget.setDisabled( true );
9934 }
9935
9936 optionWidgets.push( optionWidget );
9937 }
9938
9939 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9940 };
9941
9942 /**
9943 * Create a menu option widget.
9944 *
9945 * @protected
9946 * @param {string} data Item data
9947 * @param {string} label Item label
9948 * @return {OO.ui.MenuOptionWidget} Option widget
9949 */
9950 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9951 return new OO.ui.MenuOptionWidget( {
9952 data: data,
9953 label: label
9954 } );
9955 };
9956
9957 /**
9958 * Create a menu section option widget.
9959 *
9960 * @protected
9961 * @param {string} label Section item label
9962 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9963 */
9964 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9965 return new OO.ui.MenuSectionOptionWidget( {
9966 label: label
9967 } );
9968 };
9969
9970 /**
9971 * Update the user-visible interface to match the internal list of options and value.
9972 *
9973 * This method must only be called after the parent constructor.
9974 *
9975 * @private
9976 */
9977 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9978 var
9979 $optionsContainer = this.$input,
9980 defaultValue = this.defaultValue,
9981 widget = this;
9982
9983 this.$input.empty();
9984
9985 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9986 var $optionNode;
9987
9988 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9989 $optionNode = $( '<option>' )
9990 .attr( 'value', optionWidget.getData() )
9991 .text( optionWidget.getLabel() );
9992
9993 // Remember original selection state. This property can be later used to check whether
9994 // the selection state of the input has been changed since it was created.
9995 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9996
9997 $optionsContainer.append( $optionNode );
9998 } else {
9999 $optionNode = $( '<optgroup>' )
10000 .attr( 'label', optionWidget.getLabel() );
10001 widget.$input.append( $optionNode );
10002 $optionsContainer = $optionNode;
10003 }
10004
10005 // Disable the option or optgroup if required.
10006 if ( optionWidget.isDisabled() ) {
10007 $optionNode.prop( 'disabled', true );
10008 }
10009 } );
10010
10011 this.optionsDirty = false;
10012 };
10013
10014 /**
10015 * @inheritdoc
10016 */
10017 OO.ui.DropdownInputWidget.prototype.focus = function () {
10018 this.dropdownWidget.focus();
10019 return this;
10020 };
10021
10022 /**
10023 * @inheritdoc
10024 */
10025 OO.ui.DropdownInputWidget.prototype.blur = function () {
10026 this.dropdownWidget.blur();
10027 return this;
10028 };
10029
10030 /**
10031 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
10032 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
10033 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
10034 * please see the [OOUI documentation on MediaWiki][1].
10035 *
10036 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10037 *
10038 * @example
10039 * // An example of selected, unselected, and disabled radio inputs
10040 * var radio1 = new OO.ui.RadioInputWidget( {
10041 * value: 'a',
10042 * selected: true
10043 * } );
10044 * var radio2 = new OO.ui.RadioInputWidget( {
10045 * value: 'b'
10046 * } );
10047 * var radio3 = new OO.ui.RadioInputWidget( {
10048 * value: 'c',
10049 * disabled: true
10050 * } );
10051 * // Create a fieldset layout with fields for each radio button.
10052 * var fieldset = new OO.ui.FieldsetLayout( {
10053 * label: 'Radio inputs'
10054 * } );
10055 * fieldset.addItems( [
10056 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
10057 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
10058 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
10059 * ] );
10060 * $( document.body ).append( fieldset.$element );
10061 *
10062 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10063 *
10064 * @class
10065 * @extends OO.ui.InputWidget
10066 *
10067 * @constructor
10068 * @param {Object} [config] Configuration options
10069 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button
10070 * is not selected.
10071 */
10072 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
10073 // Configuration initialization
10074 config = config || {};
10075
10076 // Parent constructor
10077 OO.ui.RadioInputWidget.parent.call( this, config );
10078
10079 // Initialization
10080 this.$element
10081 .addClass( 'oo-ui-radioInputWidget' )
10082 // Required for pretty styling in WikimediaUI theme
10083 .append( $( '<span>' ) );
10084 this.setSelected( config.selected !== undefined ? config.selected : false );
10085 };
10086
10087 /* Setup */
10088
10089 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
10090
10091 /* Static Properties */
10092
10093 /**
10094 * @static
10095 * @inheritdoc
10096 */
10097 OO.ui.RadioInputWidget.static.tagName = 'span';
10098
10099 /* Static Methods */
10100
10101 /**
10102 * @inheritdoc
10103 */
10104 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10105 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
10106 state.checked = config.$input.prop( 'checked' );
10107 return state;
10108 };
10109
10110 /* Methods */
10111
10112 /**
10113 * @inheritdoc
10114 * @protected
10115 */
10116 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
10117 return $( '<input>' ).attr( 'type', 'radio' );
10118 };
10119
10120 /**
10121 * @inheritdoc
10122 */
10123 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10124 // RadioInputWidget doesn't track its state.
10125 };
10126
10127 /**
10128 * Set selection state of this radio button.
10129 *
10130 * @param {boolean} state `true` for selected
10131 * @chainable
10132 * @return {OO.ui.Widget} The widget, for chaining
10133 */
10134 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10135 // RadioInputWidget doesn't track its state.
10136 this.$input.prop( 'checked', state );
10137 // The first time that the selection state is set (probably while constructing the widget),
10138 // remember it in defaultSelected. This property can be later used to check whether
10139 // the selection state of the input has been changed since it was created.
10140 if ( this.defaultSelected === undefined ) {
10141 this.defaultSelected = state;
10142 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10143 }
10144 return this;
10145 };
10146
10147 /**
10148 * Check if this radio button is selected.
10149 *
10150 * @return {boolean} Radio is selected
10151 */
10152 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10153 return this.$input.prop( 'checked' );
10154 };
10155
10156 /**
10157 * @inheritdoc
10158 */
10159 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10160 if ( !this.isDisabled() ) {
10161 this.$input.trigger( 'click' );
10162 }
10163 this.focus();
10164 };
10165
10166 /**
10167 * @inheritdoc
10168 */
10169 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10170 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10171 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10172 this.setSelected( state.checked );
10173 }
10174 };
10175
10176 /**
10177 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10178 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10179 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10180 * more information about input widgets.
10181 *
10182 * This and OO.ui.DropdownInputWidget support similar configuration options.
10183 *
10184 * @example
10185 * // A RadioSelectInputWidget with three options
10186 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
10187 * options: [
10188 * { data: 'a', label: 'First' },
10189 * { data: 'b', label: 'Second'},
10190 * { data: 'c', label: 'Third' }
10191 * ]
10192 * } );
10193 * $( document.body ).append( radioSelectInput.$element );
10194 *
10195 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10196 *
10197 * @class
10198 * @extends OO.ui.InputWidget
10199 *
10200 * @constructor
10201 * @param {Object} [config] Configuration options
10202 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10203 */
10204 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
10205 // Configuration initialization
10206 config = config || {};
10207
10208 // Properties (must be done before parent constructor which calls #setDisabled)
10209 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
10210 // Set up the options before parent constructor, which uses them to validate config.value.
10211 // Use this instead of setOptions() because this.$input is not set up yet
10212 this.setOptionsData( config.options || [] );
10213
10214 // Parent constructor
10215 OO.ui.RadioSelectInputWidget.parent.call( this, config );
10216
10217 // Events
10218 this.radioSelectWidget.connect( this, {
10219 select: 'onMenuSelect'
10220 } );
10221
10222 // Initialization
10223 this.$element
10224 .addClass( 'oo-ui-radioSelectInputWidget' )
10225 .append( this.radioSelectWidget.$element );
10226 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
10227 };
10228
10229 /* Setup */
10230
10231 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
10232
10233 /* Static Methods */
10234
10235 /**
10236 * @inheritdoc
10237 */
10238 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10239 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
10240 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
10241 return state;
10242 };
10243
10244 /**
10245 * @inheritdoc
10246 */
10247 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10248 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10249 // Cannot reuse the `<input type=radio>` set
10250 delete config.$input;
10251 return config;
10252 };
10253
10254 /* Methods */
10255
10256 /**
10257 * @inheritdoc
10258 * @protected
10259 */
10260 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10261 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10262 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10263 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10264 };
10265
10266 /**
10267 * Handles menu select events.
10268 *
10269 * @private
10270 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10271 */
10272 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10273 this.setValue( item.getData() );
10274 };
10275
10276 /**
10277 * @inheritdoc
10278 */
10279 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10280 var selected;
10281 value = this.cleanUpValue( value );
10282 // Only allow setting values that are actually present in the dropdown
10283 selected = this.radioSelectWidget.findItemFromData( value ) ||
10284 this.radioSelectWidget.findFirstSelectableItem();
10285 this.radioSelectWidget.selectItem( selected );
10286 value = selected ? selected.getData() : '';
10287 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10288 return this;
10289 };
10290
10291 /**
10292 * @inheritdoc
10293 */
10294 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10295 this.radioSelectWidget.setDisabled( state );
10296 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10297 return this;
10298 };
10299
10300 /**
10301 * Set the options available for this input.
10302 *
10303 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10304 * @chainable
10305 * @return {OO.ui.Widget} The widget, for chaining
10306 */
10307 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10308 var value = this.getValue();
10309
10310 this.setOptionsData( options );
10311
10312 // Re-set the value to update the visible interface (RadioSelectWidget).
10313 // In case the previous value is no longer an available option, select the first valid one.
10314 this.setValue( value );
10315
10316 return this;
10317 };
10318
10319 /**
10320 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10321 *
10322 * This method may be called before the parent constructor, so various properties may not be
10323 * intialized yet.
10324 *
10325 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10326 * @private
10327 */
10328 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10329 var widget = this;
10330
10331 this.radioSelectWidget
10332 .clearItems()
10333 .addItems( options.map( function ( opt ) {
10334 var optValue = widget.cleanUpValue( opt.data );
10335 return new OO.ui.RadioOptionWidget( {
10336 data: optValue,
10337 label: opt.label !== undefined ? opt.label : optValue
10338 } );
10339 } ) );
10340 };
10341
10342 /**
10343 * @inheritdoc
10344 */
10345 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10346 this.radioSelectWidget.focus();
10347 return this;
10348 };
10349
10350 /**
10351 * @inheritdoc
10352 */
10353 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10354 this.radioSelectWidget.blur();
10355 return this;
10356 };
10357
10358 /**
10359 * CheckboxMultiselectInputWidget is a
10360 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10361 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10362 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10363 * more information about input widgets.
10364 *
10365 * @example
10366 * // A CheckboxMultiselectInputWidget with three options.
10367 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10368 * options: [
10369 * { data: 'a', label: 'First' },
10370 * { data: 'b', label: 'Second' },
10371 * { data: 'c', label: 'Third' }
10372 * ]
10373 * } );
10374 * $( document.body ).append( multiselectInput.$element );
10375 *
10376 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10377 *
10378 * @class
10379 * @extends OO.ui.InputWidget
10380 *
10381 * @constructor
10382 * @param {Object} [config] Configuration options
10383 * @cfg {Object[]} [options=[]] Array of menu options in the format
10384 * `{ data: …, label: …, disabled: … }`
10385 */
10386 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10387 // Configuration initialization
10388 config = config || {};
10389
10390 // Properties (must be done before parent constructor which calls #setDisabled)
10391 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10392 // Must be set before the #setOptionsData call below
10393 this.inputName = config.name;
10394 // Set up the options before parent constructor, which uses them to validate config.value.
10395 // Use this instead of setOptions() because this.$input is not set up yet
10396 this.setOptionsData( config.options || [] );
10397
10398 // Parent constructor
10399 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10400
10401 // Events
10402 this.checkboxMultiselectWidget.connect( this, {
10403 select: 'onCheckboxesSelect'
10404 } );
10405
10406 // Initialization
10407 this.$element
10408 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10409 .append( this.checkboxMultiselectWidget.$element );
10410 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10411 this.$input.detach();
10412 };
10413
10414 /* Setup */
10415
10416 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10417
10418 /* Static Methods */
10419
10420 /**
10421 * @inheritdoc
10422 */
10423 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10424 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState(
10425 node, config
10426 );
10427 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10428 .toArray().map( function ( el ) { return el.value; } );
10429 return state;
10430 };
10431
10432 /**
10433 * @inheritdoc
10434 */
10435 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10436 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10437 // Cannot reuse the `<input type=checkbox>` set
10438 delete config.$input;
10439 return config;
10440 };
10441
10442 /* Methods */
10443
10444 /**
10445 * @inheritdoc
10446 * @protected
10447 */
10448 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10449 // Actually unused
10450 return $( '<unused>' );
10451 };
10452
10453 /**
10454 * Handles CheckboxMultiselectWidget select events.
10455 *
10456 * @private
10457 */
10458 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10459 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10460 };
10461
10462 /**
10463 * @inheritdoc
10464 */
10465 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10466 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10467 .toArray().map( function ( el ) { return el.value; } );
10468 if ( this.value !== value ) {
10469 this.setValue( value );
10470 }
10471 return this.value;
10472 };
10473
10474 /**
10475 * @inheritdoc
10476 */
10477 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10478 value = this.cleanUpValue( value );
10479 this.checkboxMultiselectWidget.selectItemsByData( value );
10480 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10481 if ( this.optionsDirty ) {
10482 // We reached this from the constructor or from #setOptions.
10483 // We have to update the <select> element.
10484 this.updateOptionsInterface();
10485 }
10486 return this;
10487 };
10488
10489 /**
10490 * Clean up incoming value.
10491 *
10492 * @param {string[]} value Original value
10493 * @return {string[]} Cleaned up value
10494 */
10495 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10496 var i, singleValue,
10497 cleanValue = [];
10498 if ( !Array.isArray( value ) ) {
10499 return cleanValue;
10500 }
10501 for ( i = 0; i < value.length; i++ ) {
10502 singleValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10503 .call( this, value[ i ] );
10504 // Remove options that we don't have here
10505 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10506 continue;
10507 }
10508 cleanValue.push( singleValue );
10509 }
10510 return cleanValue;
10511 };
10512
10513 /**
10514 * @inheritdoc
10515 */
10516 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10517 this.checkboxMultiselectWidget.setDisabled( state );
10518 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10519 return this;
10520 };
10521
10522 /**
10523 * Set the options available for this input.
10524 *
10525 * @param {Object[]} options Array of menu options in the format
10526 * `{ data: …, label: …, disabled: … }`
10527 * @chainable
10528 * @return {OO.ui.Widget} The widget, for chaining
10529 */
10530 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10531 var value = this.getValue();
10532
10533 this.setOptionsData( options );
10534
10535 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10536 // This will also get rid of any stale options that we just removed.
10537 this.setValue( value );
10538
10539 return this;
10540 };
10541
10542 /**
10543 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10544 *
10545 * This method may be called before the parent constructor, so various properties may not be
10546 * intialized yet.
10547 *
10548 * @param {Object[]} options Array of menu options in the format
10549 * `{ data: …, label: … }`
10550 * @private
10551 */
10552 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10553 var widget = this;
10554
10555 this.optionsDirty = true;
10556
10557 this.checkboxMultiselectWidget
10558 .clearItems()
10559 .addItems( options.map( function ( opt ) {
10560 var optValue, item, optDisabled;
10561 optValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10562 .call( widget, opt.data );
10563 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10564 item = new OO.ui.CheckboxMultioptionWidget( {
10565 data: optValue,
10566 label: opt.label !== undefined ? opt.label : optValue,
10567 disabled: optDisabled
10568 } );
10569 // Set the 'name' and 'value' for form submission
10570 item.checkbox.$input.attr( 'name', widget.inputName );
10571 item.checkbox.setValue( optValue );
10572 return item;
10573 } ) );
10574 };
10575
10576 /**
10577 * Update the user-visible interface to match the internal list of options and value.
10578 *
10579 * This method must only be called after the parent constructor.
10580 *
10581 * @private
10582 */
10583 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10584 var defaultValue = this.defaultValue;
10585
10586 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10587 // Remember original selection state. This property can be later used to check whether
10588 // the selection state of the input has been changed since it was created.
10589 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10590 item.checkbox.defaultSelected = isDefault;
10591 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10592 } );
10593
10594 this.optionsDirty = false;
10595 };
10596
10597 /**
10598 * @inheritdoc
10599 */
10600 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10601 this.checkboxMultiselectWidget.focus();
10602 return this;
10603 };
10604
10605 /**
10606 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10607 * size of the field as well as its presentation. In addition, these widgets can be configured
10608 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
10609 * optional validation-pattern (used to determine if an input value is valid or not) and an input
10610 * filter, which modifies incoming values rather than validating them.
10611 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10612 *
10613 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10614 *
10615 * @example
10616 * // A TextInputWidget.
10617 * var textInput = new OO.ui.TextInputWidget( {
10618 * value: 'Text input'
10619 * } );
10620 * $( document.body ).append( textInput.$element );
10621 *
10622 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10623 *
10624 * @class
10625 * @extends OO.ui.InputWidget
10626 * @mixins OO.ui.mixin.IconElement
10627 * @mixins OO.ui.mixin.IndicatorElement
10628 * @mixins OO.ui.mixin.PendingElement
10629 * @mixins OO.ui.mixin.LabelElement
10630 * @mixins OO.ui.mixin.FlaggedElement
10631 *
10632 * @constructor
10633 * @param {Object} [config] Configuration options
10634 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10635 * 'email', 'url' or 'number'.
10636 * @cfg {string} [placeholder] Placeholder text
10637 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10638 * instruct the browser to focus this widget.
10639 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10640 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10641 *
10642 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10643 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10644 * many emojis) count as 2 characters each.
10645 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10646 * the value or placeholder text: `'before'` or `'after'`
10647 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator:
10648 * 'required'`. Note that `false` & setting `indicator: 'required' will result in no indicator
10649 * shown.
10650 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10651 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined`
10652 * means leaving it up to the browser).
10653 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10654 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10655 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10656 * value for it to be considered valid; when Function, a function receiving the value as parameter
10657 * that must return true, or promise resolving to true, for it to be considered valid.
10658 */
10659 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10660 // Configuration initialization
10661 config = $.extend( {
10662 type: 'text',
10663 labelPosition: 'after'
10664 }, config );
10665
10666 // Parent constructor
10667 OO.ui.TextInputWidget.parent.call( this, config );
10668
10669 // Mixin constructors
10670 OO.ui.mixin.IconElement.call( this, config );
10671 OO.ui.mixin.IndicatorElement.call( this, config );
10672 OO.ui.mixin.PendingElement.call( this, $.extend( { $pending: this.$input }, config ) );
10673 OO.ui.mixin.LabelElement.call( this, config );
10674 OO.ui.mixin.FlaggedElement.call( this, config );
10675
10676 // Properties
10677 this.type = this.getSaneType( config );
10678 this.readOnly = false;
10679 this.required = false;
10680 this.validate = null;
10681 this.scrollWidth = null;
10682
10683 this.setValidation( config.validate );
10684 this.setLabelPosition( config.labelPosition );
10685
10686 // Events
10687 this.$input.on( {
10688 keypress: this.onKeyPress.bind( this ),
10689 blur: this.onBlur.bind( this ),
10690 focus: this.onFocus.bind( this )
10691 } );
10692 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10693 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10694 this.on( 'labelChange', this.updatePosition.bind( this ) );
10695 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10696
10697 // Initialization
10698 this.$element
10699 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10700 .append( this.$icon, this.$indicator );
10701 this.setReadOnly( !!config.readOnly );
10702 this.setRequired( !!config.required );
10703 if ( config.placeholder !== undefined ) {
10704 this.$input.attr( 'placeholder', config.placeholder );
10705 }
10706 if ( config.maxLength !== undefined ) {
10707 this.$input.attr( 'maxlength', config.maxLength );
10708 }
10709 if ( config.autofocus ) {
10710 this.$input.attr( 'autofocus', 'autofocus' );
10711 }
10712 if ( config.autocomplete === false ) {
10713 this.$input.attr( 'autocomplete', 'off' );
10714 // Turning off autocompletion also disables "form caching" when the user navigates to a
10715 // different page and then clicks "Back". Re-enable it when leaving.
10716 // Borrowed from jQuery UI.
10717 $( window ).on( {
10718 beforeunload: function () {
10719 this.$input.removeAttr( 'autocomplete' );
10720 }.bind( this ),
10721 pageshow: function () {
10722 // Browsers don't seem to actually fire this event on "Back", they instead just
10723 // reload the whole page... it shouldn't hurt, though.
10724 this.$input.attr( 'autocomplete', 'off' );
10725 }.bind( this )
10726 } );
10727 }
10728 if ( config.spellcheck !== undefined ) {
10729 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10730 }
10731 if ( this.label ) {
10732 this.isWaitingToBeAttached = true;
10733 this.installParentChangeDetector();
10734 }
10735 };
10736
10737 /* Setup */
10738
10739 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10740 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10741 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10742 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10743 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10744 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.FlaggedElement );
10745
10746 /* Static Properties */
10747
10748 OO.ui.TextInputWidget.static.validationPatterns = {
10749 'non-empty': /.+/,
10750 integer: /^\d+$/
10751 };
10752
10753 /* Events */
10754
10755 /**
10756 * An `enter` event is emitted when the user presses Enter key inside the text box.
10757 *
10758 * @event enter
10759 */
10760
10761 /* Methods */
10762
10763 /**
10764 * Handle icon mouse down events.
10765 *
10766 * @private
10767 * @param {jQuery.Event} e Mouse down event
10768 * @return {undefined|boolean} False to prevent default if event is handled
10769 */
10770 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10771 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10772 this.focus();
10773 return false;
10774 }
10775 };
10776
10777 /**
10778 * Handle indicator mouse down events.
10779 *
10780 * @private
10781 * @param {jQuery.Event} e Mouse down event
10782 * @return {undefined|boolean} False to prevent default if event is handled
10783 */
10784 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10785 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10786 this.focus();
10787 return false;
10788 }
10789 };
10790
10791 /**
10792 * Handle key press events.
10793 *
10794 * @private
10795 * @param {jQuery.Event} e Key press event
10796 * @fires enter If Enter key is pressed
10797 */
10798 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10799 if ( e.which === OO.ui.Keys.ENTER ) {
10800 this.emit( 'enter', e );
10801 }
10802 };
10803
10804 /**
10805 * Handle blur events.
10806 *
10807 * @private
10808 * @param {jQuery.Event} e Blur event
10809 */
10810 OO.ui.TextInputWidget.prototype.onBlur = function () {
10811 this.setValidityFlag();
10812 };
10813
10814 /**
10815 * Handle focus events.
10816 *
10817 * @private
10818 * @param {jQuery.Event} e Focus event
10819 */
10820 OO.ui.TextInputWidget.prototype.onFocus = function () {
10821 if ( this.isWaitingToBeAttached ) {
10822 // If we've received focus, then we must be attached to the document, and if
10823 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10824 this.onElementAttach();
10825 }
10826 this.setValidityFlag( true );
10827 };
10828
10829 /**
10830 * Handle element attach events.
10831 *
10832 * @private
10833 * @param {jQuery.Event} e Element attach event
10834 */
10835 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10836 this.isWaitingToBeAttached = false;
10837 // Any previously calculated size is now probably invalid if we reattached elsewhere
10838 this.valCache = null;
10839 this.positionLabel();
10840 };
10841
10842 /**
10843 * Handle debounced change events.
10844 *
10845 * @param {string} value
10846 * @private
10847 */
10848 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10849 this.setValidityFlag();
10850 };
10851
10852 /**
10853 * Check if the input is {@link #readOnly read-only}.
10854 *
10855 * @return {boolean}
10856 */
10857 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10858 return this.readOnly;
10859 };
10860
10861 /**
10862 * Set the {@link #readOnly read-only} state of the input.
10863 *
10864 * @param {boolean} state Make input read-only
10865 * @chainable
10866 * @return {OO.ui.Widget} The widget, for chaining
10867 */
10868 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10869 this.readOnly = !!state;
10870 this.$input.prop( 'readOnly', this.readOnly );
10871 return this;
10872 };
10873
10874 /**
10875 * Check if the input is {@link #required required}.
10876 *
10877 * @return {boolean}
10878 */
10879 OO.ui.TextInputWidget.prototype.isRequired = function () {
10880 return this.required;
10881 };
10882
10883 /**
10884 * Set the {@link #required required} state of the input.
10885 *
10886 * @param {boolean} state Make input required
10887 * @chainable
10888 * @return {OO.ui.Widget} The widget, for chaining
10889 */
10890 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10891 this.required = !!state;
10892 if ( this.required ) {
10893 this.$input
10894 .prop( 'required', true )
10895 .attr( 'aria-required', 'true' );
10896 if ( this.getIndicator() === null ) {
10897 this.setIndicator( 'required' );
10898 }
10899 } else {
10900 this.$input
10901 .prop( 'required', false )
10902 .removeAttr( 'aria-required' );
10903 if ( this.getIndicator() === 'required' ) {
10904 this.setIndicator( null );
10905 }
10906 }
10907 return this;
10908 };
10909
10910 /**
10911 * Support function for making #onElementAttach work across browsers.
10912 *
10913 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10914 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10915 *
10916 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10917 * first time that the element gets attached to the documented.
10918 */
10919 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10920 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10921 MutationObserver = window.MutationObserver ||
10922 window.WebKitMutationObserver ||
10923 window.MozMutationObserver,
10924 widget = this;
10925
10926 if ( MutationObserver ) {
10927 // The new way. If only it wasn't so ugly.
10928
10929 if ( this.isElementAttached() ) {
10930 // Widget is attached already, do nothing. This breaks the functionality of this
10931 // function when the widget is detached and reattached. Alas, doing this correctly with
10932 // MutationObserver would require observation of the whole document, which would hurt
10933 // performance of other, more important code.
10934 return;
10935 }
10936
10937 // Find topmost node in the tree
10938 topmostNode = this.$element[ 0 ];
10939 while ( topmostNode.parentNode ) {
10940 topmostNode = topmostNode.parentNode;
10941 }
10942
10943 // We have no way to detect the $element being attached somewhere without observing the
10944 // entire DOM with subtree modifications, which would hurt performance. So we cheat: we hook
10945 // to the parent node of $element, and instead detect when $element is removed from it (and
10946 // thus probably attached somewhere else). If there is no parent, we create a "fake" one. If
10947 // it doesn't get attached, we end up back here and create the parent.
10948 mutationObserver = new MutationObserver( function ( mutations ) {
10949 var i, j, removedNodes;
10950 for ( i = 0; i < mutations.length; i++ ) {
10951 removedNodes = mutations[ i ].removedNodes;
10952 for ( j = 0; j < removedNodes.length; j++ ) {
10953 if ( removedNodes[ j ] === topmostNode ) {
10954 setTimeout( onRemove, 0 );
10955 return;
10956 }
10957 }
10958 }
10959 } );
10960
10961 onRemove = function () {
10962 // If the node was attached somewhere else, report it
10963 if ( widget.isElementAttached() ) {
10964 widget.onElementAttach();
10965 }
10966 mutationObserver.disconnect();
10967 widget.installParentChangeDetector();
10968 };
10969
10970 // Create a fake parent and observe it
10971 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10972 mutationObserver.observe( fakeParentNode, { childList: true } );
10973 } else {
10974 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10975 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10976 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10977 }
10978 };
10979
10980 /**
10981 * @inheritdoc
10982 * @protected
10983 */
10984 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10985 if ( this.getSaneType( config ) === 'number' ) {
10986 return $( '<input>' )
10987 .attr( 'step', 'any' )
10988 .attr( 'type', 'number' );
10989 } else {
10990 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10991 }
10992 };
10993
10994 /**
10995 * Get sanitized value for 'type' for given config.
10996 *
10997 * @param {Object} config Configuration options
10998 * @return {string|null}
10999 * @protected
11000 */
11001 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
11002 var allowedTypes = [
11003 'text',
11004 'password',
11005 'email',
11006 'url',
11007 'number'
11008 ];
11009 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
11010 };
11011
11012 /**
11013 * Focus the input and select a specified range within the text.
11014 *
11015 * @param {number} from Select from offset
11016 * @param {number} [to] Select to offset, defaults to from
11017 * @chainable
11018 * @return {OO.ui.Widget} The widget, for chaining
11019 */
11020 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
11021 var isBackwards, start, end,
11022 input = this.$input[ 0 ];
11023
11024 to = to || from;
11025
11026 isBackwards = to < from;
11027 start = isBackwards ? to : from;
11028 end = isBackwards ? from : to;
11029
11030 this.focus();
11031
11032 try {
11033 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
11034 } catch ( e ) {
11035 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
11036 // Rather than expensively check if the input is attached every time, just check
11037 // if it was the cause of an error being thrown. If not, rethrow the error.
11038 if ( this.getElementDocument().body.contains( input ) ) {
11039 throw e;
11040 }
11041 }
11042 return this;
11043 };
11044
11045 /**
11046 * Get an object describing the current selection range in a directional manner
11047 *
11048 * @return {Object} Object containing 'from' and 'to' offsets
11049 */
11050 OO.ui.TextInputWidget.prototype.getRange = function () {
11051 var input = this.$input[ 0 ],
11052 start = input.selectionStart,
11053 end = input.selectionEnd,
11054 isBackwards = input.selectionDirection === 'backward';
11055
11056 return {
11057 from: isBackwards ? end : start,
11058 to: isBackwards ? start : end
11059 };
11060 };
11061
11062 /**
11063 * Get the length of the text input value.
11064 *
11065 * This could differ from the length of #getValue if the
11066 * value gets filtered
11067 *
11068 * @return {number} Input length
11069 */
11070 OO.ui.TextInputWidget.prototype.getInputLength = function () {
11071 return this.$input[ 0 ].value.length;
11072 };
11073
11074 /**
11075 * Focus the input and select the entire text.
11076 *
11077 * @chainable
11078 * @return {OO.ui.Widget} The widget, for chaining
11079 */
11080 OO.ui.TextInputWidget.prototype.select = function () {
11081 return this.selectRange( 0, this.getInputLength() );
11082 };
11083
11084 /**
11085 * Focus the input and move the cursor to the start.
11086 *
11087 * @chainable
11088 * @return {OO.ui.Widget} The widget, for chaining
11089 */
11090 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
11091 return this.selectRange( 0 );
11092 };
11093
11094 /**
11095 * Focus the input and move the cursor to the end.
11096 *
11097 * @chainable
11098 * @return {OO.ui.Widget} The widget, for chaining
11099 */
11100 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
11101 return this.selectRange( this.getInputLength() );
11102 };
11103
11104 /**
11105 * Insert new content into the input.
11106 *
11107 * @param {string} content Content to be inserted
11108 * @chainable
11109 * @return {OO.ui.Widget} The widget, for chaining
11110 */
11111 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
11112 var start, end,
11113 range = this.getRange(),
11114 value = this.getValue();
11115
11116 start = Math.min( range.from, range.to );
11117 end = Math.max( range.from, range.to );
11118
11119 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11120 this.selectRange( start + content.length );
11121 return this;
11122 };
11123
11124 /**
11125 * Insert new content either side of a selection.
11126 *
11127 * @param {string} pre Content to be inserted before the selection
11128 * @param {string} post Content to be inserted after the selection
11129 * @chainable
11130 * @return {OO.ui.Widget} The widget, for chaining
11131 */
11132 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11133 var start, end,
11134 range = this.getRange(),
11135 offset = pre.length;
11136
11137 start = Math.min( range.from, range.to );
11138 end = Math.max( range.from, range.to );
11139
11140 this.selectRange( start ).insertContent( pre );
11141 this.selectRange( offset + end ).insertContent( post );
11142
11143 this.selectRange( offset + start, offset + end );
11144 return this;
11145 };
11146
11147 /**
11148 * Set the validation pattern.
11149 *
11150 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11151 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11152 * value must contain only numbers).
11153 *
11154 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11155 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11156 */
11157 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11158 if ( validate instanceof RegExp || validate instanceof Function ) {
11159 this.validate = validate;
11160 } else {
11161 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
11162 }
11163 };
11164
11165 /**
11166 * Sets the 'invalid' flag appropriately.
11167 *
11168 * @param {boolean} [isValid] Optionally override validation result
11169 */
11170 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11171 var widget = this,
11172 setFlag = function ( valid ) {
11173 if ( !valid ) {
11174 widget.$input.attr( 'aria-invalid', 'true' );
11175 } else {
11176 widget.$input.removeAttr( 'aria-invalid' );
11177 }
11178 widget.setFlags( { invalid: !valid } );
11179 };
11180
11181 if ( isValid !== undefined ) {
11182 setFlag( isValid );
11183 } else {
11184 this.getValidity().then( function () {
11185 setFlag( true );
11186 }, function () {
11187 setFlag( false );
11188 } );
11189 }
11190 };
11191
11192 /**
11193 * Get the validity of current value.
11194 *
11195 * This method returns a promise that resolves if the value is valid and rejects if
11196 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
11197 *
11198 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11199 */
11200 OO.ui.TextInputWidget.prototype.getValidity = function () {
11201 var result;
11202
11203 function rejectOrResolve( valid ) {
11204 if ( valid ) {
11205 return $.Deferred().resolve().promise();
11206 } else {
11207 return $.Deferred().reject().promise();
11208 }
11209 }
11210
11211 // Check browser validity and reject if it is invalid
11212 if (
11213 this.$input[ 0 ].checkValidity !== undefined &&
11214 this.$input[ 0 ].checkValidity() === false
11215 ) {
11216 return rejectOrResolve( false );
11217 }
11218
11219 // Run our checks if the browser thinks the field is valid
11220 if ( this.validate instanceof Function ) {
11221 result = this.validate( this.getValue() );
11222 if ( result && typeof result.promise === 'function' ) {
11223 return result.promise().then( function ( valid ) {
11224 return rejectOrResolve( valid );
11225 } );
11226 } else {
11227 return rejectOrResolve( result );
11228 }
11229 } else {
11230 return rejectOrResolve( this.getValue().match( this.validate ) );
11231 }
11232 };
11233
11234 /**
11235 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11236 *
11237 * @param {string} labelPosition Label position, 'before' or 'after'
11238 * @chainable
11239 * @return {OO.ui.Widget} The widget, for chaining
11240 */
11241 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11242 this.labelPosition = labelPosition;
11243 if ( this.label ) {
11244 // If there is no label and we only change the position, #updatePosition is a no-op,
11245 // but it takes really a lot of work to do nothing.
11246 this.updatePosition();
11247 }
11248 return this;
11249 };
11250
11251 /**
11252 * Update the position of the inline label.
11253 *
11254 * This method is called by #setLabelPosition, and can also be called on its own if
11255 * something causes the label to be mispositioned.
11256 *
11257 * @chainable
11258 * @return {OO.ui.Widget} The widget, for chaining
11259 */
11260 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11261 var after = this.labelPosition === 'after';
11262
11263 this.$element
11264 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11265 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11266
11267 this.valCache = null;
11268 this.scrollWidth = null;
11269 this.positionLabel();
11270
11271 return this;
11272 };
11273
11274 /**
11275 * Position the label by setting the correct padding on the input.
11276 *
11277 * @private
11278 * @chainable
11279 * @return {OO.ui.Widget} The widget, for chaining
11280 */
11281 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11282 var after, rtl, property, newCss;
11283
11284 if ( this.isWaitingToBeAttached ) {
11285 // #onElementAttach will be called soon, which calls this method
11286 return this;
11287 }
11288
11289 newCss = {
11290 'padding-right': '',
11291 'padding-left': ''
11292 };
11293
11294 if ( this.label ) {
11295 this.$element.append( this.$label );
11296 } else {
11297 this.$label.detach();
11298 // Clear old values if present
11299 this.$input.css( newCss );
11300 return;
11301 }
11302
11303 after = this.labelPosition === 'after';
11304 rtl = this.$element.css( 'direction' ) === 'rtl';
11305 property = after === rtl ? 'padding-left' : 'padding-right';
11306
11307 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11308 // We have to clear the padding on the other side, in case the element direction changed
11309 this.$input.css( newCss );
11310
11311 return this;
11312 };
11313
11314 /**
11315 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11316 * {@link OO.ui.mixin.IconElement search icon} by default.
11317 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11318 *
11319 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11320 *
11321 * @class
11322 * @extends OO.ui.TextInputWidget
11323 *
11324 * @constructor
11325 * @param {Object} [config] Configuration options
11326 */
11327 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11328 config = $.extend( {
11329 icon: 'search'
11330 }, config );
11331
11332 // Parent constructor
11333 OO.ui.SearchInputWidget.parent.call( this, config );
11334
11335 // Events
11336 this.connect( this, {
11337 change: 'onChange'
11338 } );
11339 this.$indicator.on( 'click', this.onIndicatorClick.bind( this ) );
11340
11341 // Initialization
11342 this.updateSearchIndicator();
11343 this.connect( this, {
11344 disable: 'onDisable'
11345 } );
11346 };
11347
11348 /* Setup */
11349
11350 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11351
11352 /* Methods */
11353
11354 /**
11355 * @inheritdoc
11356 * @protected
11357 */
11358 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11359 return 'search';
11360 };
11361
11362 /**
11363 * Handle click events on the indicator
11364 *
11365 * @param {jQuery.Event} e Click event
11366 * @return {boolean}
11367 */
11368 OO.ui.SearchInputWidget.prototype.onIndicatorClick = function ( e ) {
11369 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11370 // Clear the text field
11371 this.setValue( '' );
11372 this.focus();
11373 return false;
11374 }
11375 };
11376
11377 /**
11378 * Update the 'clear' indicator displayed on type: 'search' text
11379 * fields, hiding it when the field is already empty or when it's not
11380 * editable.
11381 */
11382 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11383 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11384 this.setIndicator( null );
11385 } else {
11386 this.setIndicator( 'clear' );
11387 }
11388 };
11389
11390 /**
11391 * Handle change events.
11392 *
11393 * @private
11394 */
11395 OO.ui.SearchInputWidget.prototype.onChange = function () {
11396 this.updateSearchIndicator();
11397 };
11398
11399 /**
11400 * Handle disable events.
11401 *
11402 * @param {boolean} disabled Element is disabled
11403 * @private
11404 */
11405 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11406 this.updateSearchIndicator();
11407 };
11408
11409 /**
11410 * @inheritdoc
11411 */
11412 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11413 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11414 this.updateSearchIndicator();
11415 return this;
11416 };
11417
11418 /**
11419 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11420 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11421 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11422 * {@link OO.ui.mixin.IndicatorElement indicators}.
11423 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11424 *
11425 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11426 *
11427 * @example
11428 * // A MultilineTextInputWidget.
11429 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11430 * value: 'Text input on multiple lines'
11431 * } );
11432 * $( document.body ).append( multilineTextInput.$element );
11433 *
11434 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11435 *
11436 * @class
11437 * @extends OO.ui.TextInputWidget
11438 *
11439 * @constructor
11440 * @param {Object} [config] Configuration options
11441 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11442 * specifies minimum number of rows to display.
11443 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11444 * Use the #maxRows config to specify a maximum number of displayed rows.
11445 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11446 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11447 */
11448 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11449 config = $.extend( {
11450 type: 'text'
11451 }, config );
11452 // Parent constructor
11453 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11454
11455 // Properties
11456 this.autosize = !!config.autosize;
11457 this.styleHeight = null;
11458 this.minRows = config.rows !== undefined ? config.rows : '';
11459 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11460
11461 // Clone for resizing
11462 if ( this.autosize ) {
11463 this.$clone = this.$input
11464 .clone()
11465 .removeAttr( 'id' )
11466 .removeAttr( 'name' )
11467 .insertAfter( this.$input )
11468 .attr( 'aria-hidden', 'true' )
11469 .addClass( 'oo-ui-element-hidden' );
11470 }
11471
11472 // Events
11473 this.connect( this, {
11474 change: 'onChange'
11475 } );
11476
11477 // Initialization
11478 if ( config.rows ) {
11479 this.$input.attr( 'rows', config.rows );
11480 }
11481 if ( this.autosize ) {
11482 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11483 this.isWaitingToBeAttached = true;
11484 this.installParentChangeDetector();
11485 }
11486 };
11487
11488 /* Setup */
11489
11490 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11491
11492 /* Static Methods */
11493
11494 /**
11495 * @inheritdoc
11496 */
11497 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11498 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11499 state.scrollTop = config.$input.scrollTop();
11500 return state;
11501 };
11502
11503 /* Methods */
11504
11505 /**
11506 * @inheritdoc
11507 */
11508 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11509 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11510 this.adjustSize();
11511 };
11512
11513 /**
11514 * Handle change events.
11515 *
11516 * @private
11517 */
11518 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11519 this.adjustSize();
11520 };
11521
11522 /**
11523 * @inheritdoc
11524 */
11525 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11526 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11527 this.adjustSize();
11528 };
11529
11530 /**
11531 * @inheritdoc
11532 *
11533 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11534 */
11535 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11536 if (
11537 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11538 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
11539 e.which === 10
11540 ) {
11541 this.emit( 'enter', e );
11542 }
11543 };
11544
11545 /**
11546 * Automatically adjust the size of the text input.
11547 *
11548 * This only affects multiline inputs that are {@link #autosize autosized}.
11549 *
11550 * @chainable
11551 * @return {OO.ui.Widget} The widget, for chaining
11552 * @fires resize
11553 */
11554 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11555 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11556 idealHeight, newHeight, scrollWidth, property;
11557
11558 if ( this.$input.val() !== this.valCache ) {
11559 if ( this.autosize ) {
11560 this.$clone
11561 .val( this.$input.val() )
11562 .attr( 'rows', this.minRows )
11563 // Set inline height property to 0 to measure scroll height
11564 .css( 'height', 0 );
11565
11566 this.$clone.removeClass( 'oo-ui-element-hidden' );
11567
11568 this.valCache = this.$input.val();
11569
11570 scrollHeight = this.$clone[ 0 ].scrollHeight;
11571
11572 // Remove inline height property to measure natural heights
11573 this.$clone.css( 'height', '' );
11574 innerHeight = this.$clone.innerHeight();
11575 outerHeight = this.$clone.outerHeight();
11576
11577 // Measure max rows height
11578 this.$clone
11579 .attr( 'rows', this.maxRows )
11580 .css( 'height', 'auto' )
11581 .val( '' );
11582 maxInnerHeight = this.$clone.innerHeight();
11583
11584 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11585 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11586 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11587 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11588
11589 this.$clone.addClass( 'oo-ui-element-hidden' );
11590
11591 // Only apply inline height when expansion beyond natural height is needed
11592 // Use the difference between the inner and outer height as a buffer
11593 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11594 if ( newHeight !== this.styleHeight ) {
11595 this.$input.css( 'height', newHeight );
11596 this.styleHeight = newHeight;
11597 this.emit( 'resize' );
11598 }
11599 }
11600 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11601 if ( scrollWidth !== this.scrollWidth ) {
11602 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11603 // Reset
11604 this.$label.css( { right: '', left: '' } );
11605 this.$indicator.css( { right: '', left: '' } );
11606
11607 if ( scrollWidth ) {
11608 this.$indicator.css( property, scrollWidth );
11609 if ( this.labelPosition === 'after' ) {
11610 this.$label.css( property, scrollWidth );
11611 }
11612 }
11613
11614 this.scrollWidth = scrollWidth;
11615 this.positionLabel();
11616 }
11617 }
11618 return this;
11619 };
11620
11621 /**
11622 * @inheritdoc
11623 * @protected
11624 */
11625 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11626 return $( '<textarea>' );
11627 };
11628
11629 /**
11630 * Check if the input automatically adjusts its size.
11631 *
11632 * @return {boolean}
11633 */
11634 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11635 return !!this.autosize;
11636 };
11637
11638 /**
11639 * @inheritdoc
11640 */
11641 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11642 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11643 if ( state.scrollTop !== undefined ) {
11644 this.$input.scrollTop( state.scrollTop );
11645 }
11646 };
11647
11648 /**
11649 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11650 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11651 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11652 *
11653 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11654 * option, that option will appear to be selected.
11655 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11656 * input field.
11657 *
11658 * After the user chooses an option, its `data` will be used as a new value for the widget.
11659 * A `label` also can be specified for each option: if given, it will be shown instead of the
11660 * `data` in the dropdown menu.
11661 *
11662 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11663 *
11664 * For more information about menus and options, please see the
11665 * [OOUI documentation on MediaWiki][1].
11666 *
11667 * @example
11668 * // A ComboBoxInputWidget.
11669 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11670 * value: 'Option 1',
11671 * options: [
11672 * { data: 'Option 1' },
11673 * { data: 'Option 2' },
11674 * { data: 'Option 3' }
11675 * ]
11676 * } );
11677 * $( document.body ).append( comboBox.$element );
11678 *
11679 * @example
11680 * // Example: A ComboBoxInputWidget with additional option labels.
11681 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11682 * value: 'Option 1',
11683 * options: [
11684 * {
11685 * data: 'Option 1',
11686 * label: 'Option One'
11687 * },
11688 * {
11689 * data: 'Option 2',
11690 * label: 'Option Two'
11691 * },
11692 * {
11693 * data: 'Option 3',
11694 * label: 'Option Three'
11695 * }
11696 * ]
11697 * } );
11698 * $( document.body ).append( comboBox.$element );
11699 *
11700 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11701 *
11702 * @class
11703 * @extends OO.ui.TextInputWidget
11704 *
11705 * @constructor
11706 * @param {Object} [config] Configuration options
11707 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11708 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
11709 * select widget}.
11710 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
11711 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
11712 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
11713 * uses relative positioning.
11714 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11715 */
11716 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11717 // Configuration initialization
11718 config = $.extend( {
11719 autocomplete: false
11720 }, config );
11721
11722 // ComboBoxInputWidget shouldn't support `multiline`
11723 config.multiline = false;
11724
11725 // See InputWidget#reusePreInfuseDOM about `config.$input`
11726 if ( config.$input ) {
11727 config.$input.removeAttr( 'list' );
11728 }
11729
11730 // Parent constructor
11731 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11732
11733 // Properties
11734 this.$overlay = ( config.$overlay === true ?
11735 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11736 this.dropdownButton = new OO.ui.ButtonWidget( {
11737 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11738 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11739 indicator: 'down',
11740 invisibleLabel: true,
11741 disabled: this.disabled
11742 } );
11743 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11744 {
11745 widget: this,
11746 input: this,
11747 $floatableContainer: this.$element,
11748 disabled: this.isDisabled()
11749 },
11750 config.menu
11751 ) );
11752
11753 // Events
11754 this.connect( this, {
11755 change: 'onInputChange',
11756 enter: 'onInputEnter'
11757 } );
11758 this.dropdownButton.connect( this, {
11759 click: 'onDropdownButtonClick'
11760 } );
11761 this.menu.connect( this, {
11762 choose: 'onMenuChoose',
11763 add: 'onMenuItemsChange',
11764 remove: 'onMenuItemsChange',
11765 toggle: 'onMenuToggle'
11766 } );
11767
11768 // Initialization
11769 this.$input.attr( {
11770 role: 'combobox',
11771 'aria-owns': this.menu.getElementId(),
11772 'aria-autocomplete': 'list'
11773 } );
11774 this.dropdownButton.$button.attr( {
11775 'aria-controls': this.menu.getElementId()
11776 } );
11777 // Do not override options set via config.menu.items
11778 if ( config.options !== undefined ) {
11779 this.setOptions( config.options );
11780 }
11781 this.$field = $( '<div>' )
11782 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11783 .append( this.$input, this.dropdownButton.$element );
11784 this.$element
11785 .addClass( 'oo-ui-comboBoxInputWidget' )
11786 .append( this.$field );
11787 this.$overlay.append( this.menu.$element );
11788 this.onMenuItemsChange();
11789 };
11790
11791 /* Setup */
11792
11793 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11794
11795 /* Methods */
11796
11797 /**
11798 * Get the combobox's menu.
11799 *
11800 * @return {OO.ui.MenuSelectWidget} Menu widget
11801 */
11802 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11803 return this.menu;
11804 };
11805
11806 /**
11807 * Get the combobox's text input widget.
11808 *
11809 * @return {OO.ui.TextInputWidget} Text input widget
11810 */
11811 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11812 return this;
11813 };
11814
11815 /**
11816 * Handle input change events.
11817 *
11818 * @private
11819 * @param {string} value New value
11820 */
11821 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11822 var match = this.menu.findItemFromData( value );
11823
11824 this.menu.selectItem( match );
11825 if ( this.menu.findHighlightedItem() ) {
11826 this.menu.highlightItem( match );
11827 }
11828
11829 if ( !this.isDisabled() ) {
11830 this.menu.toggle( true );
11831 }
11832 };
11833
11834 /**
11835 * Handle input enter events.
11836 *
11837 * @private
11838 */
11839 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11840 if ( !this.isDisabled() ) {
11841 this.menu.toggle( false );
11842 }
11843 };
11844
11845 /**
11846 * Handle button click events.
11847 *
11848 * @private
11849 */
11850 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11851 this.menu.toggle();
11852 this.focus();
11853 };
11854
11855 /**
11856 * Handle menu choose events.
11857 *
11858 * @private
11859 * @param {OO.ui.OptionWidget} item Chosen item
11860 */
11861 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11862 this.setValue( item.getData() );
11863 };
11864
11865 /**
11866 * Handle menu item change events.
11867 *
11868 * @private
11869 */
11870 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11871 var match = this.menu.findItemFromData( this.getValue() );
11872 this.menu.selectItem( match );
11873 if ( this.menu.findHighlightedItem() ) {
11874 this.menu.highlightItem( match );
11875 }
11876 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11877 };
11878
11879 /**
11880 * Handle menu toggle events.
11881 *
11882 * @private
11883 * @param {boolean} isVisible Open state of the menu
11884 */
11885 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11886 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11887 };
11888
11889 /**
11890 * Update the disabled state of the controls
11891 *
11892 * @chainable
11893 * @protected
11894 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
11895 */
11896 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
11897 var disabled = this.isDisabled() || this.isReadOnly();
11898 if ( this.dropdownButton ) {
11899 this.dropdownButton.setDisabled( disabled );
11900 }
11901 if ( this.menu ) {
11902 this.menu.setDisabled( disabled );
11903 }
11904 return this;
11905 };
11906
11907 /**
11908 * @inheritdoc
11909 */
11910 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
11911 // Parent method
11912 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.apply( this, arguments );
11913 this.updateControlsDisabled();
11914 return this;
11915 };
11916
11917 /**
11918 * @inheritdoc
11919 */
11920 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
11921 // Parent method
11922 OO.ui.ComboBoxInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
11923 this.updateControlsDisabled();
11924 return this;
11925 };
11926
11927 /**
11928 * Set the options available for this input.
11929 *
11930 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11931 * @chainable
11932 * @return {OO.ui.Widget} The widget, for chaining
11933 */
11934 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11935 this.getMenu()
11936 .clearItems()
11937 .addItems( options.map( function ( opt ) {
11938 return new OO.ui.MenuOptionWidget( {
11939 data: opt.data,
11940 label: opt.label !== undefined ? opt.label : opt.data
11941 } );
11942 } ) );
11943
11944 return this;
11945 };
11946
11947 /**
11948 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11949 * which is a widget that is specified by reference before any optional configuration settings.
11950 *
11951 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
11952 * four ways:
11953 *
11954 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11955 * A left-alignment is used for forms with many fields.
11956 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11957 * A right-alignment is used for long but familiar forms which users tab through,
11958 * verifying the current field with a quick glance at the label.
11959 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11960 * that users fill out from top to bottom.
11961 * - **inline**: The label is placed after the field-widget and aligned to the left.
11962 * An inline-alignment is best used with checkboxes or radio buttons.
11963 *
11964 * Help text can either be:
11965 *
11966 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
11967 * or
11968 * - shown as a subtle explanation below the label.
11969 *
11970 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
11971 * If it is long or not essential, leave `helpInline` to its default, `false`.
11972 *
11973 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11974 *
11975 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11976 *
11977 * @class
11978 * @extends OO.ui.Layout
11979 * @mixins OO.ui.mixin.LabelElement
11980 * @mixins OO.ui.mixin.TitledElement
11981 *
11982 * @constructor
11983 * @param {OO.ui.Widget} fieldWidget Field widget
11984 * @param {Object} [config] Configuration options
11985 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11986 * or 'inline'
11987 * @cfg {Array} [errors] Error messages about the widget, which will be
11988 * displayed below the widget.
11989 * @cfg {Array} [warnings] Warning messages about the widget, which will be
11990 * displayed below the widget.
11991 * @cfg {Array} [successMessages] Success messages on user interactions with the widget,
11992 * which will be displayed below the widget.
11993 * The array may contain strings or OO.ui.HtmlSnippet instances.
11994 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11995 * below the widget.
11996 * The array may contain strings or OO.ui.HtmlSnippet instances.
11997 * These are more visible than `help` messages when `helpInline` is set, and so
11998 * might be good for transient messages.
11999 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
12000 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
12001 * corner of the rendered field; clicking it will display the text in a popup.
12002 * If `helpInline` is `true`, then a subtle description will be shown after the
12003 * label.
12004 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
12005 * or shown when the "help" icon is clicked.
12006 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
12007 * `help` is given.
12008 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12009 *
12010 * @throws {Error} An error is thrown if no widget is specified
12011 */
12012 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
12013 // Allow passing positional parameters inside the config object
12014 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12015 config = fieldWidget;
12016 fieldWidget = config.fieldWidget;
12017 }
12018
12019 // Make sure we have required constructor arguments
12020 if ( fieldWidget === undefined ) {
12021 throw new Error( 'Widget not found' );
12022 }
12023
12024 // Configuration initialization
12025 config = $.extend( { align: 'left', helpInline: false }, config );
12026
12027 // Parent constructor
12028 OO.ui.FieldLayout.parent.call( this, config );
12029
12030 // Mixin constructors
12031 OO.ui.mixin.LabelElement.call( this, $.extend( {
12032 $label: $( '<label>' )
12033 }, config ) );
12034 OO.ui.mixin.TitledElement.call( this, $.extend( { $titled: this.$label }, config ) );
12035
12036 // Properties
12037 this.fieldWidget = fieldWidget;
12038 this.errors = [];
12039 this.warnings = [];
12040 this.successMessages = [];
12041 this.notices = [];
12042 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12043 this.$messages = $( '<div>' );
12044 this.$header = $( '<span>' );
12045 this.$body = $( '<div>' );
12046 this.align = null;
12047 this.helpInline = config.helpInline;
12048
12049 // Events
12050 this.fieldWidget.connect( this, {
12051 disable: 'onFieldDisable'
12052 } );
12053
12054 // Initialization
12055 this.$help = config.help ?
12056 this.createHelpElement( config.help, config.$overlay ) :
12057 $( [] );
12058 if ( this.fieldWidget.getInputId() ) {
12059 this.$label.attr( 'for', this.fieldWidget.getInputId() );
12060 if ( this.helpInline ) {
12061 this.$help.attr( 'for', this.fieldWidget.getInputId() );
12062 }
12063 } else {
12064 this.$label.on( 'click', function () {
12065 this.fieldWidget.simulateLabelClick();
12066 }.bind( this ) );
12067 if ( this.helpInline ) {
12068 this.$help.on( 'click', function () {
12069 this.fieldWidget.simulateLabelClick();
12070 }.bind( this ) );
12071 }
12072 }
12073 this.$element
12074 .addClass( 'oo-ui-fieldLayout' )
12075 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
12076 .append( this.$body );
12077 this.$body.addClass( 'oo-ui-fieldLayout-body' );
12078 this.$header.addClass( 'oo-ui-fieldLayout-header' );
12079 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
12080 this.$field
12081 .addClass( 'oo-ui-fieldLayout-field' )
12082 .append( this.fieldWidget.$element );
12083
12084 this.setErrors( config.errors || [] );
12085 this.setWarnings( config.warnings || [] );
12086 this.setSuccess( config.successMessages || [] );
12087 this.setNotices( config.notices || [] );
12088 this.setAlignment( config.align );
12089 // Call this again to take into account the widget's accessKey
12090 this.updateTitle();
12091 };
12092
12093 /* Setup */
12094
12095 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
12096 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
12097 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
12098
12099 /* Methods */
12100
12101 /**
12102 * Handle field disable events.
12103 *
12104 * @private
12105 * @param {boolean} value Field is disabled
12106 */
12107 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
12108 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
12109 };
12110
12111 /**
12112 * Get the widget contained by the field.
12113 *
12114 * @return {OO.ui.Widget} Field widget
12115 */
12116 OO.ui.FieldLayout.prototype.getField = function () {
12117 return this.fieldWidget;
12118 };
12119
12120 /**
12121 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12122 * #setAlignment). Return `false` if it can't or if this can't be determined.
12123 *
12124 * @return {boolean}
12125 */
12126 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12127 // This is very simplistic, but should be good enough.
12128 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12129 };
12130
12131 /**
12132 * @protected
12133 * @param {string} kind 'error' or 'notice'
12134 * @param {string|OO.ui.HtmlSnippet} text
12135 * @return {jQuery}
12136 */
12137 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12138 return new OO.ui.MessageWidget( {
12139 type: kind,
12140 inline: true,
12141 label: text
12142 } ).$element;
12143 };
12144
12145 /**
12146 * Set the field alignment mode.
12147 *
12148 * @private
12149 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12150 * @chainable
12151 * @return {OO.ui.BookletLayout} The layout, for chaining
12152 */
12153 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12154 if ( value !== this.align ) {
12155 // Default to 'left'
12156 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12157 value = 'left';
12158 }
12159 // Validate
12160 if ( value === 'inline' && !this.isFieldInline() ) {
12161 value = 'top';
12162 }
12163 // Reorder elements
12164
12165 if ( this.helpInline ) {
12166 if ( value === 'top' ) {
12167 this.$header.append( this.$label );
12168 this.$body.append( this.$header, this.$field, this.$help );
12169 } else if ( value === 'inline' ) {
12170 this.$header.append( this.$label, this.$help );
12171 this.$body.append( this.$field, this.$header );
12172 } else {
12173 this.$header.append( this.$label, this.$help );
12174 this.$body.append( this.$header, this.$field );
12175 }
12176 } else {
12177 if ( value === 'top' ) {
12178 this.$header.append( this.$help, this.$label );
12179 this.$body.append( this.$header, this.$field );
12180 } else if ( value === 'inline' ) {
12181 this.$header.append( this.$help, this.$label );
12182 this.$body.append( this.$field, this.$header );
12183 } else {
12184 this.$header.append( this.$label );
12185 this.$body.append( this.$header, this.$help, this.$field );
12186 }
12187 }
12188 // Set classes. The following classes can be used here:
12189 // * oo-ui-fieldLayout-align-left
12190 // * oo-ui-fieldLayout-align-right
12191 // * oo-ui-fieldLayout-align-top
12192 // * oo-ui-fieldLayout-align-inline
12193 if ( this.align ) {
12194 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
12195 }
12196 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
12197 this.align = value;
12198 }
12199
12200 return this;
12201 };
12202
12203 /**
12204 * Set the list of error messages.
12205 *
12206 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
12207 * The array may contain strings or OO.ui.HtmlSnippet instances.
12208 * @chainable
12209 * @return {OO.ui.BookletLayout} The layout, for chaining
12210 */
12211 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
12212 this.errors = errors.slice();
12213 this.updateMessages();
12214 return this;
12215 };
12216
12217 /**
12218 * Set the list of warning messages.
12219 *
12220 * @param {Array} warnings Warning messages about the widget, which will be displayed below
12221 * the widget.
12222 * The array may contain strings or OO.ui.HtmlSnippet instances.
12223 * @chainable
12224 * @return {OO.ui.BookletLayout} The layout, for chaining
12225 */
12226 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
12227 this.warnings = warnings.slice();
12228 this.updateMessages();
12229 return this;
12230 };
12231
12232 /**
12233 * Set the list of success messages.
12234 *
12235 * @param {Array} successMessages Success messages about the widget, which will be displayed below
12236 * the widget.
12237 * The array may contain strings or OO.ui.HtmlSnippet instances.
12238 * @chainable
12239 * @return {OO.ui.BookletLayout} The layout, for chaining
12240 */
12241 OO.ui.FieldLayout.prototype.setSuccess = function ( successMessages ) {
12242 this.successMessages = successMessages.slice();
12243 this.updateMessages();
12244 return this;
12245 };
12246
12247 /**
12248 * Set the list of notice messages.
12249 *
12250 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
12251 * The array may contain strings or OO.ui.HtmlSnippet instances.
12252 * @chainable
12253 * @return {OO.ui.BookletLayout} The layout, for chaining
12254 */
12255 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
12256 this.notices = notices.slice();
12257 this.updateMessages();
12258 return this;
12259 };
12260
12261 /**
12262 * Update the rendering of error, warning, success and notice messages.
12263 *
12264 * @private
12265 */
12266 OO.ui.FieldLayout.prototype.updateMessages = function () {
12267 var i;
12268 this.$messages.empty();
12269
12270 if (
12271 this.errors.length ||
12272 this.warnings.length ||
12273 this.successMessages.length ||
12274 this.notices.length
12275 ) {
12276 this.$body.after( this.$messages );
12277 } else {
12278 this.$messages.remove();
12279 return;
12280 }
12281
12282 for ( i = 0; i < this.errors.length; i++ ) {
12283 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
12284 }
12285 for ( i = 0; i < this.warnings.length; i++ ) {
12286 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
12287 }
12288 for ( i = 0; i < this.successMessages.length; i++ ) {
12289 this.$messages.append( this.makeMessage( 'success', this.successMessages[ i ] ) );
12290 }
12291 for ( i = 0; i < this.notices.length; i++ ) {
12292 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
12293 }
12294 };
12295
12296 /**
12297 * Include information about the widget's accessKey in our title. TitledElement calls this method.
12298 * (This is a bit of a hack.)
12299 *
12300 * @protected
12301 * @param {string} title Tooltip label for 'title' attribute
12302 * @return {string}
12303 */
12304 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
12305 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
12306 return this.fieldWidget.formatTitleWithAccessKey( title );
12307 }
12308 return title;
12309 };
12310
12311 /**
12312 * Creates and returns the help element. Also sets the `aria-describedby`
12313 * attribute on the main element of the `fieldWidget`.
12314 *
12315 * @private
12316 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
12317 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
12318 * @return {jQuery} The element that should become `this.$help`.
12319 */
12320 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
12321 var helpId, helpWidget;
12322
12323 if ( this.helpInline ) {
12324 helpWidget = new OO.ui.LabelWidget( {
12325 label: help,
12326 classes: [ 'oo-ui-inline-help' ]
12327 } );
12328
12329 helpId = helpWidget.getElementId();
12330 } else {
12331 helpWidget = new OO.ui.PopupButtonWidget( {
12332 $overlay: $overlay,
12333 popup: {
12334 padded: true
12335 },
12336 classes: [ 'oo-ui-fieldLayout-help' ],
12337 framed: false,
12338 icon: 'info',
12339 label: OO.ui.msg( 'ooui-field-help' ),
12340 invisibleLabel: true
12341 } );
12342 if ( help instanceof OO.ui.HtmlSnippet ) {
12343 helpWidget.getPopup().$body.html( help.toString() );
12344 } else {
12345 helpWidget.getPopup().$body.text( help );
12346 }
12347
12348 helpId = helpWidget.getPopup().getBodyId();
12349 }
12350
12351 // Set the 'aria-describedby' attribute on the fieldWidget
12352 // Preference given to an input or a button
12353 (
12354 this.fieldWidget.$input ||
12355 this.fieldWidget.$button ||
12356 this.fieldWidget.$element
12357 ).attr( 'aria-describedby', helpId );
12358
12359 return helpWidget.$element;
12360 };
12361
12362 /**
12363 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
12364 * a button, and an optional label and/or help text. The field-widget (e.g., a
12365 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
12366 * configuration settings.
12367 *
12368 * Labels can be aligned in one of four ways:
12369 *
12370 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12371 * A left-alignment is used for forms with many fields.
12372 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12373 * A right-alignment is used for long but familiar forms which users tab through,
12374 * verifying the current field with a quick glance at the label.
12375 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12376 * that users fill out from top to bottom.
12377 * - **inline**: The label is placed after the field-widget and aligned to the left.
12378 * An inline-alignment is best used with checkboxes or radio buttons.
12379 *
12380 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
12381 * field layout when help text is specified.
12382 *
12383 * @example
12384 * // Example of an ActionFieldLayout
12385 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12386 * new OO.ui.TextInputWidget( {
12387 * placeholder: 'Field widget'
12388 * } ),
12389 * new OO.ui.ButtonWidget( {
12390 * label: 'Button'
12391 * } ),
12392 * {
12393 * label: 'An ActionFieldLayout. This label is aligned top',
12394 * align: 'top',
12395 * help: 'This is help text'
12396 * }
12397 * );
12398 *
12399 * $( document.body ).append( actionFieldLayout.$element );
12400 *
12401 * @class
12402 * @extends OO.ui.FieldLayout
12403 *
12404 * @constructor
12405 * @param {OO.ui.Widget} fieldWidget Field widget
12406 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12407 * @param {Object} config
12408 */
12409 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12410 // Allow passing positional parameters inside the config object
12411 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12412 config = fieldWidget;
12413 fieldWidget = config.fieldWidget;
12414 buttonWidget = config.buttonWidget;
12415 }
12416
12417 // Parent constructor
12418 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12419
12420 // Properties
12421 this.buttonWidget = buttonWidget;
12422 this.$button = $( '<span>' );
12423 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12424
12425 // Initialization
12426 this.$element.addClass( 'oo-ui-actionFieldLayout' );
12427 this.$button
12428 .addClass( 'oo-ui-actionFieldLayout-button' )
12429 .append( this.buttonWidget.$element );
12430 this.$input
12431 .addClass( 'oo-ui-actionFieldLayout-input' )
12432 .append( this.fieldWidget.$element );
12433 this.$field.append( this.$input, this.$button );
12434 };
12435
12436 /* Setup */
12437
12438 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12439
12440 /**
12441 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12442 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12443 * configured with a label as well. For more information and examples,
12444 * please see the [OOUI documentation on MediaWiki][1].
12445 *
12446 * @example
12447 * // Example of a fieldset layout
12448 * var input1 = new OO.ui.TextInputWidget( {
12449 * placeholder: 'A text input field'
12450 * } );
12451 *
12452 * var input2 = new OO.ui.TextInputWidget( {
12453 * placeholder: 'A text input field'
12454 * } );
12455 *
12456 * var fieldset = new OO.ui.FieldsetLayout( {
12457 * label: 'Example of a fieldset layout'
12458 * } );
12459 *
12460 * fieldset.addItems( [
12461 * new OO.ui.FieldLayout( input1, {
12462 * label: 'Field One'
12463 * } ),
12464 * new OO.ui.FieldLayout( input2, {
12465 * label: 'Field Two'
12466 * } )
12467 * ] );
12468 * $( document.body ).append( fieldset.$element );
12469 *
12470 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12471 *
12472 * @class
12473 * @extends OO.ui.Layout
12474 * @mixins OO.ui.mixin.IconElement
12475 * @mixins OO.ui.mixin.LabelElement
12476 * @mixins OO.ui.mixin.GroupElement
12477 *
12478 * @constructor
12479 * @param {Object} [config] Configuration options
12480 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset.
12481 * See OO.ui.FieldLayout for more information about fields.
12482 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
12483 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
12484 * corner of the rendered field; clicking it will display the text in a popup.
12485 * If `helpInline` is `true`, then a subtle description will be shown after the
12486 * label.
12487 * For feedback messages, you are advised to use `notices`.
12488 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
12489 * or shown when the "help" icon is clicked.
12490 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12491 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12492 */
12493 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12494 var helpWidget;
12495
12496 // Configuration initialization
12497 config = config || {};
12498
12499 // Parent constructor
12500 OO.ui.FieldsetLayout.parent.call( this, config );
12501
12502 // Mixin constructors
12503 OO.ui.mixin.IconElement.call( this, config );
12504 OO.ui.mixin.LabelElement.call( this, config );
12505 OO.ui.mixin.GroupElement.call( this, config );
12506
12507 // Properties
12508 this.$header = $( '<legend>' );
12509
12510 // Initialization
12511 this.$header
12512 .addClass( 'oo-ui-fieldsetLayout-header' )
12513 .append( this.$icon, this.$label );
12514 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12515 this.$element
12516 .addClass( 'oo-ui-fieldsetLayout' )
12517 .prepend( this.$header, this.$group );
12518
12519 // Help
12520 if ( config.help ) {
12521 if ( config.helpInline ) {
12522 helpWidget = new OO.ui.LabelWidget( {
12523 label: config.help,
12524 classes: [ 'oo-ui-inline-help' ]
12525 } );
12526 this.$element.prepend( this.$header, helpWidget.$element, this.$group );
12527 } else {
12528 helpWidget = new OO.ui.PopupButtonWidget( {
12529 $overlay: config.$overlay,
12530 popup: {
12531 padded: true
12532 },
12533 classes: [ 'oo-ui-fieldsetLayout-help' ],
12534 framed: false,
12535 icon: 'info',
12536 label: OO.ui.msg( 'ooui-field-help' ),
12537 invisibleLabel: true
12538 } );
12539 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12540 helpWidget.getPopup().$body.html( config.help.toString() );
12541 } else {
12542 helpWidget.getPopup().$body.text( config.help );
12543 }
12544 this.$header.append( helpWidget.$element );
12545 }
12546 }
12547 if ( Array.isArray( config.items ) ) {
12548 this.addItems( config.items );
12549 }
12550 };
12551
12552 /* Setup */
12553
12554 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12555 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12556 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12557 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12558
12559 /* Static Properties */
12560
12561 /**
12562 * @static
12563 * @inheritdoc
12564 */
12565 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12566
12567 /**
12568 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
12569 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
12570 * can be configured with an HTML form action, an encoding type, and a method using the #action,
12571 * #enctype, and #method configs, respectively.
12572 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12573 *
12574 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12575 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12576 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12577 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12578 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12579 * often have simplified APIs to match the capabilities of HTML forms.
12580 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12581 *
12582 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12583 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12584 *
12585 * @example
12586 * // Example of a form layout that wraps a fieldset layout.
12587 * var input1 = new OO.ui.TextInputWidget( {
12588 * placeholder: 'Username'
12589 * } ),
12590 * input2 = new OO.ui.TextInputWidget( {
12591 * placeholder: 'Password',
12592 * type: 'password'
12593 * } ),
12594 * submit = new OO.ui.ButtonInputWidget( {
12595 * label: 'Submit'
12596 * } ),
12597 * fieldset = new OO.ui.FieldsetLayout( {
12598 * label: 'A form layout'
12599 * } );
12600 *
12601 * fieldset.addItems( [
12602 * new OO.ui.FieldLayout( input1, {
12603 * label: 'Username',
12604 * align: 'top'
12605 * } ),
12606 * new OO.ui.FieldLayout( input2, {
12607 * label: 'Password',
12608 * align: 'top'
12609 * } ),
12610 * new OO.ui.FieldLayout( submit )
12611 * ] );
12612 * var form = new OO.ui.FormLayout( {
12613 * items: [ fieldset ],
12614 * action: '/api/formhandler',
12615 * method: 'get'
12616 * } )
12617 * $( document.body ).append( form.$element );
12618 *
12619 * @class
12620 * @extends OO.ui.Layout
12621 * @mixins OO.ui.mixin.GroupElement
12622 *
12623 * @constructor
12624 * @param {Object} [config] Configuration options
12625 * @cfg {string} [method] HTML form `method` attribute
12626 * @cfg {string} [action] HTML form `action` attribute
12627 * @cfg {string} [enctype] HTML form `enctype` attribute
12628 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12629 */
12630 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12631 var action;
12632
12633 // Configuration initialization
12634 config = config || {};
12635
12636 // Parent constructor
12637 OO.ui.FormLayout.parent.call( this, config );
12638
12639 // Mixin constructors
12640 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12641
12642 // Events
12643 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12644
12645 // Make sure the action is safe
12646 action = config.action;
12647 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12648 action = './' + action;
12649 }
12650
12651 // Initialization
12652 this.$element
12653 .addClass( 'oo-ui-formLayout' )
12654 .attr( {
12655 method: config.method,
12656 action: action,
12657 enctype: config.enctype
12658 } );
12659 if ( Array.isArray( config.items ) ) {
12660 this.addItems( config.items );
12661 }
12662 };
12663
12664 /* Setup */
12665
12666 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12667 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12668
12669 /* Events */
12670
12671 /**
12672 * A 'submit' event is emitted when the form is submitted.
12673 *
12674 * @event submit
12675 */
12676
12677 /* Static Properties */
12678
12679 /**
12680 * @static
12681 * @inheritdoc
12682 */
12683 OO.ui.FormLayout.static.tagName = 'form';
12684
12685 /* Methods */
12686
12687 /**
12688 * Handle form submit events.
12689 *
12690 * @private
12691 * @param {jQuery.Event} e Submit event
12692 * @fires submit
12693 * @return {OO.ui.FormLayout} The layout, for chaining
12694 */
12695 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12696 if ( this.emit( 'submit' ) ) {
12697 return false;
12698 }
12699 };
12700
12701 /**
12702 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
12703 * scrolling, padding, and a frame, and are often used together with
12704 * {@link OO.ui.StackLayout StackLayouts}.
12705 *
12706 * @example
12707 * // Example of a panel layout
12708 * var panel = new OO.ui.PanelLayout( {
12709 * expanded: false,
12710 * framed: true,
12711 * padded: true,
12712 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12713 * } );
12714 * $( document.body ).append( panel.$element );
12715 *
12716 * @class
12717 * @extends OO.ui.Layout
12718 *
12719 * @constructor
12720 * @param {Object} [config] Configuration options
12721 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12722 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12723 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12724 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside
12725 * content.
12726 */
12727 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12728 // Configuration initialization
12729 config = $.extend( {
12730 scrollable: false,
12731 padded: false,
12732 expanded: true,
12733 framed: false
12734 }, config );
12735
12736 // Parent constructor
12737 OO.ui.PanelLayout.parent.call( this, config );
12738
12739 // Initialization
12740 this.$element.addClass( 'oo-ui-panelLayout' );
12741 if ( config.scrollable ) {
12742 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12743 }
12744 if ( config.padded ) {
12745 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12746 }
12747 if ( config.expanded ) {
12748 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12749 }
12750 if ( config.framed ) {
12751 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12752 }
12753 };
12754
12755 /* Setup */
12756
12757 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12758
12759 /* Static Methods */
12760
12761 /**
12762 * @inheritdoc
12763 */
12764 OO.ui.PanelLayout.static.reusePreInfuseDOM = function ( node, config ) {
12765 config = OO.ui.PanelLayout.parent.static.reusePreInfuseDOM( node, config );
12766 if ( config.preserveContent !== false ) {
12767 config.$content = $( node ).contents();
12768 }
12769 return config;
12770 };
12771
12772 /* Methods */
12773
12774 /**
12775 * Focus the panel layout
12776 *
12777 * The default implementation just focuses the first focusable element in the panel
12778 */
12779 OO.ui.PanelLayout.prototype.focus = function () {
12780 OO.ui.findFocusable( this.$element ).focus();
12781 };
12782
12783 /**
12784 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12785 * items), with small margins between them. Convenient when you need to put a number of block-level
12786 * widgets on a single line next to each other.
12787 *
12788 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12789 *
12790 * @example
12791 * // HorizontalLayout with a text input and a label.
12792 * var layout = new OO.ui.HorizontalLayout( {
12793 * items: [
12794 * new OO.ui.LabelWidget( { label: 'Label' } ),
12795 * new OO.ui.TextInputWidget( { value: 'Text' } )
12796 * ]
12797 * } );
12798 * $( document.body ).append( layout.$element );
12799 *
12800 * @class
12801 * @extends OO.ui.Layout
12802 * @mixins OO.ui.mixin.GroupElement
12803 *
12804 * @constructor
12805 * @param {Object} [config] Configuration options
12806 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12807 */
12808 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12809 // Configuration initialization
12810 config = config || {};
12811
12812 // Parent constructor
12813 OO.ui.HorizontalLayout.parent.call( this, config );
12814
12815 // Mixin constructors
12816 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12817
12818 // Initialization
12819 this.$element.addClass( 'oo-ui-horizontalLayout' );
12820 if ( Array.isArray( config.items ) ) {
12821 this.addItems( config.items );
12822 }
12823 };
12824
12825 /* Setup */
12826
12827 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12828 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12829
12830 /**
12831 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12832 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12833 * (to adjust the value in increments) to allow the user to enter a number.
12834 *
12835 * @example
12836 * // A NumberInputWidget.
12837 * var numberInput = new OO.ui.NumberInputWidget( {
12838 * label: 'NumberInputWidget',
12839 * input: { value: 5 },
12840 * min: 1,
12841 * max: 10
12842 * } );
12843 * $( document.body ).append( numberInput.$element );
12844 *
12845 * @class
12846 * @extends OO.ui.TextInputWidget
12847 *
12848 * @constructor
12849 * @param {Object} [config] Configuration options
12850 * @cfg {Object} [minusButton] Configuration options to pass to the
12851 * {@link OO.ui.ButtonWidget decrementing button widget}.
12852 * @cfg {Object} [plusButton] Configuration options to pass to the
12853 * {@link OO.ui.ButtonWidget incrementing button widget}.
12854 * @cfg {number} [min=-Infinity] Minimum allowed value
12855 * @cfg {number} [max=Infinity] Maximum allowed value
12856 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12857 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
12858 * Defaults to `step` if specified, otherwise `1`.
12859 * @cfg {number} [pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
12860 * Defaults to 10 times `buttonStep`.
12861 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12862 */
12863 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12864 var $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
12865
12866 // Configuration initialization
12867 config = $.extend( {
12868 min: -Infinity,
12869 max: Infinity,
12870 showButtons: true
12871 }, config );
12872
12873 // For backward compatibility
12874 $.extend( config, config.input );
12875 this.input = this;
12876
12877 // Parent constructor
12878 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12879 type: 'number'
12880 } ) );
12881
12882 if ( config.showButtons ) {
12883 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12884 {
12885 disabled: this.isDisabled(),
12886 tabIndex: -1,
12887 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12888 icon: 'subtract'
12889 },
12890 config.minusButton
12891 ) );
12892 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12893 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12894 {
12895 disabled: this.isDisabled(),
12896 tabIndex: -1,
12897 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12898 icon: 'add'
12899 },
12900 config.plusButton
12901 ) );
12902 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12903 }
12904
12905 // Events
12906 this.$input.on( {
12907 keydown: this.onKeyDown.bind( this ),
12908 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12909 } );
12910 if ( config.showButtons ) {
12911 this.plusButton.connect( this, {
12912 click: [ 'onButtonClick', +1 ]
12913 } );
12914 this.minusButton.connect( this, {
12915 click: [ 'onButtonClick', -1 ]
12916 } );
12917 }
12918
12919 // Build the field
12920 $field.append( this.$input );
12921 if ( config.showButtons ) {
12922 $field
12923 .prepend( this.minusButton.$element )
12924 .append( this.plusButton.$element );
12925 }
12926
12927 // Initialization
12928 if ( config.allowInteger || config.isInteger ) {
12929 // Backward compatibility
12930 config.step = 1;
12931 }
12932 this.setRange( config.min, config.max );
12933 this.setStep( config.buttonStep, config.pageStep, config.step );
12934 // Set the validation method after we set step and range
12935 // so that it doesn't immediately call setValidityFlag
12936 this.setValidation( this.validateNumber.bind( this ) );
12937
12938 this.$element
12939 .addClass( 'oo-ui-numberInputWidget' )
12940 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12941 .append( $field );
12942 };
12943
12944 /* Setup */
12945
12946 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12947
12948 /* Methods */
12949
12950 // Backward compatibility
12951 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12952 this.setStep( flag ? 1 : null );
12953 };
12954 // Backward compatibility
12955 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12956
12957 // Backward compatibility
12958 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12959 return this.step === 1;
12960 };
12961 // Backward compatibility
12962 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12963
12964 /**
12965 * Set the range of allowed values
12966 *
12967 * @param {number} min Minimum allowed value
12968 * @param {number} max Maximum allowed value
12969 */
12970 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12971 if ( min > max ) {
12972 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12973 }
12974 this.min = min;
12975 this.max = max;
12976 this.$input.attr( 'min', this.min );
12977 this.$input.attr( 'max', this.max );
12978 this.setValidityFlag();
12979 };
12980
12981 /**
12982 * Get the current range
12983 *
12984 * @return {number[]} Minimum and maximum values
12985 */
12986 OO.ui.NumberInputWidget.prototype.getRange = function () {
12987 return [ this.min, this.max ];
12988 };
12989
12990 /**
12991 * Set the stepping deltas
12992 *
12993 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12994 * Defaults to `step` if specified, otherwise `1`.
12995 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12996 * Defaults to 10 times `buttonStep`.
12997 * @param {number|null} [step] If specified, the field only accepts values that are multiples
12998 * of this.
12999 */
13000 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
13001 if ( buttonStep === undefined ) {
13002 buttonStep = step || 1;
13003 }
13004 if ( pageStep === undefined ) {
13005 pageStep = 10 * buttonStep;
13006 }
13007 if ( step !== null && step <= 0 ) {
13008 throw new Error( 'Step value, if given, must be positive' );
13009 }
13010 if ( buttonStep <= 0 ) {
13011 throw new Error( 'Button step value must be positive' );
13012 }
13013 if ( pageStep <= 0 ) {
13014 throw new Error( 'Page step value must be positive' );
13015 }
13016 this.step = step;
13017 this.buttonStep = buttonStep;
13018 this.pageStep = pageStep;
13019 this.$input.attr( 'step', this.step || 'any' );
13020 this.setValidityFlag();
13021 };
13022
13023 /**
13024 * @inheritdoc
13025 */
13026 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
13027 if ( value === '' ) {
13028 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
13029 // so here we make sure an 'empty' value is actually displayed as such.
13030 this.$input.val( '' );
13031 }
13032 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
13033 };
13034
13035 /**
13036 * Get the current stepping values
13037 *
13038 * @return {number[]} Button step, page step, and validity step
13039 */
13040 OO.ui.NumberInputWidget.prototype.getStep = function () {
13041 return [ this.buttonStep, this.pageStep, this.step ];
13042 };
13043
13044 /**
13045 * Get the current value of the widget as a number
13046 *
13047 * @return {number} May be NaN, or an invalid number
13048 */
13049 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
13050 return +this.getValue();
13051 };
13052
13053 /**
13054 * Adjust the value of the widget
13055 *
13056 * @param {number} delta Adjustment amount
13057 */
13058 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
13059 var n, v = this.getNumericValue();
13060
13061 delta = +delta;
13062 if ( isNaN( delta ) || !isFinite( delta ) ) {
13063 throw new Error( 'Delta must be a finite number' );
13064 }
13065
13066 if ( isNaN( v ) ) {
13067 n = 0;
13068 } else {
13069 n = v + delta;
13070 n = Math.max( Math.min( n, this.max ), this.min );
13071 if ( this.step ) {
13072 n = Math.round( n / this.step ) * this.step;
13073 }
13074 }
13075
13076 if ( n !== v ) {
13077 this.setValue( n );
13078 }
13079 };
13080 /**
13081 * Validate input
13082 *
13083 * @private
13084 * @param {string} value Field value
13085 * @return {boolean}
13086 */
13087 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
13088 var n = +value;
13089 if ( value === '' ) {
13090 return !this.isRequired();
13091 }
13092
13093 if ( isNaN( n ) || !isFinite( n ) ) {
13094 return false;
13095 }
13096
13097 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
13098 return false;
13099 }
13100
13101 if ( n < this.min || n > this.max ) {
13102 return false;
13103 }
13104
13105 return true;
13106 };
13107
13108 /**
13109 * Handle mouse click events.
13110 *
13111 * @private
13112 * @param {number} dir +1 or -1
13113 */
13114 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
13115 this.adjustValue( dir * this.buttonStep );
13116 };
13117
13118 /**
13119 * Handle mouse wheel events.
13120 *
13121 * @private
13122 * @param {jQuery.Event} event
13123 * @return {undefined|boolean} False to prevent default if event is handled
13124 */
13125 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13126 var delta = 0;
13127
13128 if ( this.isDisabled() || this.isReadOnly() ) {
13129 return;
13130 }
13131
13132 if ( this.$input.is( ':focus' ) ) {
13133 // Standard 'wheel' event
13134 if ( event.originalEvent.deltaMode !== undefined ) {
13135 this.sawWheelEvent = true;
13136 }
13137 if ( event.originalEvent.deltaY ) {
13138 delta = -event.originalEvent.deltaY;
13139 } else if ( event.originalEvent.deltaX ) {
13140 delta = event.originalEvent.deltaX;
13141 }
13142
13143 // Non-standard events
13144 if ( !this.sawWheelEvent ) {
13145 if ( event.originalEvent.wheelDeltaX ) {
13146 delta = -event.originalEvent.wheelDeltaX;
13147 } else if ( event.originalEvent.wheelDeltaY ) {
13148 delta = event.originalEvent.wheelDeltaY;
13149 } else if ( event.originalEvent.wheelDelta ) {
13150 delta = event.originalEvent.wheelDelta;
13151 } else if ( event.originalEvent.detail ) {
13152 delta = -event.originalEvent.detail;
13153 }
13154 }
13155
13156 if ( delta ) {
13157 delta = delta < 0 ? -1 : 1;
13158 this.adjustValue( delta * this.buttonStep );
13159 }
13160
13161 return false;
13162 }
13163 };
13164
13165 /**
13166 * Handle key down events.
13167 *
13168 * @private
13169 * @param {jQuery.Event} e Key down event
13170 * @return {undefined|boolean} False to prevent default if event is handled
13171 */
13172 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
13173 if ( this.isDisabled() || this.isReadOnly() ) {
13174 return;
13175 }
13176
13177 switch ( e.which ) {
13178 case OO.ui.Keys.UP:
13179 this.adjustValue( this.buttonStep );
13180 return false;
13181 case OO.ui.Keys.DOWN:
13182 this.adjustValue( -this.buttonStep );
13183 return false;
13184 case OO.ui.Keys.PAGEUP:
13185 this.adjustValue( this.pageStep );
13186 return false;
13187 case OO.ui.Keys.PAGEDOWN:
13188 this.adjustValue( -this.pageStep );
13189 return false;
13190 }
13191 };
13192
13193 /**
13194 * Update the disabled state of the controls
13195 *
13196 * @chainable
13197 * @protected
13198 * @return {OO.ui.NumberInputWidget} The widget, for chaining
13199 */
13200 OO.ui.NumberInputWidget.prototype.updateControlsDisabled = function () {
13201 var disabled = this.isDisabled() || this.isReadOnly();
13202 if ( this.minusButton ) {
13203 this.minusButton.setDisabled( disabled );
13204 }
13205 if ( this.plusButton ) {
13206 this.plusButton.setDisabled( disabled );
13207 }
13208 return this;
13209 };
13210
13211 /**
13212 * @inheritdoc
13213 */
13214 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
13215 // Parent method
13216 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
13217 this.updateControlsDisabled();
13218 return this;
13219 };
13220
13221 /**
13222 * @inheritdoc
13223 */
13224 OO.ui.NumberInputWidget.prototype.setReadOnly = function () {
13225 // Parent method
13226 OO.ui.NumberInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
13227 this.updateControlsDisabled();
13228 return this;
13229 };
13230
13231 /**
13232 * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
13233 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
13234 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
13235 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
13236 *
13237 * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
13238 *
13239 * @example
13240 * // A file select input widget.
13241 * var selectFile = new OO.ui.SelectFileInputWidget();
13242 * $( document.body ).append( selectFile.$element );
13243 *
13244 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
13245 *
13246 * @class
13247 * @extends OO.ui.InputWidget
13248 *
13249 * @constructor
13250 * @param {Object} [config] Configuration options
13251 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
13252 * @cfg {boolean} [multiple=false] Allow multiple files to be selected.
13253 * @cfg {string} [placeholder] Text to display when no file is selected.
13254 * @cfg {Object} [button] Config to pass to select file button.
13255 * @cfg {string} [icon] Icon to show next to file info
13256 */
13257 OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
13258 var widget = this;
13259
13260 config = config || {};
13261
13262 // Construct buttons before parent method is called (calling setDisabled)
13263 this.selectButton = new OO.ui.ButtonWidget( $.extend( {
13264 $element: $( '<label>' ),
13265 classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
13266 label: OO.ui.msg( 'ooui-selectfile-button-select' )
13267 }, config.button ) );
13268
13269 // Configuration initialization
13270 config = $.extend( {
13271 accept: null,
13272 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
13273 $tabIndexed: this.selectButton.$tabIndexed
13274 }, config );
13275
13276 this.info = new OO.ui.SearchInputWidget( {
13277 classes: [ 'oo-ui-selectFileInputWidget-info' ],
13278 placeholder: config.placeholder,
13279 // Pass an empty collection so that .focus() always does nothing
13280 $tabIndexed: $( [] )
13281 } ).setIcon( config.icon );
13282 // Set tabindex manually on $input as $tabIndexed has been overridden
13283 this.info.$input.attr( 'tabindex', -1 );
13284
13285 // Parent constructor
13286 OO.ui.SelectFileInputWidget.parent.call( this, config );
13287
13288 // Properties
13289 this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
13290 if ( Array.isArray( config.accept ) ) {
13291 this.accept = config.accept;
13292 } else {
13293 this.accept = null;
13294 }
13295 this.multiple = !!config.multiple;
13296
13297 // Events
13298 this.info.connect( this, { change: 'onInfoChange' } );
13299 this.selectButton.$button.on( {
13300 keypress: this.onKeyPress.bind( this )
13301 } );
13302 this.$input.on( {
13303 change: this.onFileSelected.bind( this ),
13304 // Support: IE11
13305 // In IE 11, focussing a file input (by clicking on it) displays a text cursor and scrolls
13306 // the cursor into view (in this case, it scrolls the button, which has 'overflow: hidden').
13307 // Since this messes with our custom styling (the file input has large dimensions and this
13308 // causes the label to scroll out of view), scroll the button back to top. (T192131)
13309 focus: function () {
13310 widget.$input.parent().prop( 'scrollTop', 0 );
13311 }
13312 } );
13313 this.connect( this, { change: 'updateUI' } );
13314
13315 this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
13316
13317 this.$input
13318 .attr( {
13319 type: 'file',
13320 // this.selectButton is tabindexed
13321 tabindex: -1,
13322 // Infused input may have previously by
13323 // TabIndexed, so remove aria-disabled attr.
13324 'aria-disabled': null
13325 } );
13326
13327 if ( this.accept ) {
13328 this.$input.attr( 'accept', this.accept.join( ', ' ) );
13329 }
13330 if ( this.multiple ) {
13331 this.$input.attr( 'multiple', '' );
13332 }
13333 this.selectButton.$button.append( this.$input );
13334
13335 this.$element
13336 .addClass( 'oo-ui-selectFileInputWidget' )
13337 .append( this.fieldLayout.$element );
13338
13339 this.updateUI();
13340 };
13341
13342 /* Setup */
13343
13344 OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
13345
13346 /* Static properties */
13347
13348 // Set empty title so that browser default tooltips like "No file chosen" don't appear.
13349 // On SelectFileWidget this tooltip will often be incorrect, so create a consistent
13350 // experience on SelectFileInputWidget.
13351 OO.ui.SelectFileInputWidget.static.title = '';
13352
13353 /* Methods */
13354
13355 /**
13356 * Get the filename of the currently selected file.
13357 *
13358 * @return {string} Filename
13359 */
13360 OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
13361 if ( this.currentFiles.length ) {
13362 return this.currentFiles.map( function ( file ) {
13363 return file.name;
13364 } ).join( ', ' );
13365 } else {
13366 // Try to strip leading fakepath.
13367 return this.getValue().split( '\\' ).pop();
13368 }
13369 };
13370
13371 /**
13372 * @inheritdoc
13373 */
13374 OO.ui.SelectFileInputWidget.prototype.setValue = function ( value ) {
13375 if ( value === undefined ) {
13376 // Called during init, don't replace value if just infusing.
13377 return;
13378 }
13379 if ( value ) {
13380 // We need to update this.value, but without trying to modify
13381 // the DOM value, which would throw an exception.
13382 if ( this.value !== value ) {
13383 this.value = value;
13384 this.emit( 'change', this.value );
13385 }
13386 } else {
13387 this.currentFiles = [];
13388 // Parent method
13389 OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
13390 }
13391 };
13392
13393 /**
13394 * Handle file selection from the input.
13395 *
13396 * @protected
13397 * @param {jQuery.Event} e
13398 */
13399 OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
13400 this.currentFiles = this.filterFiles( e.target.files || [] );
13401 };
13402
13403 /**
13404 * Update the user interface when a file is selected or unselected.
13405 *
13406 * @protected
13407 */
13408 OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
13409 this.info.setValue( this.getFilename() );
13410 };
13411
13412 /**
13413 * Determine if we should accept this file.
13414 *
13415 * @private
13416 * @param {FileList|File[]} files Files to filter
13417 * @return {File[]} Filter files
13418 */
13419 OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
13420 var accept = this.accept;
13421
13422 function mimeAllowed( file ) {
13423 var i, mimeTest,
13424 mimeType = file.type;
13425
13426 if ( !accept || !mimeType ) {
13427 return true;
13428 }
13429
13430 for ( i = 0; i < accept.length; i++ ) {
13431 mimeTest = accept[ i ];
13432 if ( mimeTest === mimeType ) {
13433 return true;
13434 } else if ( mimeTest.substr( -2 ) === '/*' ) {
13435 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
13436 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
13437 return true;
13438 }
13439 }
13440 }
13441 return false;
13442 }
13443
13444 return Array.prototype.filter.call( files, mimeAllowed );
13445 };
13446
13447 /**
13448 * Handle info input change events
13449 *
13450 * The info widget can only be changed by the user
13451 * with the clear button.
13452 *
13453 * @private
13454 * @param {string} value
13455 */
13456 OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
13457 if ( value === '' ) {
13458 this.setValue( null );
13459 }
13460 };
13461
13462 /**
13463 * Handle key press events.
13464 *
13465 * @private
13466 * @param {jQuery.Event} e Key press event
13467 * @return {undefined|boolean} False to prevent default if event is handled
13468 */
13469 OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
13470 if ( !this.isDisabled() && this.$input &&
13471 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
13472 ) {
13473 // Emit a click to open the file selector.
13474 this.$input.trigger( 'click' );
13475 // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
13476 this.selectButton.onDocumentKeyUp( e );
13477 return false;
13478 }
13479 };
13480
13481 /**
13482 * @inheritdoc
13483 */
13484 OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
13485 // Parent method
13486 OO.ui.SelectFileInputWidget.parent.prototype.setDisabled.call( this, disabled );
13487
13488 this.selectButton.setDisabled( disabled );
13489 this.info.setDisabled( disabled );
13490
13491 return this;
13492 };
13493
13494 }( OO ) );
13495
13496 //# sourceMappingURL=oojs-ui-core.js.map.json