Update OOUI to v0.33.4
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.33.4
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2019 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2019-07-23T03:23:32Z
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: OO.ui.Element.static.getScrollLeft( win )
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: OO.ui.Element.static.getScrollLeft( el )
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 ( function () {
1083 var rtlScrollType = null;
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 function rtlScrollTypeTest() {
1088 var $definer = $( '<div>' ).attr( {
1089 dir: 'rtl',
1090 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1091 } ).text( 'ABCD' ),
1092 definer = $definer[ 0 ];
1093
1094 $definer.appendTo( 'body' );
1095 if ( definer.scrollLeft > 0 ) {
1096 // Safari, Chrome
1097 rtlScrollType = 'default';
1098 } else {
1099 definer.scrollLeft = 1;
1100 if ( definer.scrollLeft === 0 ) {
1101 // Firefox, old Opera
1102 rtlScrollType = 'negative';
1103 } else {
1104 // Internet Explorer, Edge
1105 rtlScrollType = 'reverse';
1106 }
1107 }
1108 $definer.remove();
1109 }
1110
1111 function isRoot( el ) {
1112 return el.window === el ||
1113 el === el.ownerDocument.body ||
1114 el === el.ownerDocument.documentElement;
1115 }
1116
1117 /**
1118 * Convert native `scrollLeft` value to a value consistent between browsers. See #getScrollLeft.
1119 * @param {number} nativeOffset Native `scrollLeft` value
1120 * @param {HTMLElement|Window} el Element from which the value was obtained
1121 * @return {number}
1122 */
1123 OO.ui.Element.static.computeNormalizedScrollLeft = function ( nativeOffset, el ) {
1124 // All browsers use the correct scroll type ('negative') on the root, so don't
1125 // do any fixups when looking at the root element
1126 var direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );
1127
1128 if ( direction === 'rtl' ) {
1129 if ( rtlScrollType === null ) {
1130 rtlScrollTypeTest();
1131 }
1132 if ( rtlScrollType === 'reverse' ) {
1133 return -nativeOffset;
1134 } else if ( rtlScrollType === 'default' ) {
1135 return nativeOffset - el.scrollWidth + el.clientWidth;
1136 }
1137 }
1138
1139 return nativeOffset;
1140 };
1141
1142 /**
1143 * Convert our normalized `scrollLeft` value to a value for current browser. See #getScrollLeft.
1144 * @param {number} normalizedOffset Normalized `scrollLeft` value
1145 * @param {HTMLElement|Window} el Element on which the value will be set
1146 * @return {number}
1147 */
1148 OO.ui.Element.static.computeNativeScrollLeft = function ( normalizedOffset, el ) {
1149 // All browsers use the correct scroll type ('negative') on the root, so don't
1150 // do any fixups when looking at the root element
1151 var direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );
1152
1153 if ( direction === 'rtl' ) {
1154 if ( rtlScrollType === null ) {
1155 rtlScrollTypeTest();
1156 }
1157 if ( rtlScrollType === 'reverse' ) {
1158 return -normalizedOffset;
1159 } else if ( rtlScrollType === 'default' ) {
1160 return normalizedOffset + el.scrollWidth - el.clientWidth;
1161 }
1162 }
1163
1164 return normalizedOffset;
1165 };
1166
1167 /**
1168 * Get the number of pixels that an element's content is scrolled to the left.
1169 *
1170 * This function smooths out browser inconsistencies (nicely described in the README at
1171 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1172 * with Firefox's 'scrollLeft', which seems the sanest.
1173 *
1174 * (Firefox's scrollLeft handling is nice because it increases from left to right, consistently
1175 * with `getBoundingClientRect().left` and related APIs; because initial value is zero, so
1176 * resetting it is easy; because adapting a hardcoded scroll position to a symmetrical RTL
1177 * interface requires just negating it, rather than involving `clientWidth` and `scrollWidth`;
1178 * and because if you mess up and don't adapt your code to RTL, it will scroll to the beginning
1179 * rather than somewhere randomly in the middle but not where you wanted.)
1180 *
1181 * @static
1182 * @method
1183 * @param {HTMLElement|Window} el Element to measure
1184 * @return {number} Scroll position from the left.
1185 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1186 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1187 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1188 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1189 */
1190 OO.ui.Element.static.getScrollLeft = function ( el ) {
1191 var scrollLeft = isRoot( el ) ? $( window ).scrollLeft() : el.scrollLeft;
1192 scrollLeft = OO.ui.Element.static.computeNormalizedScrollLeft( scrollLeft, el );
1193 return scrollLeft;
1194 };
1195
1196 /**
1197 * Set the number of pixels that an element's content is scrolled to the left.
1198 *
1199 * See #getScrollLeft.
1200 *
1201 * @static
1202 * @method
1203 * @param {HTMLElement|Window} el Element to scroll (and to use in calculations)
1204 * @param {number} scrollLeft Scroll position from the left.
1205 * If the element's direction is LTR, this must be a positive number between `0` (initial scroll
1206 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1207 * If the element's direction is RTL, this must be a negative number between `0` (initial scroll
1208 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1209 */
1210 OO.ui.Element.static.setScrollLeft = function ( el, scrollLeft ) {
1211 scrollLeft = OO.ui.Element.static.computeNativeScrollLeft( scrollLeft, el );
1212 if ( isRoot( el ) ) {
1213 $( window ).scrollLeft( scrollLeft );
1214 } else {
1215 el.scrollLeft = scrollLeft;
1216 }
1217 };
1218 }() );
1219
1220 /**
1221 * Get the root scrollable element of given element's document.
1222 *
1223 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1224 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1225 * lets us use 'body' or 'documentElement' based on what is working.
1226 *
1227 * https://code.google.com/p/chromium/issues/detail?id=303131
1228 *
1229 * @static
1230 * @param {HTMLElement} el Element to find root scrollable parent for
1231 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1232 * depending on browser
1233 */
1234 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1235 var scrollTop, body;
1236
1237 if ( OO.ui.scrollableElement === undefined ) {
1238 body = el.ownerDocument.body;
1239 scrollTop = body.scrollTop;
1240 body.scrollTop = 1;
1241
1242 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1243 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1244 if ( Math.round( body.scrollTop ) === 1 ) {
1245 body.scrollTop = scrollTop;
1246 OO.ui.scrollableElement = 'body';
1247 } else {
1248 OO.ui.scrollableElement = 'documentElement';
1249 }
1250 }
1251
1252 return el.ownerDocument[ OO.ui.scrollableElement ];
1253 };
1254
1255 /**
1256 * Get closest scrollable container.
1257 *
1258 * Traverses up until either a scrollable element or the root is reached, in which case the root
1259 * scrollable element will be returned (see #getRootScrollableElement).
1260 *
1261 * @static
1262 * @param {HTMLElement} el Element to find scrollable container for
1263 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1264 * @return {HTMLElement} Closest scrollable container
1265 */
1266 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1267 var i, val,
1268 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1269 // 'overflow-y' have different values, so we need to check the separate properties.
1270 props = [ 'overflow-x', 'overflow-y' ],
1271 $parent = $( el ).parent();
1272
1273 if ( dimension === 'x' || dimension === 'y' ) {
1274 props = [ 'overflow-' + dimension ];
1275 }
1276
1277 // Special case for the document root (which doesn't really have any scrollable container,
1278 // since it is the ultimate scrollable container, but this is probably saner than null or
1279 // exception).
1280 if ( $( el ).is( 'html, body' ) ) {
1281 return this.getRootScrollableElement( el );
1282 }
1283
1284 while ( $parent.length ) {
1285 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1286 return $parent[ 0 ];
1287 }
1288 i = props.length;
1289 while ( i-- ) {
1290 val = $parent.css( props[ i ] );
1291 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1292 // never be scrolled in that direction, but they can actually be scrolled
1293 // programatically. The user can unintentionally perform a scroll in such case even if
1294 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1295 // when using built-in find functionality.
1296 // This could cause funny issues...
1297 if ( val === 'auto' || val === 'scroll' ) {
1298 return $parent[ 0 ];
1299 }
1300 }
1301 $parent = $parent.parent();
1302 }
1303 // The element is unattached... return something mostly sane
1304 return this.getRootScrollableElement( el );
1305 };
1306
1307 /**
1308 * Scroll element into view.
1309 *
1310 * @static
1311 * @param {HTMLElement|Object} elOrPosition Element to scroll into view
1312 * @param {Object} [config] Configuration options
1313 * @param {string} [config.animate=true] Animate to the new scroll offset.
1314 * @param {string} [config.duration='fast'] jQuery animation duration value
1315 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1316 * to scroll in both directions
1317 * @param {Object} [config.padding] Additional padding on the container to scroll past.
1318 * Object containing any of 'top', 'bottom', 'left', or 'right' as numbers.
1319 * @param {Object} [config.scrollContainer] Scroll container. Defaults to
1320 * getClosestScrollableContainer of the element.
1321 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1322 */
1323 OO.ui.Element.static.scrollIntoView = function ( elOrPosition, config ) {
1324 var position, animations, container, $container, elementPosition, containerDimensions,
1325 $window, padding, animate, method,
1326 deferred = $.Deferred();
1327
1328 // Configuration initialization
1329 config = config || {};
1330
1331 padding = $.extend( {
1332 top: 0,
1333 bottom: 0,
1334 left: 0,
1335 right: 0
1336 }, config.padding );
1337
1338 animate = config.animate !== false;
1339
1340 animations = {};
1341 elementPosition = elOrPosition instanceof HTMLElement ?
1342 this.getDimensions( elOrPosition ).rect :
1343 elOrPosition;
1344 container = config.scrollContainer || (
1345 elOrPosition instanceof HTMLElement ?
1346 this.getClosestScrollableContainer( elOrPosition, config.direction ) :
1347 // No scrollContainer or element
1348 this.getClosestScrollableContainer( document.body )
1349 );
1350 $container = $( container );
1351 containerDimensions = this.getDimensions( container );
1352 $window = $( this.getWindow( container ) );
1353
1354 // Compute the element's position relative to the container
1355 if ( $container.is( 'html, body' ) ) {
1356 // If the scrollable container is the root, this is easy
1357 position = {
1358 top: elementPosition.top,
1359 bottom: $window.innerHeight() - elementPosition.bottom,
1360 left: elementPosition.left,
1361 right: $window.innerWidth() - elementPosition.right
1362 };
1363 } else {
1364 // Otherwise, we have to subtract el's coordinates from container's coordinates
1365 position = {
1366 top: elementPosition.top -
1367 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1368 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1369 containerDimensions.scrollbar.bottom - elementPosition.bottom,
1370 left: elementPosition.left -
1371 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1372 right: containerDimensions.rect.right - containerDimensions.borders.right -
1373 containerDimensions.scrollbar.right - elementPosition.right
1374 };
1375 }
1376
1377 if ( !config.direction || config.direction === 'y' ) {
1378 if ( position.top < padding.top ) {
1379 animations.scrollTop = containerDimensions.scroll.top + position.top - padding.top;
1380 } else if ( position.bottom < padding.bottom ) {
1381 animations.scrollTop = containerDimensions.scroll.top +
1382 // Scroll the bottom into view, but not at the expense
1383 // of scrolling the top out of view
1384 Math.min( position.top - padding.top, -position.bottom + padding.bottom );
1385 }
1386 }
1387 if ( !config.direction || config.direction === 'x' ) {
1388 if ( position.left < padding.left ) {
1389 animations.scrollLeft = containerDimensions.scroll.left + position.left - padding.left;
1390 } else if ( position.right < padding.right ) {
1391 animations.scrollLeft = containerDimensions.scroll.left +
1392 // Scroll the right into view, but not at the expense
1393 // of scrolling the left out of view
1394 Math.min( position.left - padding.left, -position.right + padding.right );
1395 }
1396 if ( animations.scrollLeft !== undefined ) {
1397 animations.scrollLeft = OO.ui.Element.static.computeNativeScrollLeft( animations.scrollLeft, container );
1398 }
1399 }
1400 if ( !$.isEmptyObject( animations ) ) {
1401 if ( animate ) {
1402 // eslint-disable-next-line no-jquery/no-animate
1403 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1404 $container.queue( function ( next ) {
1405 deferred.resolve();
1406 next();
1407 } );
1408 } else {
1409 $container.stop( true );
1410 for ( method in animations ) {
1411 $container[ method ]( animations[ method ] );
1412 }
1413 deferred.resolve();
1414 }
1415 } else {
1416 deferred.resolve();
1417 }
1418 return deferred.promise();
1419 };
1420
1421 /**
1422 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1423 * and reserve space for them, because it probably doesn't.
1424 *
1425 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1426 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1427 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1428 * reflow, and then reattach (or show) them back.
1429 *
1430 * @static
1431 * @param {HTMLElement} el Element to reconsider the scrollbars on
1432 */
1433 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1434 var i, len, scrollLeft, scrollTop, nodes = [];
1435 // Save scroll position
1436 scrollLeft = el.scrollLeft;
1437 scrollTop = el.scrollTop;
1438 // Detach all children
1439 while ( el.firstChild ) {
1440 nodes.push( el.firstChild );
1441 el.removeChild( el.firstChild );
1442 }
1443 // Force reflow
1444 // eslint-disable-next-line no-void
1445 void el.offsetHeight;
1446 // Reattach all children
1447 for ( i = 0, len = nodes.length; i < len; i++ ) {
1448 el.appendChild( nodes[ i ] );
1449 }
1450 // Restore scroll position (no-op if scrollbars disappeared)
1451 el.scrollLeft = scrollLeft;
1452 el.scrollTop = scrollTop;
1453 };
1454
1455 /* Methods */
1456
1457 /**
1458 * Toggle visibility of an element.
1459 *
1460 * @param {boolean} [show] Make element visible, omit to toggle visibility
1461 * @fires visible
1462 * @chainable
1463 * @return {OO.ui.Element} The element, for chaining
1464 */
1465 OO.ui.Element.prototype.toggle = function ( show ) {
1466 show = show === undefined ? !this.visible : !!show;
1467
1468 if ( show !== this.isVisible() ) {
1469 this.visible = show;
1470 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1471 this.emit( 'toggle', show );
1472 }
1473
1474 return this;
1475 };
1476
1477 /**
1478 * Check if element is visible.
1479 *
1480 * @return {boolean} element is visible
1481 */
1482 OO.ui.Element.prototype.isVisible = function () {
1483 return this.visible;
1484 };
1485
1486 /**
1487 * Get element data.
1488 *
1489 * @return {Mixed} Element data
1490 */
1491 OO.ui.Element.prototype.getData = function () {
1492 return this.data;
1493 };
1494
1495 /**
1496 * Set element data.
1497 *
1498 * @param {Mixed} data Element data
1499 * @chainable
1500 * @return {OO.ui.Element} The element, for chaining
1501 */
1502 OO.ui.Element.prototype.setData = function ( data ) {
1503 this.data = data;
1504 return this;
1505 };
1506
1507 /**
1508 * Set the element has an 'id' attribute.
1509 *
1510 * @param {string} id
1511 * @chainable
1512 * @return {OO.ui.Element} The element, for chaining
1513 */
1514 OO.ui.Element.prototype.setElementId = function ( id ) {
1515 this.elementId = id;
1516 this.$element.attr( 'id', id );
1517 return this;
1518 };
1519
1520 /**
1521 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1522 * and return its value.
1523 *
1524 * @return {string}
1525 */
1526 OO.ui.Element.prototype.getElementId = function () {
1527 if ( this.elementId === null ) {
1528 this.setElementId( OO.ui.generateElementId() );
1529 }
1530 return this.elementId;
1531 };
1532
1533 /**
1534 * Check if element supports one or more methods.
1535 *
1536 * @param {string|string[]} methods Method or list of methods to check
1537 * @return {boolean} All methods are supported
1538 */
1539 OO.ui.Element.prototype.supports = function ( methods ) {
1540 var i, len,
1541 support = 0;
1542
1543 methods = Array.isArray( methods ) ? methods : [ methods ];
1544 for ( i = 0, len = methods.length; i < len; i++ ) {
1545 if ( typeof this[ methods[ i ] ] === 'function' ) {
1546 support++;
1547 }
1548 }
1549
1550 return methods.length === support;
1551 };
1552
1553 /**
1554 * Update the theme-provided classes.
1555 *
1556 * @localdoc This is called in element mixins and widget classes any time state changes.
1557 * Updating is debounced, minimizing overhead of changing multiple attributes and
1558 * guaranteeing that theme updates do not occur within an element's constructor
1559 */
1560 OO.ui.Element.prototype.updateThemeClasses = function () {
1561 OO.ui.theme.queueUpdateElementClasses( this );
1562 };
1563
1564 /**
1565 * Get the HTML tag name.
1566 *
1567 * Override this method to base the result on instance information.
1568 *
1569 * @return {string} HTML tag name
1570 */
1571 OO.ui.Element.prototype.getTagName = function () {
1572 return this.constructor.static.tagName;
1573 };
1574
1575 /**
1576 * Check if the element is attached to the DOM
1577 *
1578 * @return {boolean} The element is attached to the DOM
1579 */
1580 OO.ui.Element.prototype.isElementAttached = function () {
1581 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1582 };
1583
1584 /**
1585 * Get the DOM document.
1586 *
1587 * @return {HTMLDocument} Document object
1588 */
1589 OO.ui.Element.prototype.getElementDocument = function () {
1590 // Don't cache this in other ways either because subclasses could can change this.$element
1591 return OO.ui.Element.static.getDocument( this.$element );
1592 };
1593
1594 /**
1595 * Get the DOM window.
1596 *
1597 * @return {Window} Window object
1598 */
1599 OO.ui.Element.prototype.getElementWindow = function () {
1600 return OO.ui.Element.static.getWindow( this.$element );
1601 };
1602
1603 /**
1604 * Get closest scrollable container.
1605 *
1606 * @return {HTMLElement} Closest scrollable container
1607 */
1608 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1609 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1610 };
1611
1612 /**
1613 * Get group element is in.
1614 *
1615 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1616 */
1617 OO.ui.Element.prototype.getElementGroup = function () {
1618 return this.elementGroup;
1619 };
1620
1621 /**
1622 * Set group element is in.
1623 *
1624 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1625 * @chainable
1626 * @return {OO.ui.Element} The element, for chaining
1627 */
1628 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1629 this.elementGroup = group;
1630 return this;
1631 };
1632
1633 /**
1634 * Scroll element into view.
1635 *
1636 * @param {Object} [config] Configuration options
1637 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1638 */
1639 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1640 if (
1641 !this.isElementAttached() ||
1642 !this.isVisible() ||
1643 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1644 ) {
1645 return $.Deferred().resolve();
1646 }
1647 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1648 };
1649
1650 /**
1651 * Restore the pre-infusion dynamic state for this widget.
1652 *
1653 * This method is called after #$element has been inserted into DOM. The parameter is the return
1654 * value of #gatherPreInfuseState.
1655 *
1656 * @protected
1657 * @param {Object} state
1658 */
1659 OO.ui.Element.prototype.restorePreInfuseState = function () {
1660 };
1661
1662 /**
1663 * Wraps an HTML snippet for use with configuration values which default
1664 * to strings. This bypasses the default html-escaping done to string
1665 * values.
1666 *
1667 * @class
1668 *
1669 * @constructor
1670 * @param {string} [content] HTML content
1671 */
1672 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1673 // Properties
1674 this.content = content;
1675 };
1676
1677 /* Setup */
1678
1679 OO.initClass( OO.ui.HtmlSnippet );
1680
1681 /* Methods */
1682
1683 /**
1684 * Render into HTML.
1685 *
1686 * @return {string} Unchanged HTML snippet.
1687 */
1688 OO.ui.HtmlSnippet.prototype.toString = function () {
1689 return this.content;
1690 };
1691
1692 /**
1693 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1694 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1695 * are, combined.
1696 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1697 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1698 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1699 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1700 * for more information and examples.
1701 *
1702 * @abstract
1703 * @class
1704 * @extends OO.ui.Element
1705 * @mixins OO.EventEmitter
1706 *
1707 * @constructor
1708 * @param {Object} [config] Configuration options
1709 */
1710 OO.ui.Layout = function OoUiLayout( config ) {
1711 // Configuration initialization
1712 config = config || {};
1713
1714 // Parent constructor
1715 OO.ui.Layout.parent.call( this, config );
1716
1717 // Mixin constructors
1718 OO.EventEmitter.call( this );
1719
1720 // Initialization
1721 this.$element.addClass( 'oo-ui-layout' );
1722 };
1723
1724 /* Setup */
1725
1726 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1727 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1728
1729 /* Methods */
1730
1731 /**
1732 * Reset scroll offsets
1733 *
1734 * @chainable
1735 * @return {OO.ui.Layout} The layout, for chaining
1736 */
1737 OO.ui.Layout.prototype.resetScroll = function () {
1738 this.$element[ 0 ].scrollTop = 0;
1739 OO.ui.Element.static.setScrollLeft( this.$element[ 0 ], 0 );
1740
1741 return this;
1742 };
1743
1744 /**
1745 * Widgets are compositions of one or more OOUI elements that users can both view
1746 * and interact with. All widgets can be configured and modified via a standard API,
1747 * and their state can change dynamically according to a model.
1748 *
1749 * @abstract
1750 * @class
1751 * @extends OO.ui.Element
1752 * @mixins OO.EventEmitter
1753 *
1754 * @constructor
1755 * @param {Object} [config] Configuration options
1756 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1757 * appearance reflects this state.
1758 */
1759 OO.ui.Widget = function OoUiWidget( config ) {
1760 // Initialize config
1761 config = $.extend( { disabled: false }, config );
1762
1763 // Parent constructor
1764 OO.ui.Widget.parent.call( this, config );
1765
1766 // Mixin constructors
1767 OO.EventEmitter.call( this );
1768
1769 // Properties
1770 this.disabled = null;
1771 this.wasDisabled = null;
1772
1773 // Initialization
1774 this.$element.addClass( 'oo-ui-widget' );
1775 this.setDisabled( !!config.disabled );
1776 };
1777
1778 /* Setup */
1779
1780 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1781 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1782
1783 /* Events */
1784
1785 /**
1786 * @event disable
1787 *
1788 * A 'disable' event is emitted when the disabled state of the widget changes
1789 * (i.e. on disable **and** enable).
1790 *
1791 * @param {boolean} disabled Widget is disabled
1792 */
1793
1794 /**
1795 * @event toggle
1796 *
1797 * A 'toggle' event is emitted when the visibility of the widget changes.
1798 *
1799 * @param {boolean} visible Widget is visible
1800 */
1801
1802 /* Methods */
1803
1804 /**
1805 * Check if the widget is disabled.
1806 *
1807 * @return {boolean} Widget is disabled
1808 */
1809 OO.ui.Widget.prototype.isDisabled = function () {
1810 return this.disabled;
1811 };
1812
1813 /**
1814 * Set the 'disabled' state of the widget.
1815 *
1816 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1817 *
1818 * @param {boolean} disabled Disable widget
1819 * @chainable
1820 * @return {OO.ui.Widget} The widget, for chaining
1821 */
1822 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1823 var isDisabled;
1824
1825 this.disabled = !!disabled;
1826 isDisabled = this.isDisabled();
1827 if ( isDisabled !== this.wasDisabled ) {
1828 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1829 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1830 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1831 this.emit( 'disable', isDisabled );
1832 this.updateThemeClasses();
1833 }
1834 this.wasDisabled = isDisabled;
1835
1836 return this;
1837 };
1838
1839 /**
1840 * Update the disabled state, in case of changes in parent widget.
1841 *
1842 * @chainable
1843 * @return {OO.ui.Widget} The widget, for chaining
1844 */
1845 OO.ui.Widget.prototype.updateDisabled = function () {
1846 this.setDisabled( this.disabled );
1847 return this;
1848 };
1849
1850 /**
1851 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1852 * value.
1853 *
1854 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1855 * instead.
1856 *
1857 * @return {string|null} The ID of the labelable element
1858 */
1859 OO.ui.Widget.prototype.getInputId = function () {
1860 return null;
1861 };
1862
1863 /**
1864 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1865 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1866 * override this method to provide intuitive, accessible behavior.
1867 *
1868 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1869 * Individual widgets may override it too.
1870 *
1871 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1872 * directly.
1873 */
1874 OO.ui.Widget.prototype.simulateLabelClick = function () {
1875 };
1876
1877 /**
1878 * Theme logic.
1879 *
1880 * @abstract
1881 * @class
1882 *
1883 * @constructor
1884 */
1885 OO.ui.Theme = function OoUiTheme() {
1886 this.elementClassesQueue = [];
1887 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1888 };
1889
1890 /* Setup */
1891
1892 OO.initClass( OO.ui.Theme );
1893
1894 /* Methods */
1895
1896 /**
1897 * Get a list of classes to be applied to a widget.
1898 *
1899 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1900 * otherwise state transitions will not work properly.
1901 *
1902 * @param {OO.ui.Element} element Element for which to get classes
1903 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1904 */
1905 OO.ui.Theme.prototype.getElementClasses = function () {
1906 return { on: [], off: [] };
1907 };
1908
1909 /**
1910 * Update CSS classes provided by the theme.
1911 *
1912 * For elements with theme logic hooks, this should be called any time there's a state change.
1913 *
1914 * @param {OO.ui.Element} element Element for which to update classes
1915 */
1916 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1917 var $elements = $( [] ),
1918 classes = this.getElementClasses( element );
1919
1920 if ( element.$icon ) {
1921 $elements = $elements.add( element.$icon );
1922 }
1923 if ( element.$indicator ) {
1924 $elements = $elements.add( element.$indicator );
1925 }
1926
1927 $elements
1928 .removeClass( classes.off )
1929 .addClass( classes.on );
1930 };
1931
1932 /**
1933 * @private
1934 */
1935 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1936 var i;
1937 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1938 this.updateElementClasses( this.elementClassesQueue[ i ] );
1939 }
1940 // Clear the queue
1941 this.elementClassesQueue = [];
1942 };
1943
1944 /**
1945 * Queue #updateElementClasses to be called for this element.
1946 *
1947 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1948 * to make them synchronous.
1949 *
1950 * @param {OO.ui.Element} element Element for which to update classes
1951 */
1952 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1953 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1954 // the most common case (this method is often called repeatedly for the same element).
1955 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1956 return;
1957 }
1958 this.elementClassesQueue.push( element );
1959 this.debouncedUpdateQueuedElementClasses();
1960 };
1961
1962 /**
1963 * Get the transition duration in milliseconds for dialogs opening/closing
1964 *
1965 * The dialog should be fully rendered this many milliseconds after the
1966 * ready process has executed.
1967 *
1968 * @return {number} Transition duration in milliseconds
1969 */
1970 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1971 return 0;
1972 };
1973
1974 /**
1975 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1976 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1977 * order in which users will navigate through the focusable elements via the Tab key.
1978 *
1979 * @example
1980 * // TabIndexedElement is mixed into the ButtonWidget class
1981 * // to provide a tabIndex property.
1982 * var button1 = new OO.ui.ButtonWidget( {
1983 * label: 'fourth',
1984 * tabIndex: 4
1985 * } ),
1986 * button2 = new OO.ui.ButtonWidget( {
1987 * label: 'second',
1988 * tabIndex: 2
1989 * } ),
1990 * button3 = new OO.ui.ButtonWidget( {
1991 * label: 'third',
1992 * tabIndex: 3
1993 * } ),
1994 * button4 = new OO.ui.ButtonWidget( {
1995 * label: 'first',
1996 * tabIndex: 1
1997 * } );
1998 * $( document.body ).append(
1999 * button1.$element,
2000 * button2.$element,
2001 * button3.$element,
2002 * button4.$element
2003 * );
2004 *
2005 * @abstract
2006 * @class
2007 *
2008 * @constructor
2009 * @param {Object} [config] Configuration options
2010 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
2011 * the functionality is applied to the element created by the class ($element). If a different
2012 * element is specified, the tabindex functionality will be applied to it instead.
2013 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the
2014 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
2015 * navigation order; use -1 to remove the element from the tab-navigation flow.
2016 */
2017 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
2018 // Configuration initialization
2019 config = $.extend( { tabIndex: 0 }, config );
2020
2021 // Properties
2022 this.$tabIndexed = null;
2023 this.tabIndex = null;
2024
2025 // Events
2026 this.connect( this, {
2027 disable: 'onTabIndexedElementDisable'
2028 } );
2029
2030 // Initialization
2031 this.setTabIndex( config.tabIndex );
2032 this.setTabIndexedElement( config.$tabIndexed || this.$element );
2033 };
2034
2035 /* Setup */
2036
2037 OO.initClass( OO.ui.mixin.TabIndexedElement );
2038
2039 /* Methods */
2040
2041 /**
2042 * Set the element that should use the tabindex functionality.
2043 *
2044 * This method is used to retarget a tabindex mixin so that its functionality applies
2045 * to the specified element. If an element is currently using the functionality, the mixin’s
2046 * effect on that element is removed before the new element is set up.
2047 *
2048 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
2049 * @chainable
2050 * @return {OO.ui.Element} The element, for chaining
2051 */
2052 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
2053 var tabIndex = this.tabIndex;
2054 // Remove attributes from old $tabIndexed
2055 this.setTabIndex( null );
2056 // Force update of new $tabIndexed
2057 this.$tabIndexed = $tabIndexed;
2058 this.tabIndex = tabIndex;
2059 return this.updateTabIndex();
2060 };
2061
2062 /**
2063 * Set the value of the tabindex.
2064 *
2065 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
2066 * @chainable
2067 * @return {OO.ui.Element} The element, for chaining
2068 */
2069 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
2070 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2071
2072 if ( this.tabIndex !== tabIndex ) {
2073 this.tabIndex = tabIndex;
2074 this.updateTabIndex();
2075 }
2076
2077 return this;
2078 };
2079
2080 /**
2081 * Update the `tabindex` attribute, in case of changes to tab index or
2082 * disabled state.
2083 *
2084 * @private
2085 * @chainable
2086 * @return {OO.ui.Element} The element, for chaining
2087 */
2088 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2089 if ( this.$tabIndexed ) {
2090 if ( this.tabIndex !== null ) {
2091 // Do not index over disabled elements
2092 this.$tabIndexed.attr( {
2093 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2094 // Support: ChromeVox and NVDA
2095 // These do not seem to inherit aria-disabled from parent elements
2096 'aria-disabled': this.isDisabled().toString()
2097 } );
2098 } else {
2099 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2100 }
2101 }
2102 return this;
2103 };
2104
2105 /**
2106 * Handle disable events.
2107 *
2108 * @private
2109 * @param {boolean} disabled Element is disabled
2110 */
2111 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2112 this.updateTabIndex();
2113 };
2114
2115 /**
2116 * Get the value of the tabindex.
2117 *
2118 * @return {number|null} Tabindex value
2119 */
2120 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2121 return this.tabIndex;
2122 };
2123
2124 /**
2125 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2126 *
2127 * If the element already has an ID then that is returned, otherwise unique ID is
2128 * generated, set on the element, and returned.
2129 *
2130 * @return {string|null} The ID of the focusable element
2131 */
2132 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2133 var id;
2134
2135 if ( !this.$tabIndexed ) {
2136 return null;
2137 }
2138 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2139 return null;
2140 }
2141
2142 id = this.$tabIndexed.attr( 'id' );
2143 if ( id === undefined ) {
2144 id = OO.ui.generateElementId();
2145 this.$tabIndexed.attr( 'id', id );
2146 }
2147
2148 return id;
2149 };
2150
2151 /**
2152 * Whether the node is 'labelable' according to the HTML spec
2153 * (i.e., whether it can be interacted with through a `<label for="…">`).
2154 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2155 *
2156 * @private
2157 * @param {jQuery} $node
2158 * @return {boolean}
2159 */
2160 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2161 var
2162 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2163 tagName = ( $node.prop( 'tagName' ) || '' ).toLowerCase();
2164
2165 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2166 return true;
2167 }
2168 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2169 return true;
2170 }
2171 return false;
2172 };
2173
2174 /**
2175 * Focus this element.
2176 *
2177 * @chainable
2178 * @return {OO.ui.Element} The element, for chaining
2179 */
2180 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2181 if ( !this.isDisabled() ) {
2182 this.$tabIndexed.trigger( 'focus' );
2183 }
2184 return this;
2185 };
2186
2187 /**
2188 * Blur this element.
2189 *
2190 * @chainable
2191 * @return {OO.ui.Element} The element, for chaining
2192 */
2193 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2194 this.$tabIndexed.trigger( 'blur' );
2195 return this;
2196 };
2197
2198 /**
2199 * @inheritdoc OO.ui.Widget
2200 */
2201 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2202 this.focus();
2203 };
2204
2205 /**
2206 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2207 * interface element that can be configured with access keys for keyboard interaction.
2208 * See the [OOUI documentation on MediaWiki] [1] for examples.
2209 *
2210 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2211 *
2212 * @abstract
2213 * @class
2214 *
2215 * @constructor
2216 * @param {Object} [config] Configuration options
2217 * @cfg {jQuery} [$button] The button element created by the class.
2218 * If this configuration is omitted, the button element will use a generated `<a>`.
2219 * @cfg {boolean} [framed=true] Render the button with a frame
2220 */
2221 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2222 // Configuration initialization
2223 config = config || {};
2224
2225 // Properties
2226 this.$button = null;
2227 this.framed = null;
2228 this.active = config.active !== undefined && config.active;
2229 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2230 this.onMouseDownHandler = this.onMouseDown.bind( this );
2231 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2232 this.onKeyDownHandler = this.onKeyDown.bind( this );
2233 this.onClickHandler = this.onClick.bind( this );
2234 this.onKeyPressHandler = this.onKeyPress.bind( this );
2235
2236 // Initialization
2237 this.$element.addClass( 'oo-ui-buttonElement' );
2238 this.toggleFramed( config.framed === undefined || config.framed );
2239 this.setButtonElement( config.$button || $( '<a>' ) );
2240 };
2241
2242 /* Setup */
2243
2244 OO.initClass( OO.ui.mixin.ButtonElement );
2245
2246 /* Static Properties */
2247
2248 /**
2249 * Cancel mouse down events.
2250 *
2251 * This property is usually set to `true` to prevent the focus from changing when the button is
2252 * clicked.
2253 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2254 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2255 * behavior is possible and mousedown events can be handled by a parent widget.
2256 *
2257 * @static
2258 * @inheritable
2259 * @property {boolean}
2260 */
2261 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2262
2263 /* Events */
2264
2265 /**
2266 * A 'click' event is emitted when the button element is clicked.
2267 *
2268 * @event click
2269 */
2270
2271 /* Methods */
2272
2273 /**
2274 * Set the button element.
2275 *
2276 * This method is used to retarget a button mixin so that its functionality applies to
2277 * the specified button element instead of the one created by the class. If a button element
2278 * is already set, the method will remove the mixin’s effect on that element.
2279 *
2280 * @param {jQuery} $button Element to use as button
2281 */
2282 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2283 if ( this.$button ) {
2284 this.$button
2285 .removeClass( 'oo-ui-buttonElement-button' )
2286 .removeAttr( 'role accesskey' )
2287 .off( {
2288 mousedown: this.onMouseDownHandler,
2289 keydown: this.onKeyDownHandler,
2290 click: this.onClickHandler,
2291 keypress: this.onKeyPressHandler
2292 } );
2293 }
2294
2295 this.$button = $button
2296 .addClass( 'oo-ui-buttonElement-button' )
2297 .on( {
2298 mousedown: this.onMouseDownHandler,
2299 keydown: this.onKeyDownHandler,
2300 click: this.onClickHandler,
2301 keypress: this.onKeyPressHandler
2302 } );
2303
2304 // Add `role="button"` on `<a>` elements, where it's needed
2305 // `toUpperCase()` is added for XHTML documents
2306 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2307 this.$button.attr( 'role', 'button' );
2308 }
2309 };
2310
2311 /**
2312 * Handles mouse down events.
2313 *
2314 * @protected
2315 * @param {jQuery.Event} e Mouse down event
2316 * @return {undefined|boolean} False to prevent default if event is handled
2317 */
2318 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2319 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2320 return;
2321 }
2322 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2323 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2324 // reliably remove the pressed class
2325 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2326 // Prevent change of focus unless specifically configured otherwise
2327 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2328 return false;
2329 }
2330 };
2331
2332 /**
2333 * Handles document mouse up events.
2334 *
2335 * @protected
2336 * @param {MouseEvent} e Mouse up event
2337 */
2338 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2339 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2340 return;
2341 }
2342 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2343 // Stop listening for mouseup, since we only needed this once
2344 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2345 };
2346
2347 /**
2348 * Handles mouse click events.
2349 *
2350 * @protected
2351 * @param {jQuery.Event} e Mouse click event
2352 * @fires click
2353 * @return {undefined|boolean} False to prevent default if event is handled
2354 */
2355 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2356 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2357 if ( this.emit( 'click' ) ) {
2358 return false;
2359 }
2360 }
2361 };
2362
2363 /**
2364 * Handles key down events.
2365 *
2366 * @protected
2367 * @param {jQuery.Event} e Key down event
2368 */
2369 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2370 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2371 return;
2372 }
2373 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2374 // Run the keyup handler no matter where the key is when the button is let go, so we can
2375 // reliably remove the pressed class
2376 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2377 };
2378
2379 /**
2380 * Handles document key up events.
2381 *
2382 * @protected
2383 * @param {KeyboardEvent} e Key up event
2384 */
2385 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2386 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2387 return;
2388 }
2389 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2390 // Stop listening for keyup, since we only needed this once
2391 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2392 };
2393
2394 /**
2395 * Handles key press events.
2396 *
2397 * @protected
2398 * @param {jQuery.Event} e Key press event
2399 * @fires click
2400 * @return {undefined|boolean} False to prevent default if event is handled
2401 */
2402 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2403 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2404 if ( this.emit( 'click' ) ) {
2405 return false;
2406 }
2407 }
2408 };
2409
2410 /**
2411 * Check if button has a frame.
2412 *
2413 * @return {boolean} Button is framed
2414 */
2415 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2416 return this.framed;
2417 };
2418
2419 /**
2420 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2421 * on and off.
2422 *
2423 * @param {boolean} [framed] Make button framed, omit to toggle
2424 * @chainable
2425 * @return {OO.ui.Element} The element, for chaining
2426 */
2427 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2428 framed = framed === undefined ? !this.framed : !!framed;
2429 if ( framed !== this.framed ) {
2430 this.framed = framed;
2431 this.$element
2432 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2433 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2434 this.updateThemeClasses();
2435 }
2436
2437 return this;
2438 };
2439
2440 /**
2441 * Set the button's active state.
2442 *
2443 * The active state can be set on:
2444 *
2445 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2446 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2447 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2448 *
2449 * @protected
2450 * @param {boolean} value Make button active
2451 * @chainable
2452 * @return {OO.ui.Element} The element, for chaining
2453 */
2454 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2455 this.active = !!value;
2456 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2457 this.updateThemeClasses();
2458 return this;
2459 };
2460
2461 /**
2462 * Check if the button is active
2463 *
2464 * @protected
2465 * @return {boolean} The button is active
2466 */
2467 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2468 return this.active;
2469 };
2470
2471 /**
2472 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2473 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2474 * items from the group is done through the interface the class provides.
2475 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2476 *
2477 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2478 *
2479 * @abstract
2480 * @mixins OO.EmitterList
2481 * @class
2482 *
2483 * @constructor
2484 * @param {Object} [config] Configuration options
2485 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2486 * is omitted, the group element will use a generated `<div>`.
2487 */
2488 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2489 // Configuration initialization
2490 config = config || {};
2491
2492 // Mixin constructors
2493 OO.EmitterList.call( this, config );
2494
2495 // Properties
2496 this.$group = null;
2497
2498 // Initialization
2499 this.setGroupElement( config.$group || $( '<div>' ) );
2500 };
2501
2502 /* Setup */
2503
2504 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2505
2506 /* Events */
2507
2508 /**
2509 * @event change
2510 *
2511 * A change event is emitted when the set of selected items changes.
2512 *
2513 * @param {OO.ui.Element[]} items Items currently in the group
2514 */
2515
2516 /* Methods */
2517
2518 /**
2519 * Set the group element.
2520 *
2521 * If an element is already set, items will be moved to the new element.
2522 *
2523 * @param {jQuery} $group Element to use as group
2524 */
2525 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2526 var i, len;
2527
2528 this.$group = $group;
2529 for ( i = 0, len = this.items.length; i < len; i++ ) {
2530 this.$group.append( this.items[ i ].$element );
2531 }
2532 };
2533
2534 /**
2535 * Find an item by its data.
2536 *
2537 * Only the first item with matching data will be returned. To return all matching items,
2538 * use the #findItemsFromData method.
2539 *
2540 * @param {Object} data Item data to search for
2541 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2542 */
2543 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2544 var i, len, item,
2545 hash = OO.getHash( data );
2546
2547 for ( i = 0, len = this.items.length; i < len; i++ ) {
2548 item = this.items[ i ];
2549 if ( hash === OO.getHash( item.getData() ) ) {
2550 return item;
2551 }
2552 }
2553
2554 return null;
2555 };
2556
2557 /**
2558 * Find items by their data.
2559 *
2560 * All items with matching data will be returned. To return only the first match, use the
2561 * #findItemFromData method instead.
2562 *
2563 * @param {Object} data Item data to search for
2564 * @return {OO.ui.Element[]} Items with equivalent data
2565 */
2566 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2567 var i, len, item,
2568 hash = OO.getHash( data ),
2569 items = [];
2570
2571 for ( i = 0, len = this.items.length; i < len; i++ ) {
2572 item = this.items[ i ];
2573 if ( hash === OO.getHash( item.getData() ) ) {
2574 items.push( item );
2575 }
2576 }
2577
2578 return items;
2579 };
2580
2581 /**
2582 * Add items to the group.
2583 *
2584 * Items will be added to the end of the group array unless the optional `index` parameter
2585 * specifies a different insertion point. Adding an existing item will move it to the end of the
2586 * array or the point specified by the `index`.
2587 *
2588 * @param {OO.ui.Element[]} items An array of items to add to the group
2589 * @param {number} [index] Index of the insertion point
2590 * @chainable
2591 * @return {OO.ui.Element} The element, for chaining
2592 */
2593 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2594
2595 if ( items.length === 0 ) {
2596 return this;
2597 }
2598
2599 // Mixin method
2600 OO.EmitterList.prototype.addItems.call( this, items, index );
2601
2602 this.emit( 'change', this.getItems() );
2603 return this;
2604 };
2605
2606 /**
2607 * @inheritdoc
2608 */
2609 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2610 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2611 this.insertItemElements( items, newIndex );
2612
2613 // Mixin method
2614 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2615
2616 return newIndex;
2617 };
2618
2619 /**
2620 * @inheritdoc
2621 */
2622 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2623 item.setElementGroup( this );
2624 this.insertItemElements( item, index );
2625
2626 // Mixin method
2627 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2628
2629 return index;
2630 };
2631
2632 /**
2633 * Insert elements into the group
2634 *
2635 * @private
2636 * @param {OO.ui.Element} itemWidget Item to insert
2637 * @param {number} index Insertion index
2638 */
2639 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2640 if ( index === undefined || index < 0 || index >= this.items.length ) {
2641 this.$group.append( itemWidget.$element );
2642 } else if ( index === 0 ) {
2643 this.$group.prepend( itemWidget.$element );
2644 } else {
2645 this.items[ index ].$element.before( itemWidget.$element );
2646 }
2647 };
2648
2649 /**
2650 * Remove the specified items from a group.
2651 *
2652 * Removed items are detached (not removed) from the DOM so that they may be reused.
2653 * To remove all items from a group, you may wish to use the #clearItems method instead.
2654 *
2655 * @param {OO.ui.Element[]} items An array of items to remove
2656 * @chainable
2657 * @return {OO.ui.Element} The element, for chaining
2658 */
2659 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2660 var i, len, item, index;
2661
2662 if ( items.length === 0 ) {
2663 return this;
2664 }
2665
2666 // Remove specific items elements
2667 for ( i = 0, len = items.length; i < len; i++ ) {
2668 item = items[ i ];
2669 index = this.items.indexOf( item );
2670 if ( index !== -1 ) {
2671 item.setElementGroup( null );
2672 item.$element.detach();
2673 }
2674 }
2675
2676 // Mixin method
2677 OO.EmitterList.prototype.removeItems.call( this, items );
2678
2679 this.emit( 'change', this.getItems() );
2680 return this;
2681 };
2682
2683 /**
2684 * Clear all items from the group.
2685 *
2686 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2687 * To remove only a subset of items from a group, use the #removeItems method.
2688 *
2689 * @chainable
2690 * @return {OO.ui.Element} The element, for chaining
2691 */
2692 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2693 var i, len;
2694
2695 // Remove all item elements
2696 for ( i = 0, len = this.items.length; i < len; i++ ) {
2697 this.items[ i ].setElementGroup( null );
2698 this.items[ i ].$element.detach();
2699 }
2700
2701 // Mixin method
2702 OO.EmitterList.prototype.clearItems.call( this );
2703
2704 this.emit( 'change', this.getItems() );
2705 return this;
2706 };
2707
2708 /**
2709 * LabelElement is often mixed into other classes to generate a label, which
2710 * helps identify the function of an interface element.
2711 * See the [OOUI documentation on MediaWiki] [1] for more information.
2712 *
2713 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2714 *
2715 * @abstract
2716 * @class
2717 *
2718 * @constructor
2719 * @param {Object} [config] Configuration options
2720 * @cfg {jQuery} [$label] The label element created by the class. If this
2721 * configuration is omitted, the label element will use a generated `<span>`.
2722 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be
2723 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2724 * produce a string in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2725 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2726 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still
2727 * accessible to screen-readers).
2728 */
2729 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2730 // Configuration initialization
2731 config = config || {};
2732
2733 // Properties
2734 this.$label = null;
2735 this.label = null;
2736 this.invisibleLabel = null;
2737
2738 // Initialization
2739 this.setLabel( config.label || this.constructor.static.label );
2740 this.setLabelElement( config.$label || $( '<span>' ) );
2741 this.setInvisibleLabel( config.invisibleLabel );
2742 };
2743
2744 /* Setup */
2745
2746 OO.initClass( OO.ui.mixin.LabelElement );
2747
2748 /* Events */
2749
2750 /**
2751 * @event labelChange
2752 * @param {string} value
2753 */
2754
2755 /* Static Properties */
2756
2757 /**
2758 * The label text. The label can be specified as a plaintext string, a function that will
2759 * produce a string in the future, or `null` for no label. The static value will
2760 * be overridden if a label is specified with the #label config option.
2761 *
2762 * @static
2763 * @inheritable
2764 * @property {string|Function|null}
2765 */
2766 OO.ui.mixin.LabelElement.static.label = null;
2767
2768 /* Static methods */
2769
2770 /**
2771 * Highlight the first occurrence of the query in the given text
2772 *
2773 * @param {string} text Text
2774 * @param {string} query Query to find
2775 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2776 * @return {jQuery} Text with the first match of the query
2777 * sub-string wrapped in highlighted span
2778 */
2779 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2780 var i, tLen, qLen,
2781 offset = -1,
2782 $result = $( '<span>' );
2783
2784 if ( compare ) {
2785 tLen = text.length;
2786 qLen = query.length;
2787 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2788 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2789 offset = i;
2790 }
2791 }
2792 } else {
2793 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2794 }
2795
2796 if ( !query.length || offset === -1 ) {
2797 $result.text( text );
2798 } else {
2799 $result.append(
2800 document.createTextNode( text.slice( 0, offset ) ),
2801 $( '<span>' )
2802 .addClass( 'oo-ui-labelElement-label-highlight' )
2803 .text( text.slice( offset, offset + query.length ) ),
2804 document.createTextNode( text.slice( offset + query.length ) )
2805 );
2806 }
2807 return $result.contents();
2808 };
2809
2810 /* Methods */
2811
2812 /**
2813 * Set the label element.
2814 *
2815 * If an element is already set, it will be cleaned up before setting up the new element.
2816 *
2817 * @param {jQuery} $label Element to use as label
2818 */
2819 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2820 if ( this.$label ) {
2821 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2822 }
2823
2824 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2825 this.setLabelContent( this.label );
2826 };
2827
2828 /**
2829 * Set the label.
2830 *
2831 * An empty string will result in the label being hidden. A string containing only whitespace will
2832 * be converted to a single `&nbsp;`.
2833 *
2834 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2835 * returns nodes or text; or null for no label
2836 * @chainable
2837 * @return {OO.ui.Element} The element, for chaining
2838 */
2839 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2840 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2841 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2842
2843 if ( this.label !== label ) {
2844 if ( this.$label ) {
2845 this.setLabelContent( label );
2846 }
2847 this.label = label;
2848 this.emit( 'labelChange' );
2849 }
2850
2851 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2852
2853 return this;
2854 };
2855
2856 /**
2857 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2858 *
2859 * @param {boolean} invisibleLabel
2860 * @chainable
2861 * @return {OO.ui.Element} The element, for chaining
2862 */
2863 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2864 invisibleLabel = !!invisibleLabel;
2865
2866 if ( this.invisibleLabel !== invisibleLabel ) {
2867 this.invisibleLabel = invisibleLabel;
2868 this.emit( 'labelChange' );
2869 }
2870
2871 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2872 // Pretend that there is no label, a lot of CSS has been written with this assumption
2873 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2874
2875 return this;
2876 };
2877
2878 /**
2879 * Set the label as plain text with a highlighted query
2880 *
2881 * @param {string} text Text label to set
2882 * @param {string} query Substring of text to highlight
2883 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2884 * @chainable
2885 * @return {OO.ui.Element} The element, for chaining
2886 */
2887 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2888 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2889 };
2890
2891 /**
2892 * Get the label.
2893 *
2894 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2895 * text; or null for no label
2896 */
2897 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2898 return this.label;
2899 };
2900
2901 /**
2902 * Set the content of the label.
2903 *
2904 * Do not call this method until after the label element has been set by #setLabelElement.
2905 *
2906 * @private
2907 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2908 * text; or null for no label
2909 */
2910 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2911 if ( typeof label === 'string' ) {
2912 if ( label.match( /^\s*$/ ) ) {
2913 // Convert whitespace only string to a single non-breaking space
2914 this.$label.html( '&nbsp;' );
2915 } else {
2916 this.$label.text( label );
2917 }
2918 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2919 this.$label.html( label.toString() );
2920 } else if ( label instanceof $ ) {
2921 this.$label.empty().append( label );
2922 } else {
2923 this.$label.empty();
2924 }
2925 };
2926
2927 /**
2928 * IconElement is often mixed into other classes to generate an icon.
2929 * Icons are graphics, about the size of normal text. They are used to aid the user
2930 * in locating a control or to convey information in a space-efficient way. See the
2931 * [OOUI documentation on MediaWiki] [1] for a list of icons
2932 * included in the library.
2933 *
2934 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2935 *
2936 * @abstract
2937 * @class
2938 *
2939 * @constructor
2940 * @param {Object} [config] Configuration options
2941 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2942 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2943 * the icon element be set to an existing icon instead of the one generated by this class, set a
2944 * value using a jQuery selection. For example:
2945 *
2946 * // Use a <div> tag instead of a <span>
2947 * $icon: $( '<div>' )
2948 * // Use an existing icon element instead of the one generated by the class
2949 * $icon: this.$element
2950 * // Use an icon element from a child widget
2951 * $icon: this.childwidget.$element
2952 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
2953 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
2954 * name and additional names keyed by language code. The `default` name is used when no icon is
2955 * keyed by the user's language.
2956 *
2957 * Example of an i18n map:
2958 *
2959 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2960 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2961 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2962 */
2963 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2964 // Configuration initialization
2965 config = config || {};
2966
2967 // Properties
2968 this.$icon = null;
2969 this.icon = null;
2970
2971 // Initialization
2972 this.setIcon( config.icon || this.constructor.static.icon );
2973 this.setIconElement( config.$icon || $( '<span>' ) );
2974 };
2975
2976 /* Setup */
2977
2978 OO.initClass( OO.ui.mixin.IconElement );
2979
2980 /* Static Properties */
2981
2982 /**
2983 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
2984 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
2985 * language code. The `default` name is used when no icon is keyed by the user's language.
2986 *
2987 * Example of an i18n map:
2988 *
2989 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2990 *
2991 * Note: the static property will be overridden if the #icon configuration is used.
2992 *
2993 * @static
2994 * @inheritable
2995 * @property {Object|string}
2996 */
2997 OO.ui.mixin.IconElement.static.icon = null;
2998
2999 /**
3000 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
3001 * function that returns title text, or `null` for no title.
3002 *
3003 * The static property will be overridden if the #iconTitle configuration is used.
3004 *
3005 * @static
3006 * @inheritable
3007 * @property {string|Function|null}
3008 */
3009 OO.ui.mixin.IconElement.static.iconTitle = null;
3010
3011 /* Methods */
3012
3013 /**
3014 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
3015 * applies to the specified icon element instead of the one created by the class. If an icon
3016 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
3017 * and mixin methods will no longer affect the element.
3018 *
3019 * @param {jQuery} $icon Element to use as icon
3020 */
3021 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
3022 if ( this.$icon ) {
3023 this.$icon
3024 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
3025 .removeAttr( 'title' );
3026 }
3027
3028 this.$icon = $icon
3029 .addClass( 'oo-ui-iconElement-icon' )
3030 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
3031 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
3032 if ( this.iconTitle !== null ) {
3033 this.$icon.attr( 'title', this.iconTitle );
3034 }
3035
3036 this.updateThemeClasses();
3037 };
3038
3039 /**
3040 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
3041 * The icon parameter can also be set to a map of icon names. See the #icon config setting
3042 * for an example.
3043 *
3044 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
3045 * by language code, or `null` to remove the icon.
3046 * @chainable
3047 * @return {OO.ui.Element} The element, for chaining
3048 */
3049 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
3050 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
3051 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
3052
3053 if ( this.icon !== icon ) {
3054 if ( this.$icon ) {
3055 if ( this.icon !== null ) {
3056 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
3057 }
3058 if ( icon !== null ) {
3059 this.$icon.addClass( 'oo-ui-icon-' + icon );
3060 }
3061 }
3062 this.icon = icon;
3063 }
3064
3065 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
3066 if ( this.$icon ) {
3067 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
3068 }
3069 this.updateThemeClasses();
3070
3071 return this;
3072 };
3073
3074 /**
3075 * Get the symbolic name of the icon.
3076 *
3077 * @return {string} Icon name
3078 */
3079 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3080 return this.icon;
3081 };
3082
3083 /**
3084 * IndicatorElement is often mixed into other classes to generate an indicator.
3085 * Indicators are small graphics that are generally used in two ways:
3086 *
3087 * - To draw attention to the status of an item. For example, an indicator might be
3088 * used to show that an item in a list has errors that need to be resolved.
3089 * - To clarify the function of a control that acts in an exceptional way (a button
3090 * that opens a menu instead of performing an action directly, for example).
3091 *
3092 * For a list of indicators included in the library, please see the
3093 * [OOUI documentation on MediaWiki] [1].
3094 *
3095 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3096 *
3097 * @abstract
3098 * @class
3099 *
3100 * @constructor
3101 * @param {Object} [config] Configuration options
3102 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3103 * configuration is omitted, the indicator element will use a generated `<span>`.
3104 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3105 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3106 * in the library.
3107 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3108 */
3109 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3110 // Configuration initialization
3111 config = config || {};
3112
3113 // Properties
3114 this.$indicator = null;
3115 this.indicator = null;
3116
3117 // Initialization
3118 this.setIndicator( config.indicator || this.constructor.static.indicator );
3119 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3120 };
3121
3122 /* Setup */
3123
3124 OO.initClass( OO.ui.mixin.IndicatorElement );
3125
3126 /* Static Properties */
3127
3128 /**
3129 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3130 * The static property will be overridden if the #indicator configuration is used.
3131 *
3132 * @static
3133 * @inheritable
3134 * @property {string|null}
3135 */
3136 OO.ui.mixin.IndicatorElement.static.indicator = null;
3137
3138 /**
3139 * A text string used as the indicator title, a function that returns title text, or `null`
3140 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3141 * used.
3142 *
3143 * @static
3144 * @inheritable
3145 * @property {string|Function|null}
3146 */
3147 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3148
3149 /* Methods */
3150
3151 /**
3152 * Set the indicator element.
3153 *
3154 * If an element is already set, it will be cleaned up before setting up the new element.
3155 *
3156 * @param {jQuery} $indicator Element to use as indicator
3157 */
3158 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3159 if ( this.$indicator ) {
3160 this.$indicator
3161 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3162 .removeAttr( 'title' );
3163 }
3164
3165 this.$indicator = $indicator
3166 .addClass( 'oo-ui-indicatorElement-indicator' )
3167 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3168 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3169 if ( this.indicatorTitle !== null ) {
3170 this.$indicator.attr( 'title', this.indicatorTitle );
3171 }
3172
3173 this.updateThemeClasses();
3174 };
3175
3176 /**
3177 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null`
3178 * to remove the indicator.
3179 *
3180 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3181 * @chainable
3182 * @return {OO.ui.Element} The element, for chaining
3183 */
3184 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3185 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3186
3187 if ( this.indicator !== indicator ) {
3188 if ( this.$indicator ) {
3189 if ( this.indicator !== null ) {
3190 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3191 }
3192 if ( indicator !== null ) {
3193 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3194 }
3195 }
3196 this.indicator = indicator;
3197 }
3198
3199 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3200 if ( this.$indicator ) {
3201 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3202 }
3203 this.updateThemeClasses();
3204
3205 return this;
3206 };
3207
3208 /**
3209 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3210 *
3211 * @return {string} Symbolic name of indicator
3212 */
3213 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3214 return this.indicator;
3215 };
3216
3217 /**
3218 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3219 * additional functionality to an element created by another class. The class provides
3220 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3221 * which are used to customize the look and feel of a widget to better describe its
3222 * importance and functionality.
3223 *
3224 * The library currently contains the following styling flags for general use:
3225 *
3226 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3227 * forward in a process.
3228 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3229 * something.
3230 *
3231 * The flags affect the appearance of the buttons:
3232 *
3233 * @example
3234 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3235 * var button1 = new OO.ui.ButtonWidget( {
3236 * label: 'Progressive',
3237 * flags: 'progressive'
3238 * } ),
3239 * button2 = new OO.ui.ButtonWidget( {
3240 * label: 'Destructive',
3241 * flags: 'destructive'
3242 * } );
3243 * $( document.body ).append( button1.$element, button2.$element );
3244 *
3245 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3246 * action, use these flags: **primary** and **safe**.
3247 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3248 *
3249 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3250 *
3251 * @abstract
3252 * @class
3253 *
3254 * @constructor
3255 * @param {Object} [config] Configuration options
3256 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3257 * to apply.
3258 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3259 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3260 * @cfg {jQuery} [$flagged] The flagged element. By default,
3261 * the flagged functionality is applied to the element created by the class ($element).
3262 * If a different element is specified, the flagged functionality will be applied to it instead.
3263 */
3264 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3265 // Configuration initialization
3266 config = config || {};
3267
3268 // Properties
3269 this.flags = {};
3270 this.$flagged = null;
3271
3272 // Initialization
3273 this.setFlags( config.flags || this.constructor.static.flags );
3274 this.setFlaggedElement( config.$flagged || this.$element );
3275 };
3276
3277 /* Setup */
3278
3279 OO.initClass( OO.ui.mixin.FlaggedElement );
3280
3281 /* Events */
3282
3283 /**
3284 * @event flag
3285 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3286 * parameter contains the name of each modified flag and indicates whether it was
3287 * added or removed.
3288 *
3289 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3290 * that the flag was added, `false` that the flag was removed.
3291 */
3292
3293 /* Static Properties */
3294
3295 /**
3296 * Initial value to pass to setFlags if no value is provided in config.
3297 *
3298 * @static
3299 * @inheritable
3300 * @property {string|string[]|Object.<string, boolean>}
3301 */
3302 OO.ui.mixin.FlaggedElement.static.flags = null;
3303
3304 /* Methods */
3305
3306 /**
3307 * Set the flagged element.
3308 *
3309 * This method is used to retarget a flagged mixin so that its functionality applies to the
3310 * specified element.
3311 * If an element is already set, the method will remove the mixin’s effect on that element.
3312 *
3313 * @param {jQuery} $flagged Element that should be flagged
3314 */
3315 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3316 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3317 return 'oo-ui-flaggedElement-' + flag;
3318 } );
3319
3320 if ( this.$flagged ) {
3321 this.$flagged.removeClass( classNames );
3322 }
3323
3324 this.$flagged = $flagged.addClass( classNames );
3325 };
3326
3327 /**
3328 * Check if the specified flag is set.
3329 *
3330 * @param {string} flag Name of flag
3331 * @return {boolean} The flag is set
3332 */
3333 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3334 // This may be called before the constructor, thus before this.flags is set
3335 return this.flags && ( flag in this.flags );
3336 };
3337
3338 /**
3339 * Get the names of all flags set.
3340 *
3341 * @return {string[]} Flag names
3342 */
3343 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3344 // This may be called before the constructor, thus before this.flags is set
3345 return Object.keys( this.flags || {} );
3346 };
3347
3348 /**
3349 * Clear all flags.
3350 *
3351 * @chainable
3352 * @return {OO.ui.Element} The element, for chaining
3353 * @fires flag
3354 */
3355 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3356 var flag, className,
3357 changes = {},
3358 remove = [],
3359 classPrefix = 'oo-ui-flaggedElement-';
3360
3361 for ( flag in this.flags ) {
3362 className = classPrefix + flag;
3363 changes[ flag ] = false;
3364 delete this.flags[ flag ];
3365 remove.push( className );
3366 }
3367
3368 if ( this.$flagged ) {
3369 this.$flagged.removeClass( remove );
3370 }
3371
3372 this.updateThemeClasses();
3373 this.emit( 'flag', changes );
3374
3375 return this;
3376 };
3377
3378 /**
3379 * Add one or more flags.
3380 *
3381 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3382 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3383 * be added (`true`) or removed (`false`).
3384 * @chainable
3385 * @return {OO.ui.Element} The element, for chaining
3386 * @fires flag
3387 */
3388 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3389 var i, len, flag, className,
3390 changes = {},
3391 add = [],
3392 remove = [],
3393 classPrefix = 'oo-ui-flaggedElement-';
3394
3395 if ( typeof flags === 'string' ) {
3396 className = classPrefix + flags;
3397 // Set
3398 if ( !this.flags[ flags ] ) {
3399 this.flags[ flags ] = true;
3400 add.push( className );
3401 }
3402 } else if ( Array.isArray( flags ) ) {
3403 for ( i = 0, len = flags.length; i < len; i++ ) {
3404 flag = flags[ i ];
3405 className = classPrefix + flag;
3406 // Set
3407 if ( !this.flags[ flag ] ) {
3408 changes[ flag ] = true;
3409 this.flags[ flag ] = true;
3410 add.push( className );
3411 }
3412 }
3413 } else if ( OO.isPlainObject( flags ) ) {
3414 for ( flag in flags ) {
3415 className = classPrefix + flag;
3416 if ( flags[ flag ] ) {
3417 // Set
3418 if ( !this.flags[ flag ] ) {
3419 changes[ flag ] = true;
3420 this.flags[ flag ] = true;
3421 add.push( className );
3422 }
3423 } else {
3424 // Remove
3425 if ( this.flags[ flag ] ) {
3426 changes[ flag ] = false;
3427 delete this.flags[ flag ];
3428 remove.push( className );
3429 }
3430 }
3431 }
3432 }
3433
3434 if ( this.$flagged ) {
3435 this.$flagged
3436 .addClass( add )
3437 .removeClass( remove );
3438 }
3439
3440 this.updateThemeClasses();
3441 this.emit( 'flag', changes );
3442
3443 return this;
3444 };
3445
3446 /**
3447 * TitledElement is mixed into other classes to provide a `title` attribute.
3448 * Titles are rendered by the browser and are made visible when the user moves
3449 * the mouse over the element. Titles are not visible on touch devices.
3450 *
3451 * @example
3452 * // TitledElement provides a `title` attribute to the
3453 * // ButtonWidget class.
3454 * var button = new OO.ui.ButtonWidget( {
3455 * label: 'Button with Title',
3456 * title: 'I am a button'
3457 * } );
3458 * $( document.body ).append( button.$element );
3459 *
3460 * @abstract
3461 * @class
3462 *
3463 * @constructor
3464 * @param {Object} [config] Configuration options
3465 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3466 * If this config is omitted, the title functionality is applied to $element, the
3467 * element created by the class.
3468 * @cfg {string|Function} [title] The title text or a function that returns text. If
3469 * this config is omitted, the value of the {@link #static-title static title} property is used.
3470 */
3471 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3472 // Configuration initialization
3473 config = config || {};
3474
3475 // Properties
3476 this.$titled = null;
3477 this.title = null;
3478
3479 // Initialization
3480 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3481 this.setTitledElement( config.$titled || this.$element );
3482 };
3483
3484 /* Setup */
3485
3486 OO.initClass( OO.ui.mixin.TitledElement );
3487
3488 /* Static Properties */
3489
3490 /**
3491 * The title text, a function that returns text, or `null` for no title. The value of the static
3492 * property is overridden if the #title config option is used.
3493 *
3494 * If the element has a default title (e.g. `<input type=file>`), `null` will allow that title to be
3495 * shown. Use empty string to suppress it.
3496 *
3497 * @static
3498 * @inheritable
3499 * @property {string|Function|null}
3500 */
3501 OO.ui.mixin.TitledElement.static.title = null;
3502
3503 /* Methods */
3504
3505 /**
3506 * Set the titled element.
3507 *
3508 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3509 * specified element.
3510 * If an element is already set, the mixin’s effect on that element is removed before the new
3511 * element is set up.
3512 *
3513 * @param {jQuery} $titled Element that should use the 'titled' functionality
3514 */
3515 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3516 if ( this.$titled ) {
3517 this.$titled.removeAttr( 'title' );
3518 }
3519
3520 this.$titled = $titled;
3521 this.updateTitle();
3522 };
3523
3524 /**
3525 * Set title.
3526 *
3527 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3528 * for no title
3529 * @chainable
3530 * @return {OO.ui.Element} The element, for chaining
3531 */
3532 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3533 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3534 title = typeof title === 'string' ? title : null;
3535
3536 if ( this.title !== title ) {
3537 this.title = title;
3538 this.updateTitle();
3539 }
3540
3541 return this;
3542 };
3543
3544 /**
3545 * Update the title attribute, in case of changes to title or accessKey.
3546 *
3547 * @protected
3548 * @chainable
3549 * @return {OO.ui.Element} The element, for chaining
3550 */
3551 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3552 var title = this.getTitle();
3553 if ( this.$titled ) {
3554 if ( title !== null ) {
3555 // Only if this is an AccessKeyedElement
3556 if ( this.formatTitleWithAccessKey ) {
3557 title = this.formatTitleWithAccessKey( title );
3558 }
3559 this.$titled.attr( 'title', title );
3560 } else {
3561 this.$titled.removeAttr( 'title' );
3562 }
3563 }
3564 return this;
3565 };
3566
3567 /**
3568 * Get title.
3569 *
3570 * @return {string} Title string
3571 */
3572 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3573 return this.title;
3574 };
3575
3576 /**
3577 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3578 * Access keys allow an user to go to a specific element by using
3579 * a shortcut combination of a browser specific keys + the key
3580 * set to the field.
3581 *
3582 * @example
3583 * // AccessKeyedElement provides an `accesskey` attribute to the
3584 * // ButtonWidget class.
3585 * var button = new OO.ui.ButtonWidget( {
3586 * label: 'Button with access key',
3587 * accessKey: 'k'
3588 * } );
3589 * $( document.body ).append( button.$element );
3590 *
3591 * @abstract
3592 * @class
3593 *
3594 * @constructor
3595 * @param {Object} [config] Configuration options
3596 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3597 * If this config is omitted, the access key functionality is applied to $element, the
3598 * element created by the class.
3599 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3600 * this config is omitted, no access key will be added.
3601 */
3602 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3603 // Configuration initialization
3604 config = config || {};
3605
3606 // Properties
3607 this.$accessKeyed = null;
3608 this.accessKey = null;
3609
3610 // Initialization
3611 this.setAccessKey( config.accessKey || null );
3612 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3613
3614 // If this is also a TitledElement and it initialized before we did, we may have
3615 // to update the title with the access key
3616 if ( this.updateTitle ) {
3617 this.updateTitle();
3618 }
3619 };
3620
3621 /* Setup */
3622
3623 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3624
3625 /* Static Properties */
3626
3627 /**
3628 * The access key, a function that returns a key, or `null` for no access key.
3629 *
3630 * @static
3631 * @inheritable
3632 * @property {string|Function|null}
3633 */
3634 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3635
3636 /* Methods */
3637
3638 /**
3639 * Set the access keyed element.
3640 *
3641 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3642 * the specified element.
3643 * If an element is already set, the mixin's effect on that element is removed before the new
3644 * element is set up.
3645 *
3646 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3647 */
3648 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3649 if ( this.$accessKeyed ) {
3650 this.$accessKeyed.removeAttr( 'accesskey' );
3651 }
3652
3653 this.$accessKeyed = $accessKeyed;
3654 if ( this.accessKey ) {
3655 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3656 }
3657 };
3658
3659 /**
3660 * Set access key.
3661 *
3662 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3663 * access key
3664 * @chainable
3665 * @return {OO.ui.Element} The element, for chaining
3666 */
3667 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3668 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3669
3670 if ( this.accessKey !== accessKey ) {
3671 if ( this.$accessKeyed ) {
3672 if ( accessKey !== null ) {
3673 this.$accessKeyed.attr( 'accesskey', accessKey );
3674 } else {
3675 this.$accessKeyed.removeAttr( 'accesskey' );
3676 }
3677 }
3678 this.accessKey = accessKey;
3679
3680 // Only if this is a TitledElement
3681 if ( this.updateTitle ) {
3682 this.updateTitle();
3683 }
3684 }
3685
3686 return this;
3687 };
3688
3689 /**
3690 * Get access key.
3691 *
3692 * @return {string} accessKey string
3693 */
3694 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3695 return this.accessKey;
3696 };
3697
3698 /**
3699 * Add information about the access key to the element's tooltip label.
3700 * (This is only public for hacky usage in FieldLayout.)
3701 *
3702 * @param {string} title Tooltip label for `title` attribute
3703 * @return {string}
3704 */
3705 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3706 var accessKey;
3707
3708 if ( !this.$accessKeyed ) {
3709 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3710 // function.
3711 return title;
3712 }
3713 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3714 // single key.
3715 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3716 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3717 } else {
3718 accessKey = this.getAccessKey();
3719 }
3720 if ( accessKey ) {
3721 title += ' [' + accessKey + ']';
3722 }
3723 return title;
3724 };
3725
3726 /**
3727 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3728 * feels, and functionality can be customized via the class’s configuration options
3729 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3730 * and examples.
3731 *
3732 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3733 *
3734 * @example
3735 * // A button widget.
3736 * var button = new OO.ui.ButtonWidget( {
3737 * label: 'Button with Icon',
3738 * icon: 'trash',
3739 * title: 'Remove'
3740 * } );
3741 * $( document.body ).append( button.$element );
3742 *
3743 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3744 *
3745 * @class
3746 * @extends OO.ui.Widget
3747 * @mixins OO.ui.mixin.ButtonElement
3748 * @mixins OO.ui.mixin.IconElement
3749 * @mixins OO.ui.mixin.IndicatorElement
3750 * @mixins OO.ui.mixin.LabelElement
3751 * @mixins OO.ui.mixin.TitledElement
3752 * @mixins OO.ui.mixin.FlaggedElement
3753 * @mixins OO.ui.mixin.TabIndexedElement
3754 * @mixins OO.ui.mixin.AccessKeyedElement
3755 *
3756 * @constructor
3757 * @param {Object} [config] Configuration options
3758 * @cfg {boolean} [active=false] Whether button should be shown as active
3759 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3760 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3761 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3762 */
3763 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3764 // Configuration initialization
3765 config = config || {};
3766
3767 // Parent constructor
3768 OO.ui.ButtonWidget.parent.call( this, config );
3769
3770 // Mixin constructors
3771 OO.ui.mixin.ButtonElement.call( this, config );
3772 OO.ui.mixin.IconElement.call( this, config );
3773 OO.ui.mixin.IndicatorElement.call( this, config );
3774 OO.ui.mixin.LabelElement.call( this, config );
3775 OO.ui.mixin.TitledElement.call( this, $.extend( {
3776 $titled: this.$button
3777 }, config ) );
3778 OO.ui.mixin.FlaggedElement.call( this, config );
3779 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
3780 $tabIndexed: this.$button
3781 }, config ) );
3782 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
3783 $accessKeyed: this.$button
3784 }, config ) );
3785
3786 // Properties
3787 this.href = null;
3788 this.target = null;
3789 this.noFollow = false;
3790
3791 // Events
3792 this.connect( this, {
3793 disable: 'onDisable'
3794 } );
3795
3796 // Initialization
3797 this.$button.append( this.$icon, this.$label, this.$indicator );
3798 this.$element
3799 .addClass( 'oo-ui-buttonWidget' )
3800 .append( this.$button );
3801 this.setActive( config.active );
3802 this.setHref( config.href );
3803 this.setTarget( config.target );
3804 this.setNoFollow( config.noFollow );
3805 };
3806
3807 /* Setup */
3808
3809 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3810 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3811 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3812 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3813 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3814 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3815 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3816 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3817 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3818
3819 /* Static Properties */
3820
3821 /**
3822 * @static
3823 * @inheritdoc
3824 */
3825 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3826
3827 /**
3828 * @static
3829 * @inheritdoc
3830 */
3831 OO.ui.ButtonWidget.static.tagName = 'span';
3832
3833 /* Methods */
3834
3835 /**
3836 * Get hyperlink location.
3837 *
3838 * @return {string} Hyperlink location
3839 */
3840 OO.ui.ButtonWidget.prototype.getHref = function () {
3841 return this.href;
3842 };
3843
3844 /**
3845 * Get hyperlink target.
3846 *
3847 * @return {string} Hyperlink target
3848 */
3849 OO.ui.ButtonWidget.prototype.getTarget = function () {
3850 return this.target;
3851 };
3852
3853 /**
3854 * Get search engine traversal hint.
3855 *
3856 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3857 */
3858 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3859 return this.noFollow;
3860 };
3861
3862 /**
3863 * Set hyperlink location.
3864 *
3865 * @param {string|null} href Hyperlink location, null to remove
3866 * @chainable
3867 * @return {OO.ui.Widget} The widget, for chaining
3868 */
3869 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3870 href = typeof href === 'string' ? href : null;
3871 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3872 href = './' + href;
3873 }
3874
3875 if ( href !== this.href ) {
3876 this.href = href;
3877 this.updateHref();
3878 }
3879
3880 return this;
3881 };
3882
3883 /**
3884 * Update the `href` attribute, in case of changes to href or
3885 * disabled state.
3886 *
3887 * @private
3888 * @chainable
3889 * @return {OO.ui.Widget} The widget, for chaining
3890 */
3891 OO.ui.ButtonWidget.prototype.updateHref = function () {
3892 if ( this.href !== null && !this.isDisabled() ) {
3893 this.$button.attr( 'href', this.href );
3894 } else {
3895 this.$button.removeAttr( 'href' );
3896 }
3897
3898 return this;
3899 };
3900
3901 /**
3902 * Handle disable events.
3903 *
3904 * @private
3905 * @param {boolean} disabled Element is disabled
3906 */
3907 OO.ui.ButtonWidget.prototype.onDisable = function () {
3908 this.updateHref();
3909 };
3910
3911 /**
3912 * Set hyperlink target.
3913 *
3914 * @param {string|null} target Hyperlink target, null to remove
3915 * @return {OO.ui.Widget} The widget, for chaining
3916 */
3917 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3918 target = typeof target === 'string' ? target : null;
3919
3920 if ( target !== this.target ) {
3921 this.target = target;
3922 if ( target !== null ) {
3923 this.$button.attr( 'target', target );
3924 } else {
3925 this.$button.removeAttr( 'target' );
3926 }
3927 }
3928
3929 return this;
3930 };
3931
3932 /**
3933 * Set search engine traversal hint.
3934 *
3935 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3936 * @return {OO.ui.Widget} The widget, for chaining
3937 */
3938 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3939 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3940
3941 if ( noFollow !== this.noFollow ) {
3942 this.noFollow = noFollow;
3943 if ( noFollow ) {
3944 this.$button.attr( 'rel', 'nofollow' );
3945 } else {
3946 this.$button.removeAttr( 'rel' );
3947 }
3948 }
3949
3950 return this;
3951 };
3952
3953 // Override method visibility hints from ButtonElement
3954 /**
3955 * @method setActive
3956 * @inheritdoc
3957 */
3958 /**
3959 * @method isActive
3960 * @inheritdoc
3961 */
3962
3963 /**
3964 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3965 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3966 * removed, and cleared from the group.
3967 *
3968 * @example
3969 * // A ButtonGroupWidget with two buttons.
3970 * var button1 = new OO.ui.PopupButtonWidget( {
3971 * label: 'Select a category',
3972 * icon: 'menu',
3973 * popup: {
3974 * $content: $( '<p>List of categories…</p>' ),
3975 * padded: true,
3976 * align: 'left'
3977 * }
3978 * } ),
3979 * button2 = new OO.ui.ButtonWidget( {
3980 * label: 'Add item'
3981 * } ),
3982 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3983 * items: [ button1, button2 ]
3984 * } );
3985 * $( document.body ).append( buttonGroup.$element );
3986 *
3987 * @class
3988 * @extends OO.ui.Widget
3989 * @mixins OO.ui.mixin.GroupElement
3990 * @mixins OO.ui.mixin.TitledElement
3991 *
3992 * @constructor
3993 * @param {Object} [config] Configuration options
3994 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3995 */
3996 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3997 // Configuration initialization
3998 config = config || {};
3999
4000 // Parent constructor
4001 OO.ui.ButtonGroupWidget.parent.call( this, config );
4002
4003 // Mixin constructors
4004 OO.ui.mixin.GroupElement.call( this, $.extend( {
4005 $group: this.$element
4006 }, config ) );
4007 OO.ui.mixin.TitledElement.call( this, config );
4008
4009 // Initialization
4010 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
4011 if ( Array.isArray( config.items ) ) {
4012 this.addItems( config.items );
4013 }
4014 };
4015
4016 /* Setup */
4017
4018 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
4019 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
4020 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
4021
4022 /* Static Properties */
4023
4024 /**
4025 * @static
4026 * @inheritdoc
4027 */
4028 OO.ui.ButtonGroupWidget.static.tagName = 'span';
4029
4030 /* Methods */
4031
4032 /**
4033 * Focus the widget
4034 *
4035 * @chainable
4036 * @return {OO.ui.Widget} The widget, for chaining
4037 */
4038 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4039 if ( !this.isDisabled() ) {
4040 if ( this.items[ 0 ] ) {
4041 this.items[ 0 ].focus();
4042 }
4043 }
4044 return this;
4045 };
4046
4047 /**
4048 * @inheritdoc
4049 */
4050 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4051 this.focus();
4052 };
4053
4054 /**
4055 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
4056 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
4057 * identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4058 * for a list of icons included in the library.
4059 *
4060 * @example
4061 * // An IconWidget with a label via LabelWidget.
4062 * var myIcon = new OO.ui.IconWidget( {
4063 * icon: 'help',
4064 * title: 'Help'
4065 * } ),
4066 * // Create a label.
4067 * iconLabel = new OO.ui.LabelWidget( {
4068 * label: 'Help'
4069 * } );
4070 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4071 *
4072 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4073 *
4074 * @class
4075 * @extends OO.ui.Widget
4076 * @mixins OO.ui.mixin.IconElement
4077 * @mixins OO.ui.mixin.TitledElement
4078 * @mixins OO.ui.mixin.LabelElement
4079 * @mixins OO.ui.mixin.FlaggedElement
4080 *
4081 * @constructor
4082 * @param {Object} [config] Configuration options
4083 */
4084 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4085 // Configuration initialization
4086 config = config || {};
4087
4088 // Parent constructor
4089 OO.ui.IconWidget.parent.call( this, config );
4090
4091 // Mixin constructors
4092 OO.ui.mixin.IconElement.call( this, $.extend( {
4093 $icon: this.$element
4094 }, config ) );
4095 OO.ui.mixin.TitledElement.call( this, $.extend( {
4096 $titled: this.$element
4097 }, config ) );
4098 OO.ui.mixin.LabelElement.call( this, $.extend( {
4099 $label: this.$element,
4100 invisibleLabel: true
4101 }, config ) );
4102 OO.ui.mixin.FlaggedElement.call( this, $.extend( {
4103 $flagged: this.$element
4104 }, config ) );
4105
4106 // Initialization
4107 this.$element.addClass( 'oo-ui-iconWidget' );
4108 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4109 // nested in other widgets, because this widget used to not mix in LabelElement.
4110 this.$element.removeClass( 'oo-ui-labelElement-label' );
4111 };
4112
4113 /* Setup */
4114
4115 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4116 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4117 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4118 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4119 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4120
4121 /* Static Properties */
4122
4123 /**
4124 * @static
4125 * @inheritdoc
4126 */
4127 OO.ui.IconWidget.static.tagName = 'span';
4128
4129 /**
4130 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4131 * attention to the status of an item or to clarify the function within a control. For a list of
4132 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4133 *
4134 * @example
4135 * // An indicator widget.
4136 * var indicator1 = new OO.ui.IndicatorWidget( {
4137 * indicator: 'required'
4138 * } ),
4139 * // Create a fieldset layout to add a label.
4140 * fieldset = new OO.ui.FieldsetLayout();
4141 * fieldset.addItems( [
4142 * new OO.ui.FieldLayout( indicator1, {
4143 * label: 'A required indicator:'
4144 * } )
4145 * ] );
4146 * $( document.body ).append( fieldset.$element );
4147 *
4148 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4149 *
4150 * @class
4151 * @extends OO.ui.Widget
4152 * @mixins OO.ui.mixin.IndicatorElement
4153 * @mixins OO.ui.mixin.TitledElement
4154 * @mixins OO.ui.mixin.LabelElement
4155 *
4156 * @constructor
4157 * @param {Object} [config] Configuration options
4158 */
4159 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4160 // Configuration initialization
4161 config = config || {};
4162
4163 // Parent constructor
4164 OO.ui.IndicatorWidget.parent.call( this, config );
4165
4166 // Mixin constructors
4167 OO.ui.mixin.IndicatorElement.call( this, $.extend( {
4168 $indicator: this.$element
4169 }, config ) );
4170 OO.ui.mixin.TitledElement.call( this, $.extend( {
4171 $titled: this.$element
4172 }, config ) );
4173 OO.ui.mixin.LabelElement.call( this, $.extend( {
4174 $label: this.$element,
4175 invisibleLabel: true
4176 }, config ) );
4177
4178 // Initialization
4179 this.$element.addClass( 'oo-ui-indicatorWidget' );
4180 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4181 // nested in other widgets, because this widget used to not mix in LabelElement.
4182 this.$element.removeClass( 'oo-ui-labelElement-label' );
4183 };
4184
4185 /* Setup */
4186
4187 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4188 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4189 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4190 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4191
4192 /* Static Properties */
4193
4194 /**
4195 * @static
4196 * @inheritdoc
4197 */
4198 OO.ui.IndicatorWidget.static.tagName = 'span';
4199
4200 /**
4201 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4202 * be configured with a `label` option that is set to a string, a label node, or a function:
4203 *
4204 * - String: a plaintext string
4205 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4206 * label that includes a link or special styling, such as a gray color or additional
4207 * graphical elements.
4208 * - Function: a function that will produce a string in the future. Functions are used
4209 * in cases where the value of the label is not currently defined.
4210 *
4211 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4212 * which will come into focus when the label is clicked.
4213 *
4214 * @example
4215 * // Two LabelWidgets.
4216 * var label1 = new OO.ui.LabelWidget( {
4217 * label: 'plaintext label'
4218 * } ),
4219 * label2 = new OO.ui.LabelWidget( {
4220 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4221 * } ),
4222 * // Create a fieldset layout with fields for each example.
4223 * fieldset = new OO.ui.FieldsetLayout();
4224 * fieldset.addItems( [
4225 * new OO.ui.FieldLayout( label1 ),
4226 * new OO.ui.FieldLayout( label2 )
4227 * ] );
4228 * $( document.body ).append( fieldset.$element );
4229 *
4230 * @class
4231 * @extends OO.ui.Widget
4232 * @mixins OO.ui.mixin.LabelElement
4233 * @mixins OO.ui.mixin.TitledElement
4234 *
4235 * @constructor
4236 * @param {Object} [config] Configuration options
4237 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4238 * Clicking the label will focus the specified input field.
4239 */
4240 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4241 // Configuration initialization
4242 config = config || {};
4243
4244 // Parent constructor
4245 OO.ui.LabelWidget.parent.call( this, config );
4246
4247 // Mixin constructors
4248 OO.ui.mixin.LabelElement.call( this, $.extend( {
4249 $label: this.$element
4250 }, config ) );
4251 OO.ui.mixin.TitledElement.call( this, config );
4252
4253 // Properties
4254 this.input = config.input;
4255
4256 // Initialization
4257 if ( this.input ) {
4258 if ( this.input.getInputId() ) {
4259 this.$element.attr( 'for', this.input.getInputId() );
4260 } else {
4261 this.$label.on( 'click', function () {
4262 this.input.simulateLabelClick();
4263 }.bind( this ) );
4264 }
4265 }
4266 this.$element.addClass( 'oo-ui-labelWidget' );
4267 };
4268
4269 /* Setup */
4270
4271 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4272 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4273 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4274
4275 /* Static Properties */
4276
4277 /**
4278 * @static
4279 * @inheritdoc
4280 */
4281 OO.ui.LabelWidget.static.tagName = 'label';
4282
4283 /**
4284 * MessageWidget produces a visual component for sending a notice to the user
4285 * with an icon and distinct design noting its purpose. The MessageWidget changes
4286 * its visual presentation based on the type chosen, which also denotes its UX
4287 * purpose.
4288 *
4289 * @class
4290 * @extends OO.ui.Widget
4291 * @mixins OO.ui.mixin.IconElement
4292 * @mixins OO.ui.mixin.LabelElement
4293 * @mixins OO.ui.mixin.TitledElement
4294 * @mixins OO.ui.mixin.FlaggedElement
4295 *
4296 * @constructor
4297 * @param {Object} [config] Configuration options
4298 * @cfg {string} [type='notice'] The type of the notice widget. This will also
4299 * impact the flags that the widget receives (and hence its CSS design) as well
4300 * as the icon that appears. Available types:
4301 * 'notice', 'error', 'warning', 'success'
4302 * @cfg {boolean} [inline] Set the notice as an inline notice. The default
4303 * is not inline, or 'boxed' style.
4304 */
4305 OO.ui.MessageWidget = function OoUiMessageWidget( config ) {
4306 // Configuration initialization
4307 config = config || {};
4308
4309 // Parent constructor
4310 OO.ui.MessageWidget.parent.call( this, config );
4311
4312 // Mixin constructors
4313 OO.ui.mixin.IconElement.call( this, config );
4314 OO.ui.mixin.LabelElement.call( this, config );
4315 OO.ui.mixin.TitledElement.call( this, config );
4316 OO.ui.mixin.FlaggedElement.call( this, config );
4317
4318 // Set type
4319 this.setType( config.type );
4320 this.setInline( config.inline );
4321
4322 // Build the widget
4323 this.$element
4324 .append( this.$icon, this.$label )
4325 .addClass( 'oo-ui-messageWidget' );
4326 };
4327
4328 /* Setup */
4329
4330 OO.inheritClass( OO.ui.MessageWidget, OO.ui.Widget );
4331 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.IconElement );
4332 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.LabelElement );
4333 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.TitledElement );
4334 OO.mixinClass( OO.ui.MessageWidget, OO.ui.mixin.FlaggedElement );
4335
4336 /* Static Properties */
4337
4338 /**
4339 * An object defining the icon name per defined type.
4340 *
4341 * @static
4342 * @property {Object}
4343 */
4344 OO.ui.MessageWidget.static.iconMap = {
4345 notice: 'infoFilled',
4346 error: 'error',
4347 warning: 'alert',
4348 success: 'check'
4349 };
4350
4351 /* Methods */
4352
4353 /**
4354 * Set the inline state of the widget.
4355 *
4356 * @param {boolean} inline Widget is inline
4357 */
4358 OO.ui.MessageWidget.prototype.setInline = function ( inline ) {
4359 inline = !!inline;
4360
4361 if ( this.inline !== inline ) {
4362 this.inline = inline;
4363 this.$element
4364 .toggleClass( 'oo-ui-messageWidget-block', !this.inline );
4365 }
4366 };
4367 /**
4368 * Set the widget type. The given type must belong to the list of
4369 * legal types set by OO.ui.MessageWidget.static.iconMap
4370 *
4371 * @param {string} [type] Given type. Defaults to 'notice'
4372 */
4373 OO.ui.MessageWidget.prototype.setType = function ( type ) {
4374 // Validate type
4375 if ( Object.keys( this.constructor.static.iconMap ).indexOf( type ) === -1 ) {
4376 type = 'notice'; // Default
4377 }
4378
4379 if ( this.type !== type ) {
4380
4381 // Flags
4382 this.clearFlags();
4383 this.setFlags( type );
4384
4385 // Set the icon and its variant
4386 this.setIcon( this.constructor.static.iconMap[ type ] );
4387 this.$icon.removeClass( 'oo-ui-image-' + this.type );
4388 this.$icon.addClass( 'oo-ui-image-' + type );
4389
4390 if ( type === 'error' ) {
4391 this.$element.attr( 'role', 'alert' );
4392 this.$element.removeAttr( 'aria-live' );
4393 } else {
4394 this.$element.removeAttr( 'role' );
4395 this.$element.attr( 'aria-live', 'polite' );
4396 }
4397
4398 this.type = type;
4399 }
4400 };
4401
4402 /**
4403 * PendingElement is a mixin that is used to create elements that notify users that something is
4404 * happening and that they should wait before proceeding. The pending state is visually represented
4405 * with a pending texture that appears in the head of a pending
4406 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4407 * {@link OO.ui.TextInputWidget text input widget}.
4408 *
4409 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4410 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4411 * not currently supported for action widgets used in process dialogs.
4412 *
4413 * @example
4414 * function MessageDialog( config ) {
4415 * MessageDialog.parent.call( this, config );
4416 * }
4417 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4418 *
4419 * MessageDialog.static.name = 'myMessageDialog';
4420 * MessageDialog.static.actions = [
4421 * { action: 'save', label: 'Done', flags: 'primary' },
4422 * { label: 'Cancel', flags: 'safe' }
4423 * ];
4424 *
4425 * MessageDialog.prototype.initialize = function () {
4426 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4427 * this.content = new OO.ui.PanelLayout( { padded: true } );
4428 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4429 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4430 * 'process dialogs.</p>' );
4431 * this.$body.append( this.content.$element );
4432 * };
4433 * MessageDialog.prototype.getBodyHeight = function () {
4434 * return 100;
4435 * }
4436 * MessageDialog.prototype.getActionProcess = function ( action ) {
4437 * var dialog = this;
4438 * if ( action === 'save' ) {
4439 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4440 * return new OO.ui.Process()
4441 * .next( 1000 )
4442 * .next( function () {
4443 * dialog.getActions().get({actions: 'save'})[0].popPending();
4444 * } );
4445 * }
4446 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4447 * };
4448 *
4449 * var windowManager = new OO.ui.WindowManager();
4450 * $( document.body ).append( windowManager.$element );
4451 *
4452 * var dialog = new MessageDialog();
4453 * windowManager.addWindows( [ dialog ] );
4454 * windowManager.openWindow( dialog );
4455 *
4456 * @abstract
4457 * @class
4458 *
4459 * @constructor
4460 * @param {Object} [config] Configuration options
4461 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4462 */
4463 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4464 // Configuration initialization
4465 config = config || {};
4466
4467 // Properties
4468 this.pending = 0;
4469 this.$pending = null;
4470
4471 // Initialisation
4472 this.setPendingElement( config.$pending || this.$element );
4473 };
4474
4475 /* Setup */
4476
4477 OO.initClass( OO.ui.mixin.PendingElement );
4478
4479 /* Methods */
4480
4481 /**
4482 * Set the pending element (and clean up any existing one).
4483 *
4484 * @param {jQuery} $pending The element to set to pending.
4485 */
4486 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4487 if ( this.$pending ) {
4488 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4489 }
4490
4491 this.$pending = $pending;
4492 if ( this.pending > 0 ) {
4493 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4494 }
4495 };
4496
4497 /**
4498 * Check if an element is pending.
4499 *
4500 * @return {boolean} Element is pending
4501 */
4502 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4503 return !!this.pending;
4504 };
4505
4506 /**
4507 * Increase the pending counter. The pending state will remain active until the counter is zero
4508 * (i.e., the number of calls to #pushPending and #popPending is the same).
4509 *
4510 * @chainable
4511 * @return {OO.ui.Element} The element, for chaining
4512 */
4513 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4514 if ( this.pending === 0 ) {
4515 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4516 this.updateThemeClasses();
4517 }
4518 this.pending++;
4519
4520 return this;
4521 };
4522
4523 /**
4524 * Decrease the pending counter. The pending state will remain active until the counter is zero
4525 * (i.e., the number of calls to #pushPending and #popPending is the same).
4526 *
4527 * @chainable
4528 * @return {OO.ui.Element} The element, for chaining
4529 */
4530 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4531 if ( this.pending === 1 ) {
4532 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4533 this.updateThemeClasses();
4534 }
4535 this.pending = Math.max( 0, this.pending - 1 );
4536
4537 return this;
4538 };
4539
4540 /**
4541 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4542 * in the document (for example, in an OO.ui.Window's $overlay).
4543 *
4544 * The elements's position is automatically calculated and maintained when window is resized or the
4545 * page is scrolled. If you reposition the container manually, you have to call #position to make
4546 * sure the element is still placed correctly.
4547 *
4548 * As positioning is only possible when both the element and the container are attached to the DOM
4549 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4550 * the #toggle method to display a floating popup, for example.
4551 *
4552 * @abstract
4553 * @class
4554 *
4555 * @constructor
4556 * @param {Object} [config] Configuration options
4557 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4558 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4559 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4560 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4561 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4562 * 'top': Align the top edge with $floatableContainer's top edge
4563 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4564 * 'center': Vertically align the center with $floatableContainer's center
4565 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4566 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4567 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4568 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4569 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4570 * 'center': Horizontally align the center with $floatableContainer's center
4571 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4572 * is out of view
4573 */
4574 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4575 // Configuration initialization
4576 config = config || {};
4577
4578 // Properties
4579 this.$floatable = null;
4580 this.$floatableContainer = null;
4581 this.$floatableWindow = null;
4582 this.$floatableClosestScrollable = null;
4583 this.floatableOutOfView = false;
4584 this.onFloatableScrollHandler = this.position.bind( this );
4585 this.onFloatableWindowResizeHandler = this.position.bind( this );
4586
4587 // Initialization
4588 this.setFloatableContainer( config.$floatableContainer );
4589 this.setFloatableElement( config.$floatable || this.$element );
4590 this.setVerticalPosition( config.verticalPosition || 'below' );
4591 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4592 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
4593 true : !!config.hideWhenOutOfView;
4594 };
4595
4596 /* Methods */
4597
4598 /**
4599 * Set floatable element.
4600 *
4601 * If an element is already set, it will be cleaned up before setting up the new element.
4602 *
4603 * @param {jQuery} $floatable Element to make floatable
4604 */
4605 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4606 if ( this.$floatable ) {
4607 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4608 this.$floatable.css( { left: '', top: '' } );
4609 }
4610
4611 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4612 this.position();
4613 };
4614
4615 /**
4616 * Set floatable container.
4617 *
4618 * The element will be positioned relative to the specified container.
4619 *
4620 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4621 */
4622 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4623 this.$floatableContainer = $floatableContainer;
4624 if ( this.$floatable ) {
4625 this.position();
4626 }
4627 };
4628
4629 /**
4630 * Change how the element is positioned vertically.
4631 *
4632 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4633 */
4634 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4635 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4636 throw new Error( 'Invalid value for vertical position: ' + position );
4637 }
4638 if ( this.verticalPosition !== position ) {
4639 this.verticalPosition = position;
4640 if ( this.$floatable ) {
4641 this.position();
4642 }
4643 }
4644 };
4645
4646 /**
4647 * Change how the element is positioned horizontally.
4648 *
4649 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4650 */
4651 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4652 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4653 throw new Error( 'Invalid value for horizontal position: ' + position );
4654 }
4655 if ( this.horizontalPosition !== position ) {
4656 this.horizontalPosition = position;
4657 if ( this.$floatable ) {
4658 this.position();
4659 }
4660 }
4661 };
4662
4663 /**
4664 * Toggle positioning.
4665 *
4666 * Do not turn positioning on until after the element is attached to the DOM and visible.
4667 *
4668 * @param {boolean} [positioning] Enable positioning, omit to toggle
4669 * @chainable
4670 * @return {OO.ui.Element} The element, for chaining
4671 */
4672 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4673 var closestScrollableOfContainer;
4674
4675 if ( !this.$floatable || !this.$floatableContainer ) {
4676 return this;
4677 }
4678
4679 positioning = positioning === undefined ? !this.positioning : !!positioning;
4680
4681 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4682 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4683 this.warnedUnattached = true;
4684 }
4685
4686 if ( this.positioning !== positioning ) {
4687 this.positioning = positioning;
4688
4689 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
4690 this.$floatableContainer[ 0 ]
4691 );
4692 // If the scrollable is the root, we have to listen to scroll events
4693 // on the window because of browser inconsistencies.
4694 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4695 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
4696 closestScrollableOfContainer
4697 );
4698 }
4699
4700 if ( positioning ) {
4701 this.$floatableWindow = $( this.getElementWindow() );
4702 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4703
4704 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4705 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4706
4707 // Initial position after visible
4708 this.position();
4709 } else {
4710 if ( this.$floatableWindow ) {
4711 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4712 this.$floatableWindow = null;
4713 }
4714
4715 if ( this.$floatableClosestScrollable ) {
4716 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4717 this.$floatableClosestScrollable = null;
4718 }
4719
4720 this.$floatable.css( { left: '', right: '', top: '' } );
4721 }
4722 }
4723
4724 return this;
4725 };
4726
4727 /**
4728 * Check whether the bottom edge of the given element is within the viewport of the given
4729 * container.
4730 *
4731 * @private
4732 * @param {jQuery} $element
4733 * @param {jQuery} $container
4734 * @return {boolean}
4735 */
4736 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4737 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds,
4738 rightEdgeInBounds, startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4739 direction = $element.css( 'direction' );
4740
4741 elemRect = $element[ 0 ].getBoundingClientRect();
4742 if ( $container[ 0 ] === window ) {
4743 viewportSpacing = OO.ui.getViewportSpacing();
4744 contRect = {
4745 top: 0,
4746 left: 0,
4747 right: document.documentElement.clientWidth,
4748 bottom: document.documentElement.clientHeight
4749 };
4750 contRect.top += viewportSpacing.top;
4751 contRect.left += viewportSpacing.left;
4752 contRect.right -= viewportSpacing.right;
4753 contRect.bottom -= viewportSpacing.bottom;
4754 } else {
4755 contRect = $container[ 0 ].getBoundingClientRect();
4756 }
4757
4758 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4759 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4760 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4761 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4762 if ( direction === 'rtl' ) {
4763 startEdgeInBounds = rightEdgeInBounds;
4764 endEdgeInBounds = leftEdgeInBounds;
4765 } else {
4766 startEdgeInBounds = leftEdgeInBounds;
4767 endEdgeInBounds = rightEdgeInBounds;
4768 }
4769
4770 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4771 return false;
4772 }
4773 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4774 return false;
4775 }
4776 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4777 return false;
4778 }
4779 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4780 return false;
4781 }
4782
4783 // The other positioning values are all about being inside the container,
4784 // so in those cases all we care about is that any part of the container is visible.
4785 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4786 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4787 };
4788
4789 /**
4790 * Check if the floatable is hidden to the user because it was offscreen.
4791 *
4792 * @return {boolean} Floatable is out of view
4793 */
4794 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4795 return this.floatableOutOfView;
4796 };
4797
4798 /**
4799 * Position the floatable below its container.
4800 *
4801 * This should only be done when both of them are attached to the DOM and visible.
4802 *
4803 * @chainable
4804 * @return {OO.ui.Element} The element, for chaining
4805 */
4806 OO.ui.mixin.FloatableElement.prototype.position = function () {
4807 if ( !this.positioning ) {
4808 return this;
4809 }
4810
4811 if ( !(
4812 // To continue, some things need to be true:
4813 // The element must actually be in the DOM
4814 this.isElementAttached() && (
4815 // The closest scrollable is the current window
4816 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4817 // OR is an element in the element's DOM
4818 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4819 )
4820 ) ) {
4821 // Abort early if important parts of the widget are no longer attached to the DOM
4822 return this;
4823 }
4824
4825 this.floatableOutOfView = this.hideWhenOutOfView &&
4826 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4827 if ( this.floatableOutOfView ) {
4828 this.$floatable.addClass( 'oo-ui-element-hidden' );
4829 return this;
4830 } else {
4831 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4832 }
4833
4834 this.$floatable.css( this.computePosition() );
4835
4836 // We updated the position, so re-evaluate the clipping state.
4837 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4838 // will not notice the need to update itself.)
4839 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
4840 // Why does it not listen to the right events in the right places?
4841 if ( this.clip ) {
4842 this.clip();
4843 }
4844
4845 return this;
4846 };
4847
4848 /**
4849 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4850 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4851 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4852 *
4853 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4854 */
4855 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4856 var isBody, scrollableX, scrollableY, containerPos,
4857 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4858 newPos = { top: '', left: '', bottom: '', right: '' },
4859 direction = this.$floatableContainer.css( 'direction' ),
4860 $offsetParent = this.$floatable.offsetParent();
4861
4862 if ( $offsetParent.is( 'html' ) ) {
4863 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4864 // <html> element, but they do work on the <body>
4865 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4866 }
4867 isBody = $offsetParent.is( 'body' );
4868 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
4869 $offsetParent.css( 'overflow-x' ) === 'auto';
4870 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
4871 $offsetParent.css( 'overflow-y' ) === 'auto';
4872
4873 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4874 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4875 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
4876 // is the body, or if it isn't scrollable
4877 scrollTop = scrollableY && !isBody ?
4878 $offsetParent.scrollTop() : 0;
4879 scrollLeft = scrollableX && !isBody ?
4880 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4881
4882 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4883 // if the <body> has a margin
4884 containerPos = isBody ?
4885 this.$floatableContainer.offset() :
4886 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4887 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4888 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4889 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4890 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4891
4892 if ( this.verticalPosition === 'below' ) {
4893 newPos.top = containerPos.bottom;
4894 } else if ( this.verticalPosition === 'above' ) {
4895 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4896 } else if ( this.verticalPosition === 'top' ) {
4897 newPos.top = containerPos.top;
4898 } else if ( this.verticalPosition === 'bottom' ) {
4899 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4900 } else if ( this.verticalPosition === 'center' ) {
4901 newPos.top = containerPos.top +
4902 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4903 }
4904
4905 if ( this.horizontalPosition === 'before' ) {
4906 newPos.end = containerPos.start;
4907 } else if ( this.horizontalPosition === 'after' ) {
4908 newPos.start = containerPos.end;
4909 } else if ( this.horizontalPosition === 'start' ) {
4910 newPos.start = containerPos.start;
4911 } else if ( this.horizontalPosition === 'end' ) {
4912 newPos.end = containerPos.end;
4913 } else if ( this.horizontalPosition === 'center' ) {
4914 newPos.left = containerPos.left +
4915 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4916 }
4917
4918 if ( newPos.start !== undefined ) {
4919 if ( direction === 'rtl' ) {
4920 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4921 $offsetParent ).outerWidth() - newPos.start;
4922 } else {
4923 newPos.left = newPos.start;
4924 }
4925 delete newPos.start;
4926 }
4927 if ( newPos.end !== undefined ) {
4928 if ( direction === 'rtl' ) {
4929 newPos.left = newPos.end;
4930 } else {
4931 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4932 $offsetParent ).outerWidth() - newPos.end;
4933 }
4934 delete newPos.end;
4935 }
4936
4937 // Account for scroll position
4938 if ( newPos.top !== '' ) {
4939 newPos.top += scrollTop;
4940 }
4941 if ( newPos.bottom !== '' ) {
4942 newPos.bottom -= scrollTop;
4943 }
4944 if ( newPos.left !== '' ) {
4945 newPos.left += scrollLeft;
4946 }
4947 if ( newPos.right !== '' ) {
4948 newPos.right -= scrollLeft;
4949 }
4950
4951 // Account for scrollbar gutter
4952 if ( newPos.bottom !== '' ) {
4953 newPos.bottom -= horizScrollbarHeight;
4954 }
4955 if ( direction === 'rtl' ) {
4956 if ( newPos.left !== '' ) {
4957 newPos.left -= vertScrollbarWidth;
4958 }
4959 } else {
4960 if ( newPos.right !== '' ) {
4961 newPos.right -= vertScrollbarWidth;
4962 }
4963 }
4964
4965 return newPos;
4966 };
4967
4968 /**
4969 * Element that can be automatically clipped to visible boundaries.
4970 *
4971 * Whenever the element's natural height changes, you have to call
4972 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4973 * clipping correctly.
4974 *
4975 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4976 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4977 * then #$clippable will be given a fixed reduced height and/or width and will be made
4978 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4979 * but you can build a static footer by setting #$clippableContainer to an element that contains
4980 * #$clippable and the footer.
4981 *
4982 * @abstract
4983 * @class
4984 *
4985 * @constructor
4986 * @param {Object} [config] Configuration options
4987 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4988 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4989 * omit to use #$clippable
4990 */
4991 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4992 // Configuration initialization
4993 config = config || {};
4994
4995 // Properties
4996 this.$clippable = null;
4997 this.$clippableContainer = null;
4998 this.clipping = false;
4999 this.clippedHorizontally = false;
5000 this.clippedVertically = false;
5001 this.$clippableScrollableContainer = null;
5002 this.$clippableScroller = null;
5003 this.$clippableWindow = null;
5004 this.idealWidth = null;
5005 this.idealHeight = null;
5006 this.onClippableScrollHandler = this.clip.bind( this );
5007 this.onClippableWindowResizeHandler = this.clip.bind( this );
5008
5009 // Initialization
5010 if ( config.$clippableContainer ) {
5011 this.setClippableContainer( config.$clippableContainer );
5012 }
5013 this.setClippableElement( config.$clippable || this.$element );
5014 };
5015
5016 /* Methods */
5017
5018 /**
5019 * Set clippable element.
5020 *
5021 * If an element is already set, it will be cleaned up before setting up the new element.
5022 *
5023 * @param {jQuery} $clippable Element to make clippable
5024 */
5025 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
5026 if ( this.$clippable ) {
5027 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
5028 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
5029 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5030 }
5031
5032 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
5033 this.clip();
5034 };
5035
5036 /**
5037 * Set clippable container.
5038 *
5039 * This is the container that will be measured when deciding whether to clip. When clipping,
5040 * #$clippable will be resized in order to keep the clippable container fully visible.
5041 *
5042 * If the clippable container is unset, #$clippable will be used.
5043 *
5044 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
5045 */
5046 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
5047 this.$clippableContainer = $clippableContainer;
5048 if ( this.$clippable ) {
5049 this.clip();
5050 }
5051 };
5052
5053 /**
5054 * Toggle clipping.
5055 *
5056 * Do not turn clipping on until after the element is attached to the DOM and visible.
5057 *
5058 * @param {boolean} [clipping] Enable clipping, omit to toggle
5059 * @chainable
5060 * @return {OO.ui.Element} The element, for chaining
5061 */
5062 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
5063 clipping = clipping === undefined ? !this.clipping : !!clipping;
5064
5065 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
5066 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
5067 this.warnedUnattached = true;
5068 }
5069
5070 if ( this.clipping !== clipping ) {
5071 this.clipping = clipping;
5072 if ( clipping ) {
5073 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
5074 // If the clippable container is the root, we have to listen to scroll events and check
5075 // jQuery.scrollTop on the window because of browser inconsistencies
5076 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
5077 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
5078 this.$clippableScrollableContainer;
5079 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
5080 this.$clippableWindow = $( this.getElementWindow() )
5081 .on( 'resize', this.onClippableWindowResizeHandler );
5082 // Initial clip after visible
5083 this.clip();
5084 } else {
5085 this.$clippable.css( {
5086 width: '',
5087 height: '',
5088 maxWidth: '',
5089 maxHeight: '',
5090 overflowX: '',
5091 overflowY: ''
5092 } );
5093 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5094
5095 this.$clippableScrollableContainer = null;
5096 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
5097 this.$clippableScroller = null;
5098 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
5099 this.$clippableWindow = null;
5100 }
5101 }
5102
5103 return this;
5104 };
5105
5106 /**
5107 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
5108 *
5109 * @return {boolean} Element will be clipped to the visible area
5110 */
5111 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
5112 return this.clipping;
5113 };
5114
5115 /**
5116 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
5117 *
5118 * @return {boolean} Part of the element is being clipped
5119 */
5120 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
5121 return this.clippedHorizontally || this.clippedVertically;
5122 };
5123
5124 /**
5125 * Check if the right of the element is being clipped by the nearest scrollable container.
5126 *
5127 * @return {boolean} Part of the element is being clipped
5128 */
5129 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
5130 return this.clippedHorizontally;
5131 };
5132
5133 /**
5134 * Check if the bottom of the element is being clipped by the nearest scrollable container.
5135 *
5136 * @return {boolean} Part of the element is being clipped
5137 */
5138 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
5139 return this.clippedVertically;
5140 };
5141
5142 /**
5143 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
5144 *
5145 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
5146 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
5147 */
5148 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
5149 this.idealWidth = width;
5150 this.idealHeight = height;
5151
5152 if ( !this.clipping ) {
5153 // Update dimensions
5154 this.$clippable.css( { width: width, height: height } );
5155 }
5156 // While clipping, idealWidth and idealHeight are not considered
5157 };
5158
5159 /**
5160 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5161 * ClippableElement will clip the opposite side when reducing element's width.
5162 *
5163 * Classes that mix in ClippableElement should override this to return 'right' if their
5164 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5165 * If your class also mixes in FloatableElement, this is handled automatically.
5166 *
5167 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5168 * always in pixels, even if they were unset or set to 'auto'.)
5169 *
5170 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5171 *
5172 * @return {string} 'left' or 'right'
5173 */
5174 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5175 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5176 return 'right';
5177 }
5178 return 'left';
5179 };
5180
5181 /**
5182 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5183 * ClippableElement will clip the opposite side when reducing element's width.
5184 *
5185 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5186 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5187 * If your class also mixes in FloatableElement, this is handled automatically.
5188 *
5189 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5190 * always in pixels, even if they were unset or set to 'auto'.)
5191 *
5192 * When in doubt, 'top' is a sane fallback.
5193 *
5194 * @return {string} 'top' or 'bottom'
5195 */
5196 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5197 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5198 return 'bottom';
5199 }
5200 return 'top';
5201 };
5202
5203 /**
5204 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5205 * when the element's natural height changes.
5206 *
5207 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5208 * overlapped by, the visible area of the nearest scrollable container.
5209 *
5210 * Because calling clip() when the natural height changes isn't always possible, we also set
5211 * max-height when the element isn't being clipped. This means that if the element tries to grow
5212 * beyond the edge, something reasonable will happen before clip() is called.
5213 *
5214 * @chainable
5215 * @return {OO.ui.Element} The element, for chaining
5216 */
5217 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5218 var extraHeight, extraWidth, viewportSpacing,
5219 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5220 naturalWidth, naturalHeight, clipWidth, clipHeight,
5221 $item, itemRect, $viewport, viewportRect, availableRect,
5222 direction, vertScrollbarWidth, horizScrollbarHeight,
5223 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5224 // by one or two pixels. (And also so that we have space to display drop shadows.)
5225 // Chosen by fair dice roll.
5226 buffer = 7;
5227
5228 if ( !this.clipping ) {
5229 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5230 // will fail
5231 return this;
5232 }
5233
5234 function rectIntersection( a, b ) {
5235 var out = {};
5236 out.top = Math.max( a.top, b.top );
5237 out.left = Math.max( a.left, b.left );
5238 out.bottom = Math.min( a.bottom, b.bottom );
5239 out.right = Math.min( a.right, b.right );
5240 return out;
5241 }
5242
5243 viewportSpacing = OO.ui.getViewportSpacing();
5244
5245 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5246 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5247 // Dimensions of the browser window, rather than the element!
5248 viewportRect = {
5249 top: 0,
5250 left: 0,
5251 right: document.documentElement.clientWidth,
5252 bottom: document.documentElement.clientHeight
5253 };
5254 viewportRect.top += viewportSpacing.top;
5255 viewportRect.left += viewportSpacing.left;
5256 viewportRect.right -= viewportSpacing.right;
5257 viewportRect.bottom -= viewportSpacing.bottom;
5258 } else {
5259 $viewport = this.$clippableScrollableContainer;
5260 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5261 // Convert into a plain object
5262 viewportRect = $.extend( {}, viewportRect );
5263 }
5264
5265 // Account for scrollbar gutter
5266 direction = $viewport.css( 'direction' );
5267 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5268 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5269 viewportRect.bottom -= horizScrollbarHeight;
5270 if ( direction === 'rtl' ) {
5271 viewportRect.left += vertScrollbarWidth;
5272 } else {
5273 viewportRect.right -= vertScrollbarWidth;
5274 }
5275
5276 // Add arbitrary tolerance
5277 viewportRect.top += buffer;
5278 viewportRect.left += buffer;
5279 viewportRect.right -= buffer;
5280 viewportRect.bottom -= buffer;
5281
5282 $item = this.$clippableContainer || this.$clippable;
5283
5284 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5285 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5286
5287 itemRect = $item[ 0 ].getBoundingClientRect();
5288 // Convert into a plain object
5289 itemRect = $.extend( {}, itemRect );
5290
5291 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5292 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5293 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5294 itemRect.left = viewportRect.left;
5295 } else {
5296 itemRect.right = viewportRect.right;
5297 }
5298 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5299 itemRect.top = viewportRect.top;
5300 } else {
5301 itemRect.bottom = viewportRect.bottom;
5302 }
5303
5304 availableRect = rectIntersection( viewportRect, itemRect );
5305
5306 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5307 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5308 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5309 desiredWidth = Math.min( desiredWidth,
5310 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5311 desiredHeight = Math.min( desiredHeight,
5312 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5313 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5314 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5315 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5316 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5317 clipWidth = allotedWidth < naturalWidth;
5318 clipHeight = allotedHeight < naturalHeight;
5319
5320 if ( clipWidth ) {
5321 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5322 // See T157672.
5323 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5324 // this case.
5325 this.$clippable.css( 'overflowX', 'scroll' );
5326 // eslint-disable-next-line no-void
5327 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5328 this.$clippable.css( {
5329 width: Math.max( 0, allotedWidth ),
5330 maxWidth: ''
5331 } );
5332 } else {
5333 this.$clippable.css( {
5334 overflowX: '',
5335 width: this.idealWidth || '',
5336 maxWidth: Math.max( 0, allotedWidth )
5337 } );
5338 }
5339 if ( clipHeight ) {
5340 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5341 // See T157672.
5342 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5343 // this case.
5344 this.$clippable.css( 'overflowY', 'scroll' );
5345 // eslint-disable-next-line no-void
5346 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5347 this.$clippable.css( {
5348 height: Math.max( 0, allotedHeight ),
5349 maxHeight: ''
5350 } );
5351 } else {
5352 this.$clippable.css( {
5353 overflowY: '',
5354 height: this.idealHeight || '',
5355 maxHeight: Math.max( 0, allotedHeight )
5356 } );
5357 }
5358
5359 // If we stopped clipping in at least one of the dimensions
5360 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5361 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5362 }
5363
5364 this.clippedHorizontally = clipWidth;
5365 this.clippedVertically = clipHeight;
5366
5367 return this;
5368 };
5369
5370 /**
5371 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5372 * By default, each popup has an anchor that points toward its origin.
5373 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5374 *
5375 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5376 *
5377 * @example
5378 * // A PopupWidget.
5379 * var popup = new OO.ui.PopupWidget( {
5380 * $content: $( '<p>Hi there!</p>' ),
5381 * padded: true,
5382 * width: 300
5383 * } );
5384 *
5385 * $( document.body ).append( popup.$element );
5386 * // To display the popup, toggle the visibility to 'true'.
5387 * popup.toggle( true );
5388 *
5389 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5390 *
5391 * @class
5392 * @extends OO.ui.Widget
5393 * @mixins OO.ui.mixin.LabelElement
5394 * @mixins OO.ui.mixin.ClippableElement
5395 * @mixins OO.ui.mixin.FloatableElement
5396 *
5397 * @constructor
5398 * @param {Object} [config] Configuration options
5399 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5400 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5401 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5402 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5403 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5404 * of $floatableContainer
5405 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5406 * of $floatableContainer
5407 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5408 * endwards (right/left) to the vertical center of $floatableContainer
5409 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5410 * startwards (left/right) to the vertical center of $floatableContainer
5411 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5412 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5413 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5414 * move the popup as far downwards as possible.
5415 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5416 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5417 * move the popup as far upwards as possible.
5418 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5419 * center of the popup with the center of $floatableContainer.
5420 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5421 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5422 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5423 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5424 * desired direction to display the popup without clipping
5425 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5426 * See the [OOUI docs on MediaWiki][3] for an example.
5427 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5428 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a
5429 * number of pixels.
5430 * @cfg {jQuery} [$content] Content to append to the popup's body
5431 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5432 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5433 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5434 * This config option is only relevant if #autoClose is set to `true`. See the
5435 * [OOUI documentation on MediaWiki][2] for an example.
5436 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5437 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5438 * button.
5439 * @cfg {boolean} [padded=false] Add padding to the popup's body
5440 */
5441 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5442 // Configuration initialization
5443 config = config || {};
5444
5445 // Parent constructor
5446 OO.ui.PopupWidget.parent.call( this, config );
5447
5448 // Properties (must be set before ClippableElement constructor call)
5449 this.$body = $( '<div>' );
5450 this.$popup = $( '<div>' );
5451
5452 // Mixin constructors
5453 OO.ui.mixin.LabelElement.call( this, config );
5454 OO.ui.mixin.ClippableElement.call( this, $.extend( {
5455 $clippable: this.$body,
5456 $clippableContainer: this.$popup
5457 }, config ) );
5458 OO.ui.mixin.FloatableElement.call( this, config );
5459
5460 // Properties
5461 this.$anchor = $( '<div>' );
5462 // If undefined, will be computed lazily in computePosition()
5463 this.$container = config.$container;
5464 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5465 this.autoClose = !!config.autoClose;
5466 this.transitionTimeout = null;
5467 this.anchored = false;
5468 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5469 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5470
5471 // Initialization
5472 this.setSize( config.width, config.height );
5473 this.toggleAnchor( config.anchor === undefined || config.anchor );
5474 this.setAlignment( config.align || 'center' );
5475 this.setPosition( config.position || 'below' );
5476 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5477 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5478 this.$body.addClass( 'oo-ui-popupWidget-body' );
5479 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5480 this.$popup
5481 .addClass( 'oo-ui-popupWidget-popup' )
5482 .append( this.$body );
5483 this.$element
5484 .addClass( 'oo-ui-popupWidget' )
5485 .append( this.$popup, this.$anchor );
5486 // Move content, which was added to #$element by OO.ui.Widget, to the body
5487 // FIXME This is gross, we should use '$body' or something for the config
5488 if ( config.$content instanceof $ ) {
5489 this.$body.append( config.$content );
5490 }
5491
5492 if ( config.padded ) {
5493 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5494 }
5495
5496 if ( config.head ) {
5497 this.closeButton = new OO.ui.ButtonWidget( {
5498 framed: false,
5499 icon: 'close'
5500 } );
5501 this.closeButton.connect( this, {
5502 click: 'onCloseButtonClick'
5503 } );
5504 this.$head = $( '<div>' )
5505 .addClass( 'oo-ui-popupWidget-head' )
5506 .append( this.$label, this.closeButton.$element );
5507 this.$popup.prepend( this.$head );
5508 }
5509
5510 if ( config.$footer ) {
5511 this.$footer = $( '<div>' )
5512 .addClass( 'oo-ui-popupWidget-footer' )
5513 .append( config.$footer );
5514 this.$popup.append( this.$footer );
5515 }
5516
5517 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5518 // that reference properties not initialized at that time of parent class construction
5519 // TODO: Find a better way to handle post-constructor setup
5520 this.visible = false;
5521 this.$element.addClass( 'oo-ui-element-hidden' );
5522 };
5523
5524 /* Setup */
5525
5526 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5527 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5528 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5529 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5530
5531 /* Events */
5532
5533 /**
5534 * @event ready
5535 *
5536 * The popup is ready: it is visible and has been positioned and clipped.
5537 */
5538
5539 /* Methods */
5540
5541 /**
5542 * Handles document mouse down events.
5543 *
5544 * @private
5545 * @param {MouseEvent} e Mouse down event
5546 */
5547 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5548 if (
5549 this.isVisible() &&
5550 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5551 ) {
5552 this.toggle( false );
5553 }
5554 };
5555
5556 /**
5557 * Bind document mouse down listener.
5558 *
5559 * @private
5560 */
5561 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5562 // Capture clicks outside popup
5563 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5564 // We add 'click' event because iOS safari needs to respond to this event.
5565 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5566 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5567 // of occasionally not emitting 'click' properly, that event seems to be the standard
5568 // that it should be emitting, so we add it to this and will operate the event handler
5569 // on whichever of these events was triggered first
5570 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5571 };
5572
5573 /**
5574 * Handles close button click events.
5575 *
5576 * @private
5577 */
5578 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5579 if ( this.isVisible() ) {
5580 this.toggle( false );
5581 }
5582 };
5583
5584 /**
5585 * Unbind document mouse down listener.
5586 *
5587 * @private
5588 */
5589 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5590 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5591 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5592 };
5593
5594 /**
5595 * Handles document key down events.
5596 *
5597 * @private
5598 * @param {KeyboardEvent} e Key down event
5599 */
5600 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5601 if (
5602 e.which === OO.ui.Keys.ESCAPE &&
5603 this.isVisible()
5604 ) {
5605 this.toggle( false );
5606 e.preventDefault();
5607 e.stopPropagation();
5608 }
5609 };
5610
5611 /**
5612 * Bind document key down listener.
5613 *
5614 * @private
5615 */
5616 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5617 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5618 };
5619
5620 /**
5621 * Unbind document key down listener.
5622 *
5623 * @private
5624 */
5625 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5626 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5627 };
5628
5629 /**
5630 * Show, hide, or toggle the visibility of the anchor.
5631 *
5632 * @param {boolean} [show] Show anchor, omit to toggle
5633 */
5634 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5635 show = show === undefined ? !this.anchored : !!show;
5636
5637 if ( this.anchored !== show ) {
5638 if ( show ) {
5639 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5640 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5641 } else {
5642 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5643 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5644 }
5645 this.anchored = show;
5646 }
5647 };
5648
5649 /**
5650 * Change which edge the anchor appears on.
5651 *
5652 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5653 */
5654 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5655 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5656 throw new Error( 'Invalid value for edge: ' + edge );
5657 }
5658 if ( this.anchorEdge !== null ) {
5659 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5660 }
5661 this.anchorEdge = edge;
5662 if ( this.anchored ) {
5663 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5664 }
5665 };
5666
5667 /**
5668 * Check if the anchor is visible.
5669 *
5670 * @return {boolean} Anchor is visible
5671 */
5672 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5673 return this.anchored;
5674 };
5675
5676 /**
5677 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5678 * `.toggle( true )` after its #$element is attached to the DOM.
5679 *
5680 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5681 * it in the right place and with the right dimensions only work correctly while it is attached.
5682 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5683 * strictly enforced, so currently it only generates a warning in the browser console.
5684 *
5685 * @fires ready
5686 * @inheritdoc
5687 */
5688 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5689 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5690 show = show === undefined ? !this.isVisible() : !!show;
5691
5692 change = show !== this.isVisible();
5693
5694 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5695 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5696 this.warnedUnattached = true;
5697 }
5698 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5699 // Fall back to the parent node if the floatableContainer is not set
5700 this.setFloatableContainer( this.$element.parent() );
5701 }
5702
5703 if ( change && show && this.autoFlip ) {
5704 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
5705 // flip (e.g. if the user scrolled).
5706 this.isAutoFlipped = false;
5707 }
5708
5709 // Parent method
5710 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5711
5712 if ( change ) {
5713 this.togglePositioning( show && !!this.$floatableContainer );
5714
5715 if ( show ) {
5716 if ( this.autoClose ) {
5717 this.bindDocumentMouseDownListener();
5718 this.bindDocumentKeyDownListener();
5719 }
5720 this.updateDimensions();
5721 this.toggleClipping( true );
5722
5723 if ( this.autoFlip ) {
5724 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5725 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5726 // If opening the popup in the normal direction causes it to be clipped,
5727 // open in the opposite one instead
5728 normalHeight = this.$element.height();
5729 this.isAutoFlipped = !this.isAutoFlipped;
5730 this.position();
5731 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5732 // If that also causes it to be clipped, open in whichever direction
5733 // we have more space
5734 oppositeHeight = this.$element.height();
5735 if ( oppositeHeight < normalHeight ) {
5736 this.isAutoFlipped = !this.isAutoFlipped;
5737 this.position();
5738 }
5739 }
5740 }
5741 }
5742 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5743 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5744 // If opening the popup in the normal direction causes it to be clipped,
5745 // open in the opposite one instead
5746 normalWidth = this.$element.width();
5747 this.isAutoFlipped = !this.isAutoFlipped;
5748 // Due to T180173 horizontally clipped PopupWidgets have messed up
5749 // dimensions, which causes positioning to be off. Toggle clipping back and
5750 // forth to work around.
5751 this.toggleClipping( false );
5752 this.position();
5753 this.toggleClipping( true );
5754 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5755 // If that also causes it to be clipped, open in whichever direction
5756 // we have more space
5757 oppositeWidth = this.$element.width();
5758 if ( oppositeWidth < normalWidth ) {
5759 this.isAutoFlipped = !this.isAutoFlipped;
5760 // Due to T180173, horizontally clipped PopupWidgets have messed up
5761 // dimensions, which causes positioning to be off. Toggle clipping
5762 // back and forth to work around.
5763 this.toggleClipping( false );
5764 this.position();
5765 this.toggleClipping( true );
5766 }
5767 }
5768 }
5769 }
5770 }
5771
5772 this.emit( 'ready' );
5773 } else {
5774 this.toggleClipping( false );
5775 if ( this.autoClose ) {
5776 this.unbindDocumentMouseDownListener();
5777 this.unbindDocumentKeyDownListener();
5778 }
5779 }
5780 }
5781
5782 return this;
5783 };
5784
5785 /**
5786 * Set the size of the popup.
5787 *
5788 * Changing the size may also change the popup's position depending on the alignment.
5789 *
5790 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5791 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5792 * @param {boolean} [transition=false] Use a smooth transition
5793 * @chainable
5794 */
5795 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5796 this.width = width !== undefined ? width : 320;
5797 this.height = height !== undefined ? height : null;
5798 if ( this.isVisible() ) {
5799 this.updateDimensions( transition );
5800 }
5801 };
5802
5803 /**
5804 * Update the size and position.
5805 *
5806 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5807 * be called automatically.
5808 *
5809 * @param {boolean} [transition=false] Use a smooth transition
5810 * @chainable
5811 */
5812 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5813 var widget = this;
5814
5815 // Prevent transition from being interrupted
5816 clearTimeout( this.transitionTimeout );
5817 if ( transition ) {
5818 // Enable transition
5819 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5820 }
5821
5822 this.position();
5823
5824 if ( transition ) {
5825 // Prevent transitioning after transition is complete
5826 this.transitionTimeout = setTimeout( function () {
5827 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5828 }, 200 );
5829 } else {
5830 // Prevent transitioning immediately
5831 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5832 }
5833 };
5834
5835 /**
5836 * @inheritdoc
5837 */
5838 OO.ui.PopupWidget.prototype.computePosition = function () {
5839 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize,
5840 anchorPos, anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment,
5841 floatablePos, offsetParentPos, containerPos, popupPosition, viewportSpacing,
5842 popupPos = {},
5843 anchorCss = { left: '', right: '', top: '', bottom: '' },
5844 popupPositionOppositeMap = {
5845 above: 'below',
5846 below: 'above',
5847 before: 'after',
5848 after: 'before'
5849 },
5850 alignMap = {
5851 ltr: {
5852 'force-left': 'backwards',
5853 'force-right': 'forwards'
5854 },
5855 rtl: {
5856 'force-left': 'forwards',
5857 'force-right': 'backwards'
5858 }
5859 },
5860 anchorEdgeMap = {
5861 above: 'bottom',
5862 below: 'top',
5863 before: 'end',
5864 after: 'start'
5865 },
5866 hPosMap = {
5867 forwards: 'start',
5868 center: 'center',
5869 backwards: this.anchored ? 'before' : 'end'
5870 },
5871 vPosMap = {
5872 forwards: 'top',
5873 center: 'center',
5874 backwards: 'bottom'
5875 };
5876
5877 if ( !this.$container ) {
5878 // Lazy-initialize $container if not specified in constructor
5879 this.$container = $( this.getClosestScrollableElementContainer() );
5880 }
5881 direction = this.$container.css( 'direction' );
5882
5883 // Set height and width before we do anything else, since it might cause our measurements
5884 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5885 this.$popup.css( {
5886 width: this.width !== null ? this.width : 'auto',
5887 height: this.height !== null ? this.height : 'auto'
5888 } );
5889
5890 align = alignMap[ direction ][ this.align ] || this.align;
5891 popupPosition = this.popupPosition;
5892 if ( this.isAutoFlipped ) {
5893 popupPosition = popupPositionOppositeMap[ popupPosition ];
5894 }
5895
5896 // If the popup is positioned before or after, then the anchor positioning is vertical,
5897 // otherwise horizontal
5898 vertical = popupPosition === 'before' || popupPosition === 'after';
5899 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5900 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5901 near = vertical ? 'top' : 'left';
5902 far = vertical ? 'bottom' : 'right';
5903 sizeProp = vertical ? 'Height' : 'Width';
5904 popupSize = vertical ?
5905 ( this.height || this.$popup.height() ) :
5906 ( this.width || this.$popup.width() );
5907
5908 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5909 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5910 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5911
5912 // Parent method
5913 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5914 // Find out which property FloatableElement used for positioning, and adjust that value
5915 positionProp = vertical ?
5916 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5917 ( parentPosition.left !== '' ? 'left' : 'right' );
5918
5919 // Figure out where the near and far edges of the popup and $floatableContainer are
5920 floatablePos = this.$floatableContainer.offset();
5921 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5922 // Measure where the offsetParent is and compute our position based on that and parentPosition
5923 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5924 { top: 0, left: 0 } :
5925 this.$element.offsetParent().offset();
5926
5927 if ( positionProp === near ) {
5928 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5929 popupPos[ far ] = popupPos[ near ] + popupSize;
5930 } else {
5931 popupPos[ far ] = offsetParentPos[ near ] +
5932 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5933 popupPos[ near ] = popupPos[ far ] - popupSize;
5934 }
5935
5936 if ( this.anchored ) {
5937 // Position the anchor (which is positioned relative to the popup) to point to
5938 // $floatableContainer
5939 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5940 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5941
5942 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
5943 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
5944 // scrollWidth/Height
5945 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5946 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5947 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5948 // Not enough space for the anchor on the start side; pull the popup startwards
5949 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5950 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5951 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5952 // Not enough space for the anchor on the end side; pull the popup endwards
5953 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5954 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5955 } else {
5956 positionAdjustment = 0;
5957 }
5958 } else {
5959 positionAdjustment = 0;
5960 }
5961
5962 // Check if the popup will go beyond the edge of this.$container
5963 containerPos = this.$container[ 0 ] === document.documentElement ?
5964 { top: 0, left: 0 } :
5965 this.$container.offset();
5966 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5967 if ( this.$container[ 0 ] === document.documentElement ) {
5968 viewportSpacing = OO.ui.getViewportSpacing();
5969 containerPos[ near ] += viewportSpacing[ near ];
5970 containerPos[ far ] -= viewportSpacing[ far ];
5971 }
5972 // Take into account how much the popup will move because of the adjustments we're going to make
5973 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5974 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5975 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5976 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5977 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5978 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5979 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5980 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5981 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5982 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5983 }
5984
5985 if ( this.anchored ) {
5986 // Adjust anchorOffset for positionAdjustment
5987 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5988
5989 // Position the anchor
5990 anchorCss[ start ] = anchorOffset;
5991 this.$anchor.css( anchorCss );
5992 }
5993
5994 // Move the popup if needed
5995 parentPosition[ positionProp ] += positionAdjustment;
5996
5997 return parentPosition;
5998 };
5999
6000 /**
6001 * Set popup alignment
6002 *
6003 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
6004 * `backwards` or `forwards`.
6005 */
6006 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
6007 // Validate alignment
6008 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
6009 this.align = align;
6010 } else {
6011 this.align = 'center';
6012 }
6013 this.position();
6014 };
6015
6016 /**
6017 * Get popup alignment
6018 *
6019 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
6020 * `backwards` or `forwards`.
6021 */
6022 OO.ui.PopupWidget.prototype.getAlignment = function () {
6023 return this.align;
6024 };
6025
6026 /**
6027 * Change the positioning of the popup.
6028 *
6029 * @param {string} position 'above', 'below', 'before' or 'after'
6030 */
6031 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
6032 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
6033 position = 'below';
6034 }
6035 this.popupPosition = position;
6036 this.position();
6037 };
6038
6039 /**
6040 * Get popup positioning.
6041 *
6042 * @return {string} 'above', 'below', 'before' or 'after'
6043 */
6044 OO.ui.PopupWidget.prototype.getPosition = function () {
6045 return this.popupPosition;
6046 };
6047
6048 /**
6049 * Set popup auto-flipping.
6050 *
6051 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
6052 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
6053 * desired direction to display the popup without clipping
6054 */
6055 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
6056 autoFlip = !!autoFlip;
6057
6058 if ( this.autoFlip !== autoFlip ) {
6059 this.autoFlip = autoFlip;
6060 }
6061 };
6062
6063 /**
6064 * Set which elements will not close the popup when clicked.
6065 *
6066 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
6067 *
6068 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
6069 */
6070 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
6071 this.$autoCloseIgnore = $autoCloseIgnore;
6072 };
6073
6074 /**
6075 * Get an ID of the body element, this can be used as the
6076 * `aria-describedby` attribute for an input field.
6077 *
6078 * @return {string} The ID of the body element
6079 */
6080 OO.ui.PopupWidget.prototype.getBodyId = function () {
6081 var id = this.$body.attr( 'id' );
6082 if ( id === undefined ) {
6083 id = OO.ui.generateElementId();
6084 this.$body.attr( 'id', id );
6085 }
6086 return id;
6087 };
6088
6089 /**
6090 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
6091 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
6092 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
6093 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
6094 *
6095 * @abstract
6096 * @class
6097 *
6098 * @constructor
6099 * @param {Object} [config] Configuration options
6100 * @cfg {Object} [popup] Configuration to pass to popup
6101 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
6102 */
6103 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
6104 // Configuration initialization
6105 config = config || {};
6106
6107 // Properties
6108 this.popup = new OO.ui.PopupWidget( $.extend(
6109 {
6110 autoClose: true,
6111 $floatableContainer: this.$element
6112 },
6113 config.popup,
6114 {
6115 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
6116 }
6117 ) );
6118 };
6119
6120 /* Methods */
6121
6122 /**
6123 * Get popup.
6124 *
6125 * @return {OO.ui.PopupWidget} Popup widget
6126 */
6127 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
6128 return this.popup;
6129 };
6130
6131 /**
6132 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
6133 * which is used to display additional information or options.
6134 *
6135 * @example
6136 * // A PopupButtonWidget.
6137 * var popupButton = new OO.ui.PopupButtonWidget( {
6138 * label: 'Popup button with options',
6139 * icon: 'menu',
6140 * popup: {
6141 * $content: $( '<p>Additional options here.</p>' ),
6142 * padded: true,
6143 * align: 'force-left'
6144 * }
6145 * } );
6146 * // Append the button to the DOM.
6147 * $( document.body ).append( popupButton.$element );
6148 *
6149 * @class
6150 * @extends OO.ui.ButtonWidget
6151 * @mixins OO.ui.mixin.PopupElement
6152 *
6153 * @constructor
6154 * @param {Object} [config] Configuration options
6155 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful
6156 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
6157 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
6158 * uses relative positioning.
6159 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6160 */
6161 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6162 // Configuration initialization
6163 config = config || {};
6164
6165 // Parent constructor
6166 OO.ui.PopupButtonWidget.parent.call( this, config );
6167
6168 // Mixin constructors
6169 OO.ui.mixin.PopupElement.call( this, config );
6170
6171 // Properties
6172 this.$overlay = ( config.$overlay === true ?
6173 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6174
6175 // Events
6176 this.connect( this, {
6177 click: 'onAction'
6178 } );
6179
6180 // Initialization
6181 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6182 this.popup.$element
6183 .addClass( 'oo-ui-popupButtonWidget-popup' )
6184 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6185 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6186 this.$overlay.append( this.popup.$element );
6187 };
6188
6189 /* Setup */
6190
6191 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6192 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6193
6194 /* Methods */
6195
6196 /**
6197 * Handle the button action being triggered.
6198 *
6199 * @private
6200 */
6201 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6202 this.popup.toggle();
6203 };
6204
6205 /**
6206 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6207 *
6208 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6209 *
6210 * @private
6211 * @abstract
6212 * @class
6213 * @mixins OO.ui.mixin.GroupElement
6214 *
6215 * @constructor
6216 * @param {Object} [config] Configuration options
6217 */
6218 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6219 // Mixin constructors
6220 OO.ui.mixin.GroupElement.call( this, config );
6221 };
6222
6223 /* Setup */
6224
6225 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6226
6227 /* Methods */
6228
6229 /**
6230 * Set the disabled state of the widget.
6231 *
6232 * This will also update the disabled state of child widgets.
6233 *
6234 * @param {boolean} disabled Disable widget
6235 * @chainable
6236 * @return {OO.ui.Widget} The widget, for chaining
6237 */
6238 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6239 var i, len;
6240
6241 // Parent method
6242 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6243 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6244
6245 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6246 if ( this.items ) {
6247 for ( i = 0, len = this.items.length; i < len; i++ ) {
6248 this.items[ i ].updateDisabled();
6249 }
6250 }
6251
6252 return this;
6253 };
6254
6255 /**
6256 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6257 *
6258 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6259 * This allows bidirectional communication.
6260 *
6261 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6262 *
6263 * @private
6264 * @abstract
6265 * @class
6266 *
6267 * @constructor
6268 */
6269 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6270 //
6271 };
6272
6273 /* Methods */
6274
6275 /**
6276 * Check if widget is disabled.
6277 *
6278 * Checks parent if present, making disabled state inheritable.
6279 *
6280 * @return {boolean} Widget is disabled
6281 */
6282 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6283 return this.disabled ||
6284 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6285 };
6286
6287 /**
6288 * Set group element is in.
6289 *
6290 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6291 * @chainable
6292 * @return {OO.ui.Widget} The widget, for chaining
6293 */
6294 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6295 // Parent method
6296 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6297 OO.ui.Element.prototype.setElementGroup.call( this, group );
6298
6299 // Initialize item disabled states
6300 this.updateDisabled();
6301
6302 return this;
6303 };
6304
6305 /**
6306 * OptionWidgets are special elements that can be selected and configured with data. The
6307 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6308 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6309 * and examples, please see the [OOUI documentation on MediaWiki][1].
6310 *
6311 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6312 *
6313 * @class
6314 * @extends OO.ui.Widget
6315 * @mixins OO.ui.mixin.ItemWidget
6316 * @mixins OO.ui.mixin.LabelElement
6317 * @mixins OO.ui.mixin.FlaggedElement
6318 * @mixins OO.ui.mixin.AccessKeyedElement
6319 * @mixins OO.ui.mixin.TitledElement
6320 *
6321 * @constructor
6322 * @param {Object} [config] Configuration options
6323 */
6324 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6325 // Configuration initialization
6326 config = config || {};
6327
6328 // Parent constructor
6329 OO.ui.OptionWidget.parent.call( this, config );
6330
6331 // Mixin constructors
6332 OO.ui.mixin.ItemWidget.call( this );
6333 OO.ui.mixin.LabelElement.call( this, config );
6334 OO.ui.mixin.FlaggedElement.call( this, config );
6335 OO.ui.mixin.AccessKeyedElement.call( this, config );
6336 OO.ui.mixin.TitledElement.call( this, config );
6337
6338 // Properties
6339 this.highlighted = false;
6340 this.pressed = false;
6341 this.setSelected( !!config.selected );
6342
6343 // Initialization
6344 this.$element
6345 .data( 'oo-ui-optionWidget', this )
6346 // Allow programmatic focussing (and by access key), but not tabbing
6347 .attr( 'tabindex', '-1' )
6348 .attr( 'role', 'option' )
6349 .addClass( 'oo-ui-optionWidget' )
6350 .append( this.$label );
6351 };
6352
6353 /* Setup */
6354
6355 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6356 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6357 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6358 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6359 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6360 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6361
6362 /* Static Properties */
6363
6364 /**
6365 * Whether this option can be selected. See #setSelected.
6366 *
6367 * @static
6368 * @inheritable
6369 * @property {boolean}
6370 */
6371 OO.ui.OptionWidget.static.selectable = true;
6372
6373 /**
6374 * Whether this option can be highlighted. See #setHighlighted.
6375 *
6376 * @static
6377 * @inheritable
6378 * @property {boolean}
6379 */
6380 OO.ui.OptionWidget.static.highlightable = true;
6381
6382 /**
6383 * Whether this option can be pressed. See #setPressed.
6384 *
6385 * @static
6386 * @inheritable
6387 * @property {boolean}
6388 */
6389 OO.ui.OptionWidget.static.pressable = true;
6390
6391 /**
6392 * Whether this option will be scrolled into view when it is selected.
6393 *
6394 * @static
6395 * @inheritable
6396 * @property {boolean}
6397 */
6398 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6399
6400 /* Methods */
6401
6402 /**
6403 * Check if the option can be selected.
6404 *
6405 * @return {boolean} Item is selectable
6406 */
6407 OO.ui.OptionWidget.prototype.isSelectable = function () {
6408 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6409 };
6410
6411 /**
6412 * Check if the option can be highlighted. A highlight indicates that the option
6413 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6414 * be highlighted.
6415 *
6416 * @return {boolean} Item is highlightable
6417 */
6418 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6419 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6420 };
6421
6422 /**
6423 * Check if the option can be pressed. The pressed state occurs when a user mouses
6424 * down on an item, but has not yet let go of the mouse.
6425 *
6426 * @return {boolean} Item is pressable
6427 */
6428 OO.ui.OptionWidget.prototype.isPressable = function () {
6429 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6430 };
6431
6432 /**
6433 * Check if the option is selected.
6434 *
6435 * @return {boolean} Item is selected
6436 */
6437 OO.ui.OptionWidget.prototype.isSelected = function () {
6438 return this.selected;
6439 };
6440
6441 /**
6442 * Check if the option is highlighted. A highlight indicates that the
6443 * item may be selected when a user presses Enter key or clicks.
6444 *
6445 * @return {boolean} Item is highlighted
6446 */
6447 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6448 return this.highlighted;
6449 };
6450
6451 /**
6452 * Check if the option is pressed. The pressed state occurs when a user mouses
6453 * down on an item, but has not yet let go of the mouse. The item may appear
6454 * selected, but it will not be selected until the user releases the mouse.
6455 *
6456 * @return {boolean} Item is pressed
6457 */
6458 OO.ui.OptionWidget.prototype.isPressed = function () {
6459 return this.pressed;
6460 };
6461
6462 /**
6463 * Set the option’s selected state. In general, all modifications to the selection
6464 * should be handled by the SelectWidget’s
6465 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
6466 *
6467 * @param {boolean} [state=false] Select option
6468 * @chainable
6469 * @return {OO.ui.Widget} The widget, for chaining
6470 */
6471 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6472 if ( this.constructor.static.selectable ) {
6473 this.selected = !!state;
6474 this.$element
6475 .toggleClass( 'oo-ui-optionWidget-selected', state )
6476 .attr( 'aria-selected', state.toString() );
6477 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6478 this.scrollElementIntoView();
6479 }
6480 this.updateThemeClasses();
6481 }
6482 return this;
6483 };
6484
6485 /**
6486 * Set the option’s highlighted state. In general, all programmatic
6487 * modifications to the highlight should be handled by the
6488 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6489 * method instead of this method.
6490 *
6491 * @param {boolean} [state=false] Highlight option
6492 * @chainable
6493 * @return {OO.ui.Widget} The widget, for chaining
6494 */
6495 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6496 if ( this.constructor.static.highlightable ) {
6497 this.highlighted = !!state;
6498 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6499 this.updateThemeClasses();
6500 }
6501 return this;
6502 };
6503
6504 /**
6505 * Set the option’s pressed state. In general, all
6506 * programmatic modifications to the pressed state should be handled by the
6507 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6508 * method instead of this method.
6509 *
6510 * @param {boolean} [state=false] Press option
6511 * @chainable
6512 * @return {OO.ui.Widget} The widget, for chaining
6513 */
6514 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6515 if ( this.constructor.static.pressable ) {
6516 this.pressed = !!state;
6517 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6518 this.updateThemeClasses();
6519 }
6520 return this;
6521 };
6522
6523 /**
6524 * Get text to match search strings against.
6525 *
6526 * The default implementation returns the label text, but subclasses
6527 * can override this to provide more complex behavior.
6528 *
6529 * @return {string|boolean} String to match search string against
6530 */
6531 OO.ui.OptionWidget.prototype.getMatchText = function () {
6532 var label = this.getLabel();
6533 return typeof label === 'string' ? label : this.$label.text();
6534 };
6535
6536 /**
6537 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6538 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6539 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6540 * menu selects}.
6541 *
6542 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
6543 * more information, please see the [OOUI documentation on MediaWiki][1].
6544 *
6545 * @example
6546 * // A select widget with three options.
6547 * var select = new OO.ui.SelectWidget( {
6548 * items: [
6549 * new OO.ui.OptionWidget( {
6550 * data: 'a',
6551 * label: 'Option One',
6552 * } ),
6553 * new OO.ui.OptionWidget( {
6554 * data: 'b',
6555 * label: 'Option Two',
6556 * } ),
6557 * new OO.ui.OptionWidget( {
6558 * data: 'c',
6559 * label: 'Option Three',
6560 * } )
6561 * ]
6562 * } );
6563 * $( document.body ).append( select.$element );
6564 *
6565 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6566 *
6567 * @abstract
6568 * @class
6569 * @extends OO.ui.Widget
6570 * @mixins OO.ui.mixin.GroupWidget
6571 *
6572 * @constructor
6573 * @param {Object} [config] Configuration options
6574 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6575 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6576 * the [OOUI documentation on MediaWiki] [2] for examples.
6577 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6578 * @cfg {boolean} [multiselect] Allow for multiple selections
6579 */
6580 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6581 // Configuration initialization
6582 config = config || {};
6583
6584 // Parent constructor
6585 OO.ui.SelectWidget.parent.call( this, config );
6586
6587 // Mixin constructors
6588 OO.ui.mixin.GroupWidget.call( this, $.extend( {
6589 $group: this.$element
6590 }, config ) );
6591
6592 // Properties
6593 this.pressed = false;
6594 this.selecting = null;
6595 this.multiselect = !!config.multiselect;
6596 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6597 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6598 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6599 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6600 this.keyPressBuffer = '';
6601 this.keyPressBufferTimer = null;
6602 this.blockMouseOverEvents = 0;
6603
6604 // Events
6605 this.connect( this, {
6606 toggle: 'onToggle'
6607 } );
6608 this.$element.on( {
6609 focusin: this.onFocus.bind( this ),
6610 mousedown: this.onMouseDown.bind( this ),
6611 mouseover: this.onMouseOver.bind( this ),
6612 mouseleave: this.onMouseLeave.bind( this )
6613 } );
6614
6615 // Initialization
6616 this.$element
6617 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed' )
6618 .attr( 'role', 'listbox' );
6619 this.setFocusOwner( this.$element );
6620 if ( Array.isArray( config.items ) ) {
6621 this.addItems( config.items );
6622 }
6623 };
6624
6625 /* Setup */
6626
6627 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6628 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6629
6630 /* Events */
6631
6632 /**
6633 * @event highlight
6634 *
6635 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6636 *
6637 * @param {OO.ui.OptionWidget|null} item Highlighted item
6638 */
6639
6640 /**
6641 * @event press
6642 *
6643 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6644 * pressed state of an option.
6645 *
6646 * @param {OO.ui.OptionWidget|null} item Pressed item
6647 */
6648
6649 /**
6650 * @event select
6651 *
6652 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
6653 * method.
6654 *
6655 * @param {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} items Currently selected items
6656 */
6657
6658 /**
6659 * @event choose
6660 *
6661 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6662 *
6663 * @param {OO.ui.OptionWidget} item Chosen item
6664 * @param {boolean} selected Item is selected
6665 */
6666
6667 /**
6668 * @event add
6669 *
6670 * An `add` event is emitted when options are added to the select with the #addItems method.
6671 *
6672 * @param {OO.ui.OptionWidget[]} items Added items
6673 * @param {number} index Index of insertion point
6674 */
6675
6676 /**
6677 * @event remove
6678 *
6679 * A `remove` event is emitted when options are removed from the select with the #clearItems
6680 * or #removeItems methods.
6681 *
6682 * @param {OO.ui.OptionWidget[]} items Removed items
6683 */
6684
6685 /* Static methods */
6686
6687 /**
6688 * Normalize text for filter matching
6689 *
6690 * @param {string} text Text
6691 * @return {string} Normalized text
6692 */
6693 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
6694 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
6695 var normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
6696
6697 // Normalize Unicode
6698 // eslint-disable-next-line no-restricted-properties
6699 if ( normalized.normalize ) {
6700 // eslint-disable-next-line no-restricted-properties
6701 normalized = normalized.normalize();
6702 }
6703 return normalized;
6704 };
6705
6706 /* Methods */
6707
6708 /**
6709 * Handle focus events
6710 *
6711 * @private
6712 * @param {jQuery.Event} event
6713 */
6714 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6715 var item;
6716 if ( event.target === this.$element[ 0 ] ) {
6717 // This widget was focussed, e.g. by the user tabbing to it.
6718 // The styles for focus state depend on one of the items being selected.
6719 if ( !this.findSelectedItem() ) {
6720 item = this.findFirstSelectableItem();
6721 }
6722 } else {
6723 if ( event.target.tabIndex === -1 ) {
6724 // One of the options got focussed (and the event bubbled up here).
6725 // They can't be tabbed to, but they can be activated using access keys.
6726 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6727 item = this.findTargetItem( event );
6728 } else {
6729 // There is something actually user-focusable in one of the labels of the options, and
6730 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
6731 // the focus).
6732 return;
6733 }
6734 }
6735
6736 if ( item ) {
6737 if ( item.constructor.static.highlightable ) {
6738 this.highlightItem( item );
6739 } else {
6740 this.selectItem( item );
6741 }
6742 }
6743
6744 if ( event.target !== this.$element[ 0 ] ) {
6745 this.$focusOwner.trigger( 'focus' );
6746 }
6747 };
6748
6749 /**
6750 * Handle mouse down events.
6751 *
6752 * @private
6753 * @param {jQuery.Event} e Mouse down event
6754 * @return {undefined|boolean} False to prevent default if event is handled
6755 */
6756 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6757 var item;
6758
6759 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6760 this.togglePressed( true );
6761 item = this.findTargetItem( e );
6762 if ( item && item.isSelectable() ) {
6763 this.pressItem( item );
6764 this.selecting = item;
6765 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6766 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6767 }
6768 }
6769 return false;
6770 };
6771
6772 /**
6773 * Handle document mouse up events.
6774 *
6775 * @private
6776 * @param {MouseEvent} e Mouse up event
6777 * @return {undefined|boolean} False to prevent default if event is handled
6778 */
6779 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6780 var item;
6781
6782 this.togglePressed( false );
6783 if ( !this.selecting ) {
6784 item = this.findTargetItem( e );
6785 if ( item && item.isSelectable() ) {
6786 this.selecting = item;
6787 }
6788 }
6789 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6790 this.pressItem( null );
6791 this.chooseItem( this.selecting );
6792 this.selecting = null;
6793 }
6794
6795 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6796 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6797
6798 return false;
6799 };
6800
6801 /**
6802 * Handle document mouse move events.
6803 *
6804 * @private
6805 * @param {MouseEvent} e Mouse move event
6806 */
6807 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6808 var item;
6809
6810 if ( !this.isDisabled() && this.pressed ) {
6811 item = this.findTargetItem( e );
6812 if ( item && item !== this.selecting && item.isSelectable() ) {
6813 this.pressItem( item );
6814 this.selecting = item;
6815 }
6816 }
6817 };
6818
6819 /**
6820 * Handle mouse over events.
6821 *
6822 * @private
6823 * @param {jQuery.Event} e Mouse over event
6824 * @return {undefined|boolean} False to prevent default if event is handled
6825 */
6826 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6827 var item;
6828 if ( this.blockMouseOverEvents ) {
6829 return;
6830 }
6831 if ( !this.isDisabled() ) {
6832 item = this.findTargetItem( e );
6833 this.highlightItem( item && item.isHighlightable() ? item : null );
6834 }
6835 return false;
6836 };
6837
6838 /**
6839 * Handle mouse leave events.
6840 *
6841 * @private
6842 * @param {jQuery.Event} e Mouse over event
6843 * @return {undefined|boolean} False to prevent default if event is handled
6844 */
6845 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6846 if ( !this.isDisabled() ) {
6847 this.highlightItem( null );
6848 }
6849 return false;
6850 };
6851
6852 /**
6853 * Handle document key down events.
6854 *
6855 * @protected
6856 * @param {KeyboardEvent} e Key down event
6857 */
6858 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6859 var nextItem,
6860 handled = false,
6861 selected = this.findSelectedItems(),
6862 currentItem = this.findHighlightedItem() || (
6863 Array.isArray( selected ) ? selected[ 0 ] : selected
6864 ),
6865 firstItem = this.getItems()[ 0 ];
6866
6867 if ( !this.isDisabled() && this.isVisible() ) {
6868 switch ( e.keyCode ) {
6869 case OO.ui.Keys.ENTER:
6870 if ( currentItem ) {
6871 // Was only highlighted, now let's select it. No-op if already selected.
6872 this.chooseItem( currentItem );
6873 handled = true;
6874 }
6875 break;
6876 case OO.ui.Keys.UP:
6877 case OO.ui.Keys.LEFT:
6878 this.clearKeyPressBuffer();
6879 nextItem = currentItem ?
6880 this.findRelativeSelectableItem( currentItem, -1 ) : firstItem;
6881 handled = true;
6882 break;
6883 case OO.ui.Keys.DOWN:
6884 case OO.ui.Keys.RIGHT:
6885 this.clearKeyPressBuffer();
6886 nextItem = currentItem ?
6887 this.findRelativeSelectableItem( currentItem, 1 ) : firstItem;
6888 handled = true;
6889 break;
6890 case OO.ui.Keys.ESCAPE:
6891 case OO.ui.Keys.TAB:
6892 if ( currentItem ) {
6893 currentItem.setHighlighted( false );
6894 }
6895 this.unbindDocumentKeyDownListener();
6896 this.unbindDocumentKeyPressListener();
6897 // Don't prevent tabbing away / defocusing
6898 handled = false;
6899 break;
6900 }
6901
6902 if ( nextItem ) {
6903 if ( nextItem.constructor.static.highlightable ) {
6904 this.highlightItem( nextItem );
6905 } else {
6906 this.chooseItem( nextItem );
6907 }
6908 this.scrollItemIntoView( nextItem );
6909 }
6910
6911 if ( handled ) {
6912 e.preventDefault();
6913 e.stopPropagation();
6914 }
6915 }
6916 };
6917
6918 /**
6919 * Bind document key down listener.
6920 *
6921 * @protected
6922 */
6923 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6924 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6925 };
6926
6927 /**
6928 * Unbind document key down listener.
6929 *
6930 * @protected
6931 */
6932 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6933 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6934 };
6935
6936 /**
6937 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6938 *
6939 * @param {OO.ui.OptionWidget} item Item to scroll into view
6940 */
6941 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6942 var widget = this;
6943 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
6944 // scrolling and around 100-150 ms after it is finished.
6945 this.blockMouseOverEvents++;
6946 item.scrollElementIntoView().done( function () {
6947 setTimeout( function () {
6948 widget.blockMouseOverEvents--;
6949 }, 200 );
6950 } );
6951 };
6952
6953 /**
6954 * Clear the key-press buffer
6955 *
6956 * @protected
6957 */
6958 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6959 if ( this.keyPressBufferTimer ) {
6960 clearTimeout( this.keyPressBufferTimer );
6961 this.keyPressBufferTimer = null;
6962 }
6963 this.keyPressBuffer = '';
6964 };
6965
6966 /**
6967 * Handle key press events.
6968 *
6969 * @protected
6970 * @param {KeyboardEvent} e Key press event
6971 * @return {undefined|boolean} False to prevent default if event is handled
6972 */
6973 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6974 var c, filter, item, selected;
6975
6976 if ( !e.charCode ) {
6977 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6978 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6979 return false;
6980 }
6981 return;
6982 }
6983 // eslint-disable-next-line no-restricted-properties
6984 if ( String.fromCodePoint ) {
6985 // eslint-disable-next-line no-restricted-properties
6986 c = String.fromCodePoint( e.charCode );
6987 } else {
6988 c = String.fromCharCode( e.charCode );
6989 }
6990
6991 if ( this.keyPressBufferTimer ) {
6992 clearTimeout( this.keyPressBufferTimer );
6993 }
6994 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6995
6996 selected = this.findSelectedItems();
6997 item = this.findHighlightedItem() || (
6998 Array.isArray( selected ) ? selected[ 0 ] : selected
6999 );
7000
7001 if ( this.keyPressBuffer === c ) {
7002 // Common (if weird) special case: typing "xxxx" will cycle through all
7003 // the items beginning with "x".
7004 if ( item ) {
7005 item = this.findRelativeSelectableItem( item, 1 );
7006 }
7007 } else {
7008 this.keyPressBuffer += c;
7009 }
7010
7011 filter = this.getItemMatcher( this.keyPressBuffer, false );
7012 if ( !item || !filter( item ) ) {
7013 item = this.findRelativeSelectableItem( item, 1, filter );
7014 }
7015 if ( item ) {
7016 if ( this.isVisible() && item.constructor.static.highlightable ) {
7017 this.highlightItem( item );
7018 } else {
7019 this.chooseItem( item );
7020 }
7021 this.scrollItemIntoView( item );
7022 }
7023
7024 e.preventDefault();
7025 e.stopPropagation();
7026 };
7027
7028 /**
7029 * Get a matcher for the specific string
7030 *
7031 * @protected
7032 * @param {string} query String to match against items
7033 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
7034 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
7035 */
7036 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
7037 var normalizeForMatching = this.constructor.static.normalizeForMatching,
7038 normalizedQuery = normalizeForMatching( query );
7039
7040 // Support deprecated exact=true argument
7041 if ( mode === true ) {
7042 mode = 'exact';
7043 }
7044
7045 return function ( item ) {
7046 var matchText = normalizeForMatching( item.getMatchText() );
7047
7048 if ( normalizedQuery === '' ) {
7049 // Empty string matches all, except if we are in 'exact'
7050 // mode, where it doesn't match at all
7051 return mode !== 'exact';
7052 }
7053
7054 switch ( mode ) {
7055 case 'exact':
7056 return matchText === normalizedQuery;
7057 case 'substring':
7058 return matchText.indexOf( normalizedQuery ) !== -1;
7059 // 'prefix'
7060 default:
7061 return matchText.indexOf( normalizedQuery ) === 0;
7062 }
7063 };
7064 };
7065
7066 /**
7067 * Bind document key press listener.
7068 *
7069 * @protected
7070 */
7071 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
7072 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7073 };
7074
7075 /**
7076 * Unbind document key down listener.
7077 *
7078 * If you override this, be sure to call this.clearKeyPressBuffer() from your
7079 * implementation.
7080 *
7081 * @protected
7082 */
7083 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7084 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
7085 this.clearKeyPressBuffer();
7086 };
7087
7088 /**
7089 * Visibility change handler
7090 *
7091 * @protected
7092 * @param {boolean} visible
7093 */
7094 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
7095 if ( !visible ) {
7096 this.clearKeyPressBuffer();
7097 }
7098 };
7099
7100 /**
7101 * Get the closest item to a jQuery.Event.
7102 *
7103 * @private
7104 * @param {jQuery.Event} e
7105 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
7106 */
7107 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
7108 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
7109 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
7110 return null;
7111 }
7112 return $option.data( 'oo-ui-optionWidget' ) || null;
7113 };
7114
7115 /**
7116 * Find all selected items, if there are any. If the widget allows for multiselect
7117 * it will return an array of selected options. If the widget doesn't allow for
7118 * multiselect, it will return the selected option or null if no item is selected.
7119 *
7120 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7121 * then return an array of selected items (or empty array),
7122 * if the widget is not multiselect, return a single selected item, or `null`
7123 * if no item is selected
7124 */
7125 OO.ui.SelectWidget.prototype.findSelectedItems = function () {
7126 var selected = this.items.filter( function ( item ) {
7127 return item.isSelected();
7128 } );
7129
7130 return this.multiselect ?
7131 selected :
7132 selected[ 0 ] || null;
7133 };
7134
7135 /**
7136 * Find selected item.
7137 *
7138 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
7139 * then return an array of selected items (or empty array),
7140 * if the widget is not multiselect, return a single selected item, or `null`
7141 * if no item is selected
7142 */
7143 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
7144 return this.findSelectedItems();
7145 };
7146
7147 /**
7148 * Find highlighted item.
7149 *
7150 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
7151 */
7152 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
7153 var i, len;
7154
7155 for ( i = 0, len = this.items.length; i < len; i++ ) {
7156 if ( this.items[ i ].isHighlighted() ) {
7157 return this.items[ i ];
7158 }
7159 }
7160 return null;
7161 };
7162
7163 /**
7164 * Toggle pressed state.
7165 *
7166 * Press is a state that occurs when a user mouses down on an item, but
7167 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7168 * until the user releases the mouse.
7169 *
7170 * @param {boolean} pressed An option is being pressed
7171 */
7172 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7173 if ( pressed === undefined ) {
7174 pressed = !this.pressed;
7175 }
7176 if ( pressed !== this.pressed ) {
7177 this.$element
7178 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7179 .toggleClass( 'oo-ui-selectWidget-unpressed', !pressed );
7180 this.pressed = pressed;
7181 }
7182 };
7183
7184 /**
7185 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7186 * and any existing highlight will be removed. The highlight is mutually exclusive.
7187 *
7188 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7189 * @fires highlight
7190 * @chainable
7191 * @return {OO.ui.Widget} The widget, for chaining
7192 */
7193 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7194 var i, len, highlighted,
7195 changed = false;
7196
7197 for ( i = 0, len = this.items.length; i < len; i++ ) {
7198 highlighted = this.items[ i ] === item;
7199 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7200 this.items[ i ].setHighlighted( highlighted );
7201 changed = true;
7202 }
7203 }
7204 if ( changed ) {
7205 if ( item ) {
7206 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7207 } else {
7208 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7209 }
7210 this.emit( 'highlight', item );
7211 }
7212
7213 return this;
7214 };
7215
7216 /**
7217 * Fetch an item by its label.
7218 *
7219 * @param {string} label Label of the item to select.
7220 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7221 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7222 */
7223 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7224 var i, item, found,
7225 len = this.items.length,
7226 filter = this.getItemMatcher( label, 'exact' );
7227
7228 for ( i = 0; i < len; i++ ) {
7229 item = this.items[ i ];
7230 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7231 return item;
7232 }
7233 }
7234
7235 if ( prefix ) {
7236 found = null;
7237 filter = this.getItemMatcher( label, 'prefix' );
7238 for ( i = 0; i < len; i++ ) {
7239 item = this.items[ i ];
7240 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7241 if ( found ) {
7242 return null;
7243 }
7244 found = item;
7245 }
7246 }
7247 if ( found ) {
7248 return found;
7249 }
7250 }
7251
7252 return null;
7253 };
7254
7255 /**
7256 * Programmatically select an option by its label. If the item does not exist,
7257 * all options will be deselected.
7258 *
7259 * @param {string} [label] Label of the item to select.
7260 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7261 * @fires select
7262 * @chainable
7263 * @return {OO.ui.Widget} The widget, for chaining
7264 */
7265 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7266 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7267 if ( label === undefined || !itemFromLabel ) {
7268 return this.selectItem();
7269 }
7270 return this.selectItem( itemFromLabel );
7271 };
7272
7273 /**
7274 * Programmatically select an option by its data. If the `data` parameter is omitted,
7275 * or if the item does not exist, all options will be deselected.
7276 *
7277 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7278 * @fires select
7279 * @chainable
7280 * @return {OO.ui.Widget} The widget, for chaining
7281 */
7282 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7283 var itemFromData = this.findItemFromData( data );
7284 if ( data === undefined || !itemFromData ) {
7285 return this.selectItem();
7286 }
7287 return this.selectItem( itemFromData );
7288 };
7289
7290 /**
7291 * Programmatically unselect an option by its reference. If the widget
7292 * allows for multiple selections, there may be other items still selected;
7293 * otherwise, no items will be selected.
7294 * If no item is given, all selected items will be unselected.
7295 *
7296 * @param {OO.ui.OptionWidget} [item] Item to unselect
7297 * @fires select
7298 * @chainable
7299 * @return {OO.ui.Widget} The widget, for chaining
7300 */
7301 OO.ui.SelectWidget.prototype.unselectItem = function ( item ) {
7302 if ( item ) {
7303 item.setSelected( false );
7304 } else {
7305 this.items.forEach( function ( item ) {
7306 item.setSelected( false );
7307 } );
7308 }
7309
7310 this.emit( 'select', this.findSelectedItems() );
7311 return this;
7312 };
7313
7314 /**
7315 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7316 * all options will be deselected.
7317 *
7318 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7319 * @fires select
7320 * @chainable
7321 * @return {OO.ui.Widget} The widget, for chaining
7322 */
7323 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7324 var i, len, selected,
7325 changed = false;
7326
7327 if ( this.multiselect && item ) {
7328 // Select the item directly
7329 item.setSelected( true );
7330 } else {
7331 for ( i = 0, len = this.items.length; i < len; i++ ) {
7332 selected = this.items[ i ] === item;
7333 if ( this.items[ i ].isSelected() !== selected ) {
7334 this.items[ i ].setSelected( selected );
7335 changed = true;
7336 }
7337 }
7338 }
7339 if ( changed ) {
7340 // TODO: When should a non-highlightable element be selected?
7341 if ( item && !item.constructor.static.highlightable ) {
7342 if ( item ) {
7343 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7344 } else {
7345 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7346 }
7347 }
7348 this.emit( 'select', this.findSelectedItems() );
7349 }
7350
7351 return this;
7352 };
7353
7354 /**
7355 * Press an item.
7356 *
7357 * Press is a state that occurs when a user mouses down on an item, but has not
7358 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7359 * releases the mouse.
7360 *
7361 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7362 * @fires press
7363 * @chainable
7364 * @return {OO.ui.Widget} The widget, for chaining
7365 */
7366 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7367 var i, len, pressed,
7368 changed = false;
7369
7370 for ( i = 0, len = this.items.length; i < len; i++ ) {
7371 pressed = this.items[ i ] === item;
7372 if ( this.items[ i ].isPressed() !== pressed ) {
7373 this.items[ i ].setPressed( pressed );
7374 changed = true;
7375 }
7376 }
7377 if ( changed ) {
7378 this.emit( 'press', item );
7379 }
7380
7381 return this;
7382 };
7383
7384 /**
7385 * Choose an item.
7386 *
7387 * Note that ‘choose’ should never be modified programmatically. A user can choose
7388 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7389 * use the #selectItem method.
7390 *
7391 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7392 * when users choose an item with the keyboard or mouse.
7393 *
7394 * @param {OO.ui.OptionWidget} item Item to choose
7395 * @fires choose
7396 * @chainable
7397 * @return {OO.ui.Widget} The widget, for chaining
7398 */
7399 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7400 if ( item ) {
7401 if ( this.multiselect && item.isSelected() ) {
7402 this.unselectItem( item );
7403 } else {
7404 this.selectItem( item );
7405 }
7406
7407 this.emit( 'choose', item, item.isSelected() );
7408 }
7409
7410 return this;
7411 };
7412
7413 /**
7414 * Find an option by its position relative to the specified item (or to the start of the option
7415 * array, if item is `null`). The direction in which to search through the option array is specified
7416 * with a number: -1 for reverse (the default) or 1 for forward. The method will return an option,
7417 * or `null` if there are no options in the array.
7418 *
7419 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
7420 * the beginning of the array.
7421 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7422 * @param {Function} [filter] Only consider items for which this function returns
7423 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7424 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7425 */
7426 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7427 var currentIndex, nextIndex, i,
7428 increase = direction > 0 ? 1 : -1,
7429 len = this.items.length;
7430
7431 if ( item instanceof OO.ui.OptionWidget ) {
7432 currentIndex = this.items.indexOf( item );
7433 nextIndex = ( currentIndex + increase + len ) % len;
7434 } else {
7435 // If no item is selected and moving forward, start at the beginning.
7436 // If moving backward, start at the end.
7437 nextIndex = direction > 0 ? 0 : len - 1;
7438 }
7439
7440 for ( i = 0; i < len; i++ ) {
7441 item = this.items[ nextIndex ];
7442 if (
7443 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7444 ( !filter || filter( item ) )
7445 ) {
7446 return item;
7447 }
7448 nextIndex = ( nextIndex + increase + len ) % len;
7449 }
7450 return null;
7451 };
7452
7453 /**
7454 * Find the next selectable item or `null` if there are no selectable items.
7455 * Disabled options and menu-section markers and breaks are not selectable.
7456 *
7457 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7458 */
7459 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7460 return this.findRelativeSelectableItem( null, 1 );
7461 };
7462
7463 /**
7464 * Add an array of options to the select. Optionally, an index number can be used to
7465 * specify an insertion point.
7466 *
7467 * @param {OO.ui.OptionWidget[]} items Items to add
7468 * @param {number} [index] Index to insert items after
7469 * @fires add
7470 * @chainable
7471 * @return {OO.ui.Widget} The widget, for chaining
7472 */
7473 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7474 // Mixin method
7475 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7476
7477 // Always provide an index, even if it was omitted
7478 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7479
7480 return this;
7481 };
7482
7483 /**
7484 * Remove the specified array of options from the select. Options will be detached
7485 * from the DOM, not removed, so they can be reused later. To remove all options from
7486 * the select, you may wish to use the #clearItems method instead.
7487 *
7488 * @param {OO.ui.OptionWidget[]} items Items to remove
7489 * @fires remove
7490 * @chainable
7491 * @return {OO.ui.Widget} The widget, for chaining
7492 */
7493 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7494 var i, len, item;
7495
7496 // Deselect items being removed
7497 for ( i = 0, len = items.length; i < len; i++ ) {
7498 item = items[ i ];
7499 if ( item.isSelected() ) {
7500 this.selectItem( null );
7501 }
7502 }
7503
7504 // Mixin method
7505 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7506
7507 this.emit( 'remove', items );
7508
7509 return this;
7510 };
7511
7512 /**
7513 * Clear all options from the select. Options will be detached from the DOM, not removed,
7514 * so that they can be reused later. To remove a subset of options from the select, use
7515 * the #removeItems method.
7516 *
7517 * @fires remove
7518 * @chainable
7519 * @return {OO.ui.Widget} The widget, for chaining
7520 */
7521 OO.ui.SelectWidget.prototype.clearItems = function () {
7522 var items = this.items.slice();
7523
7524 // Mixin method
7525 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7526
7527 // Clear selection
7528 this.selectItem( null );
7529
7530 this.emit( 'remove', items );
7531
7532 return this;
7533 };
7534
7535 /**
7536 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7537 *
7538 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7539 *
7540 * @protected
7541 * @param {jQuery} $focusOwner
7542 */
7543 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7544 this.$focusOwner = $focusOwner;
7545 };
7546
7547 /**
7548 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7549 * with an {@link OO.ui.mixin.IconElement icon} and/or
7550 * {@link OO.ui.mixin.IndicatorElement indicator}.
7551 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7552 * options. For more information about options and selects, please see the
7553 * [OOUI documentation on MediaWiki][1].
7554 *
7555 * @example
7556 * // Decorated options in a select widget.
7557 * var select = new OO.ui.SelectWidget( {
7558 * items: [
7559 * new OO.ui.DecoratedOptionWidget( {
7560 * data: 'a',
7561 * label: 'Option with icon',
7562 * icon: 'help'
7563 * } ),
7564 * new OO.ui.DecoratedOptionWidget( {
7565 * data: 'b',
7566 * label: 'Option with indicator',
7567 * indicator: 'next'
7568 * } )
7569 * ]
7570 * } );
7571 * $( document.body ).append( select.$element );
7572 *
7573 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7574 *
7575 * @class
7576 * @extends OO.ui.OptionWidget
7577 * @mixins OO.ui.mixin.IconElement
7578 * @mixins OO.ui.mixin.IndicatorElement
7579 *
7580 * @constructor
7581 * @param {Object} [config] Configuration options
7582 */
7583 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7584 // Parent constructor
7585 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7586
7587 // Mixin constructors
7588 OO.ui.mixin.IconElement.call( this, config );
7589 OO.ui.mixin.IndicatorElement.call( this, config );
7590
7591 // Initialization
7592 this.$element
7593 .addClass( 'oo-ui-decoratedOptionWidget' )
7594 .prepend( this.$icon )
7595 .append( this.$indicator );
7596 };
7597
7598 /* Setup */
7599
7600 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7601 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7602 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7603
7604 /**
7605 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7606 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7607 * the [OOUI documentation on MediaWiki] [1] for more information.
7608 *
7609 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7610 *
7611 * @class
7612 * @extends OO.ui.DecoratedOptionWidget
7613 *
7614 * @constructor
7615 * @param {Object} [config] Configuration options
7616 */
7617 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7618 // Parent constructor
7619 OO.ui.MenuOptionWidget.parent.call( this, config );
7620
7621 // Properties
7622 this.checkIcon = new OO.ui.IconWidget( {
7623 icon: 'check',
7624 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7625 } );
7626
7627 // Initialization
7628 this.$element
7629 .prepend( this.checkIcon.$element )
7630 .addClass( 'oo-ui-menuOptionWidget' );
7631 };
7632
7633 /* Setup */
7634
7635 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7636
7637 /* Static Properties */
7638
7639 /**
7640 * @static
7641 * @inheritdoc
7642 */
7643 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7644
7645 /**
7646 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
7647 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
7648 * cannot be highlighted or selected.
7649 *
7650 * @example
7651 * var dropdown = new OO.ui.DropdownWidget( {
7652 * menu: {
7653 * items: [
7654 * new OO.ui.MenuSectionOptionWidget( {
7655 * label: 'Dogs'
7656 * } ),
7657 * new OO.ui.MenuOptionWidget( {
7658 * data: 'corgi',
7659 * label: 'Welsh Corgi'
7660 * } ),
7661 * new OO.ui.MenuOptionWidget( {
7662 * data: 'poodle',
7663 * label: 'Standard Poodle'
7664 * } ),
7665 * new OO.ui.MenuSectionOptionWidget( {
7666 * label: 'Cats'
7667 * } ),
7668 * new OO.ui.MenuOptionWidget( {
7669 * data: 'lion',
7670 * label: 'Lion'
7671 * } )
7672 * ]
7673 * }
7674 * } );
7675 * $( document.body ).append( dropdown.$element );
7676 *
7677 * @class
7678 * @extends OO.ui.DecoratedOptionWidget
7679 *
7680 * @constructor
7681 * @param {Object} [config] Configuration options
7682 */
7683 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7684 // Parent constructor
7685 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7686
7687 // Initialization
7688 this.$element
7689 .addClass( 'oo-ui-menuSectionOptionWidget' )
7690 .removeAttr( 'role aria-selected' );
7691 this.selected = false;
7692 };
7693
7694 /* Setup */
7695
7696 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7697
7698 /* Static Properties */
7699
7700 /**
7701 * @static
7702 * @inheritdoc
7703 */
7704 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7705
7706 /**
7707 * @static
7708 * @inheritdoc
7709 */
7710 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7711
7712 /**
7713 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7714 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7715 * See {@link OO.ui.DropdownWidget DropdownWidget},
7716 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
7717 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7718 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7719 * and customized to be opened, closed, and displayed as needed.
7720 *
7721 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7722 * mouse outside the menu.
7723 *
7724 * Menus also have support for keyboard interaction:
7725 *
7726 * - Enter/Return key: choose and select a menu option
7727 * - Up-arrow key: highlight the previous menu option
7728 * - Down-arrow key: highlight the next menu option
7729 * - Escape key: hide the menu
7730 *
7731 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7732 *
7733 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7734 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7735 *
7736 * @class
7737 * @extends OO.ui.SelectWidget
7738 * @mixins OO.ui.mixin.ClippableElement
7739 * @mixins OO.ui.mixin.FloatableElement
7740 *
7741 * @constructor
7742 * @param {Object} [config] Configuration options
7743 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu
7744 * items that match the text the user types. This config is used by
7745 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
7746 * {@link OO.ui.mixin.LookupElement LookupElement}
7747 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7748 * the text the user types. This config is used by
7749 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7750 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks
7751 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
7752 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
7753 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
7754 * in here.
7755 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7756 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7757 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7758 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7759 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7760 * @cfg {string} [filterMode='prefix'] The mode by which the menu filters the results.
7761 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
7762 * @cfg {number|string} [width] Width of the menu as a number of pixels or CSS string with unit
7763 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
7764 */
7765 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7766 // Configuration initialization
7767 config = config || {};
7768
7769 // Parent constructor
7770 OO.ui.MenuSelectWidget.parent.call( this, config );
7771
7772 // Mixin constructors
7773 OO.ui.mixin.ClippableElement.call( this, $.extend( { $clippable: this.$group }, config ) );
7774 OO.ui.mixin.FloatableElement.call( this, config );
7775
7776 // Initial vertical positions other than 'center' will result in
7777 // the menu being flipped if there is not enough space in the container.
7778 // Store the original position so we know what to reset to.
7779 this.originalVerticalPosition = this.verticalPosition;
7780
7781 // Properties
7782 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7783 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7784 this.filterFromInput = !!config.filterFromInput;
7785 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7786 this.$widget = config.widget ? config.widget.$element : null;
7787 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7788 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7789 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7790 this.highlightOnFilter = !!config.highlightOnFilter;
7791 this.lastHighlightedItem = null;
7792 this.width = config.width;
7793 this.filterMode = config.filterMode;
7794
7795 // Initialization
7796 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7797 if ( config.widget ) {
7798 this.setFocusOwner( config.widget.$tabIndexed );
7799 }
7800
7801 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7802 // that reference properties not initialized at that time of parent class construction
7803 // TODO: Find a better way to handle post-constructor setup
7804 this.visible = false;
7805 this.$element.addClass( 'oo-ui-element-hidden' );
7806 this.$focusOwner.attr( 'aria-expanded', 'false' );
7807 };
7808
7809 /* Setup */
7810
7811 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7812 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7813 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7814
7815 /* Events */
7816
7817 /**
7818 * @event ready
7819 *
7820 * The menu is ready: it is visible and has been positioned and clipped.
7821 */
7822
7823 /* Static properties */
7824
7825 /**
7826 * Positions to flip to if there isn't room in the container for the
7827 * menu in a specific direction.
7828 *
7829 * @property {Object.<string,string>}
7830 */
7831 OO.ui.MenuSelectWidget.static.flippedPositions = {
7832 below: 'above',
7833 above: 'below',
7834 top: 'bottom',
7835 bottom: 'top'
7836 };
7837
7838 /* Methods */
7839
7840 /**
7841 * Handles document mouse down events.
7842 *
7843 * @protected
7844 * @param {MouseEvent} e Mouse down event
7845 */
7846 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7847 if (
7848 this.isVisible() &&
7849 !OO.ui.contains(
7850 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7851 e.target,
7852 true
7853 )
7854 ) {
7855 this.toggle( false );
7856 }
7857 };
7858
7859 /**
7860 * @inheritdoc
7861 */
7862 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7863 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7864
7865 if ( !this.isDisabled() && this.isVisible() ) {
7866 switch ( e.keyCode ) {
7867 case OO.ui.Keys.LEFT:
7868 case OO.ui.Keys.RIGHT:
7869 // Do nothing if a text field is associated, arrow keys will be handled natively
7870 if ( !this.$input ) {
7871 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7872 }
7873 break;
7874 case OO.ui.Keys.ESCAPE:
7875 case OO.ui.Keys.TAB:
7876 if ( currentItem && !this.multiselect ) {
7877 currentItem.setHighlighted( false );
7878 }
7879 this.toggle( false );
7880 // Don't prevent tabbing away, prevent defocusing
7881 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7882 e.preventDefault();
7883 e.stopPropagation();
7884 }
7885 break;
7886 default:
7887 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7888 return;
7889 }
7890 }
7891 };
7892
7893 /**
7894 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7895 * or after items were added/removed (always).
7896 *
7897 * @protected
7898 */
7899 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7900 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7901 anyVisible = false,
7902 len = this.items.length,
7903 showAll = !this.isVisible(),
7904 exactMatch = false;
7905
7906 if ( this.$input && this.filterFromInput ) {
7907 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
7908 exactFilter = this.getItemMatcher( this.$input.val(), 'exact' );
7909 // Hide non-matching options, and also hide section headers if all options
7910 // in their section are hidden.
7911 for ( i = 0; i < len; i++ ) {
7912 item = this.items[ i ];
7913 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7914 if ( section ) {
7915 // If the previous section was empty, hide its header
7916 section.toggle( showAll || !sectionEmpty );
7917 }
7918 section = item;
7919 sectionEmpty = true;
7920 } else if ( item instanceof OO.ui.OptionWidget ) {
7921 visible = showAll || filter( item );
7922 exactMatch = exactMatch || exactFilter( item );
7923 anyVisible = anyVisible || visible;
7924 sectionEmpty = sectionEmpty && !visible;
7925 item.toggle( visible );
7926 }
7927 }
7928 // Process the final section
7929 if ( section ) {
7930 section.toggle( showAll || !sectionEmpty );
7931 }
7932
7933 if ( !anyVisible ) {
7934 this.highlightItem( null );
7935 }
7936
7937 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7938
7939 if (
7940 this.highlightOnFilter &&
7941 !( this.lastHighlightedItem && this.lastHighlightedItem.isVisible() ) &&
7942 this.isVisible()
7943 ) {
7944 // Highlight the first item on the list
7945 item = null;
7946 items = this.getItems();
7947 for ( i = 0; i < items.length; i++ ) {
7948 if ( items[ i ].isVisible() ) {
7949 item = items[ i ];
7950 break;
7951 }
7952 }
7953 this.highlightItem( item );
7954 this.lastHighlightedItem = item;
7955 }
7956 }
7957
7958 // Reevaluate clipping
7959 this.clip();
7960 };
7961
7962 /**
7963 * @inheritdoc
7964 */
7965 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7966 if ( this.$input ) {
7967 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7968 } else {
7969 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7970 }
7971 };
7972
7973 /**
7974 * @inheritdoc
7975 */
7976 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7977 if ( this.$input ) {
7978 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7979 } else {
7980 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7981 }
7982 };
7983
7984 /**
7985 * @inheritdoc
7986 */
7987 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7988 if ( this.$input ) {
7989 if ( this.filterFromInput ) {
7990 this.$input.on(
7991 'keydown mouseup cut paste change input select',
7992 this.onInputEditHandler
7993 );
7994 this.updateItemVisibility();
7995 }
7996 } else {
7997 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7998 }
7999 };
8000
8001 /**
8002 * @inheritdoc
8003 */
8004 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
8005 if ( this.$input ) {
8006 if ( this.filterFromInput ) {
8007 this.$input.off(
8008 'keydown mouseup cut paste change input select',
8009 this.onInputEditHandler
8010 );
8011 this.updateItemVisibility();
8012 }
8013 } else {
8014 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
8015 }
8016 };
8017
8018 /**
8019 * Choose an item.
8020 *
8021 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
8022 * set to false.
8023 *
8024 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
8025 * the keyboard or mouse and it becomes selected. To select an item programmatically,
8026 * use the #selectItem method.
8027 *
8028 * @param {OO.ui.OptionWidget} item Item to choose
8029 * @chainable
8030 * @return {OO.ui.Widget} The widget, for chaining
8031 */
8032 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
8033 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
8034 if ( this.hideOnChoose ) {
8035 this.toggle( false );
8036 }
8037 return this;
8038 };
8039
8040 /**
8041 * @inheritdoc
8042 */
8043 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
8044 // Parent method
8045 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
8046
8047 this.updateItemVisibility();
8048
8049 return this;
8050 };
8051
8052 /**
8053 * @inheritdoc
8054 */
8055 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
8056 // Parent method
8057 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
8058
8059 this.updateItemVisibility();
8060
8061 return this;
8062 };
8063
8064 /**
8065 * @inheritdoc
8066 */
8067 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
8068 // Parent method
8069 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
8070
8071 this.updateItemVisibility();
8072
8073 return this;
8074 };
8075
8076 /**
8077 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
8078 * `.toggle( true )` after its #$element is attached to the DOM.
8079 *
8080 * Do not show the menu while it is not attached to the DOM. The calculations required to display
8081 * it in the right place and with the right dimensions only work correctly while it is attached.
8082 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
8083 * strictly enforced, so currently it only generates a warning in the browser console.
8084 *
8085 * @fires ready
8086 * @inheritdoc
8087 */
8088 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
8089 var change, originalHeight, flippedHeight, selectedItem;
8090
8091 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
8092 change = visible !== this.isVisible();
8093
8094 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
8095 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
8096 this.warnedUnattached = true;
8097 }
8098
8099 if ( change && visible ) {
8100 // Reset position before showing the popup again. It's possible we no longer need to flip
8101 // (e.g. if the user scrolled).
8102 this.setVerticalPosition( this.originalVerticalPosition );
8103 }
8104
8105 // Parent method
8106 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
8107
8108 if ( change ) {
8109 if ( visible ) {
8110
8111 if ( this.width ) {
8112 this.setIdealSize( this.width );
8113 } else if ( this.$floatableContainer ) {
8114 this.$clippable.css( 'width', 'auto' );
8115 this.setIdealSize(
8116 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
8117 // Dropdown is smaller than handle so expand to width
8118 this.$floatableContainer[ 0 ].offsetWidth :
8119 // Dropdown is larger than handle so auto size
8120 'auto'
8121 );
8122 this.$clippable.css( 'width', '' );
8123 }
8124
8125 this.togglePositioning( !!this.$floatableContainer );
8126 this.toggleClipping( true );
8127
8128 this.bindDocumentKeyDownListener();
8129 this.bindDocumentKeyPressListener();
8130
8131 if (
8132 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
8133 this.originalVerticalPosition !== 'center'
8134 ) {
8135 // If opening the menu in one direction causes it to be clipped, flip it
8136 originalHeight = this.$element.height();
8137 this.setVerticalPosition(
8138 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
8139 );
8140 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
8141 // If flipping also causes it to be clipped, open in whichever direction
8142 // we have more space
8143 flippedHeight = this.$element.height();
8144 if ( originalHeight > flippedHeight ) {
8145 this.setVerticalPosition( this.originalVerticalPosition );
8146 }
8147 }
8148 }
8149 // Note that we do not flip the menu's opening direction if the clipping changes
8150 // later (e.g. after the user scrolls), that seems like it would be annoying
8151
8152 this.$focusOwner.attr( 'aria-expanded', 'true' );
8153
8154 selectedItem = this.findSelectedItem();
8155 if ( !this.multiselect && selectedItem ) {
8156 // TODO: Verify if this is even needed; This is already done on highlight changes
8157 // in SelectWidget#highlightItem, so we should just need to highlight the item
8158 // we need to highlight here and not bother with attr or checking selections.
8159 this.$focusOwner.attr( 'aria-activedescendant', selectedItem.getElementId() );
8160 selectedItem.scrollElementIntoView( { duration: 0 } );
8161 }
8162
8163 // Auto-hide
8164 if ( this.autoHide ) {
8165 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8166 }
8167
8168 this.emit( 'ready' );
8169 } else {
8170 this.$focusOwner.removeAttr( 'aria-activedescendant' );
8171 this.unbindDocumentKeyDownListener();
8172 this.unbindDocumentKeyPressListener();
8173 this.$focusOwner.attr( 'aria-expanded', 'false' );
8174 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
8175 this.togglePositioning( false );
8176 this.toggleClipping( false );
8177 this.lastHighlightedItem = null;
8178 }
8179 }
8180
8181 return this;
8182 };
8183
8184 /**
8185 * Scroll to the top of the menu
8186 */
8187 OO.ui.MenuSelectWidget.prototype.scrollToTop = function () {
8188 this.$element.scrollTop( 0 );
8189 };
8190
8191 /**
8192 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8193 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8194 * users can interact with it.
8195 *
8196 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8197 * OO.ui.DropdownInputWidget instead.
8198 *
8199 * @example
8200 * // A DropdownWidget with a menu that contains three options.
8201 * var dropDown = new OO.ui.DropdownWidget( {
8202 * label: 'Dropdown menu: Select a menu option',
8203 * menu: {
8204 * items: [
8205 * new OO.ui.MenuOptionWidget( {
8206 * data: 'a',
8207 * label: 'First'
8208 * } ),
8209 * new OO.ui.MenuOptionWidget( {
8210 * data: 'b',
8211 * label: 'Second'
8212 * } ),
8213 * new OO.ui.MenuOptionWidget( {
8214 * data: 'c',
8215 * label: 'Third'
8216 * } )
8217 * ]
8218 * }
8219 * } );
8220 *
8221 * $( document.body ).append( dropDown.$element );
8222 *
8223 * dropDown.getMenu().selectItemByData( 'b' );
8224 *
8225 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8226 *
8227 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8228 *
8229 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8230 *
8231 * @class
8232 * @extends OO.ui.Widget
8233 * @mixins OO.ui.mixin.IconElement
8234 * @mixins OO.ui.mixin.IndicatorElement
8235 * @mixins OO.ui.mixin.LabelElement
8236 * @mixins OO.ui.mixin.TitledElement
8237 * @mixins OO.ui.mixin.TabIndexedElement
8238 *
8239 * @constructor
8240 * @param {Object} [config] Configuration options
8241 * @cfg {Object} [menu] Configuration options to pass to
8242 * {@link OO.ui.MenuSelectWidget menu select widget}.
8243 * @cfg {jQuery|boolean} [$overlay] Render the menu into a separate layer. This configuration is
8244 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
8245 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
8246 * the menu uses relative positioning. Pass 'true' to use the default overlay.
8247 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8248 */
8249 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8250 // Configuration initialization
8251 config = $.extend( { indicator: 'down' }, config );
8252
8253 // Parent constructor
8254 OO.ui.DropdownWidget.parent.call( this, config );
8255
8256 // Properties (must be set before TabIndexedElement constructor call)
8257 this.$handle = $( '<span>' );
8258 this.$overlay = ( config.$overlay === true ?
8259 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8260
8261 // Mixin constructors
8262 OO.ui.mixin.IconElement.call( this, config );
8263 OO.ui.mixin.IndicatorElement.call( this, config );
8264 OO.ui.mixin.LabelElement.call( this, config );
8265 OO.ui.mixin.TitledElement.call( this, $.extend( {
8266 $titled: this.$label
8267 }, config ) );
8268 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
8269 $tabIndexed: this.$handle
8270 }, config ) );
8271
8272 // Properties
8273 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8274 widget: this,
8275 $floatableContainer: this.$element
8276 }, config.menu ) );
8277
8278 // Events
8279 this.$handle.on( {
8280 click: this.onClick.bind( this ),
8281 keydown: this.onKeyDown.bind( this ),
8282 // Hack? Handle type-to-search when menu is not expanded and not handling its own events.
8283 keypress: this.menu.onDocumentKeyPressHandler,
8284 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8285 } );
8286 this.menu.connect( this, {
8287 select: 'onMenuSelect',
8288 toggle: 'onMenuToggle'
8289 } );
8290
8291 // Initialization
8292 this.$label
8293 .attr( {
8294 role: 'textbox',
8295 'aria-readonly': 'true'
8296 } );
8297 this.$handle
8298 .addClass( 'oo-ui-dropdownWidget-handle' )
8299 .append( this.$icon, this.$label, this.$indicator )
8300 .attr( {
8301 role: 'combobox',
8302 'aria-autocomplete': 'list',
8303 'aria-expanded': 'false',
8304 'aria-haspopup': 'true',
8305 'aria-owns': this.menu.getElementId()
8306 } );
8307 this.$element
8308 .addClass( 'oo-ui-dropdownWidget' )
8309 .append( this.$handle );
8310 this.$overlay.append( this.menu.$element );
8311 };
8312
8313 /* Setup */
8314
8315 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8316 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8317 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8318 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8319 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8320 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8321
8322 /* Methods */
8323
8324 /**
8325 * Get the menu.
8326 *
8327 * @return {OO.ui.MenuSelectWidget} Menu of widget
8328 */
8329 OO.ui.DropdownWidget.prototype.getMenu = function () {
8330 return this.menu;
8331 };
8332
8333 /**
8334 * Handles menu select events.
8335 *
8336 * @private
8337 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8338 */
8339 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8340 var selectedLabel;
8341
8342 if ( !item ) {
8343 this.setLabel( null );
8344 return;
8345 }
8346
8347 selectedLabel = item.getLabel();
8348
8349 // If the label is a DOM element, clone it, because setLabel will append() it
8350 if ( selectedLabel instanceof $ ) {
8351 selectedLabel = selectedLabel.clone();
8352 }
8353
8354 this.setLabel( selectedLabel );
8355 };
8356
8357 /**
8358 * Handle menu toggle events.
8359 *
8360 * @private
8361 * @param {boolean} isVisible Open state of the menu
8362 */
8363 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8364 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8365 };
8366
8367 /**
8368 * Handle mouse click events.
8369 *
8370 * @private
8371 * @param {jQuery.Event} e Mouse click event
8372 * @return {undefined|boolean} False to prevent default if event is handled
8373 */
8374 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8375 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8376 this.menu.toggle();
8377 }
8378 return false;
8379 };
8380
8381 /**
8382 * Handle key down events.
8383 *
8384 * @private
8385 * @param {jQuery.Event} e Key down event
8386 * @return {undefined|boolean} False to prevent default if event is handled
8387 */
8388 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8389 if (
8390 !this.isDisabled() &&
8391 (
8392 e.which === OO.ui.Keys.ENTER ||
8393 (
8394 e.which === OO.ui.Keys.SPACE &&
8395 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8396 // Space only closes the menu is the user is not typing to search.
8397 this.menu.keyPressBuffer === ''
8398 ) ||
8399 (
8400 !this.menu.isVisible() &&
8401 (
8402 e.which === OO.ui.Keys.UP ||
8403 e.which === OO.ui.Keys.DOWN
8404 )
8405 )
8406 )
8407 ) {
8408 this.menu.toggle();
8409 return false;
8410 }
8411 };
8412
8413 /**
8414 * RadioOptionWidget is an option widget that looks like a radio button.
8415 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8416 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8417 *
8418 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8419 *
8420 * @class
8421 * @extends OO.ui.OptionWidget
8422 *
8423 * @constructor
8424 * @param {Object} [config] Configuration options
8425 */
8426 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8427 // Configuration initialization
8428 config = config || {};
8429
8430 // Properties (must be done before parent constructor which calls #setDisabled)
8431 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8432
8433 // Parent constructor
8434 OO.ui.RadioOptionWidget.parent.call( this, config );
8435
8436 // Initialization
8437 // Remove implicit role, we're handling it ourselves
8438 this.radio.$input.attr( 'role', 'presentation' );
8439 this.$element
8440 .addClass( 'oo-ui-radioOptionWidget' )
8441 .attr( 'role', 'radio' )
8442 .attr( 'aria-checked', 'false' )
8443 .removeAttr( 'aria-selected' )
8444 .prepend( this.radio.$element );
8445 };
8446
8447 /* Setup */
8448
8449 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8450
8451 /* Static Properties */
8452
8453 /**
8454 * @static
8455 * @inheritdoc
8456 */
8457 OO.ui.RadioOptionWidget.static.highlightable = false;
8458
8459 /**
8460 * @static
8461 * @inheritdoc
8462 */
8463 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8464
8465 /**
8466 * @static
8467 * @inheritdoc
8468 */
8469 OO.ui.RadioOptionWidget.static.pressable = false;
8470
8471 /**
8472 * @static
8473 * @inheritdoc
8474 */
8475 OO.ui.RadioOptionWidget.static.tagName = 'label';
8476
8477 /* Methods */
8478
8479 /**
8480 * @inheritdoc
8481 */
8482 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8483 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8484
8485 this.radio.setSelected( state );
8486 this.$element
8487 .attr( 'aria-checked', state.toString() )
8488 .removeAttr( 'aria-selected' );
8489
8490 return this;
8491 };
8492
8493 /**
8494 * @inheritdoc
8495 */
8496 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8497 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8498
8499 this.radio.setDisabled( this.isDisabled() );
8500
8501 return this;
8502 };
8503
8504 /**
8505 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8506 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8507 * an interface for adding, removing and selecting options.
8508 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8509 *
8510 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8511 * OO.ui.RadioSelectInputWidget instead.
8512 *
8513 * @example
8514 * // A RadioSelectWidget with RadioOptions.
8515 * var option1 = new OO.ui.RadioOptionWidget( {
8516 * data: 'a',
8517 * label: 'Selected radio option'
8518 * } ),
8519 * option2 = new OO.ui.RadioOptionWidget( {
8520 * data: 'b',
8521 * label: 'Unselected radio option'
8522 * } );
8523 * radioSelect = new OO.ui.RadioSelectWidget( {
8524 * items: [ option1, option2 ]
8525 * } );
8526 *
8527 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8528 * radioSelect.selectItem( option1 );
8529 *
8530 * $( document.body ).append( radioSelect.$element );
8531 *
8532 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8533
8534 *
8535 * @class
8536 * @extends OO.ui.SelectWidget
8537 * @mixins OO.ui.mixin.TabIndexedElement
8538 *
8539 * @constructor
8540 * @param {Object} [config] Configuration options
8541 */
8542 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8543 // Parent constructor
8544 OO.ui.RadioSelectWidget.parent.call( this, config );
8545
8546 // Mixin constructors
8547 OO.ui.mixin.TabIndexedElement.call( this, config );
8548
8549 // Events
8550 this.$element.on( {
8551 focus: this.bindDocumentKeyDownListener.bind( this ),
8552 blur: this.unbindDocumentKeyDownListener.bind( this )
8553 } );
8554
8555 // Initialization
8556 this.$element
8557 .addClass( 'oo-ui-radioSelectWidget' )
8558 .attr( 'role', 'radiogroup' );
8559 };
8560
8561 /* Setup */
8562
8563 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8564 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8565
8566 /**
8567 * MultioptionWidgets are special elements that can be selected and configured with data. The
8568 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8569 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8570 * and examples, please see the [OOUI documentation on MediaWiki][1].
8571 *
8572 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8573 *
8574 * @class
8575 * @extends OO.ui.Widget
8576 * @mixins OO.ui.mixin.ItemWidget
8577 * @mixins OO.ui.mixin.LabelElement
8578 * @mixins OO.ui.mixin.TitledElement
8579 *
8580 * @constructor
8581 * @param {Object} [config] Configuration options
8582 * @cfg {boolean} [selected=false] Whether the option is initially selected
8583 */
8584 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8585 // Configuration initialization
8586 config = config || {};
8587
8588 // Parent constructor
8589 OO.ui.MultioptionWidget.parent.call( this, config );
8590
8591 // Mixin constructors
8592 OO.ui.mixin.ItemWidget.call( this );
8593 OO.ui.mixin.LabelElement.call( this, config );
8594 OO.ui.mixin.TitledElement.call( this, config );
8595
8596 // Properties
8597 this.selected = null;
8598
8599 // Initialization
8600 this.$element
8601 .addClass( 'oo-ui-multioptionWidget' )
8602 .append( this.$label );
8603 this.setSelected( config.selected );
8604 };
8605
8606 /* Setup */
8607
8608 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8609 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8610 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8611 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8612
8613 /* Events */
8614
8615 /**
8616 * @event change
8617 *
8618 * A change event is emitted when the selected state of the option changes.
8619 *
8620 * @param {boolean} selected Whether the option is now selected
8621 */
8622
8623 /* Methods */
8624
8625 /**
8626 * Check if the option is selected.
8627 *
8628 * @return {boolean} Item is selected
8629 */
8630 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8631 return this.selected;
8632 };
8633
8634 /**
8635 * Set the option’s selected state. In general, all modifications to the selection
8636 * should be handled by the SelectWidget’s
8637 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
8638 *
8639 * @param {boolean} [state=false] Select option
8640 * @chainable
8641 * @return {OO.ui.Widget} The widget, for chaining
8642 */
8643 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8644 state = !!state;
8645 if ( this.selected !== state ) {
8646 this.selected = state;
8647 this.emit( 'change', state );
8648 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8649 }
8650 return this;
8651 };
8652
8653 /**
8654 * MultiselectWidget allows selecting multiple options from a list.
8655 *
8656 * For more information about menus and options, please see the [OOUI documentation
8657 * on MediaWiki][1].
8658 *
8659 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8660 *
8661 * @class
8662 * @abstract
8663 * @extends OO.ui.Widget
8664 * @mixins OO.ui.mixin.GroupWidget
8665 * @mixins OO.ui.mixin.TitledElement
8666 *
8667 * @constructor
8668 * @param {Object} [config] Configuration options
8669 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8670 */
8671 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8672 // Parent constructor
8673 OO.ui.MultiselectWidget.parent.call( this, config );
8674
8675 // Configuration initialization
8676 config = config || {};
8677
8678 // Mixin constructors
8679 OO.ui.mixin.GroupWidget.call( this, config );
8680 OO.ui.mixin.TitledElement.call( this, config );
8681
8682 // Events
8683 this.aggregate( {
8684 change: 'select'
8685 } );
8686 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8687 // by GroupElement only when items are added/removed
8688 this.connect( this, {
8689 select: [ 'emit', 'change' ]
8690 } );
8691
8692 // Initialization
8693 if ( config.items ) {
8694 this.addItems( config.items );
8695 }
8696 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8697 this.$element.addClass( 'oo-ui-multiselectWidget' )
8698 .append( this.$group );
8699 };
8700
8701 /* Setup */
8702
8703 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8704 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8705 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8706
8707 /* Events */
8708
8709 /**
8710 * @event change
8711 *
8712 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8713 */
8714
8715 /**
8716 * @event select
8717 *
8718 * A select event is emitted when an item is selected or deselected.
8719 */
8720
8721 /* Methods */
8722
8723 /**
8724 * Find options that are selected.
8725 *
8726 * @return {OO.ui.MultioptionWidget[]} Selected options
8727 */
8728 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8729 return this.items.filter( function ( item ) {
8730 return item.isSelected();
8731 } );
8732 };
8733
8734 /**
8735 * Find the data of options that are selected.
8736 *
8737 * @return {Object[]|string[]} Values of selected options
8738 */
8739 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8740 return this.findSelectedItems().map( function ( item ) {
8741 return item.data;
8742 } );
8743 };
8744
8745 /**
8746 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8747 *
8748 * @param {OO.ui.MultioptionWidget[]} items Items to select
8749 * @chainable
8750 * @return {OO.ui.Widget} The widget, for chaining
8751 */
8752 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8753 this.items.forEach( function ( item ) {
8754 var selected = items.indexOf( item ) !== -1;
8755 item.setSelected( selected );
8756 } );
8757 return this;
8758 };
8759
8760 /**
8761 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8762 *
8763 * @param {Object[]|string[]} datas Values of items to select
8764 * @chainable
8765 * @return {OO.ui.Widget} The widget, for chaining
8766 */
8767 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8768 var items,
8769 widget = this;
8770 items = datas.map( function ( data ) {
8771 return widget.findItemFromData( data );
8772 } );
8773 this.selectItems( items );
8774 return this;
8775 };
8776
8777 /**
8778 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8779 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8780 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8781 *
8782 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8783 *
8784 * @class
8785 * @extends OO.ui.MultioptionWidget
8786 *
8787 * @constructor
8788 * @param {Object} [config] Configuration options
8789 */
8790 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8791 // Configuration initialization
8792 config = config || {};
8793
8794 // Properties (must be done before parent constructor which calls #setDisabled)
8795 this.checkbox = new OO.ui.CheckboxInputWidget();
8796
8797 // Parent constructor
8798 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8799
8800 // Events
8801 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8802 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8803
8804 // Initialization
8805 this.$element
8806 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8807 .prepend( this.checkbox.$element );
8808 };
8809
8810 /* Setup */
8811
8812 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8813
8814 /* Static Properties */
8815
8816 /**
8817 * @static
8818 * @inheritdoc
8819 */
8820 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8821
8822 /* Methods */
8823
8824 /**
8825 * Handle checkbox selected state change.
8826 *
8827 * @private
8828 */
8829 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8830 this.setSelected( this.checkbox.isSelected() );
8831 };
8832
8833 /**
8834 * @inheritdoc
8835 */
8836 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8837 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8838 this.checkbox.setSelected( state );
8839 return this;
8840 };
8841
8842 /**
8843 * @inheritdoc
8844 */
8845 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8846 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8847 this.checkbox.setDisabled( this.isDisabled() );
8848 return this;
8849 };
8850
8851 /**
8852 * Focus the widget.
8853 */
8854 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8855 this.checkbox.focus();
8856 };
8857
8858 /**
8859 * Handle key down events.
8860 *
8861 * @protected
8862 * @param {jQuery.Event} e
8863 */
8864 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8865 var
8866 element = this.getElementGroup(),
8867 nextItem;
8868
8869 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8870 nextItem = element.getRelativeFocusableItem( this, -1 );
8871 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8872 nextItem = element.getRelativeFocusableItem( this, 1 );
8873 }
8874
8875 if ( nextItem ) {
8876 e.preventDefault();
8877 nextItem.focus();
8878 }
8879 };
8880
8881 /**
8882 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8883 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8884 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8885 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8886 *
8887 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8888 * OO.ui.CheckboxMultiselectInputWidget instead.
8889 *
8890 * @example
8891 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8892 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8893 * data: 'a',
8894 * selected: true,
8895 * label: 'Selected checkbox'
8896 * } ),
8897 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8898 * data: 'b',
8899 * label: 'Unselected checkbox'
8900 * } ),
8901 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8902 * items: [ option1, option2 ]
8903 * } );
8904 * $( document.body ).append( multiselect.$element );
8905 *
8906 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8907 *
8908 * @class
8909 * @extends OO.ui.MultiselectWidget
8910 *
8911 * @constructor
8912 * @param {Object} [config] Configuration options
8913 */
8914 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8915 // Parent constructor
8916 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8917
8918 // Properties
8919 this.$lastClicked = null;
8920
8921 // Events
8922 this.$group.on( 'click', this.onClick.bind( this ) );
8923
8924 // Initialization
8925 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
8926 };
8927
8928 /* Setup */
8929
8930 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8931
8932 /* Methods */
8933
8934 /**
8935 * Get an option by its position relative to the specified item (or to the start of the
8936 * option array, if item is `null`). The direction in which to search through the option array
8937 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
8938 * return an option, or `null` if there are no options in the array.
8939 *
8940 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
8941 * `null` to start at the beginning of the array.
8942 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8943 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
8944 * in the select.
8945 */
8946 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8947 var currentIndex, nextIndex, i,
8948 increase = direction > 0 ? 1 : -1,
8949 len = this.items.length;
8950
8951 if ( item ) {
8952 currentIndex = this.items.indexOf( item );
8953 nextIndex = ( currentIndex + increase + len ) % len;
8954 } else {
8955 // If no item is selected and moving forward, start at the beginning.
8956 // If moving backward, start at the end.
8957 nextIndex = direction > 0 ? 0 : len - 1;
8958 }
8959
8960 for ( i = 0; i < len; i++ ) {
8961 item = this.items[ nextIndex ];
8962 if ( item && !item.isDisabled() ) {
8963 return item;
8964 }
8965 nextIndex = ( nextIndex + increase + len ) % len;
8966 }
8967 return null;
8968 };
8969
8970 /**
8971 * Handle click events on checkboxes.
8972 *
8973 * @param {jQuery.Event} e
8974 */
8975 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8976 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8977 $lastClicked = this.$lastClicked,
8978 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8979 .not( '.oo-ui-widget-disabled' );
8980
8981 // Allow selecting multiple options at once by Shift-clicking them
8982 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8983 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8984 lastClickedIndex = $options.index( $lastClicked );
8985 nowClickedIndex = $options.index( $nowClicked );
8986 // If it's the same item, either the user is being silly, or it's a fake event generated
8987 // by the browser. In either case we don't need custom handling.
8988 if ( nowClickedIndex !== lastClickedIndex ) {
8989 items = this.items;
8990 wasSelected = items[ nowClickedIndex ].isSelected();
8991 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8992
8993 // This depends on the DOM order of the items and the order of the .items array being
8994 // the same.
8995 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8996 if ( !items[ i ].isDisabled() ) {
8997 items[ i ].setSelected( !wasSelected );
8998 }
8999 }
9000 // For the now-clicked element, use immediate timeout to allow the browser to do its own
9001 // handling first, then set our value. The order in which events happen is different for
9002 // clicks on the <input> and on the <label> and there are additional fake clicks fired
9003 // for non-click actions that change the checkboxes.
9004 e.preventDefault();
9005 setTimeout( function () {
9006 if ( !items[ nowClickedIndex ].isDisabled() ) {
9007 items[ nowClickedIndex ].setSelected( !wasSelected );
9008 }
9009 } );
9010 }
9011 }
9012
9013 if ( $nowClicked.length ) {
9014 this.$lastClicked = $nowClicked;
9015 }
9016 };
9017
9018 /**
9019 * Focus the widget
9020 *
9021 * @chainable
9022 * @return {OO.ui.Widget} The widget, for chaining
9023 */
9024 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
9025 var item;
9026 if ( !this.isDisabled() ) {
9027 item = this.getRelativeFocusableItem( null, 1 );
9028 if ( item ) {
9029 item.focus();
9030 }
9031 }
9032 return this;
9033 };
9034
9035 /**
9036 * @inheritdoc
9037 */
9038 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
9039 this.focus();
9040 };
9041
9042 /**
9043 * Progress bars visually display the status of an operation, such as a download,
9044 * and can be either determinate or indeterminate:
9045 *
9046 * - **determinate** process bars show the percent of an operation that is complete.
9047 *
9048 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
9049 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
9050 * not use percentages.
9051 *
9052 * The value of the `progress` configuration determines whether the bar is determinate
9053 * or indeterminate.
9054 *
9055 * @example
9056 * // Examples of determinate and indeterminate progress bars.
9057 * var progressBar1 = new OO.ui.ProgressBarWidget( {
9058 * progress: 33
9059 * } );
9060 * var progressBar2 = new OO.ui.ProgressBarWidget();
9061 *
9062 * // Create a FieldsetLayout to layout progress bars.
9063 * var fieldset = new OO.ui.FieldsetLayout;
9064 * fieldset.addItems( [
9065 * new OO.ui.FieldLayout( progressBar1, {
9066 * label: 'Determinate',
9067 * align: 'top'
9068 * } ),
9069 * new OO.ui.FieldLayout( progressBar2, {
9070 * label: 'Indeterminate',
9071 * align: 'top'
9072 * } )
9073 * ] );
9074 * $( document.body ).append( fieldset.$element );
9075 *
9076 * @class
9077 * @extends OO.ui.Widget
9078 *
9079 * @constructor
9080 * @param {Object} [config] Configuration options
9081 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
9082 * To create a determinate progress bar, specify a number that reflects the initial
9083 * percent complete.
9084 * By default, the progress bar is indeterminate.
9085 */
9086 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
9087 // Configuration initialization
9088 config = config || {};
9089
9090 // Parent constructor
9091 OO.ui.ProgressBarWidget.parent.call( this, config );
9092
9093 // Properties
9094 this.$bar = $( '<div>' );
9095 this.progress = null;
9096
9097 // Initialization
9098 this.setProgress( config.progress !== undefined ? config.progress : false );
9099 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
9100 this.$element
9101 .attr( {
9102 role: 'progressbar',
9103 'aria-valuemin': 0,
9104 'aria-valuemax': 100
9105 } )
9106 .addClass( 'oo-ui-progressBarWidget' )
9107 .append( this.$bar );
9108 };
9109
9110 /* Setup */
9111
9112 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
9113
9114 /* Static Properties */
9115
9116 /**
9117 * @static
9118 * @inheritdoc
9119 */
9120 OO.ui.ProgressBarWidget.static.tagName = 'div';
9121
9122 /* Methods */
9123
9124 /**
9125 * Get the percent of the progress that has been completed. Indeterminate progresses will
9126 * return `false`.
9127 *
9128 * @return {number|boolean} Progress percent
9129 */
9130 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
9131 return this.progress;
9132 };
9133
9134 /**
9135 * Set the percent of the process completed or `false` for an indeterminate process.
9136 *
9137 * @param {number|boolean} progress Progress percent or `false` for indeterminate
9138 */
9139 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
9140 this.progress = progress;
9141
9142 if ( progress !== false ) {
9143 this.$bar.css( 'width', this.progress + '%' );
9144 this.$element.attr( 'aria-valuenow', this.progress );
9145 } else {
9146 this.$bar.css( 'width', '' );
9147 this.$element.removeAttr( 'aria-valuenow' );
9148 }
9149 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
9150 };
9151
9152 /**
9153 * InputWidget is the base class for all input widgets, which
9154 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
9155 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
9156 * {@link OO.ui.ButtonInputWidget button inputs}.
9157 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
9158 *
9159 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9160 *
9161 * @abstract
9162 * @class
9163 * @extends OO.ui.Widget
9164 * @mixins OO.ui.mixin.TabIndexedElement
9165 * @mixins OO.ui.mixin.TitledElement
9166 * @mixins OO.ui.mixin.AccessKeyedElement
9167 *
9168 * @constructor
9169 * @param {Object} [config] Configuration options
9170 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9171 * @cfg {string} [value=''] The value of the input.
9172 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
9173 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
9174 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the
9175 * value of an input before it is accepted.
9176 */
9177 OO.ui.InputWidget = function OoUiInputWidget( config ) {
9178 // Configuration initialization
9179 config = config || {};
9180
9181 // Parent constructor
9182 OO.ui.InputWidget.parent.call( this, config );
9183
9184 // Properties
9185 // See #reusePreInfuseDOM about config.$input
9186 this.$input = config.$input || this.getInputElement( config );
9187 this.value = '';
9188 this.inputFilter = config.inputFilter;
9189
9190 // Mixin constructors
9191 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
9192 $tabIndexed: this.$input
9193 }, config ) );
9194 OO.ui.mixin.TitledElement.call( this, $.extend( {
9195 $titled: this.$input
9196 }, config ) );
9197 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
9198 $accessKeyed: this.$input
9199 }, config ) );
9200
9201 // Events
9202 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9203
9204 // Initialization
9205 this.$input
9206 .addClass( 'oo-ui-inputWidget-input' )
9207 .attr( 'name', config.name )
9208 .prop( 'disabled', this.isDisabled() );
9209 this.$element
9210 .addClass( 'oo-ui-inputWidget' )
9211 .append( this.$input );
9212 this.setValue( config.value );
9213 if ( config.dir ) {
9214 this.setDir( config.dir );
9215 }
9216 if ( config.inputId !== undefined ) {
9217 this.setInputId( config.inputId );
9218 }
9219 };
9220
9221 /* Setup */
9222
9223 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9224 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9225 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9226 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9227
9228 /* Static Methods */
9229
9230 /**
9231 * @inheritdoc
9232 */
9233 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9234 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
9235 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9236 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
9237 return config;
9238 };
9239
9240 /**
9241 * @inheritdoc
9242 */
9243 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9244 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
9245 if ( config.$input && config.$input.length ) {
9246 state.value = config.$input.val();
9247 // Might be better in TabIndexedElement, but it's awkward to do there because
9248 // mixins are awkward
9249 state.focus = config.$input.is( ':focus' );
9250 }
9251 return state;
9252 };
9253
9254 /* Events */
9255
9256 /**
9257 * @event change
9258 *
9259 * A change event is emitted when the value of the input changes.
9260 *
9261 * @param {string} value
9262 */
9263
9264 /* Methods */
9265
9266 /**
9267 * Get input element.
9268 *
9269 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9270 * different circumstances. The element must have a `value` property (like form elements).
9271 *
9272 * @protected
9273 * @param {Object} config Configuration options
9274 * @return {jQuery} Input element
9275 */
9276 OO.ui.InputWidget.prototype.getInputElement = function () {
9277 return $( '<input>' );
9278 };
9279
9280 /**
9281 * Handle potentially value-changing events.
9282 *
9283 * @private
9284 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9285 */
9286 OO.ui.InputWidget.prototype.onEdit = function () {
9287 var widget = this;
9288 if ( !this.isDisabled() ) {
9289 // Allow the stack to clear so the value will be updated
9290 setTimeout( function () {
9291 widget.setValue( widget.$input.val() );
9292 } );
9293 }
9294 };
9295
9296 /**
9297 * Get the value of the input.
9298 *
9299 * @return {string} Input value
9300 */
9301 OO.ui.InputWidget.prototype.getValue = function () {
9302 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9303 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9304 var value = this.$input.val();
9305 if ( this.value !== value ) {
9306 this.setValue( value );
9307 }
9308 return this.value;
9309 };
9310
9311 /**
9312 * Set the directionality of the input.
9313 *
9314 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9315 * @chainable
9316 * @return {OO.ui.Widget} The widget, for chaining
9317 */
9318 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9319 this.$input.prop( 'dir', dir );
9320 return this;
9321 };
9322
9323 /**
9324 * Set the value of the input.
9325 *
9326 * @param {string} value New value
9327 * @fires change
9328 * @chainable
9329 * @return {OO.ui.Widget} The widget, for chaining
9330 */
9331 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9332 value = this.cleanUpValue( value );
9333 // Update the DOM if it has changed. Note that with cleanUpValue, it
9334 // is possible for the DOM value to change without this.value changing.
9335 if ( this.$input.val() !== value ) {
9336 this.$input.val( value );
9337 }
9338 if ( this.value !== value ) {
9339 this.value = value;
9340 this.emit( 'change', this.value );
9341 }
9342 // The first time that the value is set (probably while constructing the widget),
9343 // remember it in defaultValue. This property can be later used to check whether
9344 // the value of the input has been changed since it was created.
9345 if ( this.defaultValue === undefined ) {
9346 this.defaultValue = this.value;
9347 this.$input[ 0 ].defaultValue = this.defaultValue;
9348 }
9349 return this;
9350 };
9351
9352 /**
9353 * Clean up incoming value.
9354 *
9355 * Ensures value is a string, and converts undefined and null to empty string.
9356 *
9357 * @private
9358 * @param {string} value Original value
9359 * @return {string} Cleaned up value
9360 */
9361 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9362 if ( value === undefined || value === null ) {
9363 return '';
9364 } else if ( this.inputFilter ) {
9365 return this.inputFilter( String( value ) );
9366 } else {
9367 return String( value );
9368 }
9369 };
9370
9371 /**
9372 * @inheritdoc
9373 */
9374 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9375 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9376 if ( this.$input ) {
9377 this.$input.prop( 'disabled', this.isDisabled() );
9378 }
9379 return this;
9380 };
9381
9382 /**
9383 * Set the 'id' attribute of the `<input>` element.
9384 *
9385 * @param {string} id
9386 * @chainable
9387 * @return {OO.ui.Widget} The widget, for chaining
9388 */
9389 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9390 this.$input.attr( 'id', id );
9391 return this;
9392 };
9393
9394 /**
9395 * @inheritdoc
9396 */
9397 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9398 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9399 if ( state.value !== undefined && state.value !== this.getValue() ) {
9400 this.setValue( state.value );
9401 }
9402 if ( state.focus ) {
9403 this.focus();
9404 }
9405 };
9406
9407 /**
9408 * Data widget intended for creating `<input type="hidden">` inputs.
9409 *
9410 * @class
9411 * @extends OO.ui.Widget
9412 *
9413 * @constructor
9414 * @param {Object} [config] Configuration options
9415 * @cfg {string} [value=''] The value of the input.
9416 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9417 */
9418 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9419 // Configuration initialization
9420 config = $.extend( { value: '', name: '' }, config );
9421
9422 // Parent constructor
9423 OO.ui.HiddenInputWidget.parent.call( this, config );
9424
9425 // Initialization
9426 this.$element.attr( {
9427 type: 'hidden',
9428 value: config.value,
9429 name: config.name
9430 } );
9431 this.$element.removeAttr( 'aria-disabled' );
9432 };
9433
9434 /* Setup */
9435
9436 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9437
9438 /* Static Properties */
9439
9440 /**
9441 * @static
9442 * @inheritdoc
9443 */
9444 OO.ui.HiddenInputWidget.static.tagName = 'input';
9445
9446 /**
9447 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9448 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9449 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9450 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9451 * [OOUI documentation on MediaWiki] [1] for more information.
9452 *
9453 * @example
9454 * // A ButtonInputWidget rendered as an HTML button, the default.
9455 * var button = new OO.ui.ButtonInputWidget( {
9456 * label: 'Input button',
9457 * icon: 'check',
9458 * value: 'check'
9459 * } );
9460 * $( document.body ).append( button.$element );
9461 *
9462 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9463 *
9464 * @class
9465 * @extends OO.ui.InputWidget
9466 * @mixins OO.ui.mixin.ButtonElement
9467 * @mixins OO.ui.mixin.IconElement
9468 * @mixins OO.ui.mixin.IndicatorElement
9469 * @mixins OO.ui.mixin.LabelElement
9470 * @mixins OO.ui.mixin.FlaggedElement
9471 *
9472 * @constructor
9473 * @param {Object} [config] Configuration options
9474 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute:
9475 * 'button', 'submit' or 'reset'.
9476 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9477 * Widgets configured to be an `<input>` do not support {@link #icon icons} and
9478 * {@link #indicator indicators},
9479 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should
9480 * only be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9481 */
9482 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9483 // Configuration initialization
9484 config = $.extend( { type: 'button', useInputTag: false }, config );
9485
9486 // See InputWidget#reusePreInfuseDOM about config.$input
9487 if ( config.$input ) {
9488 config.$input.empty();
9489 }
9490
9491 // Properties (must be set before parent constructor, which calls #setValue)
9492 this.useInputTag = config.useInputTag;
9493
9494 // Parent constructor
9495 OO.ui.ButtonInputWidget.parent.call( this, config );
9496
9497 // Mixin constructors
9498 OO.ui.mixin.ButtonElement.call( this, $.extend( {
9499 $button: this.$input
9500 }, config ) );
9501 OO.ui.mixin.IconElement.call( this, config );
9502 OO.ui.mixin.IndicatorElement.call( this, config );
9503 OO.ui.mixin.LabelElement.call( this, config );
9504 OO.ui.mixin.FlaggedElement.call( this, config );
9505
9506 // Initialization
9507 if ( !config.useInputTag ) {
9508 this.$input.append( this.$icon, this.$label, this.$indicator );
9509 }
9510 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9511 };
9512
9513 /* Setup */
9514
9515 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9516 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9517 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9518 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9519 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9520 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.FlaggedElement );
9521
9522 /* Static Properties */
9523
9524 /**
9525 * @static
9526 * @inheritdoc
9527 */
9528 OO.ui.ButtonInputWidget.static.tagName = 'span';
9529
9530 /* Methods */
9531
9532 /**
9533 * @inheritdoc
9534 * @protected
9535 */
9536 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9537 var type;
9538 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9539 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9540 };
9541
9542 /**
9543 * Set label value.
9544 *
9545 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9546 *
9547 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9548 * text, or `null` for no label
9549 * @chainable
9550 * @return {OO.ui.Widget} The widget, for chaining
9551 */
9552 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9553 if ( typeof label === 'function' ) {
9554 label = OO.ui.resolveMsg( label );
9555 }
9556
9557 if ( this.useInputTag ) {
9558 // Discard non-plaintext labels
9559 if ( typeof label !== 'string' ) {
9560 label = '';
9561 }
9562
9563 this.$input.val( label );
9564 }
9565
9566 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9567 };
9568
9569 /**
9570 * Set the value of the input.
9571 *
9572 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9573 * they do not support {@link #value values}.
9574 *
9575 * @param {string} value New value
9576 * @chainable
9577 * @return {OO.ui.Widget} The widget, for chaining
9578 */
9579 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9580 if ( !this.useInputTag ) {
9581 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9582 }
9583 return this;
9584 };
9585
9586 /**
9587 * @inheritdoc
9588 */
9589 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9590 // Disable generating `<label>` elements for buttons. One would very rarely need additional
9591 // label for a button, and it's already a big clickable target, and it causes
9592 // unexpected rendering.
9593 return null;
9594 };
9595
9596 /**
9597 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9598 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9599 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9600 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9601 *
9602 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9603 *
9604 * @example
9605 * // An example of selected, unselected, and disabled checkbox inputs.
9606 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9607 * value: 'a',
9608 * selected: true
9609 * } ),
9610 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9611 * value: 'b'
9612 * } ),
9613 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9614 * value:'c',
9615 * disabled: true
9616 * } ),
9617 * // Create a fieldset layout with fields for each checkbox.
9618 * fieldset = new OO.ui.FieldsetLayout( {
9619 * label: 'Checkboxes'
9620 * } );
9621 * fieldset.addItems( [
9622 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9623 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9624 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9625 * ] );
9626 * $( document.body ).append( fieldset.$element );
9627 *
9628 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9629 *
9630 * @class
9631 * @extends OO.ui.InputWidget
9632 *
9633 * @constructor
9634 * @param {Object} [config] Configuration options
9635 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is
9636 * not selected.
9637 * @cfg {boolean} [indeterminate=false] Whether the checkbox is in the indeterminate state.
9638 */
9639 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9640 // Configuration initialization
9641 config = config || {};
9642
9643 // Parent constructor
9644 OO.ui.CheckboxInputWidget.parent.call( this, config );
9645
9646 // Properties
9647 this.checkIcon = new OO.ui.IconWidget( {
9648 icon: 'check',
9649 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9650 } );
9651
9652 // Initialization
9653 this.$element
9654 .addClass( 'oo-ui-checkboxInputWidget' )
9655 // Required for pretty styling in WikimediaUI theme
9656 .append( this.checkIcon.$element );
9657 this.setSelected( config.selected !== undefined ? config.selected : false );
9658 this.setIndeterminate( config.indeterminate !== undefined ? config.indeterminate : false );
9659 };
9660
9661 /* Setup */
9662
9663 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9664
9665 /* Events */
9666
9667 /**
9668 * @event change
9669 *
9670 * A change event is emitted when the state of the input changes.
9671 *
9672 * @param {boolean} selected
9673 * @param {boolean} indeterminate
9674 */
9675
9676 /* Static Properties */
9677
9678 /**
9679 * @static
9680 * @inheritdoc
9681 */
9682 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9683
9684 /* Static Methods */
9685
9686 /**
9687 * @inheritdoc
9688 */
9689 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9690 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9691 state.checked = config.$input.prop( 'checked' );
9692 return state;
9693 };
9694
9695 /* Methods */
9696
9697 /**
9698 * @inheritdoc
9699 * @protected
9700 */
9701 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9702 return $( '<input>' ).attr( 'type', 'checkbox' );
9703 };
9704
9705 /**
9706 * @inheritdoc
9707 */
9708 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9709 var widget = this;
9710 if ( !this.isDisabled() ) {
9711 // Allow the stack to clear so the value will be updated
9712 setTimeout( function () {
9713 widget.setSelected( widget.$input.prop( 'checked' ) );
9714 widget.setIndeterminate( widget.$input.prop( 'indeterminate' ) );
9715 } );
9716 }
9717 };
9718
9719 /**
9720 * Set selection state of this checkbox.
9721 *
9722 * @param {boolean} state Selected state
9723 * @param {boolean} internal Used for internal calls to suppress events
9724 * @chainable
9725 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9726 */
9727 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state, internal ) {
9728 state = !!state;
9729 if ( this.selected !== state ) {
9730 this.selected = state;
9731 this.$input.prop( 'checked', this.selected );
9732 if ( !internal ) {
9733 this.setIndeterminate( false, true );
9734 this.emit( 'change', this.selected, this.indeterminate );
9735 }
9736 }
9737 // The first time that the selection state is set (probably while constructing the widget),
9738 // remember it in defaultSelected. This property can be later used to check whether
9739 // the selection state of the input has been changed since it was created.
9740 if ( this.defaultSelected === undefined ) {
9741 this.defaultSelected = this.selected;
9742 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9743 }
9744 return this;
9745 };
9746
9747 /**
9748 * Check if this checkbox is selected.
9749 *
9750 * @return {boolean} Checkbox is selected
9751 */
9752 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9753 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9754 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9755 var selected = this.$input.prop( 'checked' );
9756 if ( this.selected !== selected ) {
9757 this.setSelected( selected );
9758 }
9759 return this.selected;
9760 };
9761
9762 /**
9763 * Set indeterminate state of this checkbox.
9764 *
9765 * @param {boolean} state Indeterminate state
9766 * @param {boolean} internal Used for internal calls to suppress events
9767 * @chainable
9768 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9769 */
9770 OO.ui.CheckboxInputWidget.prototype.setIndeterminate = function ( state, internal ) {
9771 state = !!state;
9772 if ( this.indeterminate !== state ) {
9773 this.indeterminate = state;
9774 this.$input.prop( 'indeterminate', this.indeterminate );
9775 if ( !internal ) {
9776 this.setSelected( false, true );
9777 this.emit( 'change', this.selected, this.indeterminate );
9778 }
9779 }
9780 return this;
9781 };
9782
9783 /**
9784 * Check if this checkbox is selected.
9785 *
9786 * @return {boolean} Checkbox is selected
9787 */
9788 OO.ui.CheckboxInputWidget.prototype.isIndeterminate = function () {
9789 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9790 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9791 var indeterminate = this.$input.prop( 'indeterminate' );
9792 if ( this.indeterminate !== indeterminate ) {
9793 this.setIndeterminate( indeterminate );
9794 }
9795 return this.indeterminate;
9796 };
9797
9798 /**
9799 * @inheritdoc
9800 */
9801 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9802 if ( !this.isDisabled() ) {
9803 this.$handle.trigger( 'click' );
9804 }
9805 this.focus();
9806 };
9807
9808 /**
9809 * @inheritdoc
9810 */
9811 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9812 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9813 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9814 this.setSelected( state.checked );
9815 }
9816 };
9817
9818 /**
9819 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9820 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
9821 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9822 * more information about input widgets.
9823 *
9824 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9825 * are no options. If no `value` configuration option is provided, the first option is selected.
9826 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9827 *
9828 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9829 *
9830 * @example
9831 * // A DropdownInputWidget with three options.
9832 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9833 * options: [
9834 * { data: 'a', label: 'First' },
9835 * { data: 'b', label: 'Second', disabled: true },
9836 * { optgroup: 'Group label' },
9837 * { data: 'c', label: 'First sub-item)' }
9838 * ]
9839 * } );
9840 * $( document.body ).append( dropdownInput.$element );
9841 *
9842 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9843 *
9844 * @class
9845 * @extends OO.ui.InputWidget
9846 *
9847 * @constructor
9848 * @param {Object} [config] Configuration options
9849 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9850 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9851 * @cfg {jQuery|boolean} [$overlay] Render the menu into a separate layer. This configuration is
9852 * useful in cases where the expanded menu is larger than its containing `<div>`. The specified
9853 * overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
9854 * the menu uses relative positioning. Pass 'true' to use the default overlay.
9855 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9856 */
9857 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9858 // Configuration initialization
9859 config = config || {};
9860
9861 // Properties (must be done before parent constructor which calls #setDisabled)
9862 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9863 {
9864 $overlay: config.$overlay
9865 },
9866 config.dropdown
9867 ) );
9868 // Set up the options before parent constructor, which uses them to validate config.value.
9869 // Use this instead of setOptions() because this.$input is not set up yet.
9870 this.setOptionsData( config.options || [] );
9871
9872 // Parent constructor
9873 OO.ui.DropdownInputWidget.parent.call( this, config );
9874
9875 // Events
9876 this.dropdownWidget.getMenu().connect( this, {
9877 select: 'onMenuSelect'
9878 } );
9879
9880 // Initialization
9881 this.$element
9882 .addClass( 'oo-ui-dropdownInputWidget' )
9883 .append( this.dropdownWidget.$element );
9884 if ( OO.ui.isMobile() ) {
9885 this.$element.addClass( 'oo-ui-isMobile' );
9886 }
9887 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9888 this.setTitledElement( this.dropdownWidget.$handle );
9889 };
9890
9891 /* Setup */
9892
9893 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9894
9895 /* Methods */
9896
9897 /**
9898 * @inheritdoc
9899 * @protected
9900 */
9901 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9902 return $( '<select>' ).addClass( 'oo-ui-indicator-down' );
9903 };
9904
9905 /**
9906 * Handles menu select events.
9907 *
9908 * @private
9909 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9910 */
9911 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9912 this.setValue( item ? item.getData() : '' );
9913 };
9914
9915 /**
9916 * @inheritdoc
9917 */
9918 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9919 var selected;
9920 value = this.cleanUpValue( value );
9921 // Only allow setting values that are actually present in the dropdown
9922 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9923 this.dropdownWidget.getMenu().findFirstSelectableItem();
9924 this.dropdownWidget.getMenu().selectItem( selected );
9925 value = selected ? selected.getData() : '';
9926 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9927 if ( this.optionsDirty ) {
9928 // We reached this from the constructor or from #setOptions.
9929 // We have to update the <select> element.
9930 this.updateOptionsInterface();
9931 }
9932 return this;
9933 };
9934
9935 /**
9936 * @inheritdoc
9937 */
9938 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9939 this.dropdownWidget.setDisabled( state );
9940 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9941 return this;
9942 };
9943
9944 /**
9945 * Set the options available for this input.
9946 *
9947 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9948 * @chainable
9949 * @return {OO.ui.Widget} The widget, for chaining
9950 */
9951 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9952 var value = this.getValue();
9953
9954 this.setOptionsData( options );
9955
9956 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9957 // In case the previous value is no longer an available option, select the first valid one.
9958 this.setValue( value );
9959
9960 return this;
9961 };
9962
9963 /**
9964 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9965 *
9966 * This method may be called before the parent constructor, so various properties may not be
9967 * initialized yet.
9968 *
9969 * @param {Object[]} options Array of menu options (see #constructor for details).
9970 * @private
9971 */
9972 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9973 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9974 widget = this;
9975
9976 this.optionsDirty = true;
9977
9978 // Go through all the supplied option configs and create either
9979 // MenuSectionOption or MenuOption widgets from each.
9980 optionWidgets = [];
9981 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9982 opt = options[ optIndex ];
9983
9984 if ( opt.optgroup !== undefined ) {
9985 // Create a <optgroup> menu item.
9986 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9987 previousOptgroup = optionWidget;
9988
9989 } else {
9990 // Create a normal <option> menu item.
9991 optValue = widget.cleanUpValue( opt.data );
9992 optionWidget = widget.createMenuOptionWidget(
9993 optValue,
9994 opt.label !== undefined ? opt.label : optValue
9995 );
9996 }
9997
9998 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9999 if (
10000 opt.disabled !== undefined ||
10001 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
10002 previousOptgroup.isDisabled()
10003 ) {
10004 optionWidget.setDisabled( true );
10005 }
10006
10007 optionWidgets.push( optionWidget );
10008 }
10009
10010 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
10011 };
10012
10013 /**
10014 * Create a menu option widget.
10015 *
10016 * @protected
10017 * @param {string} data Item data
10018 * @param {string} label Item label
10019 * @return {OO.ui.MenuOptionWidget} Option widget
10020 */
10021 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
10022 return new OO.ui.MenuOptionWidget( {
10023 data: data,
10024 label: label
10025 } );
10026 };
10027
10028 /**
10029 * Create a menu section option widget.
10030 *
10031 * @protected
10032 * @param {string} label Section item label
10033 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
10034 */
10035 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
10036 return new OO.ui.MenuSectionOptionWidget( {
10037 label: label
10038 } );
10039 };
10040
10041 /**
10042 * Update the user-visible interface to match the internal list of options and value.
10043 *
10044 * This method must only be called after the parent constructor.
10045 *
10046 * @private
10047 */
10048 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
10049 var
10050 $optionsContainer = this.$input,
10051 defaultValue = this.defaultValue,
10052 widget = this;
10053
10054 this.$input.empty();
10055
10056 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
10057 var $optionNode;
10058
10059 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
10060 $optionNode = $( '<option>' )
10061 .attr( 'value', optionWidget.getData() )
10062 .text( optionWidget.getLabel() );
10063
10064 // Remember original selection state. This property can be later used to check whether
10065 // the selection state of the input has been changed since it was created.
10066 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
10067
10068 $optionsContainer.append( $optionNode );
10069 } else {
10070 $optionNode = $( '<optgroup>' )
10071 .attr( 'label', optionWidget.getLabel() );
10072 widget.$input.append( $optionNode );
10073 $optionsContainer = $optionNode;
10074 }
10075
10076 // Disable the option or optgroup if required.
10077 if ( optionWidget.isDisabled() ) {
10078 $optionNode.prop( 'disabled', true );
10079 }
10080 } );
10081
10082 this.optionsDirty = false;
10083 };
10084
10085 /**
10086 * @inheritdoc
10087 */
10088 OO.ui.DropdownInputWidget.prototype.focus = function () {
10089 this.dropdownWidget.focus();
10090 return this;
10091 };
10092
10093 /**
10094 * @inheritdoc
10095 */
10096 OO.ui.DropdownInputWidget.prototype.blur = function () {
10097 this.dropdownWidget.blur();
10098 return this;
10099 };
10100
10101 /**
10102 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
10103 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
10104 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
10105 * please see the [OOUI documentation on MediaWiki][1].
10106 *
10107 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10108 *
10109 * @example
10110 * // An example of selected, unselected, and disabled radio inputs
10111 * var radio1 = new OO.ui.RadioInputWidget( {
10112 * value: 'a',
10113 * selected: true
10114 * } );
10115 * var radio2 = new OO.ui.RadioInputWidget( {
10116 * value: 'b'
10117 * } );
10118 * var radio3 = new OO.ui.RadioInputWidget( {
10119 * value: 'c',
10120 * disabled: true
10121 * } );
10122 * // Create a fieldset layout with fields for each radio button.
10123 * var fieldset = new OO.ui.FieldsetLayout( {
10124 * label: 'Radio inputs'
10125 * } );
10126 * fieldset.addItems( [
10127 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
10128 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
10129 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
10130 * ] );
10131 * $( document.body ).append( fieldset.$element );
10132 *
10133 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10134 *
10135 * @class
10136 * @extends OO.ui.InputWidget
10137 *
10138 * @constructor
10139 * @param {Object} [config] Configuration options
10140 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button
10141 * is not selected.
10142 */
10143 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
10144 // Configuration initialization
10145 config = config || {};
10146
10147 // Parent constructor
10148 OO.ui.RadioInputWidget.parent.call( this, config );
10149
10150 // Initialization
10151 this.$element
10152 .addClass( 'oo-ui-radioInputWidget' )
10153 // Required for pretty styling in WikimediaUI theme
10154 .append( $( '<span>' ) );
10155 this.setSelected( config.selected !== undefined ? config.selected : false );
10156 };
10157
10158 /* Setup */
10159
10160 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
10161
10162 /* Static Properties */
10163
10164 /**
10165 * @static
10166 * @inheritdoc
10167 */
10168 OO.ui.RadioInputWidget.static.tagName = 'span';
10169
10170 /* Static Methods */
10171
10172 /**
10173 * @inheritdoc
10174 */
10175 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10176 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
10177 state.checked = config.$input.prop( 'checked' );
10178 return state;
10179 };
10180
10181 /* Methods */
10182
10183 /**
10184 * @inheritdoc
10185 * @protected
10186 */
10187 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
10188 return $( '<input>' ).attr( 'type', 'radio' );
10189 };
10190
10191 /**
10192 * @inheritdoc
10193 */
10194 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10195 // RadioInputWidget doesn't track its state.
10196 };
10197
10198 /**
10199 * Set selection state of this radio button.
10200 *
10201 * @param {boolean} state `true` for selected
10202 * @chainable
10203 * @return {OO.ui.Widget} The widget, for chaining
10204 */
10205 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10206 // RadioInputWidget doesn't track its state.
10207 this.$input.prop( 'checked', state );
10208 // The first time that the selection state is set (probably while constructing the widget),
10209 // remember it in defaultSelected. This property can be later used to check whether
10210 // the selection state of the input has been changed since it was created.
10211 if ( this.defaultSelected === undefined ) {
10212 this.defaultSelected = state;
10213 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10214 }
10215 return this;
10216 };
10217
10218 /**
10219 * Check if this radio button is selected.
10220 *
10221 * @return {boolean} Radio is selected
10222 */
10223 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10224 return this.$input.prop( 'checked' );
10225 };
10226
10227 /**
10228 * @inheritdoc
10229 */
10230 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10231 if ( !this.isDisabled() ) {
10232 this.$input.trigger( 'click' );
10233 }
10234 this.focus();
10235 };
10236
10237 /**
10238 * @inheritdoc
10239 */
10240 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10241 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10242 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10243 this.setSelected( state.checked );
10244 }
10245 };
10246
10247 /**
10248 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10249 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10250 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10251 * more information about input widgets.
10252 *
10253 * This and OO.ui.DropdownInputWidget support similar configuration options.
10254 *
10255 * @example
10256 * // A RadioSelectInputWidget with three options
10257 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
10258 * options: [
10259 * { data: 'a', label: 'First' },
10260 * { data: 'b', label: 'Second'},
10261 * { data: 'c', label: 'Third' }
10262 * ]
10263 * } );
10264 * $( document.body ).append( radioSelectInput.$element );
10265 *
10266 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10267 *
10268 * @class
10269 * @extends OO.ui.InputWidget
10270 *
10271 * @constructor
10272 * @param {Object} [config] Configuration options
10273 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10274 */
10275 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
10276 // Configuration initialization
10277 config = config || {};
10278
10279 // Properties (must be done before parent constructor which calls #setDisabled)
10280 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
10281 // Set up the options before parent constructor, which uses them to validate config.value.
10282 // Use this instead of setOptions() because this.$input is not set up yet
10283 this.setOptionsData( config.options || [] );
10284
10285 // Parent constructor
10286 OO.ui.RadioSelectInputWidget.parent.call( this, config );
10287
10288 // Events
10289 this.radioSelectWidget.connect( this, {
10290 select: 'onMenuSelect'
10291 } );
10292
10293 // Initialization
10294 this.$element
10295 .addClass( 'oo-ui-radioSelectInputWidget' )
10296 .append( this.radioSelectWidget.$element );
10297 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
10298 };
10299
10300 /* Setup */
10301
10302 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
10303
10304 /* Static Methods */
10305
10306 /**
10307 * @inheritdoc
10308 */
10309 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10310 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
10311 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
10312 return state;
10313 };
10314
10315 /**
10316 * @inheritdoc
10317 */
10318 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10319 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10320 // Cannot reuse the `<input type=radio>` set
10321 delete config.$input;
10322 return config;
10323 };
10324
10325 /* Methods */
10326
10327 /**
10328 * @inheritdoc
10329 * @protected
10330 */
10331 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10332 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10333 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10334 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10335 };
10336
10337 /**
10338 * Handles menu select events.
10339 *
10340 * @private
10341 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10342 */
10343 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10344 this.setValue( item.getData() );
10345 };
10346
10347 /**
10348 * @inheritdoc
10349 */
10350 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10351 var selected;
10352 value = this.cleanUpValue( value );
10353 // Only allow setting values that are actually present in the dropdown
10354 selected = this.radioSelectWidget.findItemFromData( value ) ||
10355 this.radioSelectWidget.findFirstSelectableItem();
10356 this.radioSelectWidget.selectItem( selected );
10357 value = selected ? selected.getData() : '';
10358 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10359 return this;
10360 };
10361
10362 /**
10363 * @inheritdoc
10364 */
10365 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10366 this.radioSelectWidget.setDisabled( state );
10367 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10368 return this;
10369 };
10370
10371 /**
10372 * Set the options available for this input.
10373 *
10374 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10375 * @chainable
10376 * @return {OO.ui.Widget} The widget, for chaining
10377 */
10378 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10379 var value = this.getValue();
10380
10381 this.setOptionsData( options );
10382
10383 // Re-set the value to update the visible interface (RadioSelectWidget).
10384 // In case the previous value is no longer an available option, select the first valid one.
10385 this.setValue( value );
10386
10387 return this;
10388 };
10389
10390 /**
10391 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10392 *
10393 * This method may be called before the parent constructor, so various properties may not be
10394 * intialized yet.
10395 *
10396 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10397 * @private
10398 */
10399 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10400 var widget = this;
10401
10402 this.radioSelectWidget
10403 .clearItems()
10404 .addItems( options.map( function ( opt ) {
10405 var optValue = widget.cleanUpValue( opt.data );
10406 return new OO.ui.RadioOptionWidget( {
10407 data: optValue,
10408 label: opt.label !== undefined ? opt.label : optValue
10409 } );
10410 } ) );
10411 };
10412
10413 /**
10414 * @inheritdoc
10415 */
10416 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10417 this.radioSelectWidget.focus();
10418 return this;
10419 };
10420
10421 /**
10422 * @inheritdoc
10423 */
10424 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10425 this.radioSelectWidget.blur();
10426 return this;
10427 };
10428
10429 /**
10430 * CheckboxMultiselectInputWidget is a
10431 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10432 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10433 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10434 * more information about input widgets.
10435 *
10436 * @example
10437 * // A CheckboxMultiselectInputWidget with three options.
10438 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10439 * options: [
10440 * { data: 'a', label: 'First' },
10441 * { data: 'b', label: 'Second' },
10442 * { data: 'c', label: 'Third' }
10443 * ]
10444 * } );
10445 * $( document.body ).append( multiselectInput.$element );
10446 *
10447 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10448 *
10449 * @class
10450 * @extends OO.ui.InputWidget
10451 *
10452 * @constructor
10453 * @param {Object} [config] Configuration options
10454 * @cfg {Object[]} [options=[]] Array of menu options in the format
10455 * `{ data: …, label: …, disabled: … }`
10456 */
10457 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10458 // Configuration initialization
10459 config = config || {};
10460
10461 // Properties (must be done before parent constructor which calls #setDisabled)
10462 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10463 // Must be set before the #setOptionsData call below
10464 this.inputName = config.name;
10465 // Set up the options before parent constructor, which uses them to validate config.value.
10466 // Use this instead of setOptions() because this.$input is not set up yet
10467 this.setOptionsData( config.options || [] );
10468
10469 // Parent constructor
10470 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10471
10472 // Events
10473 this.checkboxMultiselectWidget.connect( this, {
10474 select: 'onCheckboxesSelect'
10475 } );
10476
10477 // Initialization
10478 this.$element
10479 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10480 .append( this.checkboxMultiselectWidget.$element );
10481 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10482 this.$input.detach();
10483 };
10484
10485 /* Setup */
10486
10487 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10488
10489 /* Static Methods */
10490
10491 /**
10492 * @inheritdoc
10493 */
10494 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10495 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState(
10496 node, config
10497 );
10498 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10499 .toArray().map( function ( el ) { return el.value; } );
10500 return state;
10501 };
10502
10503 /**
10504 * @inheritdoc
10505 */
10506 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10507 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10508 // Cannot reuse the `<input type=checkbox>` set
10509 delete config.$input;
10510 return config;
10511 };
10512
10513 /* Methods */
10514
10515 /**
10516 * @inheritdoc
10517 * @protected
10518 */
10519 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10520 // Actually unused
10521 return $( '<unused>' );
10522 };
10523
10524 /**
10525 * Handles CheckboxMultiselectWidget select events.
10526 *
10527 * @private
10528 */
10529 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10530 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10531 };
10532
10533 /**
10534 * @inheritdoc
10535 */
10536 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10537 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10538 .toArray().map( function ( el ) { return el.value; } );
10539 if ( this.value !== value ) {
10540 this.setValue( value );
10541 }
10542 return this.value;
10543 };
10544
10545 /**
10546 * @inheritdoc
10547 */
10548 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10549 value = this.cleanUpValue( value );
10550 this.checkboxMultiselectWidget.selectItemsByData( value );
10551 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10552 if ( this.optionsDirty ) {
10553 // We reached this from the constructor or from #setOptions.
10554 // We have to update the <select> element.
10555 this.updateOptionsInterface();
10556 }
10557 return this;
10558 };
10559
10560 /**
10561 * Clean up incoming value.
10562 *
10563 * @param {string[]} value Original value
10564 * @return {string[]} Cleaned up value
10565 */
10566 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10567 var i, singleValue,
10568 cleanValue = [];
10569 if ( !Array.isArray( value ) ) {
10570 return cleanValue;
10571 }
10572 for ( i = 0; i < value.length; i++ ) {
10573 singleValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10574 .call( this, value[ i ] );
10575 // Remove options that we don't have here
10576 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10577 continue;
10578 }
10579 cleanValue.push( singleValue );
10580 }
10581 return cleanValue;
10582 };
10583
10584 /**
10585 * @inheritdoc
10586 */
10587 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10588 this.checkboxMultiselectWidget.setDisabled( state );
10589 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10590 return this;
10591 };
10592
10593 /**
10594 * Set the options available for this input.
10595 *
10596 * @param {Object[]} options Array of menu options in the format
10597 * `{ data: …, label: …, disabled: … }`
10598 * @chainable
10599 * @return {OO.ui.Widget} The widget, for chaining
10600 */
10601 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10602 var value = this.getValue();
10603
10604 this.setOptionsData( options );
10605
10606 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10607 // This will also get rid of any stale options that we just removed.
10608 this.setValue( value );
10609
10610 return this;
10611 };
10612
10613 /**
10614 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10615 *
10616 * This method may be called before the parent constructor, so various properties may not be
10617 * intialized yet.
10618 *
10619 * @param {Object[]} options Array of menu options in the format
10620 * `{ data: …, label: … }`
10621 * @private
10622 */
10623 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10624 var widget = this;
10625
10626 this.optionsDirty = true;
10627
10628 this.checkboxMultiselectWidget
10629 .clearItems()
10630 .addItems( options.map( function ( opt ) {
10631 var optValue, item, optDisabled;
10632 optValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10633 .call( widget, opt.data );
10634 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10635 item = new OO.ui.CheckboxMultioptionWidget( {
10636 data: optValue,
10637 label: opt.label !== undefined ? opt.label : optValue,
10638 disabled: optDisabled
10639 } );
10640 // Set the 'name' and 'value' for form submission
10641 item.checkbox.$input.attr( 'name', widget.inputName );
10642 item.checkbox.setValue( optValue );
10643 return item;
10644 } ) );
10645 };
10646
10647 /**
10648 * Update the user-visible interface to match the internal list of options and value.
10649 *
10650 * This method must only be called after the parent constructor.
10651 *
10652 * @private
10653 */
10654 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10655 var defaultValue = this.defaultValue;
10656
10657 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10658 // Remember original selection state. This property can be later used to check whether
10659 // the selection state of the input has been changed since it was created.
10660 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10661 item.checkbox.defaultSelected = isDefault;
10662 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10663 } );
10664
10665 this.optionsDirty = false;
10666 };
10667
10668 /**
10669 * @inheritdoc
10670 */
10671 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10672 this.checkboxMultiselectWidget.focus();
10673 return this;
10674 };
10675
10676 /**
10677 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10678 * size of the field as well as its presentation. In addition, these widgets can be configured
10679 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
10680 * optional validation-pattern (used to determine if an input value is valid or not) and an input
10681 * filter, which modifies incoming values rather than validating them.
10682 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10683 *
10684 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10685 *
10686 * @example
10687 * // A TextInputWidget.
10688 * var textInput = new OO.ui.TextInputWidget( {
10689 * value: 'Text input'
10690 * } );
10691 * $( document.body ).append( textInput.$element );
10692 *
10693 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10694 *
10695 * @class
10696 * @extends OO.ui.InputWidget
10697 * @mixins OO.ui.mixin.IconElement
10698 * @mixins OO.ui.mixin.IndicatorElement
10699 * @mixins OO.ui.mixin.PendingElement
10700 * @mixins OO.ui.mixin.LabelElement
10701 * @mixins OO.ui.mixin.FlaggedElement
10702 *
10703 * @constructor
10704 * @param {Object} [config] Configuration options
10705 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10706 * 'email', 'url' or 'number'.
10707 * @cfg {string} [placeholder] Placeholder text
10708 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10709 * instruct the browser to focus this widget.
10710 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10711 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10712 *
10713 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10714 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10715 * many emojis) count as 2 characters each.
10716 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10717 * the value or placeholder text: `'before'` or `'after'`
10718 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator:
10719 * 'required'`. Note that `false` & setting `indicator: 'required' will result in no indicator
10720 * shown.
10721 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10722 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined`
10723 * means leaving it up to the browser).
10724 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10725 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10726 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10727 * value for it to be considered valid; when Function, a function receiving the value as parameter
10728 * that must return true, or promise resolving to true, for it to be considered valid.
10729 */
10730 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10731 // Configuration initialization
10732 config = $.extend( {
10733 type: 'text',
10734 labelPosition: 'after'
10735 }, config );
10736
10737 // Parent constructor
10738 OO.ui.TextInputWidget.parent.call( this, config );
10739
10740 // Mixin constructors
10741 OO.ui.mixin.IconElement.call( this, config );
10742 OO.ui.mixin.IndicatorElement.call( this, config );
10743 OO.ui.mixin.PendingElement.call( this, $.extend( { $pending: this.$input }, config ) );
10744 OO.ui.mixin.LabelElement.call( this, config );
10745 OO.ui.mixin.FlaggedElement.call( this, config );
10746
10747 // Properties
10748 this.type = this.getSaneType( config );
10749 this.readOnly = false;
10750 this.required = false;
10751 this.validate = null;
10752 this.scrollWidth = null;
10753
10754 this.setValidation( config.validate );
10755 this.setLabelPosition( config.labelPosition );
10756
10757 // Events
10758 this.$input.on( {
10759 keypress: this.onKeyPress.bind( this ),
10760 blur: this.onBlur.bind( this ),
10761 focus: this.onFocus.bind( this )
10762 } );
10763 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10764 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10765 this.on( 'labelChange', this.updatePosition.bind( this ) );
10766 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10767
10768 // Initialization
10769 this.$element
10770 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10771 .append( this.$icon, this.$indicator );
10772 this.setReadOnly( !!config.readOnly );
10773 this.setRequired( !!config.required );
10774 if ( config.placeholder !== undefined ) {
10775 this.$input.attr( 'placeholder', config.placeholder );
10776 }
10777 if ( config.maxLength !== undefined ) {
10778 this.$input.attr( 'maxlength', config.maxLength );
10779 }
10780 if ( config.autofocus ) {
10781 this.$input.attr( 'autofocus', 'autofocus' );
10782 }
10783 if ( config.autocomplete === false ) {
10784 this.$input.attr( 'autocomplete', 'off' );
10785 // Turning off autocompletion also disables "form caching" when the user navigates to a
10786 // different page and then clicks "Back". Re-enable it when leaving.
10787 // Borrowed from jQuery UI.
10788 $( window ).on( {
10789 beforeunload: function () {
10790 this.$input.removeAttr( 'autocomplete' );
10791 }.bind( this ),
10792 pageshow: function () {
10793 // Browsers don't seem to actually fire this event on "Back", they instead just
10794 // reload the whole page... it shouldn't hurt, though.
10795 this.$input.attr( 'autocomplete', 'off' );
10796 }.bind( this )
10797 } );
10798 }
10799 if ( config.spellcheck !== undefined ) {
10800 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10801 }
10802 if ( this.label ) {
10803 this.isWaitingToBeAttached = true;
10804 this.installParentChangeDetector();
10805 }
10806 };
10807
10808 /* Setup */
10809
10810 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10811 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10812 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10813 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10814 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10815 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.FlaggedElement );
10816
10817 /* Static Properties */
10818
10819 OO.ui.TextInputWidget.static.validationPatterns = {
10820 'non-empty': /.+/,
10821 integer: /^\d+$/
10822 };
10823
10824 /* Events */
10825
10826 /**
10827 * An `enter` event is emitted when the user presses Enter key inside the text box.
10828 *
10829 * @event enter
10830 */
10831
10832 /* Methods */
10833
10834 /**
10835 * Handle icon mouse down events.
10836 *
10837 * @private
10838 * @param {jQuery.Event} e Mouse down event
10839 * @return {undefined|boolean} False to prevent default if event is handled
10840 */
10841 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10842 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10843 this.focus();
10844 return false;
10845 }
10846 };
10847
10848 /**
10849 * Handle indicator mouse down events.
10850 *
10851 * @private
10852 * @param {jQuery.Event} e Mouse down event
10853 * @return {undefined|boolean} False to prevent default if event is handled
10854 */
10855 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10856 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10857 this.focus();
10858 return false;
10859 }
10860 };
10861
10862 /**
10863 * Handle key press events.
10864 *
10865 * @private
10866 * @param {jQuery.Event} e Key press event
10867 * @fires enter If Enter key is pressed
10868 */
10869 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10870 if ( e.which === OO.ui.Keys.ENTER ) {
10871 this.emit( 'enter', e );
10872 }
10873 };
10874
10875 /**
10876 * Handle blur events.
10877 *
10878 * @private
10879 * @param {jQuery.Event} e Blur event
10880 */
10881 OO.ui.TextInputWidget.prototype.onBlur = function () {
10882 this.setValidityFlag();
10883 };
10884
10885 /**
10886 * Handle focus events.
10887 *
10888 * @private
10889 * @param {jQuery.Event} e Focus event
10890 */
10891 OO.ui.TextInputWidget.prototype.onFocus = function () {
10892 if ( this.isWaitingToBeAttached ) {
10893 // If we've received focus, then we must be attached to the document, and if
10894 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10895 this.onElementAttach();
10896 }
10897 this.setValidityFlag( true );
10898 };
10899
10900 /**
10901 * Handle element attach events.
10902 *
10903 * @private
10904 * @param {jQuery.Event} e Element attach event
10905 */
10906 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10907 this.isWaitingToBeAttached = false;
10908 // Any previously calculated size is now probably invalid if we reattached elsewhere
10909 this.valCache = null;
10910 this.positionLabel();
10911 };
10912
10913 /**
10914 * Handle debounced change events.
10915 *
10916 * @param {string} value
10917 * @private
10918 */
10919 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10920 this.setValidityFlag();
10921 };
10922
10923 /**
10924 * Check if the input is {@link #readOnly read-only}.
10925 *
10926 * @return {boolean}
10927 */
10928 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10929 return this.readOnly;
10930 };
10931
10932 /**
10933 * Set the {@link #readOnly read-only} state of the input.
10934 *
10935 * @param {boolean} state Make input read-only
10936 * @chainable
10937 * @return {OO.ui.Widget} The widget, for chaining
10938 */
10939 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10940 this.readOnly = !!state;
10941 this.$input.prop( 'readOnly', this.readOnly );
10942 return this;
10943 };
10944
10945 /**
10946 * Check if the input is {@link #required required}.
10947 *
10948 * @return {boolean}
10949 */
10950 OO.ui.TextInputWidget.prototype.isRequired = function () {
10951 return this.required;
10952 };
10953
10954 /**
10955 * Set the {@link #required required} state of the input.
10956 *
10957 * @param {boolean} state Make input required
10958 * @chainable
10959 * @return {OO.ui.Widget} The widget, for chaining
10960 */
10961 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10962 this.required = !!state;
10963 if ( this.required ) {
10964 this.$input
10965 .prop( 'required', true )
10966 .attr( 'aria-required', 'true' );
10967 if ( this.getIndicator() === null ) {
10968 this.setIndicator( 'required' );
10969 }
10970 } else {
10971 this.$input
10972 .prop( 'required', false )
10973 .removeAttr( 'aria-required' );
10974 if ( this.getIndicator() === 'required' ) {
10975 this.setIndicator( null );
10976 }
10977 }
10978 return this;
10979 };
10980
10981 /**
10982 * Support function for making #onElementAttach work across browsers.
10983 *
10984 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10985 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10986 *
10987 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10988 * first time that the element gets attached to the documented.
10989 */
10990 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10991 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10992 MutationObserver = window.MutationObserver ||
10993 window.WebKitMutationObserver ||
10994 window.MozMutationObserver,
10995 widget = this;
10996
10997 if ( MutationObserver ) {
10998 // The new way. If only it wasn't so ugly.
10999
11000 if ( this.isElementAttached() ) {
11001 // Widget is attached already, do nothing. This breaks the functionality of this
11002 // function when the widget is detached and reattached. Alas, doing this correctly with
11003 // MutationObserver would require observation of the whole document, which would hurt
11004 // performance of other, more important code.
11005 return;
11006 }
11007
11008 // Find topmost node in the tree
11009 topmostNode = this.$element[ 0 ];
11010 while ( topmostNode.parentNode ) {
11011 topmostNode = topmostNode.parentNode;
11012 }
11013
11014 // We have no way to detect the $element being attached somewhere without observing the
11015 // entire DOM with subtree modifications, which would hurt performance. So we cheat: we hook
11016 // to the parent node of $element, and instead detect when $element is removed from it (and
11017 // thus probably attached somewhere else). If there is no parent, we create a "fake" one. If
11018 // it doesn't get attached, we end up back here and create the parent.
11019 mutationObserver = new MutationObserver( function ( mutations ) {
11020 var i, j, removedNodes;
11021 for ( i = 0; i < mutations.length; i++ ) {
11022 removedNodes = mutations[ i ].removedNodes;
11023 for ( j = 0; j < removedNodes.length; j++ ) {
11024 if ( removedNodes[ j ] === topmostNode ) {
11025 setTimeout( onRemove, 0 );
11026 return;
11027 }
11028 }
11029 }
11030 } );
11031
11032 onRemove = function () {
11033 // If the node was attached somewhere else, report it
11034 if ( widget.isElementAttached() ) {
11035 widget.onElementAttach();
11036 }
11037 mutationObserver.disconnect();
11038 widget.installParentChangeDetector();
11039 };
11040
11041 // Create a fake parent and observe it
11042 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
11043 mutationObserver.observe( fakeParentNode, { childList: true } );
11044 } else {
11045 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
11046 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
11047 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
11048 }
11049 };
11050
11051 /**
11052 * @inheritdoc
11053 * @protected
11054 */
11055 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
11056 if ( this.getSaneType( config ) === 'number' ) {
11057 return $( '<input>' )
11058 .attr( 'step', 'any' )
11059 .attr( 'type', 'number' );
11060 } else {
11061 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
11062 }
11063 };
11064
11065 /**
11066 * Get sanitized value for 'type' for given config.
11067 *
11068 * @param {Object} config Configuration options
11069 * @return {string|null}
11070 * @protected
11071 */
11072 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
11073 var allowedTypes = [
11074 'text',
11075 'password',
11076 'email',
11077 'url',
11078 'number'
11079 ];
11080 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
11081 };
11082
11083 /**
11084 * Focus the input and select a specified range within the text.
11085 *
11086 * @param {number} from Select from offset
11087 * @param {number} [to] Select to offset, defaults to from
11088 * @chainable
11089 * @return {OO.ui.Widget} The widget, for chaining
11090 */
11091 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
11092 var isBackwards, start, end,
11093 input = this.$input[ 0 ];
11094
11095 to = to || from;
11096
11097 isBackwards = to < from;
11098 start = isBackwards ? to : from;
11099 end = isBackwards ? from : to;
11100
11101 this.focus();
11102
11103 try {
11104 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
11105 } catch ( e ) {
11106 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
11107 // Rather than expensively check if the input is attached every time, just check
11108 // if it was the cause of an error being thrown. If not, rethrow the error.
11109 if ( this.getElementDocument().body.contains( input ) ) {
11110 throw e;
11111 }
11112 }
11113 return this;
11114 };
11115
11116 /**
11117 * Get an object describing the current selection range in a directional manner
11118 *
11119 * @return {Object} Object containing 'from' and 'to' offsets
11120 */
11121 OO.ui.TextInputWidget.prototype.getRange = function () {
11122 var input = this.$input[ 0 ],
11123 start = input.selectionStart,
11124 end = input.selectionEnd,
11125 isBackwards = input.selectionDirection === 'backward';
11126
11127 return {
11128 from: isBackwards ? end : start,
11129 to: isBackwards ? start : end
11130 };
11131 };
11132
11133 /**
11134 * Get the length of the text input value.
11135 *
11136 * This could differ from the length of #getValue if the
11137 * value gets filtered
11138 *
11139 * @return {number} Input length
11140 */
11141 OO.ui.TextInputWidget.prototype.getInputLength = function () {
11142 return this.$input[ 0 ].value.length;
11143 };
11144
11145 /**
11146 * Focus the input and select the entire text.
11147 *
11148 * @chainable
11149 * @return {OO.ui.Widget} The widget, for chaining
11150 */
11151 OO.ui.TextInputWidget.prototype.select = function () {
11152 return this.selectRange( 0, this.getInputLength() );
11153 };
11154
11155 /**
11156 * Focus the input and move the cursor to the start.
11157 *
11158 * @chainable
11159 * @return {OO.ui.Widget} The widget, for chaining
11160 */
11161 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
11162 return this.selectRange( 0 );
11163 };
11164
11165 /**
11166 * Focus the input and move the cursor to the end.
11167 *
11168 * @chainable
11169 * @return {OO.ui.Widget} The widget, for chaining
11170 */
11171 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
11172 return this.selectRange( this.getInputLength() );
11173 };
11174
11175 /**
11176 * Insert new content into the input.
11177 *
11178 * @param {string} content Content to be inserted
11179 * @chainable
11180 * @return {OO.ui.Widget} The widget, for chaining
11181 */
11182 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
11183 var start, end,
11184 range = this.getRange(),
11185 value = this.getValue();
11186
11187 start = Math.min( range.from, range.to );
11188 end = Math.max( range.from, range.to );
11189
11190 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11191 this.selectRange( start + content.length );
11192 return this;
11193 };
11194
11195 /**
11196 * Insert new content either side of a selection.
11197 *
11198 * @param {string} pre Content to be inserted before the selection
11199 * @param {string} post Content to be inserted after the selection
11200 * @chainable
11201 * @return {OO.ui.Widget} The widget, for chaining
11202 */
11203 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11204 var start, end,
11205 range = this.getRange(),
11206 offset = pre.length;
11207
11208 start = Math.min( range.from, range.to );
11209 end = Math.max( range.from, range.to );
11210
11211 this.selectRange( start ).insertContent( pre );
11212 this.selectRange( offset + end ).insertContent( post );
11213
11214 this.selectRange( offset + start, offset + end );
11215 return this;
11216 };
11217
11218 /**
11219 * Set the validation pattern.
11220 *
11221 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11222 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11223 * value must contain only numbers).
11224 *
11225 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11226 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11227 */
11228 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11229 if ( validate instanceof RegExp || validate instanceof Function ) {
11230 this.validate = validate;
11231 } else {
11232 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
11233 }
11234 };
11235
11236 /**
11237 * Sets the 'invalid' flag appropriately.
11238 *
11239 * @param {boolean} [isValid] Optionally override validation result
11240 */
11241 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11242 var widget = this,
11243 setFlag = function ( valid ) {
11244 if ( !valid ) {
11245 widget.$input.attr( 'aria-invalid', 'true' );
11246 } else {
11247 widget.$input.removeAttr( 'aria-invalid' );
11248 }
11249 widget.setFlags( { invalid: !valid } );
11250 };
11251
11252 if ( isValid !== undefined ) {
11253 setFlag( isValid );
11254 } else {
11255 this.getValidity().then( function () {
11256 setFlag( true );
11257 }, function () {
11258 setFlag( false );
11259 } );
11260 }
11261 };
11262
11263 /**
11264 * Get the validity of current value.
11265 *
11266 * This method returns a promise that resolves if the value is valid and rejects if
11267 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
11268 *
11269 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11270 */
11271 OO.ui.TextInputWidget.prototype.getValidity = function () {
11272 var result;
11273
11274 function rejectOrResolve( valid ) {
11275 if ( valid ) {
11276 return $.Deferred().resolve().promise();
11277 } else {
11278 return $.Deferred().reject().promise();
11279 }
11280 }
11281
11282 // Check browser validity and reject if it is invalid
11283 if (
11284 this.$input[ 0 ].checkValidity !== undefined &&
11285 this.$input[ 0 ].checkValidity() === false
11286 ) {
11287 return rejectOrResolve( false );
11288 }
11289
11290 // Run our checks if the browser thinks the field is valid
11291 if ( this.validate instanceof Function ) {
11292 result = this.validate( this.getValue() );
11293 if ( result && typeof result.promise === 'function' ) {
11294 return result.promise().then( function ( valid ) {
11295 return rejectOrResolve( valid );
11296 } );
11297 } else {
11298 return rejectOrResolve( result );
11299 }
11300 } else {
11301 return rejectOrResolve( this.getValue().match( this.validate ) );
11302 }
11303 };
11304
11305 /**
11306 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11307 *
11308 * @param {string} labelPosition Label position, 'before' or 'after'
11309 * @chainable
11310 * @return {OO.ui.Widget} The widget, for chaining
11311 */
11312 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11313 this.labelPosition = labelPosition;
11314 if ( this.label ) {
11315 // If there is no label and we only change the position, #updatePosition is a no-op,
11316 // but it takes really a lot of work to do nothing.
11317 this.updatePosition();
11318 }
11319 return this;
11320 };
11321
11322 /**
11323 * Update the position of the inline label.
11324 *
11325 * This method is called by #setLabelPosition, and can also be called on its own if
11326 * something causes the label to be mispositioned.
11327 *
11328 * @chainable
11329 * @return {OO.ui.Widget} The widget, for chaining
11330 */
11331 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11332 var after = this.labelPosition === 'after';
11333
11334 this.$element
11335 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11336 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11337
11338 this.valCache = null;
11339 this.scrollWidth = null;
11340 this.positionLabel();
11341
11342 return this;
11343 };
11344
11345 /**
11346 * Position the label by setting the correct padding on the input.
11347 *
11348 * @private
11349 * @chainable
11350 * @return {OO.ui.Widget} The widget, for chaining
11351 */
11352 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11353 var after, rtl, property, newCss;
11354
11355 if ( this.isWaitingToBeAttached ) {
11356 // #onElementAttach will be called soon, which calls this method
11357 return this;
11358 }
11359
11360 newCss = {
11361 'padding-right': '',
11362 'padding-left': ''
11363 };
11364
11365 if ( this.label ) {
11366 this.$element.append( this.$label );
11367 } else {
11368 this.$label.detach();
11369 // Clear old values if present
11370 this.$input.css( newCss );
11371 return;
11372 }
11373
11374 after = this.labelPosition === 'after';
11375 rtl = this.$element.css( 'direction' ) === 'rtl';
11376 property = after === rtl ? 'padding-left' : 'padding-right';
11377
11378 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11379 // We have to clear the padding on the other side, in case the element direction changed
11380 this.$input.css( newCss );
11381
11382 return this;
11383 };
11384
11385 /**
11386 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11387 * {@link OO.ui.mixin.IconElement search icon} by default.
11388 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11389 *
11390 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11391 *
11392 * @class
11393 * @extends OO.ui.TextInputWidget
11394 *
11395 * @constructor
11396 * @param {Object} [config] Configuration options
11397 */
11398 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11399 config = $.extend( {
11400 icon: 'search'
11401 }, config );
11402
11403 // Parent constructor
11404 OO.ui.SearchInputWidget.parent.call( this, config );
11405
11406 // Events
11407 this.connect( this, {
11408 change: 'onChange'
11409 } );
11410 this.$indicator.on( 'click', this.onIndicatorClick.bind( this ) );
11411
11412 // Initialization
11413 this.updateSearchIndicator();
11414 this.connect( this, {
11415 disable: 'onDisable'
11416 } );
11417 };
11418
11419 /* Setup */
11420
11421 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11422
11423 /* Methods */
11424
11425 /**
11426 * @inheritdoc
11427 * @protected
11428 */
11429 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11430 return 'search';
11431 };
11432
11433 /**
11434 * Handle click events on the indicator
11435 *
11436 * @param {jQuery.Event} e Click event
11437 * @return {boolean}
11438 */
11439 OO.ui.SearchInputWidget.prototype.onIndicatorClick = function ( e ) {
11440 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11441 // Clear the text field
11442 this.setValue( '' );
11443 this.focus();
11444 return false;
11445 }
11446 };
11447
11448 /**
11449 * Update the 'clear' indicator displayed on type: 'search' text
11450 * fields, hiding it when the field is already empty or when it's not
11451 * editable.
11452 */
11453 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11454 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11455 this.setIndicator( null );
11456 } else {
11457 this.setIndicator( 'clear' );
11458 }
11459 };
11460
11461 /**
11462 * Handle change events.
11463 *
11464 * @private
11465 */
11466 OO.ui.SearchInputWidget.prototype.onChange = function () {
11467 this.updateSearchIndicator();
11468 };
11469
11470 /**
11471 * Handle disable events.
11472 *
11473 * @param {boolean} disabled Element is disabled
11474 * @private
11475 */
11476 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11477 this.updateSearchIndicator();
11478 };
11479
11480 /**
11481 * @inheritdoc
11482 */
11483 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11484 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11485 this.updateSearchIndicator();
11486 return this;
11487 };
11488
11489 /**
11490 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11491 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11492 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11493 * {@link OO.ui.mixin.IndicatorElement indicators}.
11494 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11495 *
11496 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11497 *
11498 * @example
11499 * // A MultilineTextInputWidget.
11500 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11501 * value: 'Text input on multiple lines'
11502 * } );
11503 * $( document.body ).append( multilineTextInput.$element );
11504 *
11505 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11506 *
11507 * @class
11508 * @extends OO.ui.TextInputWidget
11509 *
11510 * @constructor
11511 * @param {Object} [config] Configuration options
11512 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11513 * specifies minimum number of rows to display.
11514 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11515 * Use the #maxRows config to specify a maximum number of displayed rows.
11516 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11517 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11518 */
11519 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11520 config = $.extend( {
11521 type: 'text'
11522 }, config );
11523 // Parent constructor
11524 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11525
11526 // Properties
11527 this.autosize = !!config.autosize;
11528 this.styleHeight = null;
11529 this.minRows = config.rows !== undefined ? config.rows : '';
11530 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11531
11532 // Clone for resizing
11533 if ( this.autosize ) {
11534 this.$clone = this.$input
11535 .clone()
11536 .removeAttr( 'id' )
11537 .removeAttr( 'name' )
11538 .insertAfter( this.$input )
11539 .attr( 'aria-hidden', 'true' )
11540 .addClass( 'oo-ui-element-hidden' );
11541 }
11542
11543 // Events
11544 this.connect( this, {
11545 change: 'onChange'
11546 } );
11547
11548 // Initialization
11549 if ( config.rows ) {
11550 this.$input.attr( 'rows', config.rows );
11551 }
11552 if ( this.autosize ) {
11553 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11554 this.isWaitingToBeAttached = true;
11555 this.installParentChangeDetector();
11556 }
11557 };
11558
11559 /* Setup */
11560
11561 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11562
11563 /* Static Methods */
11564
11565 /**
11566 * @inheritdoc
11567 */
11568 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11569 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11570 state.scrollTop = config.$input.scrollTop();
11571 return state;
11572 };
11573
11574 /* Methods */
11575
11576 /**
11577 * @inheritdoc
11578 */
11579 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11580 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11581 this.adjustSize();
11582 };
11583
11584 /**
11585 * Handle change events.
11586 *
11587 * @private
11588 */
11589 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11590 this.adjustSize();
11591 };
11592
11593 /**
11594 * @inheritdoc
11595 */
11596 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11597 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11598 this.adjustSize();
11599 };
11600
11601 /**
11602 * @inheritdoc
11603 *
11604 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11605 */
11606 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11607 if (
11608 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11609 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
11610 e.which === 10
11611 ) {
11612 this.emit( 'enter', e );
11613 }
11614 };
11615
11616 /**
11617 * Automatically adjust the size of the text input.
11618 *
11619 * This only affects multiline inputs that are {@link #autosize autosized}.
11620 *
11621 * @chainable
11622 * @return {OO.ui.Widget} The widget, for chaining
11623 * @fires resize
11624 */
11625 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11626 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11627 idealHeight, newHeight, scrollWidth, property;
11628
11629 if ( this.$input.val() !== this.valCache ) {
11630 if ( this.autosize ) {
11631 this.$clone
11632 .val( this.$input.val() )
11633 .attr( 'rows', this.minRows )
11634 // Set inline height property to 0 to measure scroll height
11635 .css( 'height', 0 );
11636
11637 this.$clone.removeClass( 'oo-ui-element-hidden' );
11638
11639 this.valCache = this.$input.val();
11640
11641 scrollHeight = this.$clone[ 0 ].scrollHeight;
11642
11643 // Remove inline height property to measure natural heights
11644 this.$clone.css( 'height', '' );
11645 innerHeight = this.$clone.innerHeight();
11646 outerHeight = this.$clone.outerHeight();
11647
11648 // Measure max rows height
11649 this.$clone
11650 .attr( 'rows', this.maxRows )
11651 .css( 'height', 'auto' )
11652 .val( '' );
11653 maxInnerHeight = this.$clone.innerHeight();
11654
11655 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11656 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11657 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11658 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11659
11660 this.$clone.addClass( 'oo-ui-element-hidden' );
11661
11662 // Only apply inline height when expansion beyond natural height is needed
11663 // Use the difference between the inner and outer height as a buffer
11664 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11665 if ( newHeight !== this.styleHeight ) {
11666 this.$input.css( 'height', newHeight );
11667 this.styleHeight = newHeight;
11668 this.emit( 'resize' );
11669 }
11670 }
11671 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11672 if ( scrollWidth !== this.scrollWidth ) {
11673 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11674 // Reset
11675 this.$label.css( { right: '', left: '' } );
11676 this.$indicator.css( { right: '', left: '' } );
11677
11678 if ( scrollWidth ) {
11679 this.$indicator.css( property, scrollWidth );
11680 if ( this.labelPosition === 'after' ) {
11681 this.$label.css( property, scrollWidth );
11682 }
11683 }
11684
11685 this.scrollWidth = scrollWidth;
11686 this.positionLabel();
11687 }
11688 }
11689 return this;
11690 };
11691
11692 /**
11693 * @inheritdoc
11694 * @protected
11695 */
11696 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11697 return $( '<textarea>' );
11698 };
11699
11700 /**
11701 * Check if the input automatically adjusts its size.
11702 *
11703 * @return {boolean}
11704 */
11705 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11706 return !!this.autosize;
11707 };
11708
11709 /**
11710 * @inheritdoc
11711 */
11712 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11713 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11714 if ( state.scrollTop !== undefined ) {
11715 this.$input.scrollTop( state.scrollTop );
11716 }
11717 };
11718
11719 /**
11720 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11721 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11722 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11723 *
11724 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11725 * option, that option will appear to be selected.
11726 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11727 * input field.
11728 *
11729 * After the user chooses an option, its `data` will be used as a new value for the widget.
11730 * A `label` also can be specified for each option: if given, it will be shown instead of the
11731 * `data` in the dropdown menu.
11732 *
11733 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11734 *
11735 * For more information about menus and options, please see the
11736 * [OOUI documentation on MediaWiki][1].
11737 *
11738 * @example
11739 * // A ComboBoxInputWidget.
11740 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11741 * value: 'Option 1',
11742 * options: [
11743 * { data: 'Option 1' },
11744 * { data: 'Option 2' },
11745 * { data: 'Option 3' }
11746 * ]
11747 * } );
11748 * $( document.body ).append( comboBox.$element );
11749 *
11750 * @example
11751 * // Example: A ComboBoxInputWidget with additional option labels.
11752 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11753 * value: 'Option 1',
11754 * options: [
11755 * {
11756 * data: 'Option 1',
11757 * label: 'Option One'
11758 * },
11759 * {
11760 * data: 'Option 2',
11761 * label: 'Option Two'
11762 * },
11763 * {
11764 * data: 'Option 3',
11765 * label: 'Option Three'
11766 * }
11767 * ]
11768 * } );
11769 * $( document.body ).append( comboBox.$element );
11770 *
11771 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11772 *
11773 * @class
11774 * @extends OO.ui.TextInputWidget
11775 *
11776 * @constructor
11777 * @param {Object} [config] Configuration options
11778 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11779 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
11780 * select widget}.
11781 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
11782 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
11783 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
11784 * uses relative positioning.
11785 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11786 */
11787 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11788 // Configuration initialization
11789 config = $.extend( {
11790 autocomplete: false
11791 }, config );
11792
11793 // ComboBoxInputWidget shouldn't support `multiline`
11794 config.multiline = false;
11795
11796 // See InputWidget#reusePreInfuseDOM about `config.$input`
11797 if ( config.$input ) {
11798 config.$input.removeAttr( 'list' );
11799 }
11800
11801 // Parent constructor
11802 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11803
11804 // Properties
11805 this.$overlay = ( config.$overlay === true ?
11806 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11807 this.dropdownButton = new OO.ui.ButtonWidget( {
11808 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11809 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11810 indicator: 'down',
11811 invisibleLabel: true,
11812 disabled: this.disabled
11813 } );
11814 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11815 {
11816 widget: this,
11817 input: this,
11818 $floatableContainer: this.$element,
11819 disabled: this.isDisabled()
11820 },
11821 config.menu
11822 ) );
11823
11824 // Events
11825 this.connect( this, {
11826 change: 'onInputChange',
11827 enter: 'onInputEnter'
11828 } );
11829 this.dropdownButton.connect( this, {
11830 click: 'onDropdownButtonClick'
11831 } );
11832 this.menu.connect( this, {
11833 choose: 'onMenuChoose',
11834 add: 'onMenuItemsChange',
11835 remove: 'onMenuItemsChange',
11836 toggle: 'onMenuToggle'
11837 } );
11838
11839 // Initialization
11840 this.$input.attr( {
11841 role: 'combobox',
11842 'aria-owns': this.menu.getElementId(),
11843 'aria-autocomplete': 'list'
11844 } );
11845 this.dropdownButton.$button.attr( {
11846 'aria-controls': this.menu.getElementId()
11847 } );
11848 // Do not override options set via config.menu.items
11849 if ( config.options !== undefined ) {
11850 this.setOptions( config.options );
11851 }
11852 this.$field = $( '<div>' )
11853 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11854 .append( this.$input, this.dropdownButton.$element );
11855 this.$element
11856 .addClass( 'oo-ui-comboBoxInputWidget' )
11857 .append( this.$field );
11858 this.$overlay.append( this.menu.$element );
11859 this.onMenuItemsChange();
11860 };
11861
11862 /* Setup */
11863
11864 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11865
11866 /* Methods */
11867
11868 /**
11869 * Get the combobox's menu.
11870 *
11871 * @return {OO.ui.MenuSelectWidget} Menu widget
11872 */
11873 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11874 return this.menu;
11875 };
11876
11877 /**
11878 * Get the combobox's text input widget.
11879 *
11880 * @return {OO.ui.TextInputWidget} Text input widget
11881 */
11882 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11883 return this;
11884 };
11885
11886 /**
11887 * Handle input change events.
11888 *
11889 * @private
11890 * @param {string} value New value
11891 */
11892 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11893 var match = this.menu.findItemFromData( value );
11894
11895 this.menu.selectItem( match );
11896 if ( this.menu.findHighlightedItem() ) {
11897 this.menu.highlightItem( match );
11898 }
11899
11900 if ( !this.isDisabled() ) {
11901 this.menu.toggle( true );
11902 }
11903 };
11904
11905 /**
11906 * Handle input enter events.
11907 *
11908 * @private
11909 */
11910 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11911 if ( !this.isDisabled() ) {
11912 this.menu.toggle( false );
11913 }
11914 };
11915
11916 /**
11917 * Handle button click events.
11918 *
11919 * @private
11920 */
11921 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11922 this.menu.toggle();
11923 this.focus();
11924 };
11925
11926 /**
11927 * Handle menu choose events.
11928 *
11929 * @private
11930 * @param {OO.ui.OptionWidget} item Chosen item
11931 */
11932 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11933 this.setValue( item.getData() );
11934 };
11935
11936 /**
11937 * Handle menu item change events.
11938 *
11939 * @private
11940 */
11941 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11942 var match = this.menu.findItemFromData( this.getValue() );
11943 this.menu.selectItem( match );
11944 if ( this.menu.findHighlightedItem() ) {
11945 this.menu.highlightItem( match );
11946 }
11947 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11948 };
11949
11950 /**
11951 * Handle menu toggle events.
11952 *
11953 * @private
11954 * @param {boolean} isVisible Open state of the menu
11955 */
11956 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11957 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11958 };
11959
11960 /**
11961 * Update the disabled state of the controls
11962 *
11963 * @chainable
11964 * @protected
11965 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
11966 */
11967 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
11968 var disabled = this.isDisabled() || this.isReadOnly();
11969 if ( this.dropdownButton ) {
11970 this.dropdownButton.setDisabled( disabled );
11971 }
11972 if ( this.menu ) {
11973 this.menu.setDisabled( disabled );
11974 }
11975 return this;
11976 };
11977
11978 /**
11979 * @inheritdoc
11980 */
11981 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
11982 // Parent method
11983 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.apply( this, arguments );
11984 this.updateControlsDisabled();
11985 return this;
11986 };
11987
11988 /**
11989 * @inheritdoc
11990 */
11991 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
11992 // Parent method
11993 OO.ui.ComboBoxInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
11994 this.updateControlsDisabled();
11995 return this;
11996 };
11997
11998 /**
11999 * Set the options available for this input.
12000 *
12001 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
12002 * @chainable
12003 * @return {OO.ui.Widget} The widget, for chaining
12004 */
12005 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
12006 this.getMenu()
12007 .clearItems()
12008 .addItems( options.map( function ( opt ) {
12009 return new OO.ui.MenuOptionWidget( {
12010 data: opt.data,
12011 label: opt.label !== undefined ? opt.label : opt.data
12012 } );
12013 } ) );
12014
12015 return this;
12016 };
12017
12018 /**
12019 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
12020 * which is a widget that is specified by reference before any optional configuration settings.
12021 *
12022 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
12023 * four ways:
12024 *
12025 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12026 * A left-alignment is used for forms with many fields.
12027 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12028 * A right-alignment is used for long but familiar forms which users tab through,
12029 * verifying the current field with a quick glance at the label.
12030 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12031 * that users fill out from top to bottom.
12032 * - **inline**: The label is placed after the field-widget and aligned to the left.
12033 * An inline-alignment is best used with checkboxes or radio buttons.
12034 *
12035 * Help text can either be:
12036 *
12037 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
12038 * or
12039 * - shown as a subtle explanation below the label.
12040 *
12041 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
12042 * If it is long or not essential, leave `helpInline` to its default, `false`.
12043 *
12044 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
12045 *
12046 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12047 *
12048 * @class
12049 * @extends OO.ui.Layout
12050 * @mixins OO.ui.mixin.LabelElement
12051 * @mixins OO.ui.mixin.TitledElement
12052 *
12053 * @constructor
12054 * @param {OO.ui.Widget} fieldWidget Field widget
12055 * @param {Object} [config] Configuration options
12056 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
12057 * or 'inline'
12058 * @cfg {Array} [errors] Error messages about the widget, which will be
12059 * displayed below the widget.
12060 * @cfg {Array} [warnings] Warning messages about the widget, which will be
12061 * displayed below the widget.
12062 * @cfg {Array} [successMessages] Success messages on user interactions with the widget,
12063 * which will be displayed below the widget.
12064 * The array may contain strings or OO.ui.HtmlSnippet instances.
12065 * @cfg {Array} [notices] Notices about the widget, which will be displayed
12066 * below the widget.
12067 * The array may contain strings or OO.ui.HtmlSnippet instances.
12068 * These are more visible than `help` messages when `helpInline` is set, and so
12069 * might be good for transient messages.
12070 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
12071 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
12072 * corner of the rendered field; clicking it will display the text in a popup.
12073 * If `helpInline` is `true`, then a subtle description will be shown after the
12074 * label.
12075 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
12076 * or shown when the "help" icon is clicked.
12077 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
12078 * `help` is given.
12079 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12080 *
12081 * @throws {Error} An error is thrown if no widget is specified
12082 */
12083 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
12084 // Allow passing positional parameters inside the config object
12085 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12086 config = fieldWidget;
12087 fieldWidget = config.fieldWidget;
12088 }
12089
12090 // Make sure we have required constructor arguments
12091 if ( fieldWidget === undefined ) {
12092 throw new Error( 'Widget not found' );
12093 }
12094
12095 // Configuration initialization
12096 config = $.extend( { align: 'left', helpInline: false }, config );
12097
12098 // Parent constructor
12099 OO.ui.FieldLayout.parent.call( this, config );
12100
12101 // Mixin constructors
12102 OO.ui.mixin.LabelElement.call( this, $.extend( {
12103 $label: $( '<label>' )
12104 }, config ) );
12105 OO.ui.mixin.TitledElement.call( this, $.extend( { $titled: this.$label }, config ) );
12106
12107 // Properties
12108 this.fieldWidget = fieldWidget;
12109 this.errors = [];
12110 this.warnings = [];
12111 this.successMessages = [];
12112 this.notices = [];
12113 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12114 this.$messages = $( '<div>' );
12115 this.$header = $( '<span>' );
12116 this.$body = $( '<div>' );
12117 this.align = null;
12118 this.helpInline = config.helpInline;
12119
12120 // Events
12121 this.fieldWidget.connect( this, {
12122 disable: 'onFieldDisable'
12123 } );
12124
12125 // Initialization
12126 this.$help = config.help ?
12127 this.createHelpElement( config.help, config.$overlay ) :
12128 $( [] );
12129 if ( this.fieldWidget.getInputId() ) {
12130 this.$label.attr( 'for', this.fieldWidget.getInputId() );
12131 if ( this.helpInline ) {
12132 this.$help.attr( 'for', this.fieldWidget.getInputId() );
12133 }
12134 } else {
12135 this.$label.on( 'click', function () {
12136 this.fieldWidget.simulateLabelClick();
12137 }.bind( this ) );
12138 if ( this.helpInline ) {
12139 this.$help.on( 'click', function () {
12140 this.fieldWidget.simulateLabelClick();
12141 }.bind( this ) );
12142 }
12143 }
12144 this.$element
12145 .addClass( 'oo-ui-fieldLayout' )
12146 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
12147 .append( this.$body );
12148 this.$body.addClass( 'oo-ui-fieldLayout-body' );
12149 this.$header.addClass( 'oo-ui-fieldLayout-header' );
12150 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
12151 this.$field
12152 .addClass( 'oo-ui-fieldLayout-field' )
12153 .append( this.fieldWidget.$element );
12154
12155 this.setErrors( config.errors || [] );
12156 this.setWarnings( config.warnings || [] );
12157 this.setSuccess( config.successMessages || [] );
12158 this.setNotices( config.notices || [] );
12159 this.setAlignment( config.align );
12160 // Call this again to take into account the widget's accessKey
12161 this.updateTitle();
12162 };
12163
12164 /* Setup */
12165
12166 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
12167 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
12168 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
12169
12170 /* Methods */
12171
12172 /**
12173 * Handle field disable events.
12174 *
12175 * @private
12176 * @param {boolean} value Field is disabled
12177 */
12178 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
12179 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
12180 };
12181
12182 /**
12183 * Get the widget contained by the field.
12184 *
12185 * @return {OO.ui.Widget} Field widget
12186 */
12187 OO.ui.FieldLayout.prototype.getField = function () {
12188 return this.fieldWidget;
12189 };
12190
12191 /**
12192 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12193 * #setAlignment). Return `false` if it can't or if this can't be determined.
12194 *
12195 * @return {boolean}
12196 */
12197 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12198 // This is very simplistic, but should be good enough.
12199 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12200 };
12201
12202 /**
12203 * @protected
12204 * @param {string} kind 'error' or 'notice'
12205 * @param {string|OO.ui.HtmlSnippet} text
12206 * @return {jQuery}
12207 */
12208 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12209 return new OO.ui.MessageWidget( {
12210 type: kind,
12211 inline: true,
12212 label: text
12213 } ).$element;
12214 };
12215
12216 /**
12217 * Set the field alignment mode.
12218 *
12219 * @private
12220 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12221 * @chainable
12222 * @return {OO.ui.BookletLayout} The layout, for chaining
12223 */
12224 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12225 if ( value !== this.align ) {
12226 // Default to 'left'
12227 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12228 value = 'left';
12229 }
12230 // Validate
12231 if ( value === 'inline' && !this.isFieldInline() ) {
12232 value = 'top';
12233 }
12234 // Reorder elements
12235
12236 if ( this.helpInline ) {
12237 if ( value === 'top' ) {
12238 this.$header.append( this.$label );
12239 this.$body.append( this.$header, this.$field, this.$help );
12240 } else if ( value === 'inline' ) {
12241 this.$header.append( this.$label, this.$help );
12242 this.$body.append( this.$field, this.$header );
12243 } else {
12244 this.$header.append( this.$label, this.$help );
12245 this.$body.append( this.$header, this.$field );
12246 }
12247 } else {
12248 if ( value === 'top' ) {
12249 this.$header.append( this.$help, this.$label );
12250 this.$body.append( this.$header, this.$field );
12251 } else if ( value === 'inline' ) {
12252 this.$header.append( this.$help, this.$label );
12253 this.$body.append( this.$field, this.$header );
12254 } else {
12255 this.$header.append( this.$label );
12256 this.$body.append( this.$header, this.$help, this.$field );
12257 }
12258 }
12259 // Set classes. The following classes can be used here:
12260 // * oo-ui-fieldLayout-align-left
12261 // * oo-ui-fieldLayout-align-right
12262 // * oo-ui-fieldLayout-align-top
12263 // * oo-ui-fieldLayout-align-inline
12264 if ( this.align ) {
12265 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
12266 }
12267 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
12268 this.align = value;
12269 }
12270
12271 return this;
12272 };
12273
12274 /**
12275 * Set the list of error messages.
12276 *
12277 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
12278 * The array may contain strings or OO.ui.HtmlSnippet instances.
12279 * @chainable
12280 * @return {OO.ui.BookletLayout} The layout, for chaining
12281 */
12282 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
12283 this.errors = errors.slice();
12284 this.updateMessages();
12285 return this;
12286 };
12287
12288 /**
12289 * Set the list of warning messages.
12290 *
12291 * @param {Array} warnings Warning messages about the widget, which will be displayed below
12292 * the widget.
12293 * The array may contain strings or OO.ui.HtmlSnippet instances.
12294 * @chainable
12295 * @return {OO.ui.BookletLayout} The layout, for chaining
12296 */
12297 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
12298 this.warnings = warnings.slice();
12299 this.updateMessages();
12300 return this;
12301 };
12302
12303 /**
12304 * Set the list of success messages.
12305 *
12306 * @param {Array} successMessages Success messages about the widget, which will be displayed below
12307 * the widget.
12308 * The array may contain strings or OO.ui.HtmlSnippet instances.
12309 * @chainable
12310 * @return {OO.ui.BookletLayout} The layout, for chaining
12311 */
12312 OO.ui.FieldLayout.prototype.setSuccess = function ( successMessages ) {
12313 this.successMessages = successMessages.slice();
12314 this.updateMessages();
12315 return this;
12316 };
12317
12318 /**
12319 * Set the list of notice messages.
12320 *
12321 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
12322 * The array may contain strings or OO.ui.HtmlSnippet instances.
12323 * @chainable
12324 * @return {OO.ui.BookletLayout} The layout, for chaining
12325 */
12326 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
12327 this.notices = notices.slice();
12328 this.updateMessages();
12329 return this;
12330 };
12331
12332 /**
12333 * Update the rendering of error, warning, success and notice messages.
12334 *
12335 * @private
12336 */
12337 OO.ui.FieldLayout.prototype.updateMessages = function () {
12338 var i;
12339 this.$messages.empty();
12340
12341 if (
12342 this.errors.length ||
12343 this.warnings.length ||
12344 this.successMessages.length ||
12345 this.notices.length
12346 ) {
12347 this.$body.after( this.$messages );
12348 } else {
12349 this.$messages.remove();
12350 return;
12351 }
12352
12353 for ( i = 0; i < this.errors.length; i++ ) {
12354 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
12355 }
12356 for ( i = 0; i < this.warnings.length; i++ ) {
12357 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
12358 }
12359 for ( i = 0; i < this.successMessages.length; i++ ) {
12360 this.$messages.append( this.makeMessage( 'success', this.successMessages[ i ] ) );
12361 }
12362 for ( i = 0; i < this.notices.length; i++ ) {
12363 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
12364 }
12365 };
12366
12367 /**
12368 * Include information about the widget's accessKey in our title. TitledElement calls this method.
12369 * (This is a bit of a hack.)
12370 *
12371 * @protected
12372 * @param {string} title Tooltip label for 'title' attribute
12373 * @return {string}
12374 */
12375 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
12376 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
12377 return this.fieldWidget.formatTitleWithAccessKey( title );
12378 }
12379 return title;
12380 };
12381
12382 /**
12383 * Creates and returns the help element. Also sets the `aria-describedby`
12384 * attribute on the main element of the `fieldWidget`.
12385 *
12386 * @private
12387 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
12388 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
12389 * @return {jQuery} The element that should become `this.$help`.
12390 */
12391 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
12392 var helpId, helpWidget;
12393
12394 if ( this.helpInline ) {
12395 helpWidget = new OO.ui.LabelWidget( {
12396 label: help,
12397 classes: [ 'oo-ui-inline-help' ]
12398 } );
12399
12400 helpId = helpWidget.getElementId();
12401 } else {
12402 helpWidget = new OO.ui.PopupButtonWidget( {
12403 $overlay: $overlay,
12404 popup: {
12405 padded: true
12406 },
12407 classes: [ 'oo-ui-fieldLayout-help' ],
12408 framed: false,
12409 icon: 'info',
12410 label: OO.ui.msg( 'ooui-field-help' ),
12411 invisibleLabel: true
12412 } );
12413 if ( help instanceof OO.ui.HtmlSnippet ) {
12414 helpWidget.getPopup().$body.html( help.toString() );
12415 } else {
12416 helpWidget.getPopup().$body.text( help );
12417 }
12418
12419 helpId = helpWidget.getPopup().getBodyId();
12420 }
12421
12422 // Set the 'aria-describedby' attribute on the fieldWidget
12423 // Preference given to an input or a button
12424 (
12425 this.fieldWidget.$input ||
12426 this.fieldWidget.$button ||
12427 this.fieldWidget.$element
12428 ).attr( 'aria-describedby', helpId );
12429
12430 return helpWidget.$element;
12431 };
12432
12433 /**
12434 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
12435 * a button, and an optional label and/or help text. The field-widget (e.g., a
12436 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
12437 * configuration settings.
12438 *
12439 * Labels can be aligned in one of four ways:
12440 *
12441 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12442 * A left-alignment is used for forms with many fields.
12443 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12444 * A right-alignment is used for long but familiar forms which users tab through,
12445 * verifying the current field with a quick glance at the label.
12446 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12447 * that users fill out from top to bottom.
12448 * - **inline**: The label is placed after the field-widget and aligned to the left.
12449 * An inline-alignment is best used with checkboxes or radio buttons.
12450 *
12451 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
12452 * field layout when help text is specified.
12453 *
12454 * @example
12455 * // Example of an ActionFieldLayout
12456 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12457 * new OO.ui.TextInputWidget( {
12458 * placeholder: 'Field widget'
12459 * } ),
12460 * new OO.ui.ButtonWidget( {
12461 * label: 'Button'
12462 * } ),
12463 * {
12464 * label: 'An ActionFieldLayout. This label is aligned top',
12465 * align: 'top',
12466 * help: 'This is help text'
12467 * }
12468 * );
12469 *
12470 * $( document.body ).append( actionFieldLayout.$element );
12471 *
12472 * @class
12473 * @extends OO.ui.FieldLayout
12474 *
12475 * @constructor
12476 * @param {OO.ui.Widget} fieldWidget Field widget
12477 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12478 * @param {Object} config
12479 */
12480 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12481 // Allow passing positional parameters inside the config object
12482 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12483 config = fieldWidget;
12484 fieldWidget = config.fieldWidget;
12485 buttonWidget = config.buttonWidget;
12486 }
12487
12488 // Parent constructor
12489 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12490
12491 // Properties
12492 this.buttonWidget = buttonWidget;
12493 this.$button = $( '<span>' );
12494 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12495
12496 // Initialization
12497 this.$element.addClass( 'oo-ui-actionFieldLayout' );
12498 this.$button
12499 .addClass( 'oo-ui-actionFieldLayout-button' )
12500 .append( this.buttonWidget.$element );
12501 this.$input
12502 .addClass( 'oo-ui-actionFieldLayout-input' )
12503 .append( this.fieldWidget.$element );
12504 this.$field.append( this.$input, this.$button );
12505 };
12506
12507 /* Setup */
12508
12509 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12510
12511 /**
12512 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12513 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12514 * configured with a label as well. For more information and examples,
12515 * please see the [OOUI documentation on MediaWiki][1].
12516 *
12517 * @example
12518 * // Example of a fieldset layout
12519 * var input1 = new OO.ui.TextInputWidget( {
12520 * placeholder: 'A text input field'
12521 * } );
12522 *
12523 * var input2 = new OO.ui.TextInputWidget( {
12524 * placeholder: 'A text input field'
12525 * } );
12526 *
12527 * var fieldset = new OO.ui.FieldsetLayout( {
12528 * label: 'Example of a fieldset layout'
12529 * } );
12530 *
12531 * fieldset.addItems( [
12532 * new OO.ui.FieldLayout( input1, {
12533 * label: 'Field One'
12534 * } ),
12535 * new OO.ui.FieldLayout( input2, {
12536 * label: 'Field Two'
12537 * } )
12538 * ] );
12539 * $( document.body ).append( fieldset.$element );
12540 *
12541 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12542 *
12543 * @class
12544 * @extends OO.ui.Layout
12545 * @mixins OO.ui.mixin.IconElement
12546 * @mixins OO.ui.mixin.LabelElement
12547 * @mixins OO.ui.mixin.GroupElement
12548 *
12549 * @constructor
12550 * @param {Object} [config] Configuration options
12551 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset.
12552 * See OO.ui.FieldLayout for more information about fields.
12553 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
12554 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
12555 * corner of the rendered field; clicking it will display the text in a popup.
12556 * If `helpInline` is `true`, then a subtle description will be shown after the
12557 * label.
12558 * For feedback messages, you are advised to use `notices`.
12559 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
12560 * or shown when the "help" icon is clicked.
12561 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12562 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12563 */
12564 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12565 var helpWidget;
12566
12567 // Configuration initialization
12568 config = config || {};
12569
12570 // Parent constructor
12571 OO.ui.FieldsetLayout.parent.call( this, config );
12572
12573 // Mixin constructors
12574 OO.ui.mixin.IconElement.call( this, config );
12575 OO.ui.mixin.LabelElement.call( this, config );
12576 OO.ui.mixin.GroupElement.call( this, config );
12577
12578 // Properties
12579 this.$header = $( '<legend>' );
12580
12581 // Initialization
12582 this.$header
12583 .addClass( 'oo-ui-fieldsetLayout-header' )
12584 .append( this.$icon, this.$label );
12585 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12586 this.$element
12587 .addClass( 'oo-ui-fieldsetLayout' )
12588 .prepend( this.$header, this.$group );
12589
12590 // Help
12591 if ( config.help ) {
12592 if ( config.helpInline ) {
12593 helpWidget = new OO.ui.LabelWidget( {
12594 label: config.help,
12595 classes: [ 'oo-ui-inline-help' ]
12596 } );
12597 this.$element.prepend( this.$header, helpWidget.$element, this.$group );
12598 } else {
12599 helpWidget = new OO.ui.PopupButtonWidget( {
12600 $overlay: config.$overlay,
12601 popup: {
12602 padded: true
12603 },
12604 classes: [ 'oo-ui-fieldsetLayout-help' ],
12605 framed: false,
12606 icon: 'info',
12607 label: OO.ui.msg( 'ooui-field-help' ),
12608 invisibleLabel: true
12609 } );
12610 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12611 helpWidget.getPopup().$body.html( config.help.toString() );
12612 } else {
12613 helpWidget.getPopup().$body.text( config.help );
12614 }
12615 this.$header.append( helpWidget.$element );
12616 }
12617 }
12618 if ( Array.isArray( config.items ) ) {
12619 this.addItems( config.items );
12620 }
12621 };
12622
12623 /* Setup */
12624
12625 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12626 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12627 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12628 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12629
12630 /* Static Properties */
12631
12632 /**
12633 * @static
12634 * @inheritdoc
12635 */
12636 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12637
12638 /**
12639 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
12640 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
12641 * can be configured with an HTML form action, an encoding type, and a method using the #action,
12642 * #enctype, and #method configs, respectively.
12643 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12644 *
12645 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12646 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12647 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12648 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12649 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12650 * often have simplified APIs to match the capabilities of HTML forms.
12651 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12652 *
12653 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12654 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12655 *
12656 * @example
12657 * // Example of a form layout that wraps a fieldset layout.
12658 * var input1 = new OO.ui.TextInputWidget( {
12659 * placeholder: 'Username'
12660 * } ),
12661 * input2 = new OO.ui.TextInputWidget( {
12662 * placeholder: 'Password',
12663 * type: 'password'
12664 * } ),
12665 * submit = new OO.ui.ButtonInputWidget( {
12666 * label: 'Submit'
12667 * } ),
12668 * fieldset = new OO.ui.FieldsetLayout( {
12669 * label: 'A form layout'
12670 * } );
12671 *
12672 * fieldset.addItems( [
12673 * new OO.ui.FieldLayout( input1, {
12674 * label: 'Username',
12675 * align: 'top'
12676 * } ),
12677 * new OO.ui.FieldLayout( input2, {
12678 * label: 'Password',
12679 * align: 'top'
12680 * } ),
12681 * new OO.ui.FieldLayout( submit )
12682 * ] );
12683 * var form = new OO.ui.FormLayout( {
12684 * items: [ fieldset ],
12685 * action: '/api/formhandler',
12686 * method: 'get'
12687 * } )
12688 * $( document.body ).append( form.$element );
12689 *
12690 * @class
12691 * @extends OO.ui.Layout
12692 * @mixins OO.ui.mixin.GroupElement
12693 *
12694 * @constructor
12695 * @param {Object} [config] Configuration options
12696 * @cfg {string} [method] HTML form `method` attribute
12697 * @cfg {string} [action] HTML form `action` attribute
12698 * @cfg {string} [enctype] HTML form `enctype` attribute
12699 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12700 */
12701 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12702 var action;
12703
12704 // Configuration initialization
12705 config = config || {};
12706
12707 // Parent constructor
12708 OO.ui.FormLayout.parent.call( this, config );
12709
12710 // Mixin constructors
12711 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12712
12713 // Events
12714 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12715
12716 // Make sure the action is safe
12717 action = config.action;
12718 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12719 action = './' + action;
12720 }
12721
12722 // Initialization
12723 this.$element
12724 .addClass( 'oo-ui-formLayout' )
12725 .attr( {
12726 method: config.method,
12727 action: action,
12728 enctype: config.enctype
12729 } );
12730 if ( Array.isArray( config.items ) ) {
12731 this.addItems( config.items );
12732 }
12733 };
12734
12735 /* Setup */
12736
12737 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12738 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12739
12740 /* Events */
12741
12742 /**
12743 * A 'submit' event is emitted when the form is submitted.
12744 *
12745 * @event submit
12746 */
12747
12748 /* Static Properties */
12749
12750 /**
12751 * @static
12752 * @inheritdoc
12753 */
12754 OO.ui.FormLayout.static.tagName = 'form';
12755
12756 /* Methods */
12757
12758 /**
12759 * Handle form submit events.
12760 *
12761 * @private
12762 * @param {jQuery.Event} e Submit event
12763 * @fires submit
12764 * @return {OO.ui.FormLayout} The layout, for chaining
12765 */
12766 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12767 if ( this.emit( 'submit' ) ) {
12768 return false;
12769 }
12770 };
12771
12772 /**
12773 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
12774 * scrolling, padding, and a frame, and are often used together with
12775 * {@link OO.ui.StackLayout StackLayouts}.
12776 *
12777 * @example
12778 * // Example of a panel layout
12779 * var panel = new OO.ui.PanelLayout( {
12780 * expanded: false,
12781 * framed: true,
12782 * padded: true,
12783 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12784 * } );
12785 * $( document.body ).append( panel.$element );
12786 *
12787 * @class
12788 * @extends OO.ui.Layout
12789 *
12790 * @constructor
12791 * @param {Object} [config] Configuration options
12792 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12793 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12794 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12795 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside
12796 * content.
12797 */
12798 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12799 // Configuration initialization
12800 config = $.extend( {
12801 scrollable: false,
12802 padded: false,
12803 expanded: true,
12804 framed: false
12805 }, config );
12806
12807 // Parent constructor
12808 OO.ui.PanelLayout.parent.call( this, config );
12809
12810 // Initialization
12811 this.$element.addClass( 'oo-ui-panelLayout' );
12812 if ( config.scrollable ) {
12813 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12814 }
12815 if ( config.padded ) {
12816 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12817 }
12818 if ( config.expanded ) {
12819 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12820 }
12821 if ( config.framed ) {
12822 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12823 }
12824 };
12825
12826 /* Setup */
12827
12828 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12829
12830 /* Static Methods */
12831
12832 /**
12833 * @inheritdoc
12834 */
12835 OO.ui.PanelLayout.static.reusePreInfuseDOM = function ( node, config ) {
12836 config = OO.ui.PanelLayout.parent.static.reusePreInfuseDOM( node, config );
12837 if ( config.preserveContent !== false ) {
12838 config.$content = $( node ).contents();
12839 }
12840 return config;
12841 };
12842
12843 /* Methods */
12844
12845 /**
12846 * Focus the panel layout
12847 *
12848 * The default implementation just focuses the first focusable element in the panel
12849 */
12850 OO.ui.PanelLayout.prototype.focus = function () {
12851 OO.ui.findFocusable( this.$element ).focus();
12852 };
12853
12854 /**
12855 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12856 * items), with small margins between them. Convenient when you need to put a number of block-level
12857 * widgets on a single line next to each other.
12858 *
12859 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12860 *
12861 * @example
12862 * // HorizontalLayout with a text input and a label.
12863 * var layout = new OO.ui.HorizontalLayout( {
12864 * items: [
12865 * new OO.ui.LabelWidget( { label: 'Label' } ),
12866 * new OO.ui.TextInputWidget( { value: 'Text' } )
12867 * ]
12868 * } );
12869 * $( document.body ).append( layout.$element );
12870 *
12871 * @class
12872 * @extends OO.ui.Layout
12873 * @mixins OO.ui.mixin.GroupElement
12874 *
12875 * @constructor
12876 * @param {Object} [config] Configuration options
12877 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12878 */
12879 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12880 // Configuration initialization
12881 config = config || {};
12882
12883 // Parent constructor
12884 OO.ui.HorizontalLayout.parent.call( this, config );
12885
12886 // Mixin constructors
12887 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12888
12889 // Initialization
12890 this.$element.addClass( 'oo-ui-horizontalLayout' );
12891 if ( Array.isArray( config.items ) ) {
12892 this.addItems( config.items );
12893 }
12894 };
12895
12896 /* Setup */
12897
12898 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12899 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12900
12901 /**
12902 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12903 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12904 * (to adjust the value in increments) to allow the user to enter a number.
12905 *
12906 * @example
12907 * // A NumberInputWidget.
12908 * var numberInput = new OO.ui.NumberInputWidget( {
12909 * label: 'NumberInputWidget',
12910 * input: { value: 5 },
12911 * min: 1,
12912 * max: 10
12913 * } );
12914 * $( document.body ).append( numberInput.$element );
12915 *
12916 * @class
12917 * @extends OO.ui.TextInputWidget
12918 *
12919 * @constructor
12920 * @param {Object} [config] Configuration options
12921 * @cfg {Object} [minusButton] Configuration options to pass to the
12922 * {@link OO.ui.ButtonWidget decrementing button widget}.
12923 * @cfg {Object} [plusButton] Configuration options to pass to the
12924 * {@link OO.ui.ButtonWidget incrementing button widget}.
12925 * @cfg {number} [min=-Infinity] Minimum allowed value
12926 * @cfg {number} [max=Infinity] Maximum allowed value
12927 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12928 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
12929 * Defaults to `step` if specified, otherwise `1`.
12930 * @cfg {number} [pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
12931 * Defaults to 10 times `buttonStep`.
12932 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12933 */
12934 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12935 var $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
12936
12937 // Configuration initialization
12938 config = $.extend( {
12939 min: -Infinity,
12940 max: Infinity,
12941 showButtons: true
12942 }, config );
12943
12944 // For backward compatibility
12945 $.extend( config, config.input );
12946 this.input = this;
12947
12948 // Parent constructor
12949 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12950 type: 'number'
12951 } ) );
12952
12953 if ( config.showButtons ) {
12954 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12955 {
12956 disabled: this.isDisabled(),
12957 tabIndex: -1,
12958 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12959 icon: 'subtract'
12960 },
12961 config.minusButton
12962 ) );
12963 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12964 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12965 {
12966 disabled: this.isDisabled(),
12967 tabIndex: -1,
12968 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12969 icon: 'add'
12970 },
12971 config.plusButton
12972 ) );
12973 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12974 }
12975
12976 // Events
12977 this.$input.on( {
12978 keydown: this.onKeyDown.bind( this ),
12979 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12980 } );
12981 if ( config.showButtons ) {
12982 this.plusButton.connect( this, {
12983 click: [ 'onButtonClick', +1 ]
12984 } );
12985 this.minusButton.connect( this, {
12986 click: [ 'onButtonClick', -1 ]
12987 } );
12988 }
12989
12990 // Build the field
12991 $field.append( this.$input );
12992 if ( config.showButtons ) {
12993 $field
12994 .prepend( this.minusButton.$element )
12995 .append( this.plusButton.$element );
12996 }
12997
12998 // Initialization
12999 if ( config.allowInteger || config.isInteger ) {
13000 // Backward compatibility
13001 config.step = 1;
13002 }
13003 this.setRange( config.min, config.max );
13004 this.setStep( config.buttonStep, config.pageStep, config.step );
13005 // Set the validation method after we set step and range
13006 // so that it doesn't immediately call setValidityFlag
13007 this.setValidation( this.validateNumber.bind( this ) );
13008
13009 this.$element
13010 .addClass( 'oo-ui-numberInputWidget' )
13011 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
13012 .append( $field );
13013 };
13014
13015 /* Setup */
13016
13017 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
13018
13019 /* Methods */
13020
13021 // Backward compatibility
13022 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
13023 this.setStep( flag ? 1 : null );
13024 };
13025 // Backward compatibility
13026 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
13027
13028 // Backward compatibility
13029 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
13030 return this.step === 1;
13031 };
13032 // Backward compatibility
13033 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
13034
13035 /**
13036 * Set the range of allowed values
13037 *
13038 * @param {number} min Minimum allowed value
13039 * @param {number} max Maximum allowed value
13040 */
13041 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
13042 if ( min > max ) {
13043 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
13044 }
13045 this.min = min;
13046 this.max = max;
13047 this.$input.attr( 'min', this.min );
13048 this.$input.attr( 'max', this.max );
13049 this.setValidityFlag();
13050 };
13051
13052 /**
13053 * Get the current range
13054 *
13055 * @return {number[]} Minimum and maximum values
13056 */
13057 OO.ui.NumberInputWidget.prototype.getRange = function () {
13058 return [ this.min, this.max ];
13059 };
13060
13061 /**
13062 * Set the stepping deltas
13063 *
13064 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
13065 * Defaults to `step` if specified, otherwise `1`.
13066 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
13067 * Defaults to 10 times `buttonStep`.
13068 * @param {number|null} [step] If specified, the field only accepts values that are multiples
13069 * of this.
13070 */
13071 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
13072 if ( buttonStep === undefined ) {
13073 buttonStep = step || 1;
13074 }
13075 if ( pageStep === undefined ) {
13076 pageStep = 10 * buttonStep;
13077 }
13078 if ( step !== null && step <= 0 ) {
13079 throw new Error( 'Step value, if given, must be positive' );
13080 }
13081 if ( buttonStep <= 0 ) {
13082 throw new Error( 'Button step value must be positive' );
13083 }
13084 if ( pageStep <= 0 ) {
13085 throw new Error( 'Page step value must be positive' );
13086 }
13087 this.step = step;
13088 this.buttonStep = buttonStep;
13089 this.pageStep = pageStep;
13090 this.$input.attr( 'step', this.step || 'any' );
13091 this.setValidityFlag();
13092 };
13093
13094 /**
13095 * @inheritdoc
13096 */
13097 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
13098 if ( value === '' ) {
13099 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
13100 // so here we make sure an 'empty' value is actually displayed as such.
13101 this.$input.val( '' );
13102 }
13103 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
13104 };
13105
13106 /**
13107 * Get the current stepping values
13108 *
13109 * @return {number[]} Button step, page step, and validity step
13110 */
13111 OO.ui.NumberInputWidget.prototype.getStep = function () {
13112 return [ this.buttonStep, this.pageStep, this.step ];
13113 };
13114
13115 /**
13116 * Get the current value of the widget as a number
13117 *
13118 * @return {number} May be NaN, or an invalid number
13119 */
13120 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
13121 return +this.getValue();
13122 };
13123
13124 /**
13125 * Adjust the value of the widget
13126 *
13127 * @param {number} delta Adjustment amount
13128 */
13129 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
13130 var n, v = this.getNumericValue();
13131
13132 delta = +delta;
13133 if ( isNaN( delta ) || !isFinite( delta ) ) {
13134 throw new Error( 'Delta must be a finite number' );
13135 }
13136
13137 if ( isNaN( v ) ) {
13138 n = 0;
13139 } else {
13140 n = v + delta;
13141 n = Math.max( Math.min( n, this.max ), this.min );
13142 if ( this.step ) {
13143 n = Math.round( n / this.step ) * this.step;
13144 }
13145 }
13146
13147 if ( n !== v ) {
13148 this.setValue( n );
13149 }
13150 };
13151 /**
13152 * Validate input
13153 *
13154 * @private
13155 * @param {string} value Field value
13156 * @return {boolean}
13157 */
13158 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
13159 var n = +value;
13160 if ( value === '' ) {
13161 return !this.isRequired();
13162 }
13163
13164 if ( isNaN( n ) || !isFinite( n ) ) {
13165 return false;
13166 }
13167
13168 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
13169 return false;
13170 }
13171
13172 if ( n < this.min || n > this.max ) {
13173 return false;
13174 }
13175
13176 return true;
13177 };
13178
13179 /**
13180 * Handle mouse click events.
13181 *
13182 * @private
13183 * @param {number} dir +1 or -1
13184 */
13185 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
13186 this.adjustValue( dir * this.buttonStep );
13187 };
13188
13189 /**
13190 * Handle mouse wheel events.
13191 *
13192 * @private
13193 * @param {jQuery.Event} event
13194 * @return {undefined|boolean} False to prevent default if event is handled
13195 */
13196 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13197 var delta = 0;
13198
13199 if ( this.isDisabled() || this.isReadOnly() ) {
13200 return;
13201 }
13202
13203 if ( this.$input.is( ':focus' ) ) {
13204 // Standard 'wheel' event
13205 if ( event.originalEvent.deltaMode !== undefined ) {
13206 this.sawWheelEvent = true;
13207 }
13208 if ( event.originalEvent.deltaY ) {
13209 delta = -event.originalEvent.deltaY;
13210 } else if ( event.originalEvent.deltaX ) {
13211 delta = event.originalEvent.deltaX;
13212 }
13213
13214 // Non-standard events
13215 if ( !this.sawWheelEvent ) {
13216 if ( event.originalEvent.wheelDeltaX ) {
13217 delta = -event.originalEvent.wheelDeltaX;
13218 } else if ( event.originalEvent.wheelDeltaY ) {
13219 delta = event.originalEvent.wheelDeltaY;
13220 } else if ( event.originalEvent.wheelDelta ) {
13221 delta = event.originalEvent.wheelDelta;
13222 } else if ( event.originalEvent.detail ) {
13223 delta = -event.originalEvent.detail;
13224 }
13225 }
13226
13227 if ( delta ) {
13228 delta = delta < 0 ? -1 : 1;
13229 this.adjustValue( delta * this.buttonStep );
13230 }
13231
13232 return false;
13233 }
13234 };
13235
13236 /**
13237 * Handle key down events.
13238 *
13239 * @private
13240 * @param {jQuery.Event} e Key down event
13241 * @return {undefined|boolean} False to prevent default if event is handled
13242 */
13243 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
13244 if ( this.isDisabled() || this.isReadOnly() ) {
13245 return;
13246 }
13247
13248 switch ( e.which ) {
13249 case OO.ui.Keys.UP:
13250 this.adjustValue( this.buttonStep );
13251 return false;
13252 case OO.ui.Keys.DOWN:
13253 this.adjustValue( -this.buttonStep );
13254 return false;
13255 case OO.ui.Keys.PAGEUP:
13256 this.adjustValue( this.pageStep );
13257 return false;
13258 case OO.ui.Keys.PAGEDOWN:
13259 this.adjustValue( -this.pageStep );
13260 return false;
13261 }
13262 };
13263
13264 /**
13265 * Update the disabled state of the controls
13266 *
13267 * @chainable
13268 * @protected
13269 * @return {OO.ui.NumberInputWidget} The widget, for chaining
13270 */
13271 OO.ui.NumberInputWidget.prototype.updateControlsDisabled = function () {
13272 var disabled = this.isDisabled() || this.isReadOnly();
13273 if ( this.minusButton ) {
13274 this.minusButton.setDisabled( disabled );
13275 }
13276 if ( this.plusButton ) {
13277 this.plusButton.setDisabled( disabled );
13278 }
13279 return this;
13280 };
13281
13282 /**
13283 * @inheritdoc
13284 */
13285 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
13286 // Parent method
13287 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
13288 this.updateControlsDisabled();
13289 return this;
13290 };
13291
13292 /**
13293 * @inheritdoc
13294 */
13295 OO.ui.NumberInputWidget.prototype.setReadOnly = function () {
13296 // Parent method
13297 OO.ui.NumberInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
13298 this.updateControlsDisabled();
13299 return this;
13300 };
13301
13302 /**
13303 * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
13304 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
13305 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
13306 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
13307 *
13308 * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
13309 *
13310 * @example
13311 * // A file select input widget.
13312 * var selectFile = new OO.ui.SelectFileInputWidget();
13313 * $( document.body ).append( selectFile.$element );
13314 *
13315 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
13316 *
13317 * @class
13318 * @extends OO.ui.InputWidget
13319 *
13320 * @constructor
13321 * @param {Object} [config] Configuration options
13322 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
13323 * @cfg {boolean} [multiple=false] Allow multiple files to be selected.
13324 * @cfg {string} [placeholder] Text to display when no file is selected.
13325 * @cfg {Object} [button] Config to pass to select file button.
13326 * @cfg {string} [icon] Icon to show next to file info
13327 */
13328 OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
13329 var widget = this;
13330
13331 config = config || {};
13332
13333 // Construct buttons before parent method is called (calling setDisabled)
13334 this.selectButton = new OO.ui.ButtonWidget( $.extend( {
13335 $element: $( '<label>' ),
13336 classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
13337 label: OO.ui.msg( 'ooui-selectfile-button-select' )
13338 }, config.button ) );
13339
13340 // Configuration initialization
13341 config = $.extend( {
13342 accept: null,
13343 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
13344 $tabIndexed: this.selectButton.$tabIndexed
13345 }, config );
13346
13347 this.info = new OO.ui.SearchInputWidget( {
13348 classes: [ 'oo-ui-selectFileInputWidget-info' ],
13349 placeholder: config.placeholder,
13350 // Pass an empty collection so that .focus() always does nothing
13351 $tabIndexed: $( [] )
13352 } ).setIcon( config.icon );
13353 // Set tabindex manually on $input as $tabIndexed has been overridden
13354 this.info.$input.attr( 'tabindex', -1 );
13355
13356 // Parent constructor
13357 OO.ui.SelectFileInputWidget.parent.call( this, config );
13358
13359 // Properties
13360 this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
13361 if ( Array.isArray( config.accept ) ) {
13362 this.accept = config.accept;
13363 } else {
13364 this.accept = null;
13365 }
13366 this.multiple = !!config.multiple;
13367
13368 // Events
13369 this.info.connect( this, { change: 'onInfoChange' } );
13370 this.selectButton.$button.on( {
13371 keypress: this.onKeyPress.bind( this )
13372 } );
13373 this.$input.on( {
13374 change: this.onFileSelected.bind( this ),
13375 // Support: IE11
13376 // In IE 11, focussing a file input (by clicking on it) displays a text cursor and scrolls
13377 // the cursor into view (in this case, it scrolls the button, which has 'overflow: hidden').
13378 // Since this messes with our custom styling (the file input has large dimensions and this
13379 // causes the label to scroll out of view), scroll the button back to top. (T192131)
13380 focus: function () {
13381 widget.$input.parent().prop( 'scrollTop', 0 );
13382 }
13383 } );
13384 this.connect( this, { change: 'updateUI' } );
13385
13386 this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
13387
13388 this.$input
13389 .attr( {
13390 type: 'file',
13391 // this.selectButton is tabindexed
13392 tabindex: -1,
13393 // Infused input may have previously by
13394 // TabIndexed, so remove aria-disabled attr.
13395 'aria-disabled': null
13396 } );
13397
13398 if ( this.accept ) {
13399 this.$input.attr( 'accept', this.accept.join( ', ' ) );
13400 }
13401 if ( this.multiple ) {
13402 this.$input.attr( 'multiple', '' );
13403 }
13404 this.selectButton.$button.append( this.$input );
13405
13406 this.$element
13407 .addClass( 'oo-ui-selectFileInputWidget' )
13408 .append( this.fieldLayout.$element );
13409
13410 this.updateUI();
13411 };
13412
13413 /* Setup */
13414
13415 OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
13416
13417 /* Static properties */
13418
13419 // Set empty title so that browser default tooltips like "No file chosen" don't appear.
13420 // On SelectFileWidget this tooltip will often be incorrect, so create a consistent
13421 // experience on SelectFileInputWidget.
13422 OO.ui.SelectFileInputWidget.static.title = '';
13423
13424 /* Methods */
13425
13426 /**
13427 * Get the filename of the currently selected file.
13428 *
13429 * @return {string} Filename
13430 */
13431 OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
13432 if ( this.currentFiles.length ) {
13433 return this.currentFiles.map( function ( file ) {
13434 return file.name;
13435 } ).join( ', ' );
13436 } else {
13437 // Try to strip leading fakepath.
13438 return this.getValue().split( '\\' ).pop();
13439 }
13440 };
13441
13442 /**
13443 * @inheritdoc
13444 */
13445 OO.ui.SelectFileInputWidget.prototype.setValue = function ( value ) {
13446 if ( value === undefined ) {
13447 // Called during init, don't replace value if just infusing.
13448 return;
13449 }
13450 if ( value ) {
13451 // We need to update this.value, but without trying to modify
13452 // the DOM value, which would throw an exception.
13453 if ( this.value !== value ) {
13454 this.value = value;
13455 this.emit( 'change', this.value );
13456 }
13457 } else {
13458 this.currentFiles = [];
13459 // Parent method
13460 OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
13461 }
13462 };
13463
13464 /**
13465 * Handle file selection from the input.
13466 *
13467 * @protected
13468 * @param {jQuery.Event} e
13469 */
13470 OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
13471 this.currentFiles = this.filterFiles( e.target.files || [] );
13472 };
13473
13474 /**
13475 * Update the user interface when a file is selected or unselected.
13476 *
13477 * @protected
13478 */
13479 OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
13480 this.info.setValue( this.getFilename() );
13481 };
13482
13483 /**
13484 * Determine if we should accept this file.
13485 *
13486 * @private
13487 * @param {FileList|File[]} files Files to filter
13488 * @return {File[]} Filter files
13489 */
13490 OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
13491 var accept = this.accept;
13492
13493 function mimeAllowed( file ) {
13494 var i, mimeTest,
13495 mimeType = file.type;
13496
13497 if ( !accept || !mimeType ) {
13498 return true;
13499 }
13500
13501 for ( i = 0; i < accept.length; i++ ) {
13502 mimeTest = accept[ i ];
13503 if ( mimeTest === mimeType ) {
13504 return true;
13505 } else if ( mimeTest.substr( -2 ) === '/*' ) {
13506 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
13507 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
13508 return true;
13509 }
13510 }
13511 }
13512 return false;
13513 }
13514
13515 return Array.prototype.filter.call( files, mimeAllowed );
13516 };
13517
13518 /**
13519 * Handle info input change events
13520 *
13521 * The info widget can only be changed by the user
13522 * with the clear button.
13523 *
13524 * @private
13525 * @param {string} value
13526 */
13527 OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
13528 if ( value === '' ) {
13529 this.setValue( null );
13530 }
13531 };
13532
13533 /**
13534 * Handle key press events.
13535 *
13536 * @private
13537 * @param {jQuery.Event} e Key press event
13538 * @return {undefined|boolean} False to prevent default if event is handled
13539 */
13540 OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
13541 if ( !this.isDisabled() && this.$input &&
13542 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
13543 ) {
13544 // Emit a click to open the file selector.
13545 this.$input.trigger( 'click' );
13546 // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
13547 this.selectButton.onDocumentKeyUp( e );
13548 return false;
13549 }
13550 };
13551
13552 /**
13553 * @inheritdoc
13554 */
13555 OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
13556 // Parent method
13557 OO.ui.SelectFileInputWidget.parent.prototype.setDisabled.call( this, disabled );
13558
13559 this.selectButton.setDisabled( disabled );
13560 this.info.setDisabled( disabled );
13561
13562 return this;
13563 };
13564
13565 }( OO ) );
13566
13567 //# sourceMappingURL=oojs-ui-core.js.map.json