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