Merge "RCFilters: define consistent interface in ChangesListFilterGroup"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.24.0
3 * https://www.mediawiki.org/wiki/OOjs_UI
4 *
5 * Copyright 2011–2017 OOjs UI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2017-10-17T23:18:51Z
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 'oojsui-' + 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, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
273 }
274 };
275
276 /**
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
280 *
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @return {OO.ui.Element}
337 * The `OO.ui.Element` corresponding to this (infusable) document node.
338 */
339 OO.ui.infuse = function ( idOrNode ) {
340 return OO.ui.Element.static.infuse( idOrNode );
341 };
342
343 ( function () {
344 /**
345 * Message store for the default implementation of OO.ui.msg
346 *
347 * Environments that provide a localization system should not use this, but should override
348 * OO.ui.msg altogether.
349 *
350 * @private
351 */
352 var messages = {
353 // Tool tip for a button that moves items in a list down one place
354 'ooui-outline-control-move-down': 'Move item down',
355 // Tool tip for a button that moves items in a list up one place
356 'ooui-outline-control-move-up': 'Move item up',
357 // Tool tip for a button that removes items from a list
358 'ooui-outline-control-remove': 'Remove item',
359 // Label for the toolbar group that contains a list of all other available tools
360 'ooui-toolbar-more': 'More',
361 // Label for the fake tool that expands the full list of tools in a toolbar group
362 'ooui-toolgroup-expand': 'More',
363 // Label for the fake tool that collapses the full list of tools in a toolbar group
364 'ooui-toolgroup-collapse': 'Fewer',
365 // Default label for the tooltip for the button that removes a tag item
366 'ooui-item-remove': 'Remove',
367 // Default label for the accept button of a confirmation dialog
368 'ooui-dialog-message-accept': 'OK',
369 // Default label for the reject button of a confirmation dialog
370 'ooui-dialog-message-reject': 'Cancel',
371 // Title for process dialog error description
372 'ooui-dialog-process-error': 'Something went wrong',
373 // Label for process dialog dismiss error button, visible when describing errors
374 'ooui-dialog-process-dismiss': 'Dismiss',
375 // Label for process dialog retry action button, visible when describing only recoverable errors
376 'ooui-dialog-process-retry': 'Try again',
377 // Label for process dialog retry action button, visible when describing only warnings
378 'ooui-dialog-process-continue': 'Continue',
379 // Label for the file selection widget's select file button
380 'ooui-selectfile-button-select': 'Select a file',
381 // Label for the file selection widget if file selection is not supported
382 'ooui-selectfile-not-supported': 'File selection is not supported',
383 // Label for the file selection widget when no file is currently selected
384 'ooui-selectfile-placeholder': 'No file is selected',
385 // Label for the file selection widget's drop target
386 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
387 };
388
389 /**
390 * Get a localized message.
391 *
392 * After the message key, message parameters may optionally be passed. In the default implementation,
393 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
394 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
395 * they support unnamed, ordered message parameters.
396 *
397 * In environments that provide a localization system, this function should be overridden to
398 * return the message translated in the user's language. The default implementation always returns
399 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
400 * follows.
401 *
402 * @example
403 * var i, iLen, button,
404 * messagePath = 'oojs-ui/dist/i18n/',
405 * languages = [ $.i18n().locale, 'ur', 'en' ],
406 * languageMap = {};
407 *
408 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
409 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
410 * }
411 *
412 * $.i18n().load( languageMap ).done( function() {
413 * // Replace the built-in `msg` only once we've loaded the internationalization.
414 * // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
415 * // you put off creating any widgets until this promise is complete, no English
416 * // will be displayed.
417 * OO.ui.msg = $.i18n;
418 *
419 * // A button displaying "OK" in the default locale
420 * button = new OO.ui.ButtonWidget( {
421 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
422 * icon: 'check'
423 * } );
424 * $( 'body' ).append( button.$element );
425 *
426 * // A button displaying "OK" in Urdu
427 * $.i18n().locale = 'ur';
428 * button = new OO.ui.ButtonWidget( {
429 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
430 * icon: 'check'
431 * } );
432 * $( 'body' ).append( button.$element );
433 * } );
434 *
435 * @param {string} key Message key
436 * @param {...Mixed} [params] Message parameters
437 * @return {string} Translated message with parameters substituted
438 */
439 OO.ui.msg = function ( key ) {
440 var message = messages[ key ],
441 params = Array.prototype.slice.call( arguments, 1 );
442 if ( typeof message === 'string' ) {
443 // Perform $1 substitution
444 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
445 var i = parseInt( n, 10 );
446 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
447 } );
448 } else {
449 // Return placeholder if message not found
450 message = '[' + key + ']';
451 }
452 return message;
453 };
454 }() );
455
456 /**
457 * Package a message and arguments for deferred resolution.
458 *
459 * Use this when you are statically specifying a message and the message may not yet be present.
460 *
461 * @param {string} key Message key
462 * @param {...Mixed} [params] Message parameters
463 * @return {Function} Function that returns the resolved message when executed
464 */
465 OO.ui.deferMsg = function () {
466 var args = arguments;
467 return function () {
468 return OO.ui.msg.apply( OO.ui, args );
469 };
470 };
471
472 /**
473 * Resolve a message.
474 *
475 * If the message is a function it will be executed, otherwise it will pass through directly.
476 *
477 * @param {Function|string} msg Deferred message, or message text
478 * @return {string} Resolved message
479 */
480 OO.ui.resolveMsg = function ( msg ) {
481 if ( $.isFunction( msg ) ) {
482 return msg();
483 }
484 return msg;
485 };
486
487 /**
488 * @param {string} url
489 * @return {boolean}
490 */
491 OO.ui.isSafeUrl = function ( url ) {
492 // Keep this function in sync with php/Tag.php
493 var i, protocolWhitelist;
494
495 function stringStartsWith( haystack, needle ) {
496 return haystack.substr( 0, needle.length ) === needle;
497 }
498
499 protocolWhitelist = [
500 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
501 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
502 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
503 ];
504
505 if ( url === '' ) {
506 return true;
507 }
508
509 for ( i = 0; i < protocolWhitelist.length; i++ ) {
510 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
511 return true;
512 }
513 }
514
515 // This matches '//' too
516 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
517 return true;
518 }
519 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
520 return true;
521 }
522
523 return false;
524 };
525
526 /**
527 * Check if the user has a 'mobile' device.
528 *
529 * For our purposes this means the user is primarily using an
530 * on-screen keyboard, touch input instead of a mouse and may
531 * have a physically small display.
532 *
533 * It is left up to implementors to decide how to compute this
534 * so the default implementation always returns false.
535 *
536 * @return {boolean} Use is on a mobile device
537 */
538 OO.ui.isMobile = function () {
539 return false;
540 };
541
542 /*!
543 * Mixin namespace.
544 */
545
546 /**
547 * Namespace for OOjs UI mixins.
548 *
549 * Mixins are named according to the type of object they are intended to
550 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
551 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
552 * is intended to be mixed in to an instance of OO.ui.Widget.
553 *
554 * @class
555 * @singleton
556 */
557 OO.ui.mixin = {};
558
559 /**
560 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
561 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
562 * connected to them and can't be interacted with.
563 *
564 * @abstract
565 * @class
566 *
567 * @constructor
568 * @param {Object} [config] Configuration options
569 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
570 * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
571 * for an example.
572 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
573 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
574 * @cfg {string} [text] Text to insert
575 * @cfg {Array} [content] An array of content elements to append (after #text).
576 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
577 * Instances of OO.ui.Element will have their $element appended.
578 * @cfg {jQuery} [$content] Content elements to append (after #text).
579 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
580 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
581 * Data can also be specified with the #setData method.
582 */
583 OO.ui.Element = function OoUiElement( config ) {
584 if ( OO.ui.isDemo ) {
585 this.initialConfig = config;
586 }
587 // Configuration initialization
588 config = config || {};
589
590 // Properties
591 this.$ = $;
592 this.elementId = null;
593 this.visible = true;
594 this.data = config.data;
595 this.$element = config.$element ||
596 $( document.createElement( this.getTagName() ) );
597 this.elementGroup = null;
598
599 // Initialization
600 if ( Array.isArray( config.classes ) ) {
601 this.$element.addClass( config.classes.join( ' ' ) );
602 }
603 if ( config.id ) {
604 this.setElementId( config.id );
605 }
606 if ( config.text ) {
607 this.$element.text( config.text );
608 }
609 if ( config.content ) {
610 // The `content` property treats plain strings as text; use an
611 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
612 // appropriate $element appended.
613 this.$element.append( config.content.map( function ( v ) {
614 if ( typeof v === 'string' ) {
615 // Escape string so it is properly represented in HTML.
616 return document.createTextNode( v );
617 } else if ( v instanceof OO.ui.HtmlSnippet ) {
618 // Bypass escaping.
619 return v.toString();
620 } else if ( v instanceof OO.ui.Element ) {
621 return v.$element;
622 }
623 return v;
624 } ) );
625 }
626 if ( config.$content ) {
627 // The `$content` property treats plain strings as HTML.
628 this.$element.append( config.$content );
629 }
630 };
631
632 /* Setup */
633
634 OO.initClass( OO.ui.Element );
635
636 /* Static Properties */
637
638 /**
639 * The name of the HTML tag used by the element.
640 *
641 * The static value may be ignored if the #getTagName method is overridden.
642 *
643 * @static
644 * @inheritable
645 * @property {string}
646 */
647 OO.ui.Element.static.tagName = 'div';
648
649 /* Static Methods */
650
651 /**
652 * Reconstitute a JavaScript object corresponding to a widget created
653 * by the PHP implementation.
654 *
655 * @param {string|HTMLElement|jQuery} idOrNode
656 * A DOM id (if a string) or node for the widget to infuse.
657 * @return {OO.ui.Element}
658 * The `OO.ui.Element` corresponding to this (infusable) document node.
659 * For `Tag` objects emitted on the HTML side (used occasionally for content)
660 * the value returned is a newly-created Element wrapping around the existing
661 * DOM node.
662 */
663 OO.ui.Element.static.infuse = function ( idOrNode ) {
664 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
665 // Verify that the type matches up.
666 // FIXME: uncomment after T89721 is fixed, see T90929.
667 /*
668 if ( !( obj instanceof this['class'] ) ) {
669 throw new Error( 'Infusion type mismatch!' );
670 }
671 */
672 return obj;
673 };
674
675 /**
676 * Implementation helper for `infuse`; skips the type check and has an
677 * extra property so that only the top-level invocation touches the DOM.
678 *
679 * @private
680 * @param {string|HTMLElement|jQuery} idOrNode
681 * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
682 * when the top-level widget of this infusion is inserted into DOM,
683 * replacing the original node; or false for top-level invocation.
684 * @return {OO.ui.Element}
685 */
686 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
687 // look for a cached result of a previous infusion.
688 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
689 if ( typeof idOrNode === 'string' ) {
690 id = idOrNode;
691 $elem = $( document.getElementById( id ) );
692 } else {
693 $elem = $( idOrNode );
694 id = $elem.attr( 'id' );
695 }
696 if ( !$elem.length ) {
697 if ( typeof idOrNode === 'string' ) {
698 error = 'Widget not found: ' + idOrNode;
699 } else if ( idOrNode && idOrNode.selector ) {
700 error = 'Widget not found: ' + idOrNode.selector;
701 } else {
702 error = 'Widget not found';
703 }
704 throw new Error( error );
705 }
706 if ( $elem[ 0 ].oouiInfused ) {
707 $elem = $elem[ 0 ].oouiInfused;
708 }
709 data = $elem.data( 'ooui-infused' );
710 if ( data ) {
711 // cached!
712 if ( data === true ) {
713 throw new Error( 'Circular dependency! ' + id );
714 }
715 if ( domPromise ) {
716 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
717 state = data.constructor.static.gatherPreInfuseState( $elem, data );
718 // restore dynamic state after the new element is re-inserted into DOM under infused parent
719 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
720 infusedChildren = $elem.data( 'ooui-infused-children' );
721 if ( infusedChildren && infusedChildren.length ) {
722 infusedChildren.forEach( function ( data ) {
723 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
724 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
725 } );
726 }
727 }
728 return data;
729 }
730 data = $elem.attr( 'data-ooui' );
731 if ( !data ) {
732 throw new Error( 'No infusion data found: ' + id );
733 }
734 try {
735 data = JSON.parse( data );
736 } catch ( _ ) {
737 data = null;
738 }
739 if ( !( data && data._ ) ) {
740 throw new Error( 'No valid infusion data found: ' + id );
741 }
742 if ( data._ === 'Tag' ) {
743 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
744 return new OO.ui.Element( { $element: $elem } );
745 }
746 parts = data._.split( '.' );
747 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
748 if ( cls === undefined ) {
749 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
750 }
751
752 // Verify that we're creating an OO.ui.Element instance
753 parent = cls.parent;
754
755 while ( parent !== undefined ) {
756 if ( parent === OO.ui.Element ) {
757 // Safe
758 break;
759 }
760
761 parent = parent.parent;
762 }
763
764 if ( parent !== OO.ui.Element ) {
765 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
766 }
767
768 if ( domPromise === false ) {
769 top = $.Deferred();
770 domPromise = top.promise();
771 }
772 $elem.data( 'ooui-infused', true ); // prevent loops
773 data.id = id; // implicit
774 infusedChildren = [];
775 data = OO.copy( data, null, function deserialize( value ) {
776 var infused;
777 if ( OO.isPlainObject( value ) ) {
778 if ( value.tag ) {
779 infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
780 infusedChildren.push( infused );
781 // Flatten the structure
782 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
783 infused.$element.removeData( 'ooui-infused-children' );
784 return infused;
785 }
786 if ( value.html !== undefined ) {
787 return new OO.ui.HtmlSnippet( value.html );
788 }
789 }
790 } );
791 // allow widgets to reuse parts of the DOM
792 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
793 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
794 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
795 // rebuild widget
796 // eslint-disable-next-line new-cap
797 obj = new cls( data );
798 // now replace old DOM with this new DOM.
799 if ( top ) {
800 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
801 // so only mutate the DOM if we need to.
802 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
803 $elem.replaceWith( obj.$element );
804 // This element is now gone from the DOM, but if anyone is holding a reference to it,
805 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
806 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
807 $elem[ 0 ].oouiInfused = obj.$element;
808 }
809 top.resolve();
810 }
811 obj.$element.data( 'ooui-infused', obj );
812 obj.$element.data( 'ooui-infused-children', infusedChildren );
813 // set the 'data-ooui' attribute so we can identify infused widgets
814 obj.$element.attr( 'data-ooui', '' );
815 // restore dynamic state after the new element is inserted into DOM
816 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
817 return obj;
818 };
819
820 /**
821 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
822 *
823 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
824 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
825 * constructor, which will be given the enhanced config.
826 *
827 * @protected
828 * @param {HTMLElement} node
829 * @param {Object} config
830 * @return {Object}
831 */
832 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
833 return config;
834 };
835
836 /**
837 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
838 * (and its children) that represent an Element of the same class and the given configuration,
839 * generated by the PHP implementation.
840 *
841 * This method is called just before `node` is detached from the DOM. The return value of this
842 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
843 * is inserted into DOM to replace `node`.
844 *
845 * @protected
846 * @param {HTMLElement} node
847 * @param {Object} config
848 * @return {Object}
849 */
850 OO.ui.Element.static.gatherPreInfuseState = function () {
851 return {};
852 };
853
854 /**
855 * Get a jQuery function within a specific document.
856 *
857 * @static
858 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
859 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
860 * not in an iframe
861 * @return {Function} Bound jQuery function
862 */
863 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
864 function wrapper( selector ) {
865 return $( selector, wrapper.context );
866 }
867
868 wrapper.context = this.getDocument( context );
869
870 if ( $iframe ) {
871 wrapper.$iframe = $iframe;
872 }
873
874 return wrapper;
875 };
876
877 /**
878 * Get the document of an element.
879 *
880 * @static
881 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
882 * @return {HTMLDocument|null} Document object
883 */
884 OO.ui.Element.static.getDocument = function ( obj ) {
885 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
886 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
887 // Empty jQuery selections might have a context
888 obj.context ||
889 // HTMLElement
890 obj.ownerDocument ||
891 // Window
892 obj.document ||
893 // HTMLDocument
894 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
895 null;
896 };
897
898 /**
899 * Get the window of an element or document.
900 *
901 * @static
902 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
903 * @return {Window} Window object
904 */
905 OO.ui.Element.static.getWindow = function ( obj ) {
906 var doc = this.getDocument( obj );
907 return doc.defaultView;
908 };
909
910 /**
911 * Get the direction of an element or document.
912 *
913 * @static
914 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
915 * @return {string} Text direction, either 'ltr' or 'rtl'
916 */
917 OO.ui.Element.static.getDir = function ( obj ) {
918 var isDoc, isWin;
919
920 if ( obj instanceof jQuery ) {
921 obj = obj[ 0 ];
922 }
923 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
924 isWin = obj.document !== undefined;
925 if ( isDoc || isWin ) {
926 if ( isWin ) {
927 obj = obj.document;
928 }
929 obj = obj.body;
930 }
931 return $( obj ).css( 'direction' );
932 };
933
934 /**
935 * Get the offset between two frames.
936 *
937 * TODO: Make this function not use recursion.
938 *
939 * @static
940 * @param {Window} from Window of the child frame
941 * @param {Window} [to=window] Window of the parent frame
942 * @param {Object} [offset] Offset to start with, used internally
943 * @return {Object} Offset object, containing left and top properties
944 */
945 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
946 var i, len, frames, frame, rect;
947
948 if ( !to ) {
949 to = window;
950 }
951 if ( !offset ) {
952 offset = { top: 0, left: 0 };
953 }
954 if ( from.parent === from ) {
955 return offset;
956 }
957
958 // Get iframe element
959 frames = from.parent.document.getElementsByTagName( 'iframe' );
960 for ( i = 0, len = frames.length; i < len; i++ ) {
961 if ( frames[ i ].contentWindow === from ) {
962 frame = frames[ i ];
963 break;
964 }
965 }
966
967 // Recursively accumulate offset values
968 if ( frame ) {
969 rect = frame.getBoundingClientRect();
970 offset.left += rect.left;
971 offset.top += rect.top;
972 if ( from !== to ) {
973 this.getFrameOffset( from.parent, offset );
974 }
975 }
976 return offset;
977 };
978
979 /**
980 * Get the offset between two elements.
981 *
982 * The two elements may be in a different frame, but in that case the frame $element is in must
983 * be contained in the frame $anchor is in.
984 *
985 * @static
986 * @param {jQuery} $element Element whose position to get
987 * @param {jQuery} $anchor Element to get $element's position relative to
988 * @return {Object} Translated position coordinates, containing top and left properties
989 */
990 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
991 var iframe, iframePos,
992 pos = $element.offset(),
993 anchorPos = $anchor.offset(),
994 elementDocument = this.getDocument( $element ),
995 anchorDocument = this.getDocument( $anchor );
996
997 // If $element isn't in the same document as $anchor, traverse up
998 while ( elementDocument !== anchorDocument ) {
999 iframe = elementDocument.defaultView.frameElement;
1000 if ( !iframe ) {
1001 throw new Error( '$element frame is not contained in $anchor frame' );
1002 }
1003 iframePos = $( iframe ).offset();
1004 pos.left += iframePos.left;
1005 pos.top += iframePos.top;
1006 elementDocument = iframe.ownerDocument;
1007 }
1008 pos.left -= anchorPos.left;
1009 pos.top -= anchorPos.top;
1010 return pos;
1011 };
1012
1013 /**
1014 * Get element border sizes.
1015 *
1016 * @static
1017 * @param {HTMLElement} el Element to measure
1018 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1019 */
1020 OO.ui.Element.static.getBorders = function ( el ) {
1021 var doc = el.ownerDocument,
1022 win = doc.defaultView,
1023 style = win.getComputedStyle( el, null ),
1024 $el = $( el ),
1025 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1026 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1027 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1028 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1029
1030 return {
1031 top: top,
1032 left: left,
1033 bottom: bottom,
1034 right: right
1035 };
1036 };
1037
1038 /**
1039 * Get dimensions of an element or window.
1040 *
1041 * @static
1042 * @param {HTMLElement|Window} el Element to measure
1043 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1044 */
1045 OO.ui.Element.static.getDimensions = function ( el ) {
1046 var $el, $win,
1047 doc = el.ownerDocument || el.document,
1048 win = doc.defaultView;
1049
1050 if ( win === el || el === doc.documentElement ) {
1051 $win = $( win );
1052 return {
1053 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1054 scroll: {
1055 top: $win.scrollTop(),
1056 left: $win.scrollLeft()
1057 },
1058 scrollbar: { right: 0, bottom: 0 },
1059 rect: {
1060 top: 0,
1061 left: 0,
1062 bottom: $win.innerHeight(),
1063 right: $win.innerWidth()
1064 }
1065 };
1066 } else {
1067 $el = $( el );
1068 return {
1069 borders: this.getBorders( el ),
1070 scroll: {
1071 top: $el.scrollTop(),
1072 left: $el.scrollLeft()
1073 },
1074 scrollbar: {
1075 right: $el.innerWidth() - el.clientWidth,
1076 bottom: $el.innerHeight() - el.clientHeight
1077 },
1078 rect: el.getBoundingClientRect()
1079 };
1080 }
1081 };
1082
1083 /**
1084 * Get the number of pixels that an element's content is scrolled to the left.
1085 *
1086 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1087 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1088 *
1089 * This function smooths out browser inconsistencies (nicely described in the README at
1090 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1091 * with Firefox's 'scrollLeft', which seems the sanest.
1092 *
1093 * @static
1094 * @method
1095 * @param {HTMLElement|Window} el Element to measure
1096 * @return {number} Scroll position from the left.
1097 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1098 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1099 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1100 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1101 */
1102 OO.ui.Element.static.getScrollLeft = ( function () {
1103 var rtlScrollType = null;
1104
1105 function test() {
1106 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1107 definer = $definer[ 0 ];
1108
1109 $definer.appendTo( 'body' );
1110 if ( definer.scrollLeft > 0 ) {
1111 // Safari, Chrome
1112 rtlScrollType = 'default';
1113 } else {
1114 definer.scrollLeft = 1;
1115 if ( definer.scrollLeft === 0 ) {
1116 // Firefox, old Opera
1117 rtlScrollType = 'negative';
1118 } else {
1119 // Internet Explorer, Edge
1120 rtlScrollType = 'reverse';
1121 }
1122 }
1123 $definer.remove();
1124 }
1125
1126 return function getScrollLeft( el ) {
1127 var isRoot = el.window === el ||
1128 el === el.ownerDocument.body ||
1129 el === el.ownerDocument.documentElement,
1130 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1131 // All browsers use the correct scroll type ('negative') on the root, so don't
1132 // do any fixups when looking at the root element
1133 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1134
1135 if ( direction === 'rtl' ) {
1136 if ( rtlScrollType === null ) {
1137 test();
1138 }
1139 if ( rtlScrollType === 'reverse' ) {
1140 scrollLeft = -scrollLeft;
1141 } else if ( rtlScrollType === 'default' ) {
1142 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1143 }
1144 }
1145
1146 return scrollLeft;
1147 };
1148 }() );
1149
1150 /**
1151 * Get the root scrollable element of given element's document.
1152 *
1153 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1154 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1155 * lets us use 'body' or 'documentElement' based on what is working.
1156 *
1157 * https://code.google.com/p/chromium/issues/detail?id=303131
1158 *
1159 * @static
1160 * @param {HTMLElement} el Element to find root scrollable parent for
1161 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1162 * depending on browser
1163 */
1164 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1165 var scrollTop, body;
1166
1167 if ( OO.ui.scrollableElement === undefined ) {
1168 body = el.ownerDocument.body;
1169 scrollTop = body.scrollTop;
1170 body.scrollTop = 1;
1171
1172 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1173 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1174 if ( Math.round( body.scrollTop ) === 1 ) {
1175 body.scrollTop = scrollTop;
1176 OO.ui.scrollableElement = 'body';
1177 } else {
1178 OO.ui.scrollableElement = 'documentElement';
1179 }
1180 }
1181
1182 return el.ownerDocument[ OO.ui.scrollableElement ];
1183 };
1184
1185 /**
1186 * Get closest scrollable container.
1187 *
1188 * Traverses up until either a scrollable element or the root is reached, in which case the root
1189 * scrollable element will be returned (see #getRootScrollableElement).
1190 *
1191 * @static
1192 * @param {HTMLElement} el Element to find scrollable container for
1193 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1194 * @return {HTMLElement} Closest scrollable container
1195 */
1196 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1197 var i, val,
1198 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1199 // 'overflow-y' have different values, so we need to check the separate properties.
1200 props = [ 'overflow-x', 'overflow-y' ],
1201 $parent = $( el ).parent();
1202
1203 if ( dimension === 'x' || dimension === 'y' ) {
1204 props = [ 'overflow-' + dimension ];
1205 }
1206
1207 // Special case for the document root (which doesn't really have any scrollable container, since
1208 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1209 if ( $( el ).is( 'html, body' ) ) {
1210 return this.getRootScrollableElement( el );
1211 }
1212
1213 while ( $parent.length ) {
1214 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1215 return $parent[ 0 ];
1216 }
1217 i = props.length;
1218 while ( i-- ) {
1219 val = $parent.css( props[ i ] );
1220 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1221 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1222 // unintentionally perform a scroll in such case even if the application doesn't scroll
1223 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1224 // This could cause funny issues...
1225 if ( val === 'auto' || val === 'scroll' ) {
1226 return $parent[ 0 ];
1227 }
1228 }
1229 $parent = $parent.parent();
1230 }
1231 // The element is unattached... return something mostly sane
1232 return this.getRootScrollableElement( el );
1233 };
1234
1235 /**
1236 * Scroll element into view.
1237 *
1238 * @static
1239 * @param {HTMLElement} el Element to scroll into view
1240 * @param {Object} [config] Configuration options
1241 * @param {string} [config.duration='fast'] jQuery animation duration value
1242 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1243 * to scroll in both directions
1244 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1245 */
1246 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1247 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1248 deferred = $.Deferred();
1249
1250 // Configuration initialization
1251 config = config || {};
1252
1253 animations = {};
1254 container = this.getClosestScrollableContainer( el, config.direction );
1255 $container = $( container );
1256 elementDimensions = this.getDimensions( el );
1257 containerDimensions = this.getDimensions( container );
1258 $window = $( this.getWindow( el ) );
1259
1260 // Compute the element's position relative to the container
1261 if ( $container.is( 'html, body' ) ) {
1262 // If the scrollable container is the root, this is easy
1263 position = {
1264 top: elementDimensions.rect.top,
1265 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1266 left: elementDimensions.rect.left,
1267 right: $window.innerWidth() - elementDimensions.rect.right
1268 };
1269 } else {
1270 // Otherwise, we have to subtract el's coordinates from container's coordinates
1271 position = {
1272 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1273 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1274 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1275 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1276 };
1277 }
1278
1279 if ( !config.direction || config.direction === 'y' ) {
1280 if ( position.top < 0 ) {
1281 animations.scrollTop = containerDimensions.scroll.top + position.top;
1282 } else if ( position.top > 0 && position.bottom < 0 ) {
1283 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1284 }
1285 }
1286 if ( !config.direction || config.direction === 'x' ) {
1287 if ( position.left < 0 ) {
1288 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1289 } else if ( position.left > 0 && position.right < 0 ) {
1290 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1291 }
1292 }
1293 if ( !$.isEmptyObject( animations ) ) {
1294 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1295 $container.queue( function ( next ) {
1296 deferred.resolve();
1297 next();
1298 } );
1299 } else {
1300 deferred.resolve();
1301 }
1302 return deferred.promise();
1303 };
1304
1305 /**
1306 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1307 * and reserve space for them, because it probably doesn't.
1308 *
1309 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1310 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1311 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1312 * and then reattach (or show) them back.
1313 *
1314 * @static
1315 * @param {HTMLElement} el Element to reconsider the scrollbars on
1316 */
1317 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1318 var i, len, scrollLeft, scrollTop, nodes = [];
1319 // Save scroll position
1320 scrollLeft = el.scrollLeft;
1321 scrollTop = el.scrollTop;
1322 // Detach all children
1323 while ( el.firstChild ) {
1324 nodes.push( el.firstChild );
1325 el.removeChild( el.firstChild );
1326 }
1327 // Force reflow
1328 void el.offsetHeight;
1329 // Reattach all children
1330 for ( i = 0, len = nodes.length; i < len; i++ ) {
1331 el.appendChild( nodes[ i ] );
1332 }
1333 // Restore scroll position (no-op if scrollbars disappeared)
1334 el.scrollLeft = scrollLeft;
1335 el.scrollTop = scrollTop;
1336 };
1337
1338 /* Methods */
1339
1340 /**
1341 * Toggle visibility of an element.
1342 *
1343 * @param {boolean} [show] Make element visible, omit to toggle visibility
1344 * @fires visible
1345 * @chainable
1346 */
1347 OO.ui.Element.prototype.toggle = function ( show ) {
1348 show = show === undefined ? !this.visible : !!show;
1349
1350 if ( show !== this.isVisible() ) {
1351 this.visible = show;
1352 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1353 this.emit( 'toggle', show );
1354 }
1355
1356 return this;
1357 };
1358
1359 /**
1360 * Check if element is visible.
1361 *
1362 * @return {boolean} element is visible
1363 */
1364 OO.ui.Element.prototype.isVisible = function () {
1365 return this.visible;
1366 };
1367
1368 /**
1369 * Get element data.
1370 *
1371 * @return {Mixed} Element data
1372 */
1373 OO.ui.Element.prototype.getData = function () {
1374 return this.data;
1375 };
1376
1377 /**
1378 * Set element data.
1379 *
1380 * @param {Mixed} data Element data
1381 * @chainable
1382 */
1383 OO.ui.Element.prototype.setData = function ( data ) {
1384 this.data = data;
1385 return this;
1386 };
1387
1388 /**
1389 * Set the element has an 'id' attribute.
1390 *
1391 * @param {string} id
1392 * @chainable
1393 */
1394 OO.ui.Element.prototype.setElementId = function ( id ) {
1395 this.elementId = id;
1396 this.$element.attr( 'id', id );
1397 return this;
1398 };
1399
1400 /**
1401 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1402 * and return its value.
1403 *
1404 * @return {string}
1405 */
1406 OO.ui.Element.prototype.getElementId = function () {
1407 if ( this.elementId === null ) {
1408 this.setElementId( OO.ui.generateElementId() );
1409 }
1410 return this.elementId;
1411 };
1412
1413 /**
1414 * Check if element supports one or more methods.
1415 *
1416 * @param {string|string[]} methods Method or list of methods to check
1417 * @return {boolean} All methods are supported
1418 */
1419 OO.ui.Element.prototype.supports = function ( methods ) {
1420 var i, len,
1421 support = 0;
1422
1423 methods = Array.isArray( methods ) ? methods : [ methods ];
1424 for ( i = 0, len = methods.length; i < len; i++ ) {
1425 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1426 support++;
1427 }
1428 }
1429
1430 return methods.length === support;
1431 };
1432
1433 /**
1434 * Update the theme-provided classes.
1435 *
1436 * @localdoc This is called in element mixins and widget classes any time state changes.
1437 * Updating is debounced, minimizing overhead of changing multiple attributes and
1438 * guaranteeing that theme updates do not occur within an element's constructor
1439 */
1440 OO.ui.Element.prototype.updateThemeClasses = function () {
1441 OO.ui.theme.queueUpdateElementClasses( this );
1442 };
1443
1444 /**
1445 * Get the HTML tag name.
1446 *
1447 * Override this method to base the result on instance information.
1448 *
1449 * @return {string} HTML tag name
1450 */
1451 OO.ui.Element.prototype.getTagName = function () {
1452 return this.constructor.static.tagName;
1453 };
1454
1455 /**
1456 * Check if the element is attached to the DOM
1457 *
1458 * @return {boolean} The element is attached to the DOM
1459 */
1460 OO.ui.Element.prototype.isElementAttached = function () {
1461 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1462 };
1463
1464 /**
1465 * Get the DOM document.
1466 *
1467 * @return {HTMLDocument} Document object
1468 */
1469 OO.ui.Element.prototype.getElementDocument = function () {
1470 // Don't cache this in other ways either because subclasses could can change this.$element
1471 return OO.ui.Element.static.getDocument( this.$element );
1472 };
1473
1474 /**
1475 * Get the DOM window.
1476 *
1477 * @return {Window} Window object
1478 */
1479 OO.ui.Element.prototype.getElementWindow = function () {
1480 return OO.ui.Element.static.getWindow( this.$element );
1481 };
1482
1483 /**
1484 * Get closest scrollable container.
1485 *
1486 * @return {HTMLElement} Closest scrollable container
1487 */
1488 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1489 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1490 };
1491
1492 /**
1493 * Get group element is in.
1494 *
1495 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1496 */
1497 OO.ui.Element.prototype.getElementGroup = function () {
1498 return this.elementGroup;
1499 };
1500
1501 /**
1502 * Set group element is in.
1503 *
1504 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1505 * @chainable
1506 */
1507 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1508 this.elementGroup = group;
1509 return this;
1510 };
1511
1512 /**
1513 * Scroll element into view.
1514 *
1515 * @param {Object} [config] Configuration options
1516 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1517 */
1518 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1519 if (
1520 !this.isElementAttached() ||
1521 !this.isVisible() ||
1522 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1523 ) {
1524 return $.Deferred().resolve();
1525 }
1526 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1527 };
1528
1529 /**
1530 * Restore the pre-infusion dynamic state for this widget.
1531 *
1532 * This method is called after #$element has been inserted into DOM. The parameter is the return
1533 * value of #gatherPreInfuseState.
1534 *
1535 * @protected
1536 * @param {Object} state
1537 */
1538 OO.ui.Element.prototype.restorePreInfuseState = function () {
1539 };
1540
1541 /**
1542 * Wraps an HTML snippet for use with configuration values which default
1543 * to strings. This bypasses the default html-escaping done to string
1544 * values.
1545 *
1546 * @class
1547 *
1548 * @constructor
1549 * @param {string} [content] HTML content
1550 */
1551 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1552 // Properties
1553 this.content = content;
1554 };
1555
1556 /* Setup */
1557
1558 OO.initClass( OO.ui.HtmlSnippet );
1559
1560 /* Methods */
1561
1562 /**
1563 * Render into HTML.
1564 *
1565 * @return {string} Unchanged HTML snippet.
1566 */
1567 OO.ui.HtmlSnippet.prototype.toString = function () {
1568 return this.content;
1569 };
1570
1571 /**
1572 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1573 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1574 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1575 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1576 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1577 *
1578 * @abstract
1579 * @class
1580 * @extends OO.ui.Element
1581 * @mixins OO.EventEmitter
1582 *
1583 * @constructor
1584 * @param {Object} [config] Configuration options
1585 */
1586 OO.ui.Layout = function OoUiLayout( config ) {
1587 // Configuration initialization
1588 config = config || {};
1589
1590 // Parent constructor
1591 OO.ui.Layout.parent.call( this, config );
1592
1593 // Mixin constructors
1594 OO.EventEmitter.call( this );
1595
1596 // Initialization
1597 this.$element.addClass( 'oo-ui-layout' );
1598 };
1599
1600 /* Setup */
1601
1602 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1603 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1604
1605 /**
1606 * Widgets are compositions of one or more OOjs UI elements that users can both view
1607 * and interact with. All widgets can be configured and modified via a standard API,
1608 * and their state can change dynamically according to a model.
1609 *
1610 * @abstract
1611 * @class
1612 * @extends OO.ui.Element
1613 * @mixins OO.EventEmitter
1614 *
1615 * @constructor
1616 * @param {Object} [config] Configuration options
1617 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1618 * appearance reflects this state.
1619 */
1620 OO.ui.Widget = function OoUiWidget( config ) {
1621 // Initialize config
1622 config = $.extend( { disabled: false }, config );
1623
1624 // Parent constructor
1625 OO.ui.Widget.parent.call( this, config );
1626
1627 // Mixin constructors
1628 OO.EventEmitter.call( this );
1629
1630 // Properties
1631 this.disabled = null;
1632 this.wasDisabled = null;
1633
1634 // Initialization
1635 this.$element.addClass( 'oo-ui-widget' );
1636 this.setDisabled( !!config.disabled );
1637 };
1638
1639 /* Setup */
1640
1641 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1642 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1643
1644 /* Events */
1645
1646 /**
1647 * @event disable
1648 *
1649 * A 'disable' event is emitted when the disabled state of the widget changes
1650 * (i.e. on disable **and** enable).
1651 *
1652 * @param {boolean} disabled Widget is disabled
1653 */
1654
1655 /**
1656 * @event toggle
1657 *
1658 * A 'toggle' event is emitted when the visibility of the widget changes.
1659 *
1660 * @param {boolean} visible Widget is visible
1661 */
1662
1663 /* Methods */
1664
1665 /**
1666 * Check if the widget is disabled.
1667 *
1668 * @return {boolean} Widget is disabled
1669 */
1670 OO.ui.Widget.prototype.isDisabled = function () {
1671 return this.disabled;
1672 };
1673
1674 /**
1675 * Set the 'disabled' state of the widget.
1676 *
1677 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1678 *
1679 * @param {boolean} disabled Disable widget
1680 * @chainable
1681 */
1682 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1683 var isDisabled;
1684
1685 this.disabled = !!disabled;
1686 isDisabled = this.isDisabled();
1687 if ( isDisabled !== this.wasDisabled ) {
1688 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1689 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1690 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1691 this.emit( 'disable', isDisabled );
1692 this.updateThemeClasses();
1693 }
1694 this.wasDisabled = isDisabled;
1695
1696 return this;
1697 };
1698
1699 /**
1700 * Update the disabled state, in case of changes in parent widget.
1701 *
1702 * @chainable
1703 */
1704 OO.ui.Widget.prototype.updateDisabled = function () {
1705 this.setDisabled( this.disabled );
1706 return this;
1707 };
1708
1709 /**
1710 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1711 * value.
1712 *
1713 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1714 * instead.
1715 *
1716 * @return {string|null} The ID of the labelable element
1717 */
1718 OO.ui.Widget.prototype.getInputId = function () {
1719 return null;
1720 };
1721
1722 /**
1723 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1724 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1725 * override this method to provide intuitive, accessible behavior.
1726 *
1727 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1728 * Individual widgets may override it too.
1729 *
1730 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1731 * directly.
1732 */
1733 OO.ui.Widget.prototype.simulateLabelClick = function () {
1734 };
1735
1736 /**
1737 * Theme logic.
1738 *
1739 * @abstract
1740 * @class
1741 *
1742 * @constructor
1743 */
1744 OO.ui.Theme = function OoUiTheme() {
1745 this.elementClassesQueue = [];
1746 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1747 };
1748
1749 /* Setup */
1750
1751 OO.initClass( OO.ui.Theme );
1752
1753 /* Methods */
1754
1755 /**
1756 * Get a list of classes to be applied to a widget.
1757 *
1758 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1759 * otherwise state transitions will not work properly.
1760 *
1761 * @param {OO.ui.Element} element Element for which to get classes
1762 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1763 */
1764 OO.ui.Theme.prototype.getElementClasses = function () {
1765 return { on: [], off: [] };
1766 };
1767
1768 /**
1769 * Update CSS classes provided by the theme.
1770 *
1771 * For elements with theme logic hooks, this should be called any time there's a state change.
1772 *
1773 * @param {OO.ui.Element} element Element for which to update classes
1774 */
1775 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1776 var $elements = $( [] ),
1777 classes = this.getElementClasses( element );
1778
1779 if ( element.$icon ) {
1780 $elements = $elements.add( element.$icon );
1781 }
1782 if ( element.$indicator ) {
1783 $elements = $elements.add( element.$indicator );
1784 }
1785
1786 $elements
1787 .removeClass( classes.off.join( ' ' ) )
1788 .addClass( classes.on.join( ' ' ) );
1789 };
1790
1791 /**
1792 * @private
1793 */
1794 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1795 var i;
1796 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1797 this.updateElementClasses( this.elementClassesQueue[ i ] );
1798 }
1799 // Clear the queue
1800 this.elementClassesQueue = [];
1801 };
1802
1803 /**
1804 * Queue #updateElementClasses to be called for this element.
1805 *
1806 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1807 * to make them synchronous.
1808 *
1809 * @param {OO.ui.Element} element Element for which to update classes
1810 */
1811 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1812 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1813 // the most common case (this method is often called repeatedly for the same element).
1814 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1815 return;
1816 }
1817 this.elementClassesQueue.push( element );
1818 this.debouncedUpdateQueuedElementClasses();
1819 };
1820
1821 /**
1822 * Get the transition duration in milliseconds for dialogs opening/closing
1823 *
1824 * The dialog should be fully rendered this many milliseconds after the
1825 * ready process has executed.
1826 *
1827 * @return {number} Transition duration in milliseconds
1828 */
1829 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1830 return 0;
1831 };
1832
1833 /**
1834 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1835 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1836 * order in which users will navigate through the focusable elements via the "tab" key.
1837 *
1838 * @example
1839 * // TabIndexedElement is mixed into the ButtonWidget class
1840 * // to provide a tabIndex property.
1841 * var button1 = new OO.ui.ButtonWidget( {
1842 * label: 'fourth',
1843 * tabIndex: 4
1844 * } );
1845 * var button2 = new OO.ui.ButtonWidget( {
1846 * label: 'second',
1847 * tabIndex: 2
1848 * } );
1849 * var button3 = new OO.ui.ButtonWidget( {
1850 * label: 'third',
1851 * tabIndex: 3
1852 * } );
1853 * var button4 = new OO.ui.ButtonWidget( {
1854 * label: 'first',
1855 * tabIndex: 1
1856 * } );
1857 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1858 *
1859 * @abstract
1860 * @class
1861 *
1862 * @constructor
1863 * @param {Object} [config] Configuration options
1864 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1865 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1866 * functionality will be applied to it instead.
1867 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1868 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1869 * to remove the element from the tab-navigation flow.
1870 */
1871 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1872 // Configuration initialization
1873 config = $.extend( { tabIndex: 0 }, config );
1874
1875 // Properties
1876 this.$tabIndexed = null;
1877 this.tabIndex = null;
1878
1879 // Events
1880 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1881
1882 // Initialization
1883 this.setTabIndex( config.tabIndex );
1884 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1885 };
1886
1887 /* Setup */
1888
1889 OO.initClass( OO.ui.mixin.TabIndexedElement );
1890
1891 /* Methods */
1892
1893 /**
1894 * Set the element that should use the tabindex functionality.
1895 *
1896 * This method is used to retarget a tabindex mixin so that its functionality applies
1897 * to the specified element. If an element is currently using the functionality, the mixin’s
1898 * effect on that element is removed before the new element is set up.
1899 *
1900 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1901 * @chainable
1902 */
1903 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1904 var tabIndex = this.tabIndex;
1905 // Remove attributes from old $tabIndexed
1906 this.setTabIndex( null );
1907 // Force update of new $tabIndexed
1908 this.$tabIndexed = $tabIndexed;
1909 this.tabIndex = tabIndex;
1910 return this.updateTabIndex();
1911 };
1912
1913 /**
1914 * Set the value of the tabindex.
1915 *
1916 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1917 * @chainable
1918 */
1919 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1920 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1921
1922 if ( this.tabIndex !== tabIndex ) {
1923 this.tabIndex = tabIndex;
1924 this.updateTabIndex();
1925 }
1926
1927 return this;
1928 };
1929
1930 /**
1931 * Update the `tabindex` attribute, in case of changes to tab index or
1932 * disabled state.
1933 *
1934 * @private
1935 * @chainable
1936 */
1937 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1938 if ( this.$tabIndexed ) {
1939 if ( this.tabIndex !== null ) {
1940 // Do not index over disabled elements
1941 this.$tabIndexed.attr( {
1942 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1943 // Support: ChromeVox and NVDA
1944 // These do not seem to inherit aria-disabled from parent elements
1945 'aria-disabled': this.isDisabled().toString()
1946 } );
1947 } else {
1948 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1949 }
1950 }
1951 return this;
1952 };
1953
1954 /**
1955 * Handle disable events.
1956 *
1957 * @private
1958 * @param {boolean} disabled Element is disabled
1959 */
1960 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1961 this.updateTabIndex();
1962 };
1963
1964 /**
1965 * Get the value of the tabindex.
1966 *
1967 * @return {number|null} Tabindex value
1968 */
1969 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
1970 return this.tabIndex;
1971 };
1972
1973 /**
1974 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
1975 *
1976 * If the element already has an ID then that is returned, otherwise unique ID is
1977 * generated, set on the element, and returned.
1978 *
1979 * @return {string|null} The ID of the focusable element
1980 */
1981 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
1982 var id;
1983
1984 if ( !this.$tabIndexed ) {
1985 return null;
1986 }
1987 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
1988 return null;
1989 }
1990
1991 id = this.$tabIndexed.attr( 'id' );
1992 if ( id === undefined ) {
1993 id = OO.ui.generateElementId();
1994 this.$tabIndexed.attr( 'id', id );
1995 }
1996
1997 return id;
1998 };
1999
2000 /**
2001 * Whether the node is 'labelable' according to the HTML spec
2002 * (i.e., whether it can be interacted with through a `<label for="…">`).
2003 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2004 *
2005 * @private
2006 * @param {jQuery} $node
2007 * @return {boolean}
2008 */
2009 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2010 var
2011 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2012 tagName = $node.prop( 'tagName' ).toLowerCase();
2013
2014 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2015 return true;
2016 }
2017 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2018 return true;
2019 }
2020 return false;
2021 };
2022
2023 /**
2024 * Focus this element.
2025 *
2026 * @chainable
2027 */
2028 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2029 if ( !this.isDisabled() ) {
2030 this.$tabIndexed.focus();
2031 }
2032 return this;
2033 };
2034
2035 /**
2036 * Blur this element.
2037 *
2038 * @chainable
2039 */
2040 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2041 this.$tabIndexed.blur();
2042 return this;
2043 };
2044
2045 /**
2046 * @inheritdoc OO.ui.Widget
2047 */
2048 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2049 this.focus();
2050 };
2051
2052 /**
2053 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2054 * interface element that can be configured with access keys for accessibility.
2055 * See the [OOjs UI documentation on MediaWiki] [1] for examples.
2056 *
2057 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons
2058 *
2059 * @abstract
2060 * @class
2061 *
2062 * @constructor
2063 * @param {Object} [config] Configuration options
2064 * @cfg {jQuery} [$button] The button element created by the class.
2065 * If this configuration is omitted, the button element will use a generated `<a>`.
2066 * @cfg {boolean} [framed=true] Render the button with a frame
2067 */
2068 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2069 // Configuration initialization
2070 config = config || {};
2071
2072 // Properties
2073 this.$button = null;
2074 this.framed = null;
2075 this.active = config.active !== undefined && config.active;
2076 this.onMouseUpHandler = this.onMouseUp.bind( this );
2077 this.onMouseDownHandler = this.onMouseDown.bind( this );
2078 this.onKeyDownHandler = this.onKeyDown.bind( this );
2079 this.onKeyUpHandler = this.onKeyUp.bind( this );
2080 this.onClickHandler = this.onClick.bind( this );
2081 this.onKeyPressHandler = this.onKeyPress.bind( this );
2082
2083 // Initialization
2084 this.$element.addClass( 'oo-ui-buttonElement' );
2085 this.toggleFramed( config.framed === undefined || config.framed );
2086 this.setButtonElement( config.$button || $( '<a>' ) );
2087 };
2088
2089 /* Setup */
2090
2091 OO.initClass( OO.ui.mixin.ButtonElement );
2092
2093 /* Static Properties */
2094
2095 /**
2096 * Cancel mouse down events.
2097 *
2098 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2099 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2100 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2101 * parent widget.
2102 *
2103 * @static
2104 * @inheritable
2105 * @property {boolean}
2106 */
2107 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2108
2109 /* Events */
2110
2111 /**
2112 * A 'click' event is emitted when the button element is clicked.
2113 *
2114 * @event click
2115 */
2116
2117 /* Methods */
2118
2119 /**
2120 * Set the button element.
2121 *
2122 * This method is used to retarget a button mixin so that its functionality applies to
2123 * the specified button element instead of the one created by the class. If a button element
2124 * is already set, the method will remove the mixin’s effect on that element.
2125 *
2126 * @param {jQuery} $button Element to use as button
2127 */
2128 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2129 if ( this.$button ) {
2130 this.$button
2131 .removeClass( 'oo-ui-buttonElement-button' )
2132 .removeAttr( 'role accesskey' )
2133 .off( {
2134 mousedown: this.onMouseDownHandler,
2135 keydown: this.onKeyDownHandler,
2136 click: this.onClickHandler,
2137 keypress: this.onKeyPressHandler
2138 } );
2139 }
2140
2141 this.$button = $button
2142 .addClass( 'oo-ui-buttonElement-button' )
2143 .on( {
2144 mousedown: this.onMouseDownHandler,
2145 keydown: this.onKeyDownHandler,
2146 click: this.onClickHandler,
2147 keypress: this.onKeyPressHandler
2148 } );
2149
2150 // Add `role="button"` on `<a>` elements, where it's needed
2151 // `toUppercase()` is added for XHTML documents
2152 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2153 this.$button.attr( 'role', 'button' );
2154 }
2155 };
2156
2157 /**
2158 * Handles mouse down events.
2159 *
2160 * @protected
2161 * @param {jQuery.Event} e Mouse down event
2162 */
2163 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2164 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2165 return;
2166 }
2167 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2168 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2169 // reliably remove the pressed class
2170 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2171 // Prevent change of focus unless specifically configured otherwise
2172 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2173 return false;
2174 }
2175 };
2176
2177 /**
2178 * Handles mouse up events.
2179 *
2180 * @protected
2181 * @param {MouseEvent} e Mouse up event
2182 */
2183 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2184 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2185 return;
2186 }
2187 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2188 // Stop listening for mouseup, since we only needed this once
2189 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2190 };
2191
2192 /**
2193 * Handles mouse click events.
2194 *
2195 * @protected
2196 * @param {jQuery.Event} e Mouse click event
2197 * @fires click
2198 */
2199 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2200 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2201 if ( this.emit( 'click' ) ) {
2202 return false;
2203 }
2204 }
2205 };
2206
2207 /**
2208 * Handles key down events.
2209 *
2210 * @protected
2211 * @param {jQuery.Event} e Key down event
2212 */
2213 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2214 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2215 return;
2216 }
2217 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2218 // Run the keyup handler no matter where the key is when the button is let go, so we can
2219 // reliably remove the pressed class
2220 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2221 };
2222
2223 /**
2224 * Handles key up events.
2225 *
2226 * @protected
2227 * @param {KeyboardEvent} e Key up event
2228 */
2229 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2230 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2231 return;
2232 }
2233 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2234 // Stop listening for keyup, since we only needed this once
2235 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2236 };
2237
2238 /**
2239 * Handles key press events.
2240 *
2241 * @protected
2242 * @param {jQuery.Event} e Key press event
2243 * @fires click
2244 */
2245 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2246 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2247 if ( this.emit( 'click' ) ) {
2248 return false;
2249 }
2250 }
2251 };
2252
2253 /**
2254 * Check if button has a frame.
2255 *
2256 * @return {boolean} Button is framed
2257 */
2258 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2259 return this.framed;
2260 };
2261
2262 /**
2263 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2264 *
2265 * @param {boolean} [framed] Make button framed, omit to toggle
2266 * @chainable
2267 */
2268 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2269 framed = framed === undefined ? !this.framed : !!framed;
2270 if ( framed !== this.framed ) {
2271 this.framed = framed;
2272 this.$element
2273 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2274 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2275 this.updateThemeClasses();
2276 }
2277
2278 return this;
2279 };
2280
2281 /**
2282 * Set the button's active state.
2283 *
2284 * The active state can be set on:
2285 *
2286 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2287 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2288 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2289 *
2290 * @protected
2291 * @param {boolean} value Make button active
2292 * @chainable
2293 */
2294 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2295 this.active = !!value;
2296 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2297 this.updateThemeClasses();
2298 return this;
2299 };
2300
2301 /**
2302 * Check if the button is active
2303 *
2304 * @protected
2305 * @return {boolean} The button is active
2306 */
2307 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2308 return this.active;
2309 };
2310
2311 /**
2312 * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2313 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2314 * items from the group is done through the interface the class provides.
2315 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
2316 *
2317 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups
2318 *
2319 * @abstract
2320 * @mixins OO.EmitterList
2321 * @class
2322 *
2323 * @constructor
2324 * @param {Object} [config] Configuration options
2325 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2326 * is omitted, the group element will use a generated `<div>`.
2327 */
2328 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2329 // Configuration initialization
2330 config = config || {};
2331
2332 // Mixin constructors
2333 OO.EmitterList.call( this, config );
2334
2335 // Properties
2336 this.$group = null;
2337
2338 // Initialization
2339 this.setGroupElement( config.$group || $( '<div>' ) );
2340 };
2341
2342 /* Setup */
2343
2344 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2345
2346 /* Events */
2347
2348 /**
2349 * @event change
2350 *
2351 * A change event is emitted when the set of selected items changes.
2352 *
2353 * @param {OO.ui.Element[]} items Items currently in the group
2354 */
2355
2356 /* Methods */
2357
2358 /**
2359 * Set the group element.
2360 *
2361 * If an element is already set, items will be moved to the new element.
2362 *
2363 * @param {jQuery} $group Element to use as group
2364 */
2365 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2366 var i, len;
2367
2368 this.$group = $group;
2369 for ( i = 0, len = this.items.length; i < len; i++ ) {
2370 this.$group.append( this.items[ i ].$element );
2371 }
2372 };
2373
2374 /**
2375 * Get an item by its data.
2376 *
2377 * Only the first item with matching data will be returned. To return all matching items,
2378 * use the #getItemsFromData method.
2379 *
2380 * @param {Object} data Item data to search for
2381 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2382 */
2383 OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
2384 var i, len, item,
2385 hash = OO.getHash( data );
2386
2387 for ( i = 0, len = this.items.length; i < len; i++ ) {
2388 item = this.items[ i ];
2389 if ( hash === OO.getHash( item.getData() ) ) {
2390 return item;
2391 }
2392 }
2393
2394 return null;
2395 };
2396
2397 /**
2398 * Get items by their data.
2399 *
2400 * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
2401 *
2402 * @param {Object} data Item data to search for
2403 * @return {OO.ui.Element[]} Items with equivalent data
2404 */
2405 OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
2406 var i, len, item,
2407 hash = OO.getHash( data ),
2408 items = [];
2409
2410 for ( i = 0, len = this.items.length; i < len; i++ ) {
2411 item = this.items[ i ];
2412 if ( hash === OO.getHash( item.getData() ) ) {
2413 items.push( item );
2414 }
2415 }
2416
2417 return items;
2418 };
2419
2420 /**
2421 * Add items to the group.
2422 *
2423 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2424 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2425 *
2426 * @param {OO.ui.Element[]} items An array of items to add to the group
2427 * @param {number} [index] Index of the insertion point
2428 * @chainable
2429 */
2430 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2431 // Mixin method
2432 OO.EmitterList.prototype.addItems.call( this, items, index );
2433
2434 this.emit( 'change', this.getItems() );
2435 return this;
2436 };
2437
2438 /**
2439 * @inheritdoc
2440 */
2441 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2442 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2443 this.insertItemElements( items, newIndex );
2444
2445 // Mixin method
2446 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2447
2448 return newIndex;
2449 };
2450
2451 /**
2452 * @inheritdoc
2453 */
2454 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2455 item.setElementGroup( this );
2456 this.insertItemElements( item, index );
2457
2458 // Mixin method
2459 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2460
2461 return index;
2462 };
2463
2464 /**
2465 * Insert elements into the group
2466 *
2467 * @private
2468 * @param {OO.ui.Element} itemWidget Item to insert
2469 * @param {number} index Insertion index
2470 */
2471 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2472 if ( index === undefined || index < 0 || index >= this.items.length ) {
2473 this.$group.append( itemWidget.$element );
2474 } else if ( index === 0 ) {
2475 this.$group.prepend( itemWidget.$element );
2476 } else {
2477 this.items[ index ].$element.before( itemWidget.$element );
2478 }
2479 };
2480
2481 /**
2482 * Remove the specified items from a group.
2483 *
2484 * Removed items are detached (not removed) from the DOM so that they may be reused.
2485 * To remove all items from a group, you may wish to use the #clearItems method instead.
2486 *
2487 * @param {OO.ui.Element[]} items An array of items to remove
2488 * @chainable
2489 */
2490 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2491 var i, len, item, index;
2492
2493 // Remove specific items elements
2494 for ( i = 0, len = items.length; i < len; i++ ) {
2495 item = items[ i ];
2496 index = this.items.indexOf( item );
2497 if ( index !== -1 ) {
2498 item.setElementGroup( null );
2499 item.$element.detach();
2500 }
2501 }
2502
2503 // Mixin method
2504 OO.EmitterList.prototype.removeItems.call( this, items );
2505
2506 this.emit( 'change', this.getItems() );
2507 return this;
2508 };
2509
2510 /**
2511 * Clear all items from the group.
2512 *
2513 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2514 * To remove only a subset of items from a group, use the #removeItems method.
2515 *
2516 * @chainable
2517 */
2518 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2519 var i, len;
2520
2521 // Remove all item elements
2522 for ( i = 0, len = this.items.length; i < len; i++ ) {
2523 this.items[ i ].setElementGroup( null );
2524 this.items[ i ].$element.detach();
2525 }
2526
2527 // Mixin method
2528 OO.EmitterList.prototype.clearItems.call( this );
2529
2530 this.emit( 'change', this.getItems() );
2531 return this;
2532 };
2533
2534 /**
2535 * IconElement is often mixed into other classes to generate an icon.
2536 * Icons are graphics, about the size of normal text. They are used to aid the user
2537 * in locating a control or to convey information in a space-efficient way. See the
2538 * [OOjs UI documentation on MediaWiki] [1] for a list of icons
2539 * included in the library.
2540 *
2541 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2542 *
2543 * @abstract
2544 * @class
2545 *
2546 * @constructor
2547 * @param {Object} [config] Configuration options
2548 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2549 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2550 * the icon element be set to an existing icon instead of the one generated by this class, set a
2551 * value using a jQuery selection. For example:
2552 *
2553 * // Use a <div> tag instead of a <span>
2554 * $icon: $("<div>")
2555 * // Use an existing icon element instead of the one generated by the class
2556 * $icon: this.$element
2557 * // Use an icon element from a child widget
2558 * $icon: this.childwidget.$element
2559 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2560 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2561 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2562 * by the user's language.
2563 *
2564 * Example of an i18n map:
2565 *
2566 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2567 * See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
2568 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2569 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2570 * text. The icon title is displayed when users move the mouse over the icon.
2571 */
2572 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2573 // Configuration initialization
2574 config = config || {};
2575
2576 // Properties
2577 this.$icon = null;
2578 this.icon = null;
2579 this.iconTitle = null;
2580
2581 // Initialization
2582 this.setIcon( config.icon || this.constructor.static.icon );
2583 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2584 this.setIconElement( config.$icon || $( '<span>' ) );
2585 };
2586
2587 /* Setup */
2588
2589 OO.initClass( OO.ui.mixin.IconElement );
2590
2591 /* Static Properties */
2592
2593 /**
2594 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2595 * for i18n purposes and contains a `default` icon name and additional names keyed by
2596 * language code. The `default` name is used when no icon is keyed by the user's language.
2597 *
2598 * Example of an i18n map:
2599 *
2600 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2601 *
2602 * Note: the static property will be overridden if the #icon configuration is used.
2603 *
2604 * @static
2605 * @inheritable
2606 * @property {Object|string}
2607 */
2608 OO.ui.mixin.IconElement.static.icon = null;
2609
2610 /**
2611 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2612 * function that returns title text, or `null` for no title.
2613 *
2614 * The static property will be overridden if the #iconTitle configuration is used.
2615 *
2616 * @static
2617 * @inheritable
2618 * @property {string|Function|null}
2619 */
2620 OO.ui.mixin.IconElement.static.iconTitle = null;
2621
2622 /* Methods */
2623
2624 /**
2625 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2626 * applies to the specified icon element instead of the one created by the class. If an icon
2627 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2628 * and mixin methods will no longer affect the element.
2629 *
2630 * @param {jQuery} $icon Element to use as icon
2631 */
2632 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2633 if ( this.$icon ) {
2634 this.$icon
2635 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2636 .removeAttr( 'title' );
2637 }
2638
2639 this.$icon = $icon
2640 .addClass( 'oo-ui-iconElement-icon' )
2641 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2642 if ( this.iconTitle !== null ) {
2643 this.$icon.attr( 'title', this.iconTitle );
2644 }
2645
2646 this.updateThemeClasses();
2647 };
2648
2649 /**
2650 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2651 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2652 * for an example.
2653 *
2654 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2655 * by language code, or `null` to remove the icon.
2656 * @chainable
2657 */
2658 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2659 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2660 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2661
2662 if ( this.icon !== icon ) {
2663 if ( this.$icon ) {
2664 if ( this.icon !== null ) {
2665 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2666 }
2667 if ( icon !== null ) {
2668 this.$icon.addClass( 'oo-ui-icon-' + icon );
2669 }
2670 }
2671 this.icon = icon;
2672 }
2673
2674 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2675 this.updateThemeClasses();
2676
2677 return this;
2678 };
2679
2680 /**
2681 * Set the icon title. Use `null` to remove the title.
2682 *
2683 * @param {string|Function|null} iconTitle A text string used as the icon title,
2684 * a function that returns title text, or `null` for no title.
2685 * @chainable
2686 */
2687 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2688 iconTitle =
2689 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2690 OO.ui.resolveMsg( iconTitle ) : null;
2691
2692 if ( this.iconTitle !== iconTitle ) {
2693 this.iconTitle = iconTitle;
2694 if ( this.$icon ) {
2695 if ( this.iconTitle !== null ) {
2696 this.$icon.attr( 'title', iconTitle );
2697 } else {
2698 this.$icon.removeAttr( 'title' );
2699 }
2700 }
2701 }
2702
2703 return this;
2704 };
2705
2706 /**
2707 * Get the symbolic name of the icon.
2708 *
2709 * @return {string} Icon name
2710 */
2711 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2712 return this.icon;
2713 };
2714
2715 /**
2716 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2717 *
2718 * @return {string} Icon title text
2719 */
2720 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2721 return this.iconTitle;
2722 };
2723
2724 /**
2725 * IndicatorElement is often mixed into other classes to generate an indicator.
2726 * Indicators are small graphics that are generally used in two ways:
2727 *
2728 * - To draw attention to the status of an item. For example, an indicator might be
2729 * used to show that an item in a list has errors that need to be resolved.
2730 * - To clarify the function of a control that acts in an exceptional way (a button
2731 * that opens a menu instead of performing an action directly, for example).
2732 *
2733 * For a list of indicators included in the library, please see the
2734 * [OOjs UI documentation on MediaWiki] [1].
2735 *
2736 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2737 *
2738 * @abstract
2739 * @class
2740 *
2741 * @constructor
2742 * @param {Object} [config] Configuration options
2743 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2744 * configuration is omitted, the indicator element will use a generated `<span>`.
2745 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2746 * See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
2747 * in the library.
2748 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2749 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2750 * or a function that returns title text. The indicator title is displayed when users move
2751 * the mouse over the indicator.
2752 */
2753 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2754 // Configuration initialization
2755 config = config || {};
2756
2757 // Properties
2758 this.$indicator = null;
2759 this.indicator = null;
2760 this.indicatorTitle = null;
2761
2762 // Initialization
2763 this.setIndicator( config.indicator || this.constructor.static.indicator );
2764 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2765 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2766 };
2767
2768 /* Setup */
2769
2770 OO.initClass( OO.ui.mixin.IndicatorElement );
2771
2772 /* Static Properties */
2773
2774 /**
2775 * Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2776 * The static property will be overridden if the #indicator configuration is used.
2777 *
2778 * @static
2779 * @inheritable
2780 * @property {string|null}
2781 */
2782 OO.ui.mixin.IndicatorElement.static.indicator = null;
2783
2784 /**
2785 * A text string used as the indicator title, a function that returns title text, or `null`
2786 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2787 *
2788 * @static
2789 * @inheritable
2790 * @property {string|Function|null}
2791 */
2792 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2793
2794 /* Methods */
2795
2796 /**
2797 * Set the indicator element.
2798 *
2799 * If an element is already set, it will be cleaned up before setting up the new element.
2800 *
2801 * @param {jQuery} $indicator Element to use as indicator
2802 */
2803 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2804 if ( this.$indicator ) {
2805 this.$indicator
2806 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2807 .removeAttr( 'title' );
2808 }
2809
2810 this.$indicator = $indicator
2811 .addClass( 'oo-ui-indicatorElement-indicator' )
2812 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2813 if ( this.indicatorTitle !== null ) {
2814 this.$indicator.attr( 'title', this.indicatorTitle );
2815 }
2816
2817 this.updateThemeClasses();
2818 };
2819
2820 /**
2821 * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
2822 *
2823 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2824 * @chainable
2825 */
2826 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2827 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2828
2829 if ( this.indicator !== indicator ) {
2830 if ( this.$indicator ) {
2831 if ( this.indicator !== null ) {
2832 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2833 }
2834 if ( indicator !== null ) {
2835 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2836 }
2837 }
2838 this.indicator = indicator;
2839 }
2840
2841 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2842 this.updateThemeClasses();
2843
2844 return this;
2845 };
2846
2847 /**
2848 * Set the indicator title.
2849 *
2850 * The title is displayed when a user moves the mouse over the indicator.
2851 *
2852 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2853 * `null` for no indicator title
2854 * @chainable
2855 */
2856 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2857 indicatorTitle =
2858 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2859 OO.ui.resolveMsg( indicatorTitle ) : null;
2860
2861 if ( this.indicatorTitle !== indicatorTitle ) {
2862 this.indicatorTitle = indicatorTitle;
2863 if ( this.$indicator ) {
2864 if ( this.indicatorTitle !== null ) {
2865 this.$indicator.attr( 'title', indicatorTitle );
2866 } else {
2867 this.$indicator.removeAttr( 'title' );
2868 }
2869 }
2870 }
2871
2872 return this;
2873 };
2874
2875 /**
2876 * Get the symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2877 *
2878 * @return {string} Symbolic name of indicator
2879 */
2880 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2881 return this.indicator;
2882 };
2883
2884 /**
2885 * Get the indicator title.
2886 *
2887 * The title is displayed when a user moves the mouse over the indicator.
2888 *
2889 * @return {string} Indicator title text
2890 */
2891 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2892 return this.indicatorTitle;
2893 };
2894
2895 /**
2896 * LabelElement is often mixed into other classes to generate a label, which
2897 * helps identify the function of an interface element.
2898 * See the [OOjs UI documentation on MediaWiki] [1] for more information.
2899 *
2900 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2901 *
2902 * @abstract
2903 * @class
2904 *
2905 * @constructor
2906 * @param {Object} [config] Configuration options
2907 * @cfg {jQuery} [$label] The label element created by the class. If this
2908 * configuration is omitted, the label element will use a generated `<span>`.
2909 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2910 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2911 * in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
2912 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2913 */
2914 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2915 // Configuration initialization
2916 config = config || {};
2917
2918 // Properties
2919 this.$label = null;
2920 this.label = null;
2921
2922 // Initialization
2923 this.setLabel( config.label || this.constructor.static.label );
2924 this.setLabelElement( config.$label || $( '<span>' ) );
2925 };
2926
2927 /* Setup */
2928
2929 OO.initClass( OO.ui.mixin.LabelElement );
2930
2931 /* Events */
2932
2933 /**
2934 * @event labelChange
2935 * @param {string} value
2936 */
2937
2938 /* Static Properties */
2939
2940 /**
2941 * The label text. The label can be specified as a plaintext string, a function that will
2942 * produce a string in the future, or `null` for no label. The static value will
2943 * be overridden if a label is specified with the #label config option.
2944 *
2945 * @static
2946 * @inheritable
2947 * @property {string|Function|null}
2948 */
2949 OO.ui.mixin.LabelElement.static.label = null;
2950
2951 /* Static methods */
2952
2953 /**
2954 * Highlight the first occurrence of the query in the given text
2955 *
2956 * @param {string} text Text
2957 * @param {string} query Query to find
2958 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2959 * @return {jQuery} Text with the first match of the query
2960 * sub-string wrapped in highlighted span
2961 */
2962 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2963 var i, tLen, qLen,
2964 offset = -1,
2965 $result = $( '<span>' );
2966
2967 if ( compare ) {
2968 tLen = text.length;
2969 qLen = query.length;
2970 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2971 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2972 offset = i;
2973 }
2974 }
2975 } else {
2976 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2977 }
2978
2979 if ( !query.length || offset === -1 ) {
2980 $result.text( text );
2981 } else {
2982 $result.append(
2983 document.createTextNode( text.slice( 0, offset ) ),
2984 $( '<span>' )
2985 .addClass( 'oo-ui-labelElement-label-highlight' )
2986 .text( text.slice( offset, offset + query.length ) ),
2987 document.createTextNode( text.slice( offset + query.length ) )
2988 );
2989 }
2990 return $result.contents();
2991 };
2992
2993 /* Methods */
2994
2995 /**
2996 * Set the label element.
2997 *
2998 * If an element is already set, it will be cleaned up before setting up the new element.
2999 *
3000 * @param {jQuery} $label Element to use as label
3001 */
3002 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3003 if ( this.$label ) {
3004 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3005 }
3006
3007 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3008 this.setLabelContent( this.label );
3009 };
3010
3011 /**
3012 * Set the label.
3013 *
3014 * An empty string will result in the label being hidden. A string containing only whitespace will
3015 * be converted to a single `&nbsp;`.
3016 *
3017 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3018 * text; or null for no label
3019 * @chainable
3020 */
3021 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3022 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3023 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3024
3025 if ( this.label !== label ) {
3026 if ( this.$label ) {
3027 this.setLabelContent( label );
3028 }
3029 this.label = label;
3030 this.emit( 'labelChange' );
3031 }
3032
3033 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3034
3035 return this;
3036 };
3037
3038 /**
3039 * Set the label as plain text with a highlighted query
3040 *
3041 * @param {string} text Text label to set
3042 * @param {string} query Substring of text to highlight
3043 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3044 * @chainable
3045 */
3046 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3047 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3048 };
3049
3050 /**
3051 * Get the label.
3052 *
3053 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3054 * text; or null for no label
3055 */
3056 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3057 return this.label;
3058 };
3059
3060 /**
3061 * Set the content of the label.
3062 *
3063 * Do not call this method until after the label element has been set by #setLabelElement.
3064 *
3065 * @private
3066 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3067 * text; or null for no label
3068 */
3069 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3070 if ( typeof label === 'string' ) {
3071 if ( label.match( /^\s*$/ ) ) {
3072 // Convert whitespace only string to a single non-breaking space
3073 this.$label.html( '&nbsp;' );
3074 } else {
3075 this.$label.text( label );
3076 }
3077 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3078 this.$label.html( label.toString() );
3079 } else if ( label instanceof jQuery ) {
3080 this.$label.empty().append( label );
3081 } else {
3082 this.$label.empty();
3083 }
3084 };
3085
3086 /**
3087 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3088 * additional functionality to an element created by another class. The class provides
3089 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3090 * which are used to customize the look and feel of a widget to better describe its
3091 * importance and functionality.
3092 *
3093 * The library currently contains the following styling flags for general use:
3094 *
3095 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3096 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3097 * - **constructive**: Constructive styling is deprecated since v0.23.2 and equivalent to progressive.
3098 *
3099 * The flags affect the appearance of the buttons:
3100 *
3101 * @example
3102 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3103 * var button1 = new OO.ui.ButtonWidget( {
3104 * label: 'Progressive',
3105 * flags: 'progressive'
3106 * } );
3107 * var button2 = new OO.ui.ButtonWidget( {
3108 * label: 'Destructive',
3109 * flags: 'destructive'
3110 * } );
3111 * $( 'body' ).append( button1.$element, button2.$element );
3112 *
3113 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3114 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
3115 *
3116 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3117 *
3118 * @abstract
3119 * @class
3120 *
3121 * @constructor
3122 * @param {Object} [config] Configuration options
3123 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3124 * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
3125 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3126 * @cfg {jQuery} [$flagged] The flagged element. By default,
3127 * the flagged functionality is applied to the element created by the class ($element).
3128 * If a different element is specified, the flagged functionality will be applied to it instead.
3129 */
3130 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3131 // Configuration initialization
3132 config = config || {};
3133
3134 // Properties
3135 this.flags = {};
3136 this.$flagged = null;
3137
3138 // Initialization
3139 this.setFlags( config.flags );
3140 this.setFlaggedElement( config.$flagged || this.$element );
3141 };
3142
3143 /* Events */
3144
3145 /**
3146 * @event flag
3147 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3148 * parameter contains the name of each modified flag and indicates whether it was
3149 * added or removed.
3150 *
3151 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3152 * that the flag was added, `false` that the flag was removed.
3153 */
3154
3155 /* Methods */
3156
3157 /**
3158 * Set the flagged element.
3159 *
3160 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3161 * If an element is already set, the method will remove the mixin’s effect on that element.
3162 *
3163 * @param {jQuery} $flagged Element that should be flagged
3164 */
3165 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3166 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3167 return 'oo-ui-flaggedElement-' + flag;
3168 } ).join( ' ' );
3169
3170 if ( this.$flagged ) {
3171 this.$flagged.removeClass( classNames );
3172 }
3173
3174 this.$flagged = $flagged.addClass( classNames );
3175 };
3176
3177 /**
3178 * Check if the specified flag is set.
3179 *
3180 * @param {string} flag Name of flag
3181 * @return {boolean} The flag is set
3182 */
3183 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3184 // This may be called before the constructor, thus before this.flags is set
3185 return this.flags && ( flag in this.flags );
3186 };
3187
3188 /**
3189 * Get the names of all flags set.
3190 *
3191 * @return {string[]} Flag names
3192 */
3193 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3194 // This may be called before the constructor, thus before this.flags is set
3195 return Object.keys( this.flags || {} );
3196 };
3197
3198 /**
3199 * Clear all flags.
3200 *
3201 * @chainable
3202 * @fires flag
3203 */
3204 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3205 var flag, className,
3206 changes = {},
3207 remove = [],
3208 classPrefix = 'oo-ui-flaggedElement-';
3209
3210 for ( flag in this.flags ) {
3211 className = classPrefix + flag;
3212 changes[ flag ] = false;
3213 delete this.flags[ flag ];
3214 remove.push( className );
3215 }
3216
3217 if ( this.$flagged ) {
3218 this.$flagged.removeClass( remove.join( ' ' ) );
3219 }
3220
3221 this.updateThemeClasses();
3222 this.emit( 'flag', changes );
3223
3224 return this;
3225 };
3226
3227 /**
3228 * Add one or more flags.
3229 *
3230 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3231 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3232 * be added (`true`) or removed (`false`).
3233 * @chainable
3234 * @fires flag
3235 */
3236 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3237 var i, len, flag, className,
3238 changes = {},
3239 add = [],
3240 remove = [],
3241 classPrefix = 'oo-ui-flaggedElement-';
3242
3243 if ( typeof flags === 'string' ) {
3244 className = classPrefix + flags;
3245 // Set
3246 if ( !this.flags[ flags ] ) {
3247 this.flags[ flags ] = true;
3248 add.push( className );
3249 }
3250 } else if ( Array.isArray( flags ) ) {
3251 for ( i = 0, len = flags.length; i < len; i++ ) {
3252 flag = flags[ i ];
3253 className = classPrefix + flag;
3254 // Set
3255 if ( !this.flags[ flag ] ) {
3256 changes[ flag ] = true;
3257 this.flags[ flag ] = true;
3258 add.push( className );
3259 }
3260 }
3261 } else if ( OO.isPlainObject( flags ) ) {
3262 for ( flag in flags ) {
3263 className = classPrefix + flag;
3264 if ( flags[ flag ] ) {
3265 // Set
3266 if ( !this.flags[ flag ] ) {
3267 changes[ flag ] = true;
3268 this.flags[ flag ] = true;
3269 add.push( className );
3270 }
3271 } else {
3272 // Remove
3273 if ( this.flags[ flag ] ) {
3274 changes[ flag ] = false;
3275 delete this.flags[ flag ];
3276 remove.push( className );
3277 }
3278 }
3279 }
3280 }
3281
3282 if ( this.$flagged ) {
3283 this.$flagged
3284 .addClass( add.join( ' ' ) )
3285 .removeClass( remove.join( ' ' ) );
3286 }
3287
3288 this.updateThemeClasses();
3289 this.emit( 'flag', changes );
3290
3291 return this;
3292 };
3293
3294 /**
3295 * TitledElement is mixed into other classes to provide a `title` attribute.
3296 * Titles are rendered by the browser and are made visible when the user moves
3297 * the mouse over the element. Titles are not visible on touch devices.
3298 *
3299 * @example
3300 * // TitledElement provides a 'title' attribute to the
3301 * // ButtonWidget class
3302 * var button = new OO.ui.ButtonWidget( {
3303 * label: 'Button with Title',
3304 * title: 'I am a button'
3305 * } );
3306 * $( 'body' ).append( button.$element );
3307 *
3308 * @abstract
3309 * @class
3310 *
3311 * @constructor
3312 * @param {Object} [config] Configuration options
3313 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3314 * If this config is omitted, the title functionality is applied to $element, the
3315 * element created by the class.
3316 * @cfg {string|Function} [title] The title text or a function that returns text. If
3317 * this config is omitted, the value of the {@link #static-title static title} property is used.
3318 */
3319 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3320 // Configuration initialization
3321 config = config || {};
3322
3323 // Properties
3324 this.$titled = null;
3325 this.title = null;
3326
3327 // Initialization
3328 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3329 this.setTitledElement( config.$titled || this.$element );
3330 };
3331
3332 /* Setup */
3333
3334 OO.initClass( OO.ui.mixin.TitledElement );
3335
3336 /* Static Properties */
3337
3338 /**
3339 * The title text, a function that returns text, or `null` for no title. The value of the static property
3340 * is overridden if the #title config option is used.
3341 *
3342 * @static
3343 * @inheritable
3344 * @property {string|Function|null}
3345 */
3346 OO.ui.mixin.TitledElement.static.title = null;
3347
3348 /* Methods */
3349
3350 /**
3351 * Set the titled element.
3352 *
3353 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3354 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3355 *
3356 * @param {jQuery} $titled Element that should use the 'titled' functionality
3357 */
3358 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3359 if ( this.$titled ) {
3360 this.$titled.removeAttr( 'title' );
3361 }
3362
3363 this.$titled = $titled;
3364 if ( this.title ) {
3365 this.updateTitle();
3366 }
3367 };
3368
3369 /**
3370 * Set title.
3371 *
3372 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3373 * @chainable
3374 */
3375 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3376 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3377 title = ( typeof title === 'string' && title.length ) ? title : null;
3378
3379 if ( this.title !== title ) {
3380 this.title = title;
3381 this.updateTitle();
3382 }
3383
3384 return this;
3385 };
3386
3387 /**
3388 * Update the title attribute, in case of changes to title or accessKey.
3389 *
3390 * @protected
3391 * @chainable
3392 */
3393 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3394 var title = this.getTitle();
3395 if ( this.$titled ) {
3396 if ( title !== null ) {
3397 // Only if this is an AccessKeyedElement
3398 if ( this.formatTitleWithAccessKey ) {
3399 title = this.formatTitleWithAccessKey( title );
3400 }
3401 this.$titled.attr( 'title', title );
3402 } else {
3403 this.$titled.removeAttr( 'title' );
3404 }
3405 }
3406 return this;
3407 };
3408
3409 /**
3410 * Get title.
3411 *
3412 * @return {string} Title string
3413 */
3414 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3415 return this.title;
3416 };
3417
3418 /**
3419 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3420 * Accesskeys allow an user to go to a specific element by using
3421 * a shortcut combination of a browser specific keys + the key
3422 * set to the field.
3423 *
3424 * @example
3425 * // AccessKeyedElement provides an 'accesskey' attribute to the
3426 * // ButtonWidget class
3427 * var button = new OO.ui.ButtonWidget( {
3428 * label: 'Button with Accesskey',
3429 * accessKey: 'k'
3430 * } );
3431 * $( 'body' ).append( button.$element );
3432 *
3433 * @abstract
3434 * @class
3435 *
3436 * @constructor
3437 * @param {Object} [config] Configuration options
3438 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3439 * If this config is omitted, the accesskey functionality is applied to $element, the
3440 * element created by the class.
3441 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3442 * this config is omitted, no accesskey will be added.
3443 */
3444 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3445 // Configuration initialization
3446 config = config || {};
3447
3448 // Properties
3449 this.$accessKeyed = null;
3450 this.accessKey = null;
3451
3452 // Initialization
3453 this.setAccessKey( config.accessKey || null );
3454 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3455
3456 // If this is also a TitledElement and it initialized before we did, we may have
3457 // to update the title with the access key
3458 if ( this.updateTitle ) {
3459 this.updateTitle();
3460 }
3461 };
3462
3463 /* Setup */
3464
3465 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3466
3467 /* Static Properties */
3468
3469 /**
3470 * The access key, a function that returns a key, or `null` for no accesskey.
3471 *
3472 * @static
3473 * @inheritable
3474 * @property {string|Function|null}
3475 */
3476 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3477
3478 /* Methods */
3479
3480 /**
3481 * Set the accesskeyed element.
3482 *
3483 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3484 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3485 *
3486 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3487 */
3488 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3489 if ( this.$accessKeyed ) {
3490 this.$accessKeyed.removeAttr( 'accesskey' );
3491 }
3492
3493 this.$accessKeyed = $accessKeyed;
3494 if ( this.accessKey ) {
3495 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3496 }
3497 };
3498
3499 /**
3500 * Set accesskey.
3501 *
3502 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3503 * @chainable
3504 */
3505 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3506 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3507
3508 if ( this.accessKey !== accessKey ) {
3509 if ( this.$accessKeyed ) {
3510 if ( accessKey !== null ) {
3511 this.$accessKeyed.attr( 'accesskey', accessKey );
3512 } else {
3513 this.$accessKeyed.removeAttr( 'accesskey' );
3514 }
3515 }
3516 this.accessKey = accessKey;
3517
3518 // Only if this is a TitledElement
3519 if ( this.updateTitle ) {
3520 this.updateTitle();
3521 }
3522 }
3523
3524 return this;
3525 };
3526
3527 /**
3528 * Get accesskey.
3529 *
3530 * @return {string} accessKey string
3531 */
3532 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3533 return this.accessKey;
3534 };
3535
3536 /**
3537 * Add information about the access key to the element's tooltip label.
3538 * (This is only public for hacky usage in FieldLayout.)
3539 *
3540 * @param {string} title Tooltip label for `title` attribute
3541 * @return {string}
3542 */
3543 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3544 var accessKey;
3545
3546 if ( !this.$accessKeyed ) {
3547 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3548 return title;
3549 }
3550 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3551 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3552 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3553 } else {
3554 accessKey = this.getAccessKey();
3555 }
3556 if ( accessKey ) {
3557 title += ' [' + accessKey + ']';
3558 }
3559 return title;
3560 };
3561
3562 /**
3563 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3564 * feels, and functionality can be customized via the class’s configuration options
3565 * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information
3566 * and examples.
3567 *
3568 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches
3569 *
3570 * @example
3571 * // A button widget
3572 * var button = new OO.ui.ButtonWidget( {
3573 * label: 'Button with Icon',
3574 * icon: 'trash',
3575 * iconTitle: 'Remove'
3576 * } );
3577 * $( 'body' ).append( button.$element );
3578 *
3579 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3580 *
3581 * @class
3582 * @extends OO.ui.Widget
3583 * @mixins OO.ui.mixin.ButtonElement
3584 * @mixins OO.ui.mixin.IconElement
3585 * @mixins OO.ui.mixin.IndicatorElement
3586 * @mixins OO.ui.mixin.LabelElement
3587 * @mixins OO.ui.mixin.TitledElement
3588 * @mixins OO.ui.mixin.FlaggedElement
3589 * @mixins OO.ui.mixin.TabIndexedElement
3590 * @mixins OO.ui.mixin.AccessKeyedElement
3591 *
3592 * @constructor
3593 * @param {Object} [config] Configuration options
3594 * @cfg {boolean} [active=false] Whether button should be shown as active
3595 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3596 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3597 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3598 */
3599 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3600 // Configuration initialization
3601 config = config || {};
3602
3603 // Parent constructor
3604 OO.ui.ButtonWidget.parent.call( this, config );
3605
3606 // Mixin constructors
3607 OO.ui.mixin.ButtonElement.call( this, config );
3608 OO.ui.mixin.IconElement.call( this, config );
3609 OO.ui.mixin.IndicatorElement.call( this, config );
3610 OO.ui.mixin.LabelElement.call( this, config );
3611 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3612 OO.ui.mixin.FlaggedElement.call( this, config );
3613 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3614 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3615
3616 // Properties
3617 this.href = null;
3618 this.target = null;
3619 this.noFollow = false;
3620
3621 // Events
3622 this.connect( this, { disable: 'onDisable' } );
3623
3624 // Initialization
3625 this.$button.append( this.$icon, this.$label, this.$indicator );
3626 this.$element
3627 .addClass( 'oo-ui-buttonWidget' )
3628 .append( this.$button );
3629 this.setActive( config.active );
3630 this.setHref( config.href );
3631 this.setTarget( config.target );
3632 this.setNoFollow( config.noFollow );
3633 };
3634
3635 /* Setup */
3636
3637 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3638 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3639 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3640 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3641 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3642 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3643 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3644 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3645 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3646
3647 /* Static Properties */
3648
3649 /**
3650 * @static
3651 * @inheritdoc
3652 */
3653 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3654
3655 /**
3656 * @static
3657 * @inheritdoc
3658 */
3659 OO.ui.ButtonWidget.static.tagName = 'span';
3660
3661 /* Methods */
3662
3663 /**
3664 * Get hyperlink location.
3665 *
3666 * @return {string} Hyperlink location
3667 */
3668 OO.ui.ButtonWidget.prototype.getHref = function () {
3669 return this.href;
3670 };
3671
3672 /**
3673 * Get hyperlink target.
3674 *
3675 * @return {string} Hyperlink target
3676 */
3677 OO.ui.ButtonWidget.prototype.getTarget = function () {
3678 return this.target;
3679 };
3680
3681 /**
3682 * Get search engine traversal hint.
3683 *
3684 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3685 */
3686 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3687 return this.noFollow;
3688 };
3689
3690 /**
3691 * Set hyperlink location.
3692 *
3693 * @param {string|null} href Hyperlink location, null to remove
3694 */
3695 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3696 href = typeof href === 'string' ? href : null;
3697 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3698 href = './' + href;
3699 }
3700
3701 if ( href !== this.href ) {
3702 this.href = href;
3703 this.updateHref();
3704 }
3705
3706 return this;
3707 };
3708
3709 /**
3710 * Update the `href` attribute, in case of changes to href or
3711 * disabled state.
3712 *
3713 * @private
3714 * @chainable
3715 */
3716 OO.ui.ButtonWidget.prototype.updateHref = function () {
3717 if ( this.href !== null && !this.isDisabled() ) {
3718 this.$button.attr( 'href', this.href );
3719 } else {
3720 this.$button.removeAttr( 'href' );
3721 }
3722
3723 return this;
3724 };
3725
3726 /**
3727 * Handle disable events.
3728 *
3729 * @private
3730 * @param {boolean} disabled Element is disabled
3731 */
3732 OO.ui.ButtonWidget.prototype.onDisable = function () {
3733 this.updateHref();
3734 };
3735
3736 /**
3737 * Set hyperlink target.
3738 *
3739 * @param {string|null} target Hyperlink target, null to remove
3740 */
3741 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3742 target = typeof target === 'string' ? target : null;
3743
3744 if ( target !== this.target ) {
3745 this.target = target;
3746 if ( target !== null ) {
3747 this.$button.attr( 'target', target );
3748 } else {
3749 this.$button.removeAttr( 'target' );
3750 }
3751 }
3752
3753 return this;
3754 };
3755
3756 /**
3757 * Set search engine traversal hint.
3758 *
3759 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3760 */
3761 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3762 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3763
3764 if ( noFollow !== this.noFollow ) {
3765 this.noFollow = noFollow;
3766 if ( noFollow ) {
3767 this.$button.attr( 'rel', 'nofollow' );
3768 } else {
3769 this.$button.removeAttr( 'rel' );
3770 }
3771 }
3772
3773 return this;
3774 };
3775
3776 // Override method visibility hints from ButtonElement
3777 /**
3778 * @method setActive
3779 * @inheritdoc
3780 */
3781 /**
3782 * @method isActive
3783 * @inheritdoc
3784 */
3785
3786 /**
3787 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3788 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3789 * removed, and cleared from the group.
3790 *
3791 * @example
3792 * // Example: A ButtonGroupWidget with two buttons
3793 * var button1 = new OO.ui.PopupButtonWidget( {
3794 * label: 'Select a category',
3795 * icon: 'menu',
3796 * popup: {
3797 * $content: $( '<p>List of categories...</p>' ),
3798 * padded: true,
3799 * align: 'left'
3800 * }
3801 * } );
3802 * var button2 = new OO.ui.ButtonWidget( {
3803 * label: 'Add item'
3804 * });
3805 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3806 * items: [button1, button2]
3807 * } );
3808 * $( 'body' ).append( buttonGroup.$element );
3809 *
3810 * @class
3811 * @extends OO.ui.Widget
3812 * @mixins OO.ui.mixin.GroupElement
3813 *
3814 * @constructor
3815 * @param {Object} [config] Configuration options
3816 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3817 */
3818 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3819 // Configuration initialization
3820 config = config || {};
3821
3822 // Parent constructor
3823 OO.ui.ButtonGroupWidget.parent.call( this, config );
3824
3825 // Mixin constructors
3826 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3827
3828 // Initialization
3829 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3830 if ( Array.isArray( config.items ) ) {
3831 this.addItems( config.items );
3832 }
3833 };
3834
3835 /* Setup */
3836
3837 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3838 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3839
3840 /* Static Properties */
3841
3842 /**
3843 * @static
3844 * @inheritdoc
3845 */
3846 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3847
3848 /* Methods */
3849
3850 /**
3851 * Focus the widget
3852 *
3853 * @chainable
3854 */
3855 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3856 if ( !this.isDisabled() ) {
3857 if ( this.items[ 0 ] ) {
3858 this.items[ 0 ].focus();
3859 }
3860 }
3861 return this;
3862 };
3863
3864 /**
3865 * @inheritdoc
3866 */
3867 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3868 this.focus();
3869 };
3870
3871 /**
3872 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3873 * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
3874 * for a list of icons included in the library.
3875 *
3876 * @example
3877 * // An icon widget with a label
3878 * var myIcon = new OO.ui.IconWidget( {
3879 * icon: 'help',
3880 * iconTitle: 'Help'
3881 * } );
3882 * // Create a label.
3883 * var iconLabel = new OO.ui.LabelWidget( {
3884 * label: 'Help'
3885 * } );
3886 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3887 *
3888 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
3889 *
3890 * @class
3891 * @extends OO.ui.Widget
3892 * @mixins OO.ui.mixin.IconElement
3893 * @mixins OO.ui.mixin.TitledElement
3894 * @mixins OO.ui.mixin.FlaggedElement
3895 *
3896 * @constructor
3897 * @param {Object} [config] Configuration options
3898 */
3899 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3900 // Configuration initialization
3901 config = config || {};
3902
3903 // Parent constructor
3904 OO.ui.IconWidget.parent.call( this, config );
3905
3906 // Mixin constructors
3907 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3908 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3909 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3910
3911 // Initialization
3912 this.$element.addClass( 'oo-ui-iconWidget' );
3913 };
3914
3915 /* Setup */
3916
3917 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3918 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3919 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3920 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3921
3922 /* Static Properties */
3923
3924 /**
3925 * @static
3926 * @inheritdoc
3927 */
3928 OO.ui.IconWidget.static.tagName = 'span';
3929
3930 /**
3931 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3932 * attention to the status of an item or to clarify the function of a control. For a list of
3933 * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
3934 *
3935 * @example
3936 * // Example of an indicator widget
3937 * var indicator1 = new OO.ui.IndicatorWidget( {
3938 * indicator: 'alert'
3939 * } );
3940 *
3941 * // Create a fieldset layout to add a label
3942 * var fieldset = new OO.ui.FieldsetLayout();
3943 * fieldset.addItems( [
3944 * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
3945 * ] );
3946 * $( 'body' ).append( fieldset.$element );
3947 *
3948 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3949 *
3950 * @class
3951 * @extends OO.ui.Widget
3952 * @mixins OO.ui.mixin.IndicatorElement
3953 * @mixins OO.ui.mixin.TitledElement
3954 *
3955 * @constructor
3956 * @param {Object} [config] Configuration options
3957 */
3958 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
3959 // Configuration initialization
3960 config = config || {};
3961
3962 // Parent constructor
3963 OO.ui.IndicatorWidget.parent.call( this, config );
3964
3965 // Mixin constructors
3966 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
3967 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3968
3969 // Initialization
3970 this.$element.addClass( 'oo-ui-indicatorWidget' );
3971 };
3972
3973 /* Setup */
3974
3975 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
3976 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
3977 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
3978
3979 /* Static Properties */
3980
3981 /**
3982 * @static
3983 * @inheritdoc
3984 */
3985 OO.ui.IndicatorWidget.static.tagName = 'span';
3986
3987 /**
3988 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
3989 * be configured with a `label` option that is set to a string, a label node, or a function:
3990 *
3991 * - String: a plaintext string
3992 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
3993 * label that includes a link or special styling, such as a gray color or additional graphical elements.
3994 * - Function: a function that will produce a string in the future. Functions are used
3995 * in cases where the value of the label is not currently defined.
3996 *
3997 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
3998 * will come into focus when the label is clicked.
3999 *
4000 * @example
4001 * // Examples of LabelWidgets
4002 * var label1 = new OO.ui.LabelWidget( {
4003 * label: 'plaintext label'
4004 * } );
4005 * var label2 = new OO.ui.LabelWidget( {
4006 * label: $( '<a href="default.html">jQuery label</a>' )
4007 * } );
4008 * // Create a fieldset layout with fields for each example
4009 * var fieldset = new OO.ui.FieldsetLayout();
4010 * fieldset.addItems( [
4011 * new OO.ui.FieldLayout( label1 ),
4012 * new OO.ui.FieldLayout( label2 )
4013 * ] );
4014 * $( 'body' ).append( fieldset.$element );
4015 *
4016 * @class
4017 * @extends OO.ui.Widget
4018 * @mixins OO.ui.mixin.LabelElement
4019 * @mixins OO.ui.mixin.TitledElement
4020 *
4021 * @constructor
4022 * @param {Object} [config] Configuration options
4023 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4024 * Clicking the label will focus the specified input field.
4025 */
4026 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4027 // Configuration initialization
4028 config = config || {};
4029
4030 // Parent constructor
4031 OO.ui.LabelWidget.parent.call( this, config );
4032
4033 // Mixin constructors
4034 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4035 OO.ui.mixin.TitledElement.call( this, config );
4036
4037 // Properties
4038 this.input = config.input;
4039
4040 // Initialization
4041 if ( this.input ) {
4042 if ( this.input.getInputId() ) {
4043 this.$element.attr( 'for', this.input.getInputId() );
4044 } else {
4045 this.$label.on( 'click', function () {
4046 this.input.simulateLabelClick();
4047 return false;
4048 }.bind( this ) );
4049 }
4050 }
4051 this.$element.addClass( 'oo-ui-labelWidget' );
4052 };
4053
4054 /* Setup */
4055
4056 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4057 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4058 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4059
4060 /* Static Properties */
4061
4062 /**
4063 * @static
4064 * @inheritdoc
4065 */
4066 OO.ui.LabelWidget.static.tagName = 'label';
4067
4068 /**
4069 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4070 * and that they should wait before proceeding. The pending state is visually represented with a pending
4071 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4072 * field of a {@link OO.ui.TextInputWidget text input widget}.
4073 *
4074 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4075 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4076 * in process dialogs.
4077 *
4078 * @example
4079 * function MessageDialog( config ) {
4080 * MessageDialog.parent.call( this, config );
4081 * }
4082 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4083 *
4084 * MessageDialog.static.name = 'myMessageDialog';
4085 * MessageDialog.static.actions = [
4086 * { action: 'save', label: 'Done', flags: 'primary' },
4087 * { label: 'Cancel', flags: 'safe' }
4088 * ];
4089 *
4090 * MessageDialog.prototype.initialize = function () {
4091 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4092 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4093 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
4094 * this.$body.append( this.content.$element );
4095 * };
4096 * MessageDialog.prototype.getBodyHeight = function () {
4097 * return 100;
4098 * }
4099 * MessageDialog.prototype.getActionProcess = function ( action ) {
4100 * var dialog = this;
4101 * if ( action === 'save' ) {
4102 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4103 * return new OO.ui.Process()
4104 * .next( 1000 )
4105 * .next( function () {
4106 * dialog.getActions().get({actions: 'save'})[0].popPending();
4107 * } );
4108 * }
4109 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4110 * };
4111 *
4112 * var windowManager = new OO.ui.WindowManager();
4113 * $( 'body' ).append( windowManager.$element );
4114 *
4115 * var dialog = new MessageDialog();
4116 * windowManager.addWindows( [ dialog ] );
4117 * windowManager.openWindow( dialog );
4118 *
4119 * @abstract
4120 * @class
4121 *
4122 * @constructor
4123 * @param {Object} [config] Configuration options
4124 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4125 */
4126 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4127 // Configuration initialization
4128 config = config || {};
4129
4130 // Properties
4131 this.pending = 0;
4132 this.$pending = null;
4133
4134 // Initialisation
4135 this.setPendingElement( config.$pending || this.$element );
4136 };
4137
4138 /* Setup */
4139
4140 OO.initClass( OO.ui.mixin.PendingElement );
4141
4142 /* Methods */
4143
4144 /**
4145 * Set the pending element (and clean up any existing one).
4146 *
4147 * @param {jQuery} $pending The element to set to pending.
4148 */
4149 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4150 if ( this.$pending ) {
4151 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4152 }
4153
4154 this.$pending = $pending;
4155 if ( this.pending > 0 ) {
4156 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4157 }
4158 };
4159
4160 /**
4161 * Check if an element is pending.
4162 *
4163 * @return {boolean} Element is pending
4164 */
4165 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4166 return !!this.pending;
4167 };
4168
4169 /**
4170 * Increase the pending counter. The pending state will remain active until the counter is zero
4171 * (i.e., the number of calls to #pushPending and #popPending is the same).
4172 *
4173 * @chainable
4174 */
4175 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4176 if ( this.pending === 0 ) {
4177 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4178 this.updateThemeClasses();
4179 }
4180 this.pending++;
4181
4182 return this;
4183 };
4184
4185 /**
4186 * Decrease the pending counter. The pending state will remain active until the counter is zero
4187 * (i.e., the number of calls to #pushPending and #popPending is the same).
4188 *
4189 * @chainable
4190 */
4191 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4192 if ( this.pending === 1 ) {
4193 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4194 this.updateThemeClasses();
4195 }
4196 this.pending = Math.max( 0, this.pending - 1 );
4197
4198 return this;
4199 };
4200
4201 /**
4202 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4203 * in the document (for example, in an OO.ui.Window's $overlay).
4204 *
4205 * The elements's position is automatically calculated and maintained when window is resized or the
4206 * page is scrolled. If you reposition the container manually, you have to call #position to make
4207 * sure the element is still placed correctly.
4208 *
4209 * As positioning is only possible when both the element and the container are attached to the DOM
4210 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4211 * the #toggle method to display a floating popup, for example.
4212 *
4213 * @abstract
4214 * @class
4215 *
4216 * @constructor
4217 * @param {Object} [config] Configuration options
4218 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4219 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4220 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4221 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4222 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4223 * 'top': Align the top edge with $floatableContainer's top edge
4224 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4225 * 'center': Vertically align the center with $floatableContainer's center
4226 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4227 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4228 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4229 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4230 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4231 * 'center': Horizontally align the center with $floatableContainer's center
4232 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4233 * is out of view
4234 */
4235 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4236 // Configuration initialization
4237 config = config || {};
4238
4239 // Properties
4240 this.$floatable = null;
4241 this.$floatableContainer = null;
4242 this.$floatableWindow = null;
4243 this.$floatableClosestScrollable = null;
4244 this.onFloatableScrollHandler = this.position.bind( this );
4245 this.onFloatableWindowResizeHandler = this.position.bind( this );
4246
4247 // Initialization
4248 this.setFloatableContainer( config.$floatableContainer );
4249 this.setFloatableElement( config.$floatable || this.$element );
4250 this.setVerticalPosition( config.verticalPosition || 'below' );
4251 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4252 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4253 };
4254
4255 /* Methods */
4256
4257 /**
4258 * Set floatable element.
4259 *
4260 * If an element is already set, it will be cleaned up before setting up the new element.
4261 *
4262 * @param {jQuery} $floatable Element to make floatable
4263 */
4264 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4265 if ( this.$floatable ) {
4266 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4267 this.$floatable.css( { left: '', top: '' } );
4268 }
4269
4270 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4271 this.position();
4272 };
4273
4274 /**
4275 * Set floatable container.
4276 *
4277 * The element will be positioned relative to the specified container.
4278 *
4279 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4280 */
4281 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4282 this.$floatableContainer = $floatableContainer;
4283 if ( this.$floatable ) {
4284 this.position();
4285 }
4286 };
4287
4288 /**
4289 * Change how the element is positioned vertically.
4290 *
4291 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4292 */
4293 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4294 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4295 throw new Error( 'Invalid value for vertical position: ' + position );
4296 }
4297 if ( this.verticalPosition !== position ) {
4298 this.verticalPosition = position;
4299 if ( this.$floatable ) {
4300 this.position();
4301 }
4302 }
4303 };
4304
4305 /**
4306 * Change how the element is positioned horizontally.
4307 *
4308 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4309 */
4310 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4311 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4312 throw new Error( 'Invalid value for horizontal position: ' + position );
4313 }
4314 if ( this.horizontalPosition !== position ) {
4315 this.horizontalPosition = position;
4316 if ( this.$floatable ) {
4317 this.position();
4318 }
4319 }
4320 };
4321
4322 /**
4323 * Toggle positioning.
4324 *
4325 * Do not turn positioning on until after the element is attached to the DOM and visible.
4326 *
4327 * @param {boolean} [positioning] Enable positioning, omit to toggle
4328 * @chainable
4329 */
4330 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4331 var closestScrollableOfContainer;
4332
4333 if ( !this.$floatable || !this.$floatableContainer ) {
4334 return this;
4335 }
4336
4337 positioning = positioning === undefined ? !this.positioning : !!positioning;
4338
4339 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4340 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4341 this.warnedUnattached = true;
4342 }
4343
4344 if ( this.positioning !== positioning ) {
4345 this.positioning = positioning;
4346
4347 this.needsCustomPosition =
4348 this.verticalPostion !== 'below' ||
4349 this.horizontalPosition !== 'start' ||
4350 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4351
4352 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4353 // If the scrollable is the root, we have to listen to scroll events
4354 // on the window because of browser inconsistencies.
4355 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4356 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4357 }
4358
4359 if ( positioning ) {
4360 this.$floatableWindow = $( this.getElementWindow() );
4361 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4362
4363 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4364 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4365
4366 // Initial position after visible
4367 this.position();
4368 } else {
4369 if ( this.$floatableWindow ) {
4370 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4371 this.$floatableWindow = null;
4372 }
4373
4374 if ( this.$floatableClosestScrollable ) {
4375 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4376 this.$floatableClosestScrollable = null;
4377 }
4378
4379 this.$floatable.css( { left: '', right: '', top: '' } );
4380 }
4381 }
4382
4383 return this;
4384 };
4385
4386 /**
4387 * Check whether the bottom edge of the given element is within the viewport of the given container.
4388 *
4389 * @private
4390 * @param {jQuery} $element
4391 * @param {jQuery} $container
4392 * @return {boolean}
4393 */
4394 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4395 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4396 startEdgeInBounds, endEdgeInBounds,
4397 direction = $element.css( 'direction' );
4398
4399 elemRect = $element[ 0 ].getBoundingClientRect();
4400 if ( $container[ 0 ] === window ) {
4401 contRect = {
4402 top: 0,
4403 left: 0,
4404 right: document.documentElement.clientWidth,
4405 bottom: document.documentElement.clientHeight
4406 };
4407 } else {
4408 contRect = $container[ 0 ].getBoundingClientRect();
4409 }
4410
4411 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4412 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4413 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4414 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4415 if ( direction === 'rtl' ) {
4416 startEdgeInBounds = rightEdgeInBounds;
4417 endEdgeInBounds = leftEdgeInBounds;
4418 } else {
4419 startEdgeInBounds = leftEdgeInBounds;
4420 endEdgeInBounds = rightEdgeInBounds;
4421 }
4422
4423 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4424 return false;
4425 }
4426 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4427 return false;
4428 }
4429 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4430 return false;
4431 }
4432 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4433 return false;
4434 }
4435
4436 // The other positioning values are all about being inside the container,
4437 // so in those cases all we care about is that any part of the container is visible.
4438 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4439 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4440 };
4441
4442 /**
4443 * Position the floatable below its container.
4444 *
4445 * This should only be done when both of them are attached to the DOM and visible.
4446 *
4447 * @chainable
4448 */
4449 OO.ui.mixin.FloatableElement.prototype.position = function () {
4450 if ( !this.positioning ) {
4451 return this;
4452 }
4453
4454 if ( !(
4455 // To continue, some things need to be true:
4456 // The element must actually be in the DOM
4457 this.isElementAttached() && (
4458 // The closest scrollable is the current window
4459 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4460 // OR is an element in the element's DOM
4461 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4462 )
4463 ) ) {
4464 // Abort early if important parts of the widget are no longer attached to the DOM
4465 return this;
4466 }
4467
4468 if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
4469 this.$floatable.addClass( 'oo-ui-element-hidden' );
4470 return this;
4471 } else {
4472 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4473 }
4474
4475 if ( !this.needsCustomPosition ) {
4476 return this;
4477 }
4478
4479 this.$floatable.css( this.computePosition() );
4480
4481 // We updated the position, so re-evaluate the clipping state.
4482 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4483 // will not notice the need to update itself.)
4484 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4485 // it not listen to the right events in the right places?
4486 if ( this.clip ) {
4487 this.clip();
4488 }
4489
4490 return this;
4491 };
4492
4493 /**
4494 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4495 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4496 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4497 *
4498 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4499 */
4500 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4501 var isBody, scrollableX, scrollableY, containerPos,
4502 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4503 newPos = { top: '', left: '', bottom: '', right: '' },
4504 direction = this.$floatableContainer.css( 'direction' ),
4505 $offsetParent = this.$floatable.offsetParent();
4506
4507 if ( $offsetParent.is( 'html' ) ) {
4508 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4509 // <html> element, but they do work on the <body>
4510 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4511 }
4512 isBody = $offsetParent.is( 'body' );
4513 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4514 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4515
4516 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4517 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4518 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4519 // or if it isn't scrollable
4520 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4521 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4522
4523 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4524 // if the <body> has a margin
4525 containerPos = isBody ?
4526 this.$floatableContainer.offset() :
4527 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4528 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4529 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4530 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4531 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4532
4533 if ( this.verticalPosition === 'below' ) {
4534 newPos.top = containerPos.bottom;
4535 } else if ( this.verticalPosition === 'above' ) {
4536 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4537 } else if ( this.verticalPosition === 'top' ) {
4538 newPos.top = containerPos.top;
4539 } else if ( this.verticalPosition === 'bottom' ) {
4540 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4541 } else if ( this.verticalPosition === 'center' ) {
4542 newPos.top = containerPos.top +
4543 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4544 }
4545
4546 if ( this.horizontalPosition === 'before' ) {
4547 newPos.end = containerPos.start;
4548 } else if ( this.horizontalPosition === 'after' ) {
4549 newPos.start = containerPos.end;
4550 } else if ( this.horizontalPosition === 'start' ) {
4551 newPos.start = containerPos.start;
4552 } else if ( this.horizontalPosition === 'end' ) {
4553 newPos.end = containerPos.end;
4554 } else if ( this.horizontalPosition === 'center' ) {
4555 newPos.left = containerPos.left +
4556 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4557 }
4558
4559 if ( newPos.start !== undefined ) {
4560 if ( direction === 'rtl' ) {
4561 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4562 } else {
4563 newPos.left = newPos.start;
4564 }
4565 delete newPos.start;
4566 }
4567 if ( newPos.end !== undefined ) {
4568 if ( direction === 'rtl' ) {
4569 newPos.left = newPos.end;
4570 } else {
4571 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4572 }
4573 delete newPos.end;
4574 }
4575
4576 // Account for scroll position
4577 if ( newPos.top !== '' ) {
4578 newPos.top += scrollTop;
4579 }
4580 if ( newPos.bottom !== '' ) {
4581 newPos.bottom -= scrollTop;
4582 }
4583 if ( newPos.left !== '' ) {
4584 newPos.left += scrollLeft;
4585 }
4586 if ( newPos.right !== '' ) {
4587 newPos.right -= scrollLeft;
4588 }
4589
4590 // Account for scrollbar gutter
4591 if ( newPos.bottom !== '' ) {
4592 newPos.bottom -= horizScrollbarHeight;
4593 }
4594 if ( direction === 'rtl' ) {
4595 if ( newPos.left !== '' ) {
4596 newPos.left -= vertScrollbarWidth;
4597 }
4598 } else {
4599 if ( newPos.right !== '' ) {
4600 newPos.right -= vertScrollbarWidth;
4601 }
4602 }
4603
4604 return newPos;
4605 };
4606
4607 /**
4608 * Element that can be automatically clipped to visible boundaries.
4609 *
4610 * Whenever the element's natural height changes, you have to call
4611 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4612 * clipping correctly.
4613 *
4614 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4615 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4616 * then #$clippable will be given a fixed reduced height and/or width and will be made
4617 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4618 * but you can build a static footer by setting #$clippableContainer to an element that contains
4619 * #$clippable and the footer.
4620 *
4621 * @abstract
4622 * @class
4623 *
4624 * @constructor
4625 * @param {Object} [config] Configuration options
4626 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4627 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4628 * omit to use #$clippable
4629 */
4630 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4631 // Configuration initialization
4632 config = config || {};
4633
4634 // Properties
4635 this.$clippable = null;
4636 this.$clippableContainer = null;
4637 this.clipping = false;
4638 this.clippedHorizontally = false;
4639 this.clippedVertically = false;
4640 this.$clippableScrollableContainer = null;
4641 this.$clippableScroller = null;
4642 this.$clippableWindow = null;
4643 this.idealWidth = null;
4644 this.idealHeight = null;
4645 this.onClippableScrollHandler = this.clip.bind( this );
4646 this.onClippableWindowResizeHandler = this.clip.bind( this );
4647
4648 // Initialization
4649 if ( config.$clippableContainer ) {
4650 this.setClippableContainer( config.$clippableContainer );
4651 }
4652 this.setClippableElement( config.$clippable || this.$element );
4653 };
4654
4655 /* Methods */
4656
4657 /**
4658 * Set clippable element.
4659 *
4660 * If an element is already set, it will be cleaned up before setting up the new element.
4661 *
4662 * @param {jQuery} $clippable Element to make clippable
4663 */
4664 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4665 if ( this.$clippable ) {
4666 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4667 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4668 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4669 }
4670
4671 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4672 this.clip();
4673 };
4674
4675 /**
4676 * Set clippable container.
4677 *
4678 * This is the container that will be measured when deciding whether to clip. When clipping,
4679 * #$clippable will be resized in order to keep the clippable container fully visible.
4680 *
4681 * If the clippable container is unset, #$clippable will be used.
4682 *
4683 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4684 */
4685 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4686 this.$clippableContainer = $clippableContainer;
4687 if ( this.$clippable ) {
4688 this.clip();
4689 }
4690 };
4691
4692 /**
4693 * Toggle clipping.
4694 *
4695 * Do not turn clipping on until after the element is attached to the DOM and visible.
4696 *
4697 * @param {boolean} [clipping] Enable clipping, omit to toggle
4698 * @chainable
4699 */
4700 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4701 clipping = clipping === undefined ? !this.clipping : !!clipping;
4702
4703 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4704 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4705 this.warnedUnattached = true;
4706 }
4707
4708 if ( this.clipping !== clipping ) {
4709 this.clipping = clipping;
4710 if ( clipping ) {
4711 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4712 // If the clippable container is the root, we have to listen to scroll events and check
4713 // jQuery.scrollTop on the window because of browser inconsistencies
4714 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4715 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4716 this.$clippableScrollableContainer;
4717 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4718 this.$clippableWindow = $( this.getElementWindow() )
4719 .on( 'resize', this.onClippableWindowResizeHandler );
4720 // Initial clip after visible
4721 this.clip();
4722 } else {
4723 this.$clippable.css( {
4724 width: '',
4725 height: '',
4726 maxWidth: '',
4727 maxHeight: '',
4728 overflowX: '',
4729 overflowY: ''
4730 } );
4731 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4732
4733 this.$clippableScrollableContainer = null;
4734 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4735 this.$clippableScroller = null;
4736 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4737 this.$clippableWindow = null;
4738 }
4739 }
4740
4741 return this;
4742 };
4743
4744 /**
4745 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4746 *
4747 * @return {boolean} Element will be clipped to the visible area
4748 */
4749 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4750 return this.clipping;
4751 };
4752
4753 /**
4754 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4755 *
4756 * @return {boolean} Part of the element is being clipped
4757 */
4758 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4759 return this.clippedHorizontally || this.clippedVertically;
4760 };
4761
4762 /**
4763 * Check if the right of the element is being clipped by the nearest scrollable container.
4764 *
4765 * @return {boolean} Part of the element is being clipped
4766 */
4767 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4768 return this.clippedHorizontally;
4769 };
4770
4771 /**
4772 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4773 *
4774 * @return {boolean} Part of the element is being clipped
4775 */
4776 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4777 return this.clippedVertically;
4778 };
4779
4780 /**
4781 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4782 *
4783 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4784 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4785 */
4786 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4787 this.idealWidth = width;
4788 this.idealHeight = height;
4789
4790 if ( !this.clipping ) {
4791 // Update dimensions
4792 this.$clippable.css( { width: width, height: height } );
4793 }
4794 // While clipping, idealWidth and idealHeight are not considered
4795 };
4796
4797 /**
4798 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4799 * when the element's natural height changes.
4800 *
4801 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4802 * overlapped by, the visible area of the nearest scrollable container.
4803 *
4804 * Because calling clip() when the natural height changes isn't always possible, we also set
4805 * max-height when the element isn't being clipped. This means that if the element tries to grow
4806 * beyond the edge, something reasonable will happen before clip() is called.
4807 *
4808 * @chainable
4809 */
4810 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4811 var $container, extraHeight, extraWidth, ccOffset,
4812 $scrollableContainer, scOffset, scHeight, scWidth,
4813 ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
4814 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4815 naturalWidth, naturalHeight, clipWidth, clipHeight,
4816 buffer = 7; // Chosen by fair dice roll
4817
4818 if ( !this.clipping ) {
4819 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4820 return this;
4821 }
4822
4823 $container = this.$clippableContainer || this.$clippable;
4824 extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
4825 extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
4826 ccOffset = $container.offset();
4827 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4828 $scrollableContainer = this.$clippableWindow;
4829 scOffset = { top: 0, left: 0 };
4830 } else {
4831 $scrollableContainer = this.$clippableScrollableContainer;
4832 scOffset = $scrollableContainer.offset();
4833 }
4834 scHeight = $scrollableContainer.innerHeight() - buffer;
4835 scWidth = $scrollableContainer.innerWidth() - buffer;
4836 ccWidth = $container.outerWidth() + buffer;
4837 scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
4838 scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
4839 scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
4840 desiredWidth = ccOffset.left < 0 ?
4841 ccWidth + ccOffset.left :
4842 ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
4843 desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
4844 // It should never be desirable to exceed the dimensions of the browser viewport... right?
4845 desiredWidth = Math.min( desiredWidth, document.documentElement.clientWidth );
4846 desiredHeight = Math.min( desiredHeight, document.documentElement.clientHeight );
4847 allotedWidth = Math.ceil( desiredWidth - extraWidth );
4848 allotedHeight = Math.ceil( desiredHeight - extraHeight );
4849 naturalWidth = this.$clippable.prop( 'scrollWidth' );
4850 naturalHeight = this.$clippable.prop( 'scrollHeight' );
4851 clipWidth = allotedWidth < naturalWidth;
4852 clipHeight = allotedHeight < naturalHeight;
4853
4854 if ( clipWidth ) {
4855 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4856 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4857 this.$clippable.css( 'overflowX', 'scroll' );
4858 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4859 this.$clippable.css( {
4860 width: Math.max( 0, allotedWidth ),
4861 maxWidth: ''
4862 } );
4863 } else {
4864 this.$clippable.css( {
4865 overflowX: '',
4866 width: this.idealWidth || '',
4867 maxWidth: Math.max( 0, allotedWidth )
4868 } );
4869 }
4870 if ( clipHeight ) {
4871 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4872 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4873 this.$clippable.css( 'overflowY', 'scroll' );
4874 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4875 this.$clippable.css( {
4876 height: Math.max( 0, allotedHeight ),
4877 maxHeight: ''
4878 } );
4879 } else {
4880 this.$clippable.css( {
4881 overflowY: '',
4882 height: this.idealHeight || '',
4883 maxHeight: Math.max( 0, allotedHeight )
4884 } );
4885 }
4886
4887 // If we stopped clipping in at least one of the dimensions
4888 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
4889 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4890 }
4891
4892 this.clippedHorizontally = clipWidth;
4893 this.clippedVertically = clipHeight;
4894
4895 return this;
4896 };
4897
4898 /**
4899 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
4900 * By default, each popup has an anchor that points toward its origin.
4901 * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
4902 *
4903 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
4904 *
4905 * @example
4906 * // A popup widget.
4907 * var popup = new OO.ui.PopupWidget( {
4908 * $content: $( '<p>Hi there!</p>' ),
4909 * padded: true,
4910 * width: 300
4911 * } );
4912 *
4913 * $( 'body' ).append( popup.$element );
4914 * // To display the popup, toggle the visibility to 'true'.
4915 * popup.toggle( true );
4916 *
4917 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
4918 *
4919 * @class
4920 * @extends OO.ui.Widget
4921 * @mixins OO.ui.mixin.LabelElement
4922 * @mixins OO.ui.mixin.ClippableElement
4923 * @mixins OO.ui.mixin.FloatableElement
4924 *
4925 * @constructor
4926 * @param {Object} [config] Configuration options
4927 * @cfg {number} [width=320] Width of popup in pixels
4928 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
4929 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
4930 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
4931 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
4932 * of $floatableContainer
4933 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
4934 * of $floatableContainer
4935 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
4936 * endwards (right/left) to the vertical center of $floatableContainer
4937 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
4938 * startwards (left/right) to the vertical center of $floatableContainer
4939 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
4940 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
4941 * as possible while still keeping the anchor within the popup;
4942 * if position is before/after, move the popup as far downwards as possible.
4943 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
4944 * as possible while still keeping the anchor within the popup;
4945 * if position in before/after, move the popup as far upwards as possible.
4946 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
4947 * of the popup with the center of $floatableContainer.
4948 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
4949 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
4950 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
4951 * See the [OOjs UI docs on MediaWiki][3] for an example.
4952 * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
4953 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
4954 * @cfg {jQuery} [$content] Content to append to the popup's body
4955 * @cfg {jQuery} [$footer] Content to append to the popup's footer
4956 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
4957 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
4958 * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
4959 * for an example.
4960 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
4961 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
4962 * button.
4963 * @cfg {boolean} [padded=false] Add padding to the popup's body
4964 */
4965 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
4966 // Configuration initialization
4967 config = config || {};
4968
4969 // Parent constructor
4970 OO.ui.PopupWidget.parent.call( this, config );
4971
4972 // Properties (must be set before ClippableElement constructor call)
4973 this.$body = $( '<div>' );
4974 this.$popup = $( '<div>' );
4975
4976 // Mixin constructors
4977 OO.ui.mixin.LabelElement.call( this, config );
4978 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
4979 $clippable: this.$body,
4980 $clippableContainer: this.$popup
4981 } ) );
4982 OO.ui.mixin.FloatableElement.call( this, config );
4983
4984 // Properties
4985 this.$anchor = $( '<div>' );
4986 // If undefined, will be computed lazily in computePosition()
4987 this.$container = config.$container;
4988 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
4989 this.autoClose = !!config.autoClose;
4990 this.$autoCloseIgnore = config.$autoCloseIgnore;
4991 this.transitionTimeout = null;
4992 this.anchored = false;
4993 this.width = config.width !== undefined ? config.width : 320;
4994 this.height = config.height !== undefined ? config.height : null;
4995 this.onMouseDownHandler = this.onMouseDown.bind( this );
4996 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
4997
4998 // Initialization
4999 this.toggleAnchor( config.anchor === undefined || config.anchor );
5000 this.setAlignment( config.align || 'center' );
5001 this.setPosition( config.position || 'below' );
5002 this.$body.addClass( 'oo-ui-popupWidget-body' );
5003 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5004 this.$popup
5005 .addClass( 'oo-ui-popupWidget-popup' )
5006 .append( this.$body );
5007 this.$element
5008 .addClass( 'oo-ui-popupWidget' )
5009 .append( this.$popup, this.$anchor );
5010 // Move content, which was added to #$element by OO.ui.Widget, to the body
5011 // FIXME This is gross, we should use '$body' or something for the config
5012 if ( config.$content instanceof jQuery ) {
5013 this.$body.append( config.$content );
5014 }
5015
5016 if ( config.padded ) {
5017 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5018 }
5019
5020 if ( config.head ) {
5021 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5022 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5023 this.$head = $( '<div>' )
5024 .addClass( 'oo-ui-popupWidget-head' )
5025 .append( this.$label, this.closeButton.$element );
5026 this.$popup.prepend( this.$head );
5027 }
5028
5029 if ( config.$footer ) {
5030 this.$footer = $( '<div>' )
5031 .addClass( 'oo-ui-popupWidget-footer' )
5032 .append( config.$footer );
5033 this.$popup.append( this.$footer );
5034 }
5035
5036 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5037 // that reference properties not initialized at that time of parent class construction
5038 // TODO: Find a better way to handle post-constructor setup
5039 this.visible = false;
5040 this.$element.addClass( 'oo-ui-element-hidden' );
5041 };
5042
5043 /* Setup */
5044
5045 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5046 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5047 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5048 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5049
5050 /* Events */
5051
5052 /**
5053 * @event ready
5054 *
5055 * The popup is ready: it is visible and has been positioned and clipped.
5056 */
5057
5058 /* Methods */
5059
5060 /**
5061 * Handles mouse down events.
5062 *
5063 * @private
5064 * @param {MouseEvent} e Mouse down event
5065 */
5066 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5067 if (
5068 this.isVisible() &&
5069 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5070 ) {
5071 this.toggle( false );
5072 }
5073 };
5074
5075 /**
5076 * Bind mouse down listener.
5077 *
5078 * @private
5079 */
5080 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5081 // Capture clicks outside popup
5082 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5083 };
5084
5085 /**
5086 * Handles close button click events.
5087 *
5088 * @private
5089 */
5090 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5091 if ( this.isVisible() ) {
5092 this.toggle( false );
5093 }
5094 };
5095
5096 /**
5097 * Unbind mouse down listener.
5098 *
5099 * @private
5100 */
5101 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5102 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5103 };
5104
5105 /**
5106 * Handles key down events.
5107 *
5108 * @private
5109 * @param {KeyboardEvent} e Key down event
5110 */
5111 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5112 if (
5113 e.which === OO.ui.Keys.ESCAPE &&
5114 this.isVisible()
5115 ) {
5116 this.toggle( false );
5117 e.preventDefault();
5118 e.stopPropagation();
5119 }
5120 };
5121
5122 /**
5123 * Bind key down listener.
5124 *
5125 * @private
5126 */
5127 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5128 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5129 };
5130
5131 /**
5132 * Unbind key down listener.
5133 *
5134 * @private
5135 */
5136 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5137 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5138 };
5139
5140 /**
5141 * Show, hide, or toggle the visibility of the anchor.
5142 *
5143 * @param {boolean} [show] Show anchor, omit to toggle
5144 */
5145 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5146 show = show === undefined ? !this.anchored : !!show;
5147
5148 if ( this.anchored !== show ) {
5149 if ( show ) {
5150 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5151 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5152 } else {
5153 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5154 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5155 }
5156 this.anchored = show;
5157 }
5158 };
5159 /**
5160 * Change which edge the anchor appears on.
5161 *
5162 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5163 */
5164 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5165 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5166 throw new Error( 'Invalid value for edge: ' + edge );
5167 }
5168 if ( this.anchorEdge !== null ) {
5169 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5170 }
5171 this.anchorEdge = edge;
5172 if ( this.anchored ) {
5173 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5174 }
5175 };
5176
5177 /**
5178 * Check if the anchor is visible.
5179 *
5180 * @return {boolean} Anchor is visible
5181 */
5182 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5183 return this.anchored;
5184 };
5185
5186 /**
5187 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5188 * `.toggle( true )` after its #$element is attached to the DOM.
5189 *
5190 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5191 * it in the right place and with the right dimensions only work correctly while it is attached.
5192 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5193 * strictly enforced, so currently it only generates a warning in the browser console.
5194 *
5195 * @fires ready
5196 * @inheritdoc
5197 */
5198 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5199 var change;
5200 show = show === undefined ? !this.isVisible() : !!show;
5201
5202 change = show !== this.isVisible();
5203
5204 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5205 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5206 this.warnedUnattached = true;
5207 }
5208 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5209 // Fall back to the parent node if the floatableContainer is not set
5210 this.setFloatableContainer( this.$element.parent() );
5211 }
5212
5213 // Parent method
5214 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5215
5216 if ( change ) {
5217 this.togglePositioning( show && !!this.$floatableContainer );
5218
5219 if ( show ) {
5220 if ( this.autoClose ) {
5221 this.bindMouseDownListener();
5222 this.bindKeyDownListener();
5223 }
5224 this.updateDimensions();
5225 this.toggleClipping( true );
5226 this.emit( 'ready' );
5227 } else {
5228 this.toggleClipping( false );
5229 if ( this.autoClose ) {
5230 this.unbindMouseDownListener();
5231 this.unbindKeyDownListener();
5232 }
5233 }
5234 }
5235
5236 return this;
5237 };
5238
5239 /**
5240 * Set the size of the popup.
5241 *
5242 * Changing the size may also change the popup's position depending on the alignment.
5243 *
5244 * @param {number} width Width in pixels
5245 * @param {number} height Height in pixels
5246 * @param {boolean} [transition=false] Use a smooth transition
5247 * @chainable
5248 */
5249 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5250 this.width = width;
5251 this.height = height !== undefined ? height : null;
5252 if ( this.isVisible() ) {
5253 this.updateDimensions( transition );
5254 }
5255 };
5256
5257 /**
5258 * Update the size and position.
5259 *
5260 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5261 * be called automatically.
5262 *
5263 * @param {boolean} [transition=false] Use a smooth transition
5264 * @chainable
5265 */
5266 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5267 var widget = this;
5268
5269 // Prevent transition from being interrupted
5270 clearTimeout( this.transitionTimeout );
5271 if ( transition ) {
5272 // Enable transition
5273 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5274 }
5275
5276 this.position();
5277
5278 if ( transition ) {
5279 // Prevent transitioning after transition is complete
5280 this.transitionTimeout = setTimeout( function () {
5281 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5282 }, 200 );
5283 } else {
5284 // Prevent transitioning immediately
5285 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5286 }
5287 };
5288
5289 /**
5290 * @inheritdoc
5291 */
5292 OO.ui.PopupWidget.prototype.computePosition = function () {
5293 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5294 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5295 offsetParentPos, containerPos,
5296 popupPos = {},
5297 anchorCss = { left: '', right: '', top: '', bottom: '' },
5298 alignMap = {
5299 ltr: {
5300 'force-left': 'backwards',
5301 'force-right': 'forwards'
5302 },
5303 rtl: {
5304 'force-left': 'forwards',
5305 'force-right': 'backwards'
5306 }
5307 },
5308 anchorEdgeMap = {
5309 above: 'bottom',
5310 below: 'top',
5311 before: 'end',
5312 after: 'start'
5313 },
5314 hPosMap = {
5315 forwards: 'start',
5316 center: 'center',
5317 backwards: this.anchored ? 'before' : 'end'
5318 },
5319 vPosMap = {
5320 forwards: 'top',
5321 center: 'center',
5322 backwards: 'bottom'
5323 };
5324
5325 if ( !this.$container ) {
5326 // Lazy-initialize $container if not specified in constructor
5327 this.$container = $( this.getClosestScrollableElementContainer() );
5328 }
5329 direction = this.$container.css( 'direction' );
5330
5331 // Set height and width before we do anything else, since it might cause our measurements
5332 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5333 this.$popup.css( {
5334 width: this.width,
5335 height: this.height !== null ? this.height : 'auto'
5336 } );
5337
5338 align = alignMap[ direction ][ this.align ] || this.align;
5339 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5340 vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
5341 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5342 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5343 near = vertical ? 'top' : 'left';
5344 far = vertical ? 'bottom' : 'right';
5345 sizeProp = vertical ? 'Height' : 'Width';
5346 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5347
5348 this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
5349 this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
5350 this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
5351
5352 // Parent method
5353 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5354 // Find out which property FloatableElement used for positioning, and adjust that value
5355 positionProp = vertical ?
5356 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5357 ( parentPosition.left !== '' ? 'left' : 'right' );
5358
5359 // Figure out where the near and far edges of the popup and $floatableContainer are
5360 floatablePos = this.$floatableContainer.offset();
5361 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5362 // Measure where the offsetParent is and compute our position based on that and parentPosition
5363 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5364 { top: 0, left: 0 } :
5365 this.$element.offsetParent().offset();
5366
5367 if ( positionProp === near ) {
5368 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5369 popupPos[ far ] = popupPos[ near ] + popupSize;
5370 } else {
5371 popupPos[ far ] = offsetParentPos[ near ] +
5372 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5373 popupPos[ near ] = popupPos[ far ] - popupSize;
5374 }
5375
5376 if ( this.anchored ) {
5377 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5378 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5379 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5380
5381 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5382 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5383 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5384 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5385 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5386 // Not enough space for the anchor on the start side; pull the popup startwards
5387 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5388 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5389 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5390 // Not enough space for the anchor on the end side; pull the popup endwards
5391 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5392 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5393 } else {
5394 positionAdjustment = 0;
5395 }
5396 } else {
5397 positionAdjustment = 0;
5398 }
5399
5400 // Check if the popup will go beyond the edge of this.$container
5401 containerPos = this.$container[ 0 ] === document.documentElement ?
5402 { top: 0, left: 0 } :
5403 this.$container.offset();
5404 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5405 // Take into account how much the popup will move because of the adjustments we're going to make
5406 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5407 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5408 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5409 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5410 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5411 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5412 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5413 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5414 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5415 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5416 }
5417
5418 if ( this.anchored ) {
5419 // Adjust anchorOffset for positionAdjustment
5420 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5421
5422 // Position the anchor
5423 anchorCss[ start ] = anchorOffset;
5424 this.$anchor.css( anchorCss );
5425 }
5426
5427 // Move the popup if needed
5428 parentPosition[ positionProp ] += positionAdjustment;
5429
5430 return parentPosition;
5431 };
5432
5433 /**
5434 * Set popup alignment
5435 *
5436 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5437 * `backwards` or `forwards`.
5438 */
5439 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5440 // Validate alignment
5441 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5442 this.align = align;
5443 } else {
5444 this.align = 'center';
5445 }
5446 this.position();
5447 };
5448
5449 /**
5450 * Get popup alignment
5451 *
5452 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5453 * `backwards` or `forwards`.
5454 */
5455 OO.ui.PopupWidget.prototype.getAlignment = function () {
5456 return this.align;
5457 };
5458
5459 /**
5460 * Change the positioning of the popup.
5461 *
5462 * @param {string} position 'above', 'below', 'before' or 'after'
5463 */
5464 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5465 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5466 position = 'below';
5467 }
5468 this.popupPosition = position;
5469 this.position();
5470 };
5471
5472 /**
5473 * Get popup positioning.
5474 *
5475 * @return {string} 'above', 'below', 'before' or 'after'
5476 */
5477 OO.ui.PopupWidget.prototype.getPosition = function () {
5478 return this.popupPosition;
5479 };
5480
5481 /**
5482 * Get an ID of the body element, this can be used as the
5483 * `aria-describedby` attribute for an input field.
5484 *
5485 * @return {string} The ID of the body element
5486 */
5487 OO.ui.PopupWidget.prototype.getBodyId = function () {
5488 var id = this.$body.attr( 'id' );
5489 if ( id === undefined ) {
5490 id = OO.ui.generateElementId();
5491 this.$body.attr( 'id', id );
5492 }
5493 return id;
5494 };
5495
5496 /**
5497 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5498 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5499 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5500 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5501 *
5502 * @abstract
5503 * @class
5504 *
5505 * @constructor
5506 * @param {Object} [config] Configuration options
5507 * @cfg {Object} [popup] Configuration to pass to popup
5508 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5509 */
5510 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5511 // Configuration initialization
5512 config = config || {};
5513
5514 // Properties
5515 this.popup = new OO.ui.PopupWidget( $.extend(
5516 {
5517 autoClose: true,
5518 $floatableContainer: this.$element
5519 },
5520 config.popup,
5521 {
5522 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5523 }
5524 ) );
5525 };
5526
5527 /* Methods */
5528
5529 /**
5530 * Get popup.
5531 *
5532 * @return {OO.ui.PopupWidget} Popup widget
5533 */
5534 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5535 return this.popup;
5536 };
5537
5538 /**
5539 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5540 * which is used to display additional information or options.
5541 *
5542 * @example
5543 * // Example of a popup button.
5544 * var popupButton = new OO.ui.PopupButtonWidget( {
5545 * label: 'Popup button with options',
5546 * icon: 'menu',
5547 * popup: {
5548 * $content: $( '<p>Additional options here.</p>' ),
5549 * padded: true,
5550 * align: 'force-left'
5551 * }
5552 * } );
5553 * // Append the button to the DOM.
5554 * $( 'body' ).append( popupButton.$element );
5555 *
5556 * @class
5557 * @extends OO.ui.ButtonWidget
5558 * @mixins OO.ui.mixin.PopupElement
5559 *
5560 * @constructor
5561 * @param {Object} [config] Configuration options
5562 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5563 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5564 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5565 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
5566 */
5567 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5568 // Configuration initialization
5569 config = config || {};
5570
5571 // Parent constructor
5572 OO.ui.PopupButtonWidget.parent.call( this, config );
5573
5574 // Mixin constructors
5575 OO.ui.mixin.PopupElement.call( this, config );
5576
5577 // Properties
5578 this.$overlay = config.$overlay || this.$element;
5579
5580 // Events
5581 this.connect( this, { click: 'onAction' } );
5582
5583 // Initialization
5584 this.$element
5585 .addClass( 'oo-ui-popupButtonWidget' )
5586 .attr( 'aria-haspopup', 'true' );
5587 this.popup.$element
5588 .addClass( 'oo-ui-popupButtonWidget-popup' )
5589 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5590 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5591 this.$overlay.append( this.popup.$element );
5592 };
5593
5594 /* Setup */
5595
5596 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5597 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5598
5599 /* Methods */
5600
5601 /**
5602 * Handle the button action being triggered.
5603 *
5604 * @private
5605 */
5606 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5607 this.popup.toggle();
5608 };
5609
5610 /**
5611 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5612 *
5613 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5614 *
5615 * @private
5616 * @abstract
5617 * @class
5618 * @mixins OO.ui.mixin.GroupElement
5619 *
5620 * @constructor
5621 * @param {Object} [config] Configuration options
5622 */
5623 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5624 // Mixin constructors
5625 OO.ui.mixin.GroupElement.call( this, config );
5626 };
5627
5628 /* Setup */
5629
5630 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5631
5632 /* Methods */
5633
5634 /**
5635 * Set the disabled state of the widget.
5636 *
5637 * This will also update the disabled state of child widgets.
5638 *
5639 * @param {boolean} disabled Disable widget
5640 * @chainable
5641 */
5642 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5643 var i, len;
5644
5645 // Parent method
5646 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5647 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5648
5649 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5650 if ( this.items ) {
5651 for ( i = 0, len = this.items.length; i < len; i++ ) {
5652 this.items[ i ].updateDisabled();
5653 }
5654 }
5655
5656 return this;
5657 };
5658
5659 /**
5660 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5661 *
5662 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5663 * allows bidirectional communication.
5664 *
5665 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5666 *
5667 * @private
5668 * @abstract
5669 * @class
5670 *
5671 * @constructor
5672 */
5673 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5674 //
5675 };
5676
5677 /* Methods */
5678
5679 /**
5680 * Check if widget is disabled.
5681 *
5682 * Checks parent if present, making disabled state inheritable.
5683 *
5684 * @return {boolean} Widget is disabled
5685 */
5686 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5687 return this.disabled ||
5688 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5689 };
5690
5691 /**
5692 * Set group element is in.
5693 *
5694 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5695 * @chainable
5696 */
5697 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5698 // Parent method
5699 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5700 OO.ui.Element.prototype.setElementGroup.call( this, group );
5701
5702 // Initialize item disabled states
5703 this.updateDisabled();
5704
5705 return this;
5706 };
5707
5708 /**
5709 * OptionWidgets are special elements that can be selected and configured with data. The
5710 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5711 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5712 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
5713 *
5714 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5715 *
5716 * @class
5717 * @extends OO.ui.Widget
5718 * @mixins OO.ui.mixin.ItemWidget
5719 * @mixins OO.ui.mixin.LabelElement
5720 * @mixins OO.ui.mixin.FlaggedElement
5721 * @mixins OO.ui.mixin.AccessKeyedElement
5722 *
5723 * @constructor
5724 * @param {Object} [config] Configuration options
5725 */
5726 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5727 // Configuration initialization
5728 config = config || {};
5729
5730 // Parent constructor
5731 OO.ui.OptionWidget.parent.call( this, config );
5732
5733 // Mixin constructors
5734 OO.ui.mixin.ItemWidget.call( this );
5735 OO.ui.mixin.LabelElement.call( this, config );
5736 OO.ui.mixin.FlaggedElement.call( this, config );
5737 OO.ui.mixin.AccessKeyedElement.call( this, config );
5738
5739 // Properties
5740 this.selected = false;
5741 this.highlighted = false;
5742 this.pressed = false;
5743
5744 // Initialization
5745 this.$element
5746 .data( 'oo-ui-optionWidget', this )
5747 // Allow programmatic focussing (and by accesskey), but not tabbing
5748 .attr( 'tabindex', '-1' )
5749 .attr( 'role', 'option' )
5750 .attr( 'aria-selected', 'false' )
5751 .addClass( 'oo-ui-optionWidget' )
5752 .append( this.$label );
5753 };
5754
5755 /* Setup */
5756
5757 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
5758 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
5759 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
5760 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
5761 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
5762
5763 /* Static Properties */
5764
5765 /**
5766 * Whether this option can be selected. See #setSelected.
5767 *
5768 * @static
5769 * @inheritable
5770 * @property {boolean}
5771 */
5772 OO.ui.OptionWidget.static.selectable = true;
5773
5774 /**
5775 * Whether this option can be highlighted. See #setHighlighted.
5776 *
5777 * @static
5778 * @inheritable
5779 * @property {boolean}
5780 */
5781 OO.ui.OptionWidget.static.highlightable = true;
5782
5783 /**
5784 * Whether this option can be pressed. See #setPressed.
5785 *
5786 * @static
5787 * @inheritable
5788 * @property {boolean}
5789 */
5790 OO.ui.OptionWidget.static.pressable = true;
5791
5792 /**
5793 * Whether this option will be scrolled into view when it is selected.
5794 *
5795 * @static
5796 * @inheritable
5797 * @property {boolean}
5798 */
5799 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
5800
5801 /* Methods */
5802
5803 /**
5804 * Check if the option can be selected.
5805 *
5806 * @return {boolean} Item is selectable
5807 */
5808 OO.ui.OptionWidget.prototype.isSelectable = function () {
5809 return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
5810 };
5811
5812 /**
5813 * Check if the option can be highlighted. A highlight indicates that the option
5814 * may be selected when a user presses enter or clicks. Disabled items cannot
5815 * be highlighted.
5816 *
5817 * @return {boolean} Item is highlightable
5818 */
5819 OO.ui.OptionWidget.prototype.isHighlightable = function () {
5820 return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
5821 };
5822
5823 /**
5824 * Check if the option can be pressed. The pressed state occurs when a user mouses
5825 * down on an item, but has not yet let go of the mouse.
5826 *
5827 * @return {boolean} Item is pressable
5828 */
5829 OO.ui.OptionWidget.prototype.isPressable = function () {
5830 return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
5831 };
5832
5833 /**
5834 * Check if the option is selected.
5835 *
5836 * @return {boolean} Item is selected
5837 */
5838 OO.ui.OptionWidget.prototype.isSelected = function () {
5839 return this.selected;
5840 };
5841
5842 /**
5843 * Check if the option is highlighted. A highlight indicates that the
5844 * item may be selected when a user presses enter or clicks.
5845 *
5846 * @return {boolean} Item is highlighted
5847 */
5848 OO.ui.OptionWidget.prototype.isHighlighted = function () {
5849 return this.highlighted;
5850 };
5851
5852 /**
5853 * Check if the option is pressed. The pressed state occurs when a user mouses
5854 * down on an item, but has not yet let go of the mouse. The item may appear
5855 * selected, but it will not be selected until the user releases the mouse.
5856 *
5857 * @return {boolean} Item is pressed
5858 */
5859 OO.ui.OptionWidget.prototype.isPressed = function () {
5860 return this.pressed;
5861 };
5862
5863 /**
5864 * Set the option’s selected state. In general, all modifications to the selection
5865 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
5866 * method instead of this method.
5867 *
5868 * @param {boolean} [state=false] Select option
5869 * @chainable
5870 */
5871 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
5872 if ( this.constructor.static.selectable ) {
5873 this.selected = !!state;
5874 this.$element
5875 .toggleClass( 'oo-ui-optionWidget-selected', state )
5876 .attr( 'aria-selected', state.toString() );
5877 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
5878 this.scrollElementIntoView();
5879 }
5880 this.updateThemeClasses();
5881 }
5882 return this;
5883 };
5884
5885 /**
5886 * Set the option’s highlighted state. In general, all programmatic
5887 * modifications to the highlight should be handled by the
5888 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
5889 * method instead of this method.
5890 *
5891 * @param {boolean} [state=false] Highlight option
5892 * @chainable
5893 */
5894 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
5895 if ( this.constructor.static.highlightable ) {
5896 this.highlighted = !!state;
5897 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
5898 this.updateThemeClasses();
5899 }
5900 return this;
5901 };
5902
5903 /**
5904 * Set the option’s pressed state. In general, all
5905 * programmatic modifications to the pressed state should be handled by the
5906 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
5907 * method instead of this method.
5908 *
5909 * @param {boolean} [state=false] Press option
5910 * @chainable
5911 */
5912 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
5913 if ( this.constructor.static.pressable ) {
5914 this.pressed = !!state;
5915 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
5916 this.updateThemeClasses();
5917 }
5918 return this;
5919 };
5920
5921 /**
5922 * Get text to match search strings against.
5923 *
5924 * The default implementation returns the label text, but subclasses
5925 * can override this to provide more complex behavior.
5926 *
5927 * @return {string|boolean} String to match search string against
5928 */
5929 OO.ui.OptionWidget.prototype.getMatchText = function () {
5930 var label = this.getLabel();
5931 return typeof label === 'string' ? label : this.$label.text();
5932 };
5933
5934 /**
5935 * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
5936 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
5937 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
5938 * menu selects}.
5939 *
5940 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
5941 * information, please see the [OOjs UI documentation on MediaWiki][1].
5942 *
5943 * @example
5944 * // Example of a select widget with three options
5945 * var select = new OO.ui.SelectWidget( {
5946 * items: [
5947 * new OO.ui.OptionWidget( {
5948 * data: 'a',
5949 * label: 'Option One',
5950 * } ),
5951 * new OO.ui.OptionWidget( {
5952 * data: 'b',
5953 * label: 'Option Two',
5954 * } ),
5955 * new OO.ui.OptionWidget( {
5956 * data: 'c',
5957 * label: 'Option Three',
5958 * } )
5959 * ]
5960 * } );
5961 * $( 'body' ).append( select.$element );
5962 *
5963 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5964 *
5965 * @abstract
5966 * @class
5967 * @extends OO.ui.Widget
5968 * @mixins OO.ui.mixin.GroupWidget
5969 *
5970 * @constructor
5971 * @param {Object} [config] Configuration options
5972 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
5973 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
5974 * the [OOjs UI documentation on MediaWiki] [2] for examples.
5975 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5976 */
5977 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
5978 // Configuration initialization
5979 config = config || {};
5980
5981 // Parent constructor
5982 OO.ui.SelectWidget.parent.call( this, config );
5983
5984 // Mixin constructors
5985 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
5986
5987 // Properties
5988 this.pressed = false;
5989 this.selecting = null;
5990 this.onMouseUpHandler = this.onMouseUp.bind( this );
5991 this.onMouseMoveHandler = this.onMouseMove.bind( this );
5992 this.onKeyDownHandler = this.onKeyDown.bind( this );
5993 this.onKeyPressHandler = this.onKeyPress.bind( this );
5994 this.keyPressBuffer = '';
5995 this.keyPressBufferTimer = null;
5996 this.blockMouseOverEvents = 0;
5997
5998 // Events
5999 this.connect( this, {
6000 toggle: 'onToggle'
6001 } );
6002 this.$element.on( {
6003 focusin: this.onFocus.bind( this ),
6004 mousedown: this.onMouseDown.bind( this ),
6005 mouseover: this.onMouseOver.bind( this ),
6006 mouseleave: this.onMouseLeave.bind( this )
6007 } );
6008
6009 // Initialization
6010 this.$element
6011 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6012 .attr( 'role', 'listbox' );
6013 this.setFocusOwner( this.$element );
6014 if ( Array.isArray( config.items ) ) {
6015 this.addItems( config.items );
6016 }
6017 };
6018
6019 /* Setup */
6020
6021 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6022 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6023
6024 /* Events */
6025
6026 /**
6027 * @event highlight
6028 *
6029 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6030 *
6031 * @param {OO.ui.OptionWidget|null} item Highlighted item
6032 */
6033
6034 /**
6035 * @event press
6036 *
6037 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6038 * pressed state of an option.
6039 *
6040 * @param {OO.ui.OptionWidget|null} item Pressed item
6041 */
6042
6043 /**
6044 * @event select
6045 *
6046 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6047 *
6048 * @param {OO.ui.OptionWidget|null} item Selected item
6049 */
6050
6051 /**
6052 * @event choose
6053 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6054 * @param {OO.ui.OptionWidget} item Chosen item
6055 */
6056
6057 /**
6058 * @event add
6059 *
6060 * An `add` event is emitted when options are added to the select with the #addItems method.
6061 *
6062 * @param {OO.ui.OptionWidget[]} items Added items
6063 * @param {number} index Index of insertion point
6064 */
6065
6066 /**
6067 * @event remove
6068 *
6069 * A `remove` event is emitted when options are removed from the select with the #clearItems
6070 * or #removeItems methods.
6071 *
6072 * @param {OO.ui.OptionWidget[]} items Removed items
6073 */
6074
6075 /* Methods */
6076
6077 /**
6078 * Handle focus events
6079 *
6080 * @private
6081 * @param {jQuery.Event} event
6082 */
6083 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6084 var item;
6085 if ( event.target === this.$element[ 0 ] ) {
6086 // This widget was focussed, e.g. by the user tabbing to it.
6087 // The styles for focus state depend on one of the items being selected.
6088 if ( !this.getSelectedItem() ) {
6089 item = this.findFirstSelectableItem();
6090 }
6091 } else {
6092 if ( event.target.tabIndex === -1 ) {
6093 // One of the options got focussed (and the event bubbled up here).
6094 // They can't be tabbed to, but they can be activated using accesskeys.
6095 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6096 item = this.findTargetItem( event );
6097 } else {
6098 // There is something actually user-focusable in one of the labels of the options, and the
6099 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6100 return;
6101 }
6102 }
6103
6104 if ( item ) {
6105 if ( item.constructor.static.highlightable ) {
6106 this.highlightItem( item );
6107 } else {
6108 this.selectItem( item );
6109 }
6110 }
6111
6112 if ( event.target !== this.$element[ 0 ] ) {
6113 this.$focusOwner.focus();
6114 }
6115 };
6116
6117 /**
6118 * Handle mouse down events.
6119 *
6120 * @private
6121 * @param {jQuery.Event} e Mouse down event
6122 */
6123 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6124 var item;
6125
6126 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6127 this.togglePressed( true );
6128 item = this.findTargetItem( e );
6129 if ( item && item.isSelectable() ) {
6130 this.pressItem( item );
6131 this.selecting = item;
6132 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6133 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6134 }
6135 }
6136 return false;
6137 };
6138
6139 /**
6140 * Handle mouse up events.
6141 *
6142 * @private
6143 * @param {MouseEvent} e Mouse up event
6144 */
6145 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6146 var item;
6147
6148 this.togglePressed( false );
6149 if ( !this.selecting ) {
6150 item = this.findTargetItem( e );
6151 if ( item && item.isSelectable() ) {
6152 this.selecting = item;
6153 }
6154 }
6155 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6156 this.pressItem( null );
6157 this.chooseItem( this.selecting );
6158 this.selecting = null;
6159 }
6160
6161 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6162 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6163
6164 return false;
6165 };
6166
6167 /**
6168 * Handle mouse move events.
6169 *
6170 * @private
6171 * @param {MouseEvent} e Mouse move event
6172 */
6173 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6174 var item;
6175
6176 if ( !this.isDisabled() && this.pressed ) {
6177 item = this.findTargetItem( e );
6178 if ( item && item !== this.selecting && item.isSelectable() ) {
6179 this.pressItem( item );
6180 this.selecting = item;
6181 }
6182 }
6183 };
6184
6185 /**
6186 * Handle mouse over events.
6187 *
6188 * @private
6189 * @param {jQuery.Event} e Mouse over event
6190 */
6191 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6192 var item;
6193 if ( this.blockMouseOverEvents ) {
6194 return;
6195 }
6196 if ( !this.isDisabled() ) {
6197 item = this.findTargetItem( e );
6198 this.highlightItem( item && item.isHighlightable() ? item : null );
6199 }
6200 return false;
6201 };
6202
6203 /**
6204 * Handle mouse leave events.
6205 *
6206 * @private
6207 * @param {jQuery.Event} e Mouse over event
6208 */
6209 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6210 if ( !this.isDisabled() ) {
6211 this.highlightItem( null );
6212 }
6213 return false;
6214 };
6215
6216 /**
6217 * Handle key down events.
6218 *
6219 * @protected
6220 * @param {KeyboardEvent} e Key down event
6221 */
6222 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6223 var nextItem,
6224 handled = false,
6225 currentItem = this.findHighlightedItem() || this.getSelectedItem();
6226
6227 if ( !this.isDisabled() && this.isVisible() ) {
6228 switch ( e.keyCode ) {
6229 case OO.ui.Keys.ENTER:
6230 if ( currentItem && currentItem.constructor.static.highlightable ) {
6231 // Was only highlighted, now let's select it. No-op if already selected.
6232 this.chooseItem( currentItem );
6233 handled = true;
6234 }
6235 break;
6236 case OO.ui.Keys.UP:
6237 case OO.ui.Keys.LEFT:
6238 this.clearKeyPressBuffer();
6239 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6240 handled = true;
6241 break;
6242 case OO.ui.Keys.DOWN:
6243 case OO.ui.Keys.RIGHT:
6244 this.clearKeyPressBuffer();
6245 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6246 handled = true;
6247 break;
6248 case OO.ui.Keys.ESCAPE:
6249 case OO.ui.Keys.TAB:
6250 if ( currentItem && currentItem.constructor.static.highlightable ) {
6251 currentItem.setHighlighted( false );
6252 }
6253 this.unbindKeyDownListener();
6254 this.unbindKeyPressListener();
6255 // Don't prevent tabbing away / defocusing
6256 handled = false;
6257 break;
6258 }
6259
6260 if ( nextItem ) {
6261 if ( nextItem.constructor.static.highlightable ) {
6262 this.highlightItem( nextItem );
6263 } else {
6264 this.chooseItem( nextItem );
6265 }
6266 this.scrollItemIntoView( nextItem );
6267 }
6268
6269 if ( handled ) {
6270 e.preventDefault();
6271 e.stopPropagation();
6272 }
6273 }
6274 };
6275
6276 /**
6277 * Bind key down listener.
6278 *
6279 * @protected
6280 */
6281 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6282 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6283 };
6284
6285 /**
6286 * Unbind key down listener.
6287 *
6288 * @protected
6289 */
6290 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6291 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6292 };
6293
6294 /**
6295 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6296 *
6297 * @param {OO.ui.OptionWidget} item Item to scroll into view
6298 */
6299 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6300 var widget = this;
6301 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6302 // and around 100-150 ms after it is finished.
6303 this.blockMouseOverEvents++;
6304 item.scrollElementIntoView().done( function () {
6305 setTimeout( function () {
6306 widget.blockMouseOverEvents--;
6307 }, 200 );
6308 } );
6309 };
6310
6311 /**
6312 * Clear the key-press buffer
6313 *
6314 * @protected
6315 */
6316 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6317 if ( this.keyPressBufferTimer ) {
6318 clearTimeout( this.keyPressBufferTimer );
6319 this.keyPressBufferTimer = null;
6320 }
6321 this.keyPressBuffer = '';
6322 };
6323
6324 /**
6325 * Handle key press events.
6326 *
6327 * @protected
6328 * @param {KeyboardEvent} e Key press event
6329 */
6330 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6331 var c, filter, item;
6332
6333 if ( !e.charCode ) {
6334 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6335 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6336 return false;
6337 }
6338 return;
6339 }
6340 if ( String.fromCodePoint ) {
6341 c = String.fromCodePoint( e.charCode );
6342 } else {
6343 c = String.fromCharCode( e.charCode );
6344 }
6345
6346 if ( this.keyPressBufferTimer ) {
6347 clearTimeout( this.keyPressBufferTimer );
6348 }
6349 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6350
6351 item = this.findHighlightedItem() || this.getSelectedItem();
6352
6353 if ( this.keyPressBuffer === c ) {
6354 // Common (if weird) special case: typing "xxxx" will cycle through all
6355 // the items beginning with "x".
6356 if ( item ) {
6357 item = this.findRelativeSelectableItem( item, 1 );
6358 }
6359 } else {
6360 this.keyPressBuffer += c;
6361 }
6362
6363 filter = this.getItemMatcher( this.keyPressBuffer, false );
6364 if ( !item || !filter( item ) ) {
6365 item = this.findRelativeSelectableItem( item, 1, filter );
6366 }
6367 if ( item ) {
6368 if ( this.isVisible() && item.constructor.static.highlightable ) {
6369 this.highlightItem( item );
6370 } else {
6371 this.chooseItem( item );
6372 }
6373 this.scrollItemIntoView( item );
6374 }
6375
6376 e.preventDefault();
6377 e.stopPropagation();
6378 };
6379
6380 /**
6381 * Get a matcher for the specific string
6382 *
6383 * @protected
6384 * @param {string} s String to match against items
6385 * @param {boolean} [exact=false] Only accept exact matches
6386 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6387 */
6388 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6389 var re;
6390
6391 if ( s.normalize ) {
6392 s = s.normalize();
6393 }
6394 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6395 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6396 if ( exact ) {
6397 re += '\\s*$';
6398 }
6399 re = new RegExp( re, 'i' );
6400 return function ( item ) {
6401 var matchText = item.getMatchText();
6402 if ( matchText.normalize ) {
6403 matchText = matchText.normalize();
6404 }
6405 return re.test( matchText );
6406 };
6407 };
6408
6409 /**
6410 * Bind key press listener.
6411 *
6412 * @protected
6413 */
6414 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6415 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6416 };
6417
6418 /**
6419 * Unbind key down listener.
6420 *
6421 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6422 * implementation.
6423 *
6424 * @protected
6425 */
6426 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6427 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6428 this.clearKeyPressBuffer();
6429 };
6430
6431 /**
6432 * Visibility change handler
6433 *
6434 * @protected
6435 * @param {boolean} visible
6436 */
6437 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6438 if ( !visible ) {
6439 this.clearKeyPressBuffer();
6440 }
6441 };
6442
6443 /**
6444 * Get the closest item to a jQuery.Event.
6445 *
6446 * @private
6447 * @param {jQuery.Event} e
6448 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6449 */
6450 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6451 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6452 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6453 return null;
6454 }
6455 return $option.data( 'oo-ui-optionWidget' ) || null;
6456 };
6457
6458 /**
6459 * Get selected item.
6460 *
6461 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6462 */
6463 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6464 var i, len;
6465
6466 for ( i = 0, len = this.items.length; i < len; i++ ) {
6467 if ( this.items[ i ].isSelected() ) {
6468 return this.items[ i ];
6469 }
6470 }
6471 return null;
6472 };
6473
6474 /**
6475 * Find highlighted item.
6476 *
6477 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6478 */
6479 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6480 var i, len;
6481
6482 for ( i = 0, len = this.items.length; i < len; i++ ) {
6483 if ( this.items[ i ].isHighlighted() ) {
6484 return this.items[ i ];
6485 }
6486 }
6487 return null;
6488 };
6489
6490 /**
6491 * Get highlighted item.
6492 *
6493 * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead.
6494 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6495 */
6496 OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
6497 OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' );
6498 return this.findHighlightedItem();
6499 };
6500
6501 /**
6502 * Toggle pressed state.
6503 *
6504 * Press is a state that occurs when a user mouses down on an item, but
6505 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6506 * until the user releases the mouse.
6507 *
6508 * @param {boolean} pressed An option is being pressed
6509 */
6510 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6511 if ( pressed === undefined ) {
6512 pressed = !this.pressed;
6513 }
6514 if ( pressed !== this.pressed ) {
6515 this.$element
6516 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6517 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6518 this.pressed = pressed;
6519 }
6520 };
6521
6522 /**
6523 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6524 * and any existing highlight will be removed. The highlight is mutually exclusive.
6525 *
6526 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6527 * @fires highlight
6528 * @chainable
6529 */
6530 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6531 var i, len, highlighted,
6532 changed = false;
6533
6534 for ( i = 0, len = this.items.length; i < len; i++ ) {
6535 highlighted = this.items[ i ] === item;
6536 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6537 this.items[ i ].setHighlighted( highlighted );
6538 changed = true;
6539 }
6540 }
6541 if ( changed ) {
6542 if ( item ) {
6543 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6544 } else {
6545 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6546 }
6547 this.emit( 'highlight', item );
6548 }
6549
6550 return this;
6551 };
6552
6553 /**
6554 * Fetch an item by its label.
6555 *
6556 * @param {string} label Label of the item to select.
6557 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6558 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6559 */
6560 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6561 var i, item, found,
6562 len = this.items.length,
6563 filter = this.getItemMatcher( label, true );
6564
6565 for ( i = 0; i < len; i++ ) {
6566 item = this.items[ i ];
6567 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6568 return item;
6569 }
6570 }
6571
6572 if ( prefix ) {
6573 found = null;
6574 filter = this.getItemMatcher( label, false );
6575 for ( i = 0; i < len; i++ ) {
6576 item = this.items[ i ];
6577 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6578 if ( found ) {
6579 return null;
6580 }
6581 found = item;
6582 }
6583 }
6584 if ( found ) {
6585 return found;
6586 }
6587 }
6588
6589 return null;
6590 };
6591
6592 /**
6593 * Programmatically select an option by its label. If the item does not exist,
6594 * all options will be deselected.
6595 *
6596 * @param {string} [label] Label of the item to select.
6597 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6598 * @fires select
6599 * @chainable
6600 */
6601 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6602 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6603 if ( label === undefined || !itemFromLabel ) {
6604 return this.selectItem();
6605 }
6606 return this.selectItem( itemFromLabel );
6607 };
6608
6609 /**
6610 * Programmatically select an option by its data. If the `data` parameter is omitted,
6611 * or if the item does not exist, all options will be deselected.
6612 *
6613 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6614 * @fires select
6615 * @chainable
6616 */
6617 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6618 var itemFromData = this.getItemFromData( data );
6619 if ( data === undefined || !itemFromData ) {
6620 return this.selectItem();
6621 }
6622 return this.selectItem( itemFromData );
6623 };
6624
6625 /**
6626 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6627 * all options will be deselected.
6628 *
6629 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6630 * @fires select
6631 * @chainable
6632 */
6633 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6634 var i, len, selected,
6635 changed = false;
6636
6637 for ( i = 0, len = this.items.length; i < len; i++ ) {
6638 selected = this.items[ i ] === item;
6639 if ( this.items[ i ].isSelected() !== selected ) {
6640 this.items[ i ].setSelected( selected );
6641 changed = true;
6642 }
6643 }
6644 if ( changed ) {
6645 if ( item && !item.constructor.static.highlightable ) {
6646 if ( item ) {
6647 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6648 } else {
6649 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6650 }
6651 }
6652 this.emit( 'select', item );
6653 }
6654
6655 return this;
6656 };
6657
6658 /**
6659 * Press an item.
6660 *
6661 * Press is a state that occurs when a user mouses down on an item, but has not
6662 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6663 * releases the mouse.
6664 *
6665 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6666 * @fires press
6667 * @chainable
6668 */
6669 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6670 var i, len, pressed,
6671 changed = false;
6672
6673 for ( i = 0, len = this.items.length; i < len; i++ ) {
6674 pressed = this.items[ i ] === item;
6675 if ( this.items[ i ].isPressed() !== pressed ) {
6676 this.items[ i ].setPressed( pressed );
6677 changed = true;
6678 }
6679 }
6680 if ( changed ) {
6681 this.emit( 'press', item );
6682 }
6683
6684 return this;
6685 };
6686
6687 /**
6688 * Choose an item.
6689 *
6690 * Note that ‘choose’ should never be modified programmatically. A user can choose
6691 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6692 * use the #selectItem method.
6693 *
6694 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6695 * when users choose an item with the keyboard or mouse.
6696 *
6697 * @param {OO.ui.OptionWidget} item Item to choose
6698 * @fires choose
6699 * @chainable
6700 */
6701 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6702 if ( item ) {
6703 this.selectItem( item );
6704 this.emit( 'choose', item );
6705 }
6706
6707 return this;
6708 };
6709
6710 /**
6711 * Find an option by its position relative to the specified item (or to the start of the option array,
6712 * if item is `null`). The direction in which to search through the option array is specified with a
6713 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6714 * `null` if there are no options in the array.
6715 *
6716 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6717 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6718 * @param {Function} [filter] Only consider items for which this function returns
6719 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6720 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6721 */
6722 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6723 var currentIndex, nextIndex, i,
6724 increase = direction > 0 ? 1 : -1,
6725 len = this.items.length;
6726
6727 if ( item instanceof OO.ui.OptionWidget ) {
6728 currentIndex = this.items.indexOf( item );
6729 nextIndex = ( currentIndex + increase + len ) % len;
6730 } else {
6731 // If no item is selected and moving forward, start at the beginning.
6732 // If moving backward, start at the end.
6733 nextIndex = direction > 0 ? 0 : len - 1;
6734 }
6735
6736 for ( i = 0; i < len; i++ ) {
6737 item = this.items[ nextIndex ];
6738 if (
6739 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6740 ( !filter || filter( item ) )
6741 ) {
6742 return item;
6743 }
6744 nextIndex = ( nextIndex + increase + len ) % len;
6745 }
6746 return null;
6747 };
6748
6749 /**
6750 * Get an option by its position relative to the specified item (or to the start of the option array,
6751 * if item is `null`). The direction in which to search through the option array is specified with a
6752 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6753 * `null` if there are no options in the array.
6754 *
6755 * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead
6756 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6757 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6758 * @param {Function} [filter] Only consider items for which this function returns
6759 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6760 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6761 */
6762 OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
6763 OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' );
6764 return this.findRelativeSelectableItem( item, direction, filter );
6765 };
6766
6767 /**
6768 * Find the next selectable item or `null` if there are no selectable items.
6769 * Disabled options and menu-section markers and breaks are not selectable.
6770 *
6771 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6772 */
6773 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
6774 return this.findRelativeSelectableItem( null, 1 );
6775 };
6776
6777 /**
6778 * Get the next selectable item or `null` if there are no selectable items.
6779 * Disabled options and menu-section markers and breaks are not selectable.
6780 *
6781 * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead.
6782 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6783 */
6784 OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
6785 OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' );
6786 return this.findFirstSelectableItem();
6787 };
6788
6789 /**
6790 * Add an array of options to the select. Optionally, an index number can be used to
6791 * specify an insertion point.
6792 *
6793 * @param {OO.ui.OptionWidget[]} items Items to add
6794 * @param {number} [index] Index to insert items after
6795 * @fires add
6796 * @chainable
6797 */
6798 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
6799 // Mixin method
6800 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
6801
6802 // Always provide an index, even if it was omitted
6803 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
6804
6805 return this;
6806 };
6807
6808 /**
6809 * Remove the specified array of options from the select. Options will be detached
6810 * from the DOM, not removed, so they can be reused later. To remove all options from
6811 * the select, you may wish to use the #clearItems method instead.
6812 *
6813 * @param {OO.ui.OptionWidget[]} items Items to remove
6814 * @fires remove
6815 * @chainable
6816 */
6817 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
6818 var i, len, item;
6819
6820 // Deselect items being removed
6821 for ( i = 0, len = items.length; i < len; i++ ) {
6822 item = items[ i ];
6823 if ( item.isSelected() ) {
6824 this.selectItem( null );
6825 }
6826 }
6827
6828 // Mixin method
6829 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
6830
6831 this.emit( 'remove', items );
6832
6833 return this;
6834 };
6835
6836 /**
6837 * Clear all options from the select. Options will be detached from the DOM, not removed,
6838 * so that they can be reused later. To remove a subset of options from the select, use
6839 * the #removeItems method.
6840 *
6841 * @fires remove
6842 * @chainable
6843 */
6844 OO.ui.SelectWidget.prototype.clearItems = function () {
6845 var items = this.items.slice();
6846
6847 // Mixin method
6848 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
6849
6850 // Clear selection
6851 this.selectItem( null );
6852
6853 this.emit( 'remove', items );
6854
6855 return this;
6856 };
6857
6858 /**
6859 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
6860 *
6861 * Currently this is just used to set `aria-activedescendant` on it.
6862 *
6863 * @protected
6864 * @param {jQuery} $focusOwner
6865 */
6866 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
6867 this.$focusOwner = $focusOwner;
6868 };
6869
6870 /**
6871 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
6872 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
6873 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
6874 * options. For more information about options and selects, please see the
6875 * [OOjs UI documentation on MediaWiki][1].
6876 *
6877 * @example
6878 * // Decorated options in a select widget
6879 * var select = new OO.ui.SelectWidget( {
6880 * items: [
6881 * new OO.ui.DecoratedOptionWidget( {
6882 * data: 'a',
6883 * label: 'Option with icon',
6884 * icon: 'help'
6885 * } ),
6886 * new OO.ui.DecoratedOptionWidget( {
6887 * data: 'b',
6888 * label: 'Option with indicator',
6889 * indicator: 'next'
6890 * } )
6891 * ]
6892 * } );
6893 * $( 'body' ).append( select.$element );
6894 *
6895 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6896 *
6897 * @class
6898 * @extends OO.ui.OptionWidget
6899 * @mixins OO.ui.mixin.IconElement
6900 * @mixins OO.ui.mixin.IndicatorElement
6901 *
6902 * @constructor
6903 * @param {Object} [config] Configuration options
6904 */
6905 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
6906 // Parent constructor
6907 OO.ui.DecoratedOptionWidget.parent.call( this, config );
6908
6909 // Mixin constructors
6910 OO.ui.mixin.IconElement.call( this, config );
6911 OO.ui.mixin.IndicatorElement.call( this, config );
6912
6913 // Initialization
6914 this.$element
6915 .addClass( 'oo-ui-decoratedOptionWidget' )
6916 .prepend( this.$icon )
6917 .append( this.$indicator );
6918 };
6919
6920 /* Setup */
6921
6922 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
6923 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
6924 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
6925
6926 /**
6927 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
6928 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
6929 * the [OOjs UI documentation on MediaWiki] [1] for more information.
6930 *
6931 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
6932 *
6933 * @class
6934 * @extends OO.ui.DecoratedOptionWidget
6935 *
6936 * @constructor
6937 * @param {Object} [config] Configuration options
6938 */
6939 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
6940 // Parent constructor
6941 OO.ui.MenuOptionWidget.parent.call( this, config );
6942
6943 // Initialization
6944 this.$element.addClass( 'oo-ui-menuOptionWidget' );
6945 };
6946
6947 /* Setup */
6948
6949 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
6950
6951 /* Static Properties */
6952
6953 /**
6954 * @static
6955 * @inheritdoc
6956 */
6957 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
6958
6959 /**
6960 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
6961 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
6962 *
6963 * @example
6964 * var myDropdown = new OO.ui.DropdownWidget( {
6965 * menu: {
6966 * items: [
6967 * new OO.ui.MenuSectionOptionWidget( {
6968 * label: 'Dogs'
6969 * } ),
6970 * new OO.ui.MenuOptionWidget( {
6971 * data: 'corgi',
6972 * label: 'Welsh Corgi'
6973 * } ),
6974 * new OO.ui.MenuOptionWidget( {
6975 * data: 'poodle',
6976 * label: 'Standard Poodle'
6977 * } ),
6978 * new OO.ui.MenuSectionOptionWidget( {
6979 * label: 'Cats'
6980 * } ),
6981 * new OO.ui.MenuOptionWidget( {
6982 * data: 'lion',
6983 * label: 'Lion'
6984 * } )
6985 * ]
6986 * }
6987 * } );
6988 * $( 'body' ).append( myDropdown.$element );
6989 *
6990 * @class
6991 * @extends OO.ui.DecoratedOptionWidget
6992 *
6993 * @constructor
6994 * @param {Object} [config] Configuration options
6995 */
6996 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
6997 // Parent constructor
6998 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
6999
7000 // Initialization
7001 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7002 .removeAttr( 'role aria-selected' );
7003 };
7004
7005 /* Setup */
7006
7007 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7008
7009 /* Static Properties */
7010
7011 /**
7012 * @static
7013 * @inheritdoc
7014 */
7015 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7016
7017 /**
7018 * @static
7019 * @inheritdoc
7020 */
7021 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7022
7023 /**
7024 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7025 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7026 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7027 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7028 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7029 * and customized to be opened, closed, and displayed as needed.
7030 *
7031 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7032 * mouse outside the menu.
7033 *
7034 * Menus also have support for keyboard interaction:
7035 *
7036 * - Enter/Return key: choose and select a menu option
7037 * - Up-arrow key: highlight the previous menu option
7038 * - Down-arrow key: highlight the next menu option
7039 * - Esc key: hide the menu
7040 *
7041 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7042 *
7043 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7044 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7045 *
7046 * @class
7047 * @extends OO.ui.SelectWidget
7048 * @mixins OO.ui.mixin.ClippableElement
7049 * @mixins OO.ui.mixin.FloatableElement
7050 *
7051 * @constructor
7052 * @param {Object} [config] Configuration options
7053 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7054 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7055 * and {@link OO.ui.mixin.LookupElement LookupElement}
7056 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7057 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7058 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7059 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7060 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7061 * that button, unless the button (or its parent widget) is passed in here.
7062 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7063 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7064 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7065 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7066 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7067 * @cfg {number} [width] Width of the menu
7068 */
7069 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7070 // Configuration initialization
7071 config = config || {};
7072
7073 // Parent constructor
7074 OO.ui.MenuSelectWidget.parent.call( this, config );
7075
7076 // Mixin constructors
7077 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7078 OO.ui.mixin.FloatableElement.call( this, config );
7079
7080 // Properties
7081 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7082 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7083 this.filterFromInput = !!config.filterFromInput;
7084 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7085 this.$widget = config.widget ? config.widget.$element : null;
7086 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7087 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7088 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7089 this.highlightOnFilter = !!config.highlightOnFilter;
7090 this.width = config.width;
7091
7092 // Initialization
7093 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7094 if ( config.widget ) {
7095 this.setFocusOwner( config.widget.$tabIndexed );
7096 }
7097
7098 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7099 // that reference properties not initialized at that time of parent class construction
7100 // TODO: Find a better way to handle post-constructor setup
7101 this.visible = false;
7102 this.$element.addClass( 'oo-ui-element-hidden' );
7103 };
7104
7105 /* Setup */
7106
7107 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7108 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7109 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7110
7111 /* Events */
7112
7113 /**
7114 * @event ready
7115 *
7116 * The menu is ready: it is visible and has been positioned and clipped.
7117 */
7118
7119 /* Methods */
7120
7121 /**
7122 * Handles document mouse down events.
7123 *
7124 * @protected
7125 * @param {MouseEvent} e Mouse down event
7126 */
7127 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7128 if (
7129 this.isVisible() &&
7130 !OO.ui.contains(
7131 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7132 e.target,
7133 true
7134 )
7135 ) {
7136 this.toggle( false );
7137 }
7138 };
7139
7140 /**
7141 * @inheritdoc
7142 */
7143 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7144 var currentItem = this.findHighlightedItem() || this.getSelectedItem();
7145
7146 if ( !this.isDisabled() && this.isVisible() ) {
7147 switch ( e.keyCode ) {
7148 case OO.ui.Keys.LEFT:
7149 case OO.ui.Keys.RIGHT:
7150 // Do nothing if a text field is associated, arrow keys will be handled natively
7151 if ( !this.$input ) {
7152 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7153 }
7154 break;
7155 case OO.ui.Keys.ESCAPE:
7156 case OO.ui.Keys.TAB:
7157 if ( currentItem ) {
7158 currentItem.setHighlighted( false );
7159 }
7160 this.toggle( false );
7161 // Don't prevent tabbing away, prevent defocusing
7162 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7163 e.preventDefault();
7164 e.stopPropagation();
7165 }
7166 break;
7167 default:
7168 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7169 return;
7170 }
7171 }
7172 };
7173
7174 /**
7175 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7176 * or after items were added/removed (always).
7177 *
7178 * @protected
7179 */
7180 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7181 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7182 firstItemFound = false,
7183 anyVisible = false,
7184 len = this.items.length,
7185 showAll = !this.isVisible(),
7186 exactMatch = false;
7187
7188 if ( this.$input && this.filterFromInput ) {
7189 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7190 exactFilter = this.getItemMatcher( this.$input.val(), true );
7191
7192 // Hide non-matching options, and also hide section headers if all options
7193 // in their section are hidden.
7194 for ( i = 0; i < len; i++ ) {
7195 item = this.items[ i ];
7196 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7197 if ( section ) {
7198 // If the previous section was empty, hide its header
7199 section.toggle( showAll || !sectionEmpty );
7200 }
7201 section = item;
7202 sectionEmpty = true;
7203 } else if ( item instanceof OO.ui.OptionWidget ) {
7204 visible = showAll || filter( item );
7205 exactMatch = exactMatch || exactFilter( item );
7206 anyVisible = anyVisible || visible;
7207 sectionEmpty = sectionEmpty && !visible;
7208 item.toggle( visible );
7209 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7210 // Highlight the first item in the list
7211 this.highlightItem( item );
7212 firstItemFound = true;
7213 }
7214 }
7215 }
7216 // Process the final section
7217 if ( section ) {
7218 section.toggle( showAll || !sectionEmpty );
7219 }
7220
7221 if ( anyVisible && this.items.length && !exactMatch ) {
7222 this.scrollItemIntoView( this.items[ 0 ] );
7223 }
7224
7225 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7226 }
7227
7228 // Reevaluate clipping
7229 this.clip();
7230 };
7231
7232 /**
7233 * @inheritdoc
7234 */
7235 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7236 if ( this.$input ) {
7237 this.$input.on( 'keydown', this.onKeyDownHandler );
7238 } else {
7239 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7240 }
7241 };
7242
7243 /**
7244 * @inheritdoc
7245 */
7246 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7247 if ( this.$input ) {
7248 this.$input.off( 'keydown', this.onKeyDownHandler );
7249 } else {
7250 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7251 }
7252 };
7253
7254 /**
7255 * @inheritdoc
7256 */
7257 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7258 if ( this.$input ) {
7259 if ( this.filterFromInput ) {
7260 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7261 this.updateItemVisibility();
7262 }
7263 } else {
7264 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7265 }
7266 };
7267
7268 /**
7269 * @inheritdoc
7270 */
7271 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7272 if ( this.$input ) {
7273 if ( this.filterFromInput ) {
7274 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7275 this.updateItemVisibility();
7276 }
7277 } else {
7278 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7279 }
7280 };
7281
7282 /**
7283 * Choose an item.
7284 *
7285 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7286 *
7287 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7288 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7289 *
7290 * @param {OO.ui.OptionWidget} item Item to choose
7291 * @chainable
7292 */
7293 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7294 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7295 if ( this.hideOnChoose ) {
7296 this.toggle( false );
7297 }
7298 return this;
7299 };
7300
7301 /**
7302 * @inheritdoc
7303 */
7304 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7305 // Parent method
7306 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7307
7308 this.updateItemVisibility();
7309
7310 return this;
7311 };
7312
7313 /**
7314 * @inheritdoc
7315 */
7316 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7317 // Parent method
7318 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7319
7320 this.updateItemVisibility();
7321
7322 return this;
7323 };
7324
7325 /**
7326 * @inheritdoc
7327 */
7328 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7329 // Parent method
7330 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7331
7332 this.updateItemVisibility();
7333
7334 return this;
7335 };
7336
7337 /**
7338 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7339 * `.toggle( true )` after its #$element is attached to the DOM.
7340 *
7341 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7342 * it in the right place and with the right dimensions only work correctly while it is attached.
7343 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7344 * strictly enforced, so currently it only generates a warning in the browser console.
7345 *
7346 * @fires ready
7347 * @inheritdoc
7348 */
7349 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7350 var change;
7351
7352 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7353 change = visible !== this.isVisible();
7354
7355 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7356 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7357 this.warnedUnattached = true;
7358 }
7359
7360 if ( change && visible && ( this.width || this.$floatableContainer ) ) {
7361 this.setIdealSize( this.width || this.$floatableContainer.width() );
7362 }
7363
7364 // Parent method
7365 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7366
7367 if ( change ) {
7368 if ( visible ) {
7369 this.bindKeyDownListener();
7370 this.bindKeyPressListener();
7371
7372 this.togglePositioning( !!this.$floatableContainer );
7373 this.toggleClipping( true );
7374
7375 this.$focusOwner.attr( 'aria-expanded', 'true' );
7376
7377 if ( this.getSelectedItem() ) {
7378 this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
7379 this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
7380 }
7381
7382 // Auto-hide
7383 if ( this.autoHide ) {
7384 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7385 }
7386
7387 this.emit( 'ready' );
7388 } else {
7389 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7390 this.unbindKeyDownListener();
7391 this.unbindKeyPressListener();
7392 this.$focusOwner.attr( 'aria-expanded', 'false' );
7393 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7394 this.togglePositioning( false );
7395 this.toggleClipping( false );
7396 }
7397 }
7398
7399 return this;
7400 };
7401
7402 /**
7403 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7404 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7405 * users can interact with it.
7406 *
7407 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7408 * OO.ui.DropdownInputWidget instead.
7409 *
7410 * @example
7411 * // Example: A DropdownWidget with a menu that contains three options
7412 * var dropDown = new OO.ui.DropdownWidget( {
7413 * label: 'Dropdown menu: Select a menu option',
7414 * menu: {
7415 * items: [
7416 * new OO.ui.MenuOptionWidget( {
7417 * data: 'a',
7418 * label: 'First'
7419 * } ),
7420 * new OO.ui.MenuOptionWidget( {
7421 * data: 'b',
7422 * label: 'Second'
7423 * } ),
7424 * new OO.ui.MenuOptionWidget( {
7425 * data: 'c',
7426 * label: 'Third'
7427 * } )
7428 * ]
7429 * }
7430 * } );
7431 *
7432 * $( 'body' ).append( dropDown.$element );
7433 *
7434 * dropDown.getMenu().selectItemByData( 'b' );
7435 *
7436 * dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
7437 *
7438 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
7439 *
7440 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7441 *
7442 * @class
7443 * @extends OO.ui.Widget
7444 * @mixins OO.ui.mixin.IconElement
7445 * @mixins OO.ui.mixin.IndicatorElement
7446 * @mixins OO.ui.mixin.LabelElement
7447 * @mixins OO.ui.mixin.TitledElement
7448 * @mixins OO.ui.mixin.TabIndexedElement
7449 *
7450 * @constructor
7451 * @param {Object} [config] Configuration options
7452 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7453 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7454 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7455 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7456 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
7457 */
7458 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7459 // Configuration initialization
7460 config = $.extend( { indicator: 'down' }, config );
7461
7462 // Parent constructor
7463 OO.ui.DropdownWidget.parent.call( this, config );
7464
7465 // Properties (must be set before TabIndexedElement constructor call)
7466 this.$handle = this.$( '<span>' );
7467 this.$overlay = config.$overlay || this.$element;
7468
7469 // Mixin constructors
7470 OO.ui.mixin.IconElement.call( this, config );
7471 OO.ui.mixin.IndicatorElement.call( this, config );
7472 OO.ui.mixin.LabelElement.call( this, config );
7473 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7474 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7475
7476 // Properties
7477 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7478 widget: this,
7479 $floatableContainer: this.$element
7480 }, config.menu ) );
7481
7482 // Events
7483 this.$handle.on( {
7484 click: this.onClick.bind( this ),
7485 keydown: this.onKeyDown.bind( this ),
7486 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7487 keypress: this.menu.onKeyPressHandler,
7488 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7489 } );
7490 this.menu.connect( this, {
7491 select: 'onMenuSelect',
7492 toggle: 'onMenuToggle'
7493 } );
7494
7495 // Initialization
7496 this.$handle
7497 .addClass( 'oo-ui-dropdownWidget-handle' )
7498 .attr( {
7499 role: 'combobox',
7500 'aria-owns': this.menu.getElementId(),
7501 'aria-autocomplete': 'list'
7502 } )
7503 .append( this.$icon, this.$label, this.$indicator );
7504 this.$element
7505 .addClass( 'oo-ui-dropdownWidget' )
7506 .append( this.$handle );
7507 this.$overlay.append( this.menu.$element );
7508 };
7509
7510 /* Setup */
7511
7512 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7513 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7514 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7515 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7516 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7517 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7518
7519 /* Methods */
7520
7521 /**
7522 * Get the menu.
7523 *
7524 * @return {OO.ui.MenuSelectWidget} Menu of widget
7525 */
7526 OO.ui.DropdownWidget.prototype.getMenu = function () {
7527 return this.menu;
7528 };
7529
7530 /**
7531 * Handles menu select events.
7532 *
7533 * @private
7534 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7535 */
7536 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7537 var selectedLabel;
7538
7539 if ( !item ) {
7540 this.setLabel( null );
7541 return;
7542 }
7543
7544 selectedLabel = item.getLabel();
7545
7546 // If the label is a DOM element, clone it, because setLabel will append() it
7547 if ( selectedLabel instanceof jQuery ) {
7548 selectedLabel = selectedLabel.clone();
7549 }
7550
7551 this.setLabel( selectedLabel );
7552 };
7553
7554 /**
7555 * Handle menu toggle events.
7556 *
7557 * @private
7558 * @param {boolean} isVisible Menu toggle event
7559 */
7560 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7561 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7562 this.$handle.attr(
7563 'aria-expanded',
7564 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7565 );
7566 };
7567
7568 /**
7569 * Handle mouse click events.
7570 *
7571 * @private
7572 * @param {jQuery.Event} e Mouse click event
7573 */
7574 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7575 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7576 this.menu.toggle();
7577 }
7578 return false;
7579 };
7580
7581 /**
7582 * Handle key down events.
7583 *
7584 * @private
7585 * @param {jQuery.Event} e Key down event
7586 */
7587 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7588 if (
7589 !this.isDisabled() &&
7590 (
7591 e.which === OO.ui.Keys.ENTER ||
7592 (
7593 !this.menu.isVisible() &&
7594 (
7595 e.which === OO.ui.Keys.SPACE ||
7596 e.which === OO.ui.Keys.UP ||
7597 e.which === OO.ui.Keys.DOWN
7598 )
7599 )
7600 )
7601 ) {
7602 this.menu.toggle();
7603 return false;
7604 }
7605 };
7606
7607 /**
7608 * RadioOptionWidget is an option widget that looks like a radio button.
7609 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7610 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7611 *
7612 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7613 *
7614 * @class
7615 * @extends OO.ui.OptionWidget
7616 *
7617 * @constructor
7618 * @param {Object} [config] Configuration options
7619 */
7620 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7621 // Configuration initialization
7622 config = config || {};
7623
7624 // Properties (must be done before parent constructor which calls #setDisabled)
7625 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7626
7627 // Parent constructor
7628 OO.ui.RadioOptionWidget.parent.call( this, config );
7629
7630 // Initialization
7631 // Remove implicit role, we're handling it ourselves
7632 this.radio.$input.attr( 'role', 'presentation' );
7633 this.$element
7634 .addClass( 'oo-ui-radioOptionWidget' )
7635 .attr( 'role', 'radio' )
7636 .attr( 'aria-checked', 'false' )
7637 .removeAttr( 'aria-selected' )
7638 .prepend( this.radio.$element );
7639 };
7640
7641 /* Setup */
7642
7643 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7644
7645 /* Static Properties */
7646
7647 /**
7648 * @static
7649 * @inheritdoc
7650 */
7651 OO.ui.RadioOptionWidget.static.highlightable = false;
7652
7653 /**
7654 * @static
7655 * @inheritdoc
7656 */
7657 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7658
7659 /**
7660 * @static
7661 * @inheritdoc
7662 */
7663 OO.ui.RadioOptionWidget.static.pressable = false;
7664
7665 /**
7666 * @static
7667 * @inheritdoc
7668 */
7669 OO.ui.RadioOptionWidget.static.tagName = 'label';
7670
7671 /* Methods */
7672
7673 /**
7674 * @inheritdoc
7675 */
7676 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7677 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7678
7679 this.radio.setSelected( state );
7680 this.$element
7681 .attr( 'aria-checked', state.toString() )
7682 .removeAttr( 'aria-selected' );
7683
7684 return this;
7685 };
7686
7687 /**
7688 * @inheritdoc
7689 */
7690 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7691 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7692
7693 this.radio.setDisabled( this.isDisabled() );
7694
7695 return this;
7696 };
7697
7698 /**
7699 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7700 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7701 * an interface for adding, removing and selecting options.
7702 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7703 *
7704 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7705 * OO.ui.RadioSelectInputWidget instead.
7706 *
7707 * @example
7708 * // A RadioSelectWidget with RadioOptions.
7709 * var option1 = new OO.ui.RadioOptionWidget( {
7710 * data: 'a',
7711 * label: 'Selected radio option'
7712 * } );
7713 *
7714 * var option2 = new OO.ui.RadioOptionWidget( {
7715 * data: 'b',
7716 * label: 'Unselected radio option'
7717 * } );
7718 *
7719 * var radioSelect=new OO.ui.RadioSelectWidget( {
7720 * items: [ option1, option2 ]
7721 * } );
7722 *
7723 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7724 * radioSelect.selectItem( option1 );
7725 *
7726 * $( 'body' ).append( radioSelect.$element );
7727 *
7728 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7729
7730 *
7731 * @class
7732 * @extends OO.ui.SelectWidget
7733 * @mixins OO.ui.mixin.TabIndexedElement
7734 *
7735 * @constructor
7736 * @param {Object} [config] Configuration options
7737 */
7738 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7739 // Parent constructor
7740 OO.ui.RadioSelectWidget.parent.call( this, config );
7741
7742 // Mixin constructors
7743 OO.ui.mixin.TabIndexedElement.call( this, config );
7744
7745 // Events
7746 this.$element.on( {
7747 focus: this.bindKeyDownListener.bind( this ),
7748 blur: this.unbindKeyDownListener.bind( this )
7749 } );
7750
7751 // Initialization
7752 this.$element
7753 .addClass( 'oo-ui-radioSelectWidget' )
7754 .attr( 'role', 'radiogroup' );
7755 };
7756
7757 /* Setup */
7758
7759 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
7760 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
7761
7762 /**
7763 * MultioptionWidgets are special elements that can be selected and configured with data. The
7764 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
7765 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
7766 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
7767 *
7768 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
7769 *
7770 * @class
7771 * @extends OO.ui.Widget
7772 * @mixins OO.ui.mixin.ItemWidget
7773 * @mixins OO.ui.mixin.LabelElement
7774 *
7775 * @constructor
7776 * @param {Object} [config] Configuration options
7777 * @cfg {boolean} [selected=false] Whether the option is initially selected
7778 */
7779 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
7780 // Configuration initialization
7781 config = config || {};
7782
7783 // Parent constructor
7784 OO.ui.MultioptionWidget.parent.call( this, config );
7785
7786 // Mixin constructors
7787 OO.ui.mixin.ItemWidget.call( this );
7788 OO.ui.mixin.LabelElement.call( this, config );
7789
7790 // Properties
7791 this.selected = null;
7792
7793 // Initialization
7794 this.$element
7795 .addClass( 'oo-ui-multioptionWidget' )
7796 .append( this.$label );
7797 this.setSelected( config.selected );
7798 };
7799
7800 /* Setup */
7801
7802 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
7803 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
7804 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
7805
7806 /* Events */
7807
7808 /**
7809 * @event change
7810 *
7811 * A change event is emitted when the selected state of the option changes.
7812 *
7813 * @param {boolean} selected Whether the option is now selected
7814 */
7815
7816 /* Methods */
7817
7818 /**
7819 * Check if the option is selected.
7820 *
7821 * @return {boolean} Item is selected
7822 */
7823 OO.ui.MultioptionWidget.prototype.isSelected = function () {
7824 return this.selected;
7825 };
7826
7827 /**
7828 * Set the option’s selected state. In general, all modifications to the selection
7829 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
7830 * method instead of this method.
7831 *
7832 * @param {boolean} [state=false] Select option
7833 * @chainable
7834 */
7835 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
7836 state = !!state;
7837 if ( this.selected !== state ) {
7838 this.selected = state;
7839 this.emit( 'change', state );
7840 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
7841 }
7842 return this;
7843 };
7844
7845 /**
7846 * MultiselectWidget allows selecting multiple options from a list.
7847 *
7848 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
7849 *
7850 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7851 *
7852 * @class
7853 * @abstract
7854 * @extends OO.ui.Widget
7855 * @mixins OO.ui.mixin.GroupWidget
7856 *
7857 * @constructor
7858 * @param {Object} [config] Configuration options
7859 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
7860 */
7861 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
7862 // Parent constructor
7863 OO.ui.MultiselectWidget.parent.call( this, config );
7864
7865 // Configuration initialization
7866 config = config || {};
7867
7868 // Mixin constructors
7869 OO.ui.mixin.GroupWidget.call( this, config );
7870
7871 // Events
7872 this.aggregate( { change: 'select' } );
7873 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
7874 // by GroupElement only when items are added/removed
7875 this.connect( this, { select: [ 'emit', 'change' ] } );
7876
7877 // Initialization
7878 if ( config.items ) {
7879 this.addItems( config.items );
7880 }
7881 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
7882 this.$element.addClass( 'oo-ui-multiselectWidget' )
7883 .append( this.$group );
7884 };
7885
7886 /* Setup */
7887
7888 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
7889 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
7890
7891 /* Events */
7892
7893 /**
7894 * @event change
7895 *
7896 * A change event is emitted when the set of items changes, or an item is selected or deselected.
7897 */
7898
7899 /**
7900 * @event select
7901 *
7902 * A select event is emitted when an item is selected or deselected.
7903 */
7904
7905 /* Methods */
7906
7907 /**
7908 * Get options that are selected.
7909 *
7910 * @return {OO.ui.MultioptionWidget[]} Selected options
7911 */
7912 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
7913 return this.items.filter( function ( item ) {
7914 return item.isSelected();
7915 } );
7916 };
7917
7918 /**
7919 * Get the data of options that are selected.
7920 *
7921 * @return {Object[]|string[]} Values of selected options
7922 */
7923 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
7924 return this.getSelectedItems().map( function ( item ) {
7925 return item.data;
7926 } );
7927 };
7928
7929 /**
7930 * Select options by reference. Options not mentioned in the `items` array will be deselected.
7931 *
7932 * @param {OO.ui.MultioptionWidget[]} items Items to select
7933 * @chainable
7934 */
7935 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
7936 this.items.forEach( function ( item ) {
7937 var selected = items.indexOf( item ) !== -1;
7938 item.setSelected( selected );
7939 } );
7940 return this;
7941 };
7942
7943 /**
7944 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
7945 *
7946 * @param {Object[]|string[]} datas Values of items to select
7947 * @chainable
7948 */
7949 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
7950 var items,
7951 widget = this;
7952 items = datas.map( function ( data ) {
7953 return widget.getItemFromData( data );
7954 } );
7955 this.selectItems( items );
7956 return this;
7957 };
7958
7959 /**
7960 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
7961 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
7962 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7963 *
7964 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7965 *
7966 * @class
7967 * @extends OO.ui.MultioptionWidget
7968 *
7969 * @constructor
7970 * @param {Object} [config] Configuration options
7971 */
7972 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
7973 // Configuration initialization
7974 config = config || {};
7975
7976 // Properties (must be done before parent constructor which calls #setDisabled)
7977 this.checkbox = new OO.ui.CheckboxInputWidget();
7978
7979 // Parent constructor
7980 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
7981
7982 // Events
7983 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
7984 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
7985
7986 // Initialization
7987 this.$element
7988 .addClass( 'oo-ui-checkboxMultioptionWidget' )
7989 .prepend( this.checkbox.$element );
7990 };
7991
7992 /* Setup */
7993
7994 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
7995
7996 /* Static Properties */
7997
7998 /**
7999 * @static
8000 * @inheritdoc
8001 */
8002 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8003
8004 /* Methods */
8005
8006 /**
8007 * Handle checkbox selected state change.
8008 *
8009 * @private
8010 */
8011 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8012 this.setSelected( this.checkbox.isSelected() );
8013 };
8014
8015 /**
8016 * @inheritdoc
8017 */
8018 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8019 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8020 this.checkbox.setSelected( state );
8021 return this;
8022 };
8023
8024 /**
8025 * @inheritdoc
8026 */
8027 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8028 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8029 this.checkbox.setDisabled( this.isDisabled() );
8030 return this;
8031 };
8032
8033 /**
8034 * Focus the widget.
8035 */
8036 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8037 this.checkbox.focus();
8038 };
8039
8040 /**
8041 * Handle key down events.
8042 *
8043 * @protected
8044 * @param {jQuery.Event} e
8045 */
8046 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8047 var
8048 element = this.getElementGroup(),
8049 nextItem;
8050
8051 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8052 nextItem = element.getRelativeFocusableItem( this, -1 );
8053 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8054 nextItem = element.getRelativeFocusableItem( this, 1 );
8055 }
8056
8057 if ( nextItem ) {
8058 e.preventDefault();
8059 nextItem.focus();
8060 }
8061 };
8062
8063 /**
8064 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8065 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8066 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8067 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8068 *
8069 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8070 * OO.ui.CheckboxMultiselectInputWidget instead.
8071 *
8072 * @example
8073 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8074 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8075 * data: 'a',
8076 * selected: true,
8077 * label: 'Selected checkbox'
8078 * } );
8079 *
8080 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8081 * data: 'b',
8082 * label: 'Unselected checkbox'
8083 * } );
8084 *
8085 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8086 * items: [ option1, option2 ]
8087 * } );
8088 *
8089 * $( 'body' ).append( multiselect.$element );
8090 *
8091 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8092 *
8093 * @class
8094 * @extends OO.ui.MultiselectWidget
8095 *
8096 * @constructor
8097 * @param {Object} [config] Configuration options
8098 */
8099 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8100 // Parent constructor
8101 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8102
8103 // Properties
8104 this.$lastClicked = null;
8105
8106 // Events
8107 this.$group.on( 'click', this.onClick.bind( this ) );
8108
8109 // Initialization
8110 this.$element
8111 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8112 };
8113
8114 /* Setup */
8115
8116 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8117
8118 /* Methods */
8119
8120 /**
8121 * Get an option by its position relative to the specified item (or to the start of the option array,
8122 * if item is `null`). The direction in which to search through the option array is specified with a
8123 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8124 * `null` if there are no options in the array.
8125 *
8126 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8127 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8128 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8129 */
8130 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8131 var currentIndex, nextIndex, i,
8132 increase = direction > 0 ? 1 : -1,
8133 len = this.items.length;
8134
8135 if ( item ) {
8136 currentIndex = this.items.indexOf( item );
8137 nextIndex = ( currentIndex + increase + len ) % len;
8138 } else {
8139 // If no item is selected and moving forward, start at the beginning.
8140 // If moving backward, start at the end.
8141 nextIndex = direction > 0 ? 0 : len - 1;
8142 }
8143
8144 for ( i = 0; i < len; i++ ) {
8145 item = this.items[ nextIndex ];
8146 if ( item && !item.isDisabled() ) {
8147 return item;
8148 }
8149 nextIndex = ( nextIndex + increase + len ) % len;
8150 }
8151 return null;
8152 };
8153
8154 /**
8155 * Handle click events on checkboxes.
8156 *
8157 * @param {jQuery.Event} e
8158 */
8159 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8160 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8161 $lastClicked = this.$lastClicked,
8162 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8163 .not( '.oo-ui-widget-disabled' );
8164
8165 // Allow selecting multiple options at once by Shift-clicking them
8166 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8167 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8168 lastClickedIndex = $options.index( $lastClicked );
8169 nowClickedIndex = $options.index( $nowClicked );
8170 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8171 // browser. In either case we don't need custom handling.
8172 if ( nowClickedIndex !== lastClickedIndex ) {
8173 items = this.items;
8174 wasSelected = items[ nowClickedIndex ].isSelected();
8175 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8176
8177 // This depends on the DOM order of the items and the order of the .items array being the same.
8178 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8179 if ( !items[ i ].isDisabled() ) {
8180 items[ i ].setSelected( !wasSelected );
8181 }
8182 }
8183 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8184 // handling first, then set our value. The order in which events happen is different for
8185 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8186 // non-click actions that change the checkboxes.
8187 e.preventDefault();
8188 setTimeout( function () {
8189 if ( !items[ nowClickedIndex ].isDisabled() ) {
8190 items[ nowClickedIndex ].setSelected( !wasSelected );
8191 }
8192 } );
8193 }
8194 }
8195
8196 if ( $nowClicked.length ) {
8197 this.$lastClicked = $nowClicked;
8198 }
8199 };
8200
8201 /**
8202 * Focus the widget
8203 *
8204 * @chainable
8205 */
8206 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8207 var item;
8208 if ( !this.isDisabled() ) {
8209 item = this.getRelativeFocusableItem( null, 1 );
8210 if ( item ) {
8211 item.focus();
8212 }
8213 }
8214 return this;
8215 };
8216
8217 /**
8218 * @inheritdoc
8219 */
8220 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8221 this.focus();
8222 };
8223
8224 /**
8225 * Progress bars visually display the status of an operation, such as a download,
8226 * and can be either determinate or indeterminate:
8227 *
8228 * - **determinate** process bars show the percent of an operation that is complete.
8229 *
8230 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8231 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8232 * not use percentages.
8233 *
8234 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8235 *
8236 * @example
8237 * // Examples of determinate and indeterminate progress bars.
8238 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8239 * progress: 33
8240 * } );
8241 * var progressBar2 = new OO.ui.ProgressBarWidget();
8242 *
8243 * // Create a FieldsetLayout to layout progress bars
8244 * var fieldset = new OO.ui.FieldsetLayout;
8245 * fieldset.addItems( [
8246 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8247 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8248 * ] );
8249 * $( 'body' ).append( fieldset.$element );
8250 *
8251 * @class
8252 * @extends OO.ui.Widget
8253 *
8254 * @constructor
8255 * @param {Object} [config] Configuration options
8256 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8257 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8258 * By default, the progress bar is indeterminate.
8259 */
8260 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8261 // Configuration initialization
8262 config = config || {};
8263
8264 // Parent constructor
8265 OO.ui.ProgressBarWidget.parent.call( this, config );
8266
8267 // Properties
8268 this.$bar = $( '<div>' );
8269 this.progress = null;
8270
8271 // Initialization
8272 this.setProgress( config.progress !== undefined ? config.progress : false );
8273 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8274 this.$element
8275 .attr( {
8276 role: 'progressbar',
8277 'aria-valuemin': 0,
8278 'aria-valuemax': 100
8279 } )
8280 .addClass( 'oo-ui-progressBarWidget' )
8281 .append( this.$bar );
8282 };
8283
8284 /* Setup */
8285
8286 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8287
8288 /* Static Properties */
8289
8290 /**
8291 * @static
8292 * @inheritdoc
8293 */
8294 OO.ui.ProgressBarWidget.static.tagName = 'div';
8295
8296 /* Methods */
8297
8298 /**
8299 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8300 *
8301 * @return {number|boolean} Progress percent
8302 */
8303 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8304 return this.progress;
8305 };
8306
8307 /**
8308 * Set the percent of the process completed or `false` for an indeterminate process.
8309 *
8310 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8311 */
8312 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8313 this.progress = progress;
8314
8315 if ( progress !== false ) {
8316 this.$bar.css( 'width', this.progress + '%' );
8317 this.$element.attr( 'aria-valuenow', this.progress );
8318 } else {
8319 this.$bar.css( 'width', '' );
8320 this.$element.removeAttr( 'aria-valuenow' );
8321 }
8322 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8323 };
8324
8325 /**
8326 * InputWidget is the base class for all input widgets, which
8327 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8328 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8329 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8330 *
8331 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8332 *
8333 * @abstract
8334 * @class
8335 * @extends OO.ui.Widget
8336 * @mixins OO.ui.mixin.FlaggedElement
8337 * @mixins OO.ui.mixin.TabIndexedElement
8338 * @mixins OO.ui.mixin.TitledElement
8339 * @mixins OO.ui.mixin.AccessKeyedElement
8340 *
8341 * @constructor
8342 * @param {Object} [config] Configuration options
8343 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8344 * @cfg {string} [value=''] The value of the input.
8345 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8346 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8347 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8348 * before it is accepted.
8349 */
8350 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8351 // Configuration initialization
8352 config = config || {};
8353
8354 // Parent constructor
8355 OO.ui.InputWidget.parent.call( this, config );
8356
8357 // Properties
8358 // See #reusePreInfuseDOM about config.$input
8359 this.$input = config.$input || this.getInputElement( config );
8360 this.value = '';
8361 this.inputFilter = config.inputFilter;
8362
8363 // Mixin constructors
8364 OO.ui.mixin.FlaggedElement.call( this, config );
8365 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8366 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8367 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8368
8369 // Events
8370 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8371
8372 // Initialization
8373 this.$input
8374 .addClass( 'oo-ui-inputWidget-input' )
8375 .attr( 'name', config.name )
8376 .prop( 'disabled', this.isDisabled() );
8377 this.$element
8378 .addClass( 'oo-ui-inputWidget' )
8379 .append( this.$input );
8380 this.setValue( config.value );
8381 if ( config.dir ) {
8382 this.setDir( config.dir );
8383 }
8384 if ( config.inputId !== undefined ) {
8385 this.setInputId( config.inputId );
8386 }
8387 };
8388
8389 /* Setup */
8390
8391 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8392 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8393 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8394 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8395 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8396
8397 /* Static Methods */
8398
8399 /**
8400 * @inheritdoc
8401 */
8402 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8403 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8404 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8405 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8406 return config;
8407 };
8408
8409 /**
8410 * @inheritdoc
8411 */
8412 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8413 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8414 if ( config.$input && config.$input.length ) {
8415 state.value = config.$input.val();
8416 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8417 state.focus = config.$input.is( ':focus' );
8418 }
8419 return state;
8420 };
8421
8422 /* Events */
8423
8424 /**
8425 * @event change
8426 *
8427 * A change event is emitted when the value of the input changes.
8428 *
8429 * @param {string} value
8430 */
8431
8432 /* Methods */
8433
8434 /**
8435 * Get input element.
8436 *
8437 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8438 * different circumstances. The element must have a `value` property (like form elements).
8439 *
8440 * @protected
8441 * @param {Object} config Configuration options
8442 * @return {jQuery} Input element
8443 */
8444 OO.ui.InputWidget.prototype.getInputElement = function () {
8445 return $( '<input>' );
8446 };
8447
8448 /**
8449 * Handle potentially value-changing events.
8450 *
8451 * @private
8452 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8453 */
8454 OO.ui.InputWidget.prototype.onEdit = function () {
8455 var widget = this;
8456 if ( !this.isDisabled() ) {
8457 // Allow the stack to clear so the value will be updated
8458 setTimeout( function () {
8459 widget.setValue( widget.$input.val() );
8460 } );
8461 }
8462 };
8463
8464 /**
8465 * Get the value of the input.
8466 *
8467 * @return {string} Input value
8468 */
8469 OO.ui.InputWidget.prototype.getValue = function () {
8470 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8471 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8472 var value = this.$input.val();
8473 if ( this.value !== value ) {
8474 this.setValue( value );
8475 }
8476 return this.value;
8477 };
8478
8479 /**
8480 * Set the directionality of the input.
8481 *
8482 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8483 * @chainable
8484 */
8485 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8486 this.$input.prop( 'dir', dir );
8487 return this;
8488 };
8489
8490 /**
8491 * Set the value of the input.
8492 *
8493 * @param {string} value New value
8494 * @fires change
8495 * @chainable
8496 */
8497 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8498 value = this.cleanUpValue( value );
8499 // Update the DOM if it has changed. Note that with cleanUpValue, it
8500 // is possible for the DOM value to change without this.value changing.
8501 if ( this.$input.val() !== value ) {
8502 this.$input.val( value );
8503 }
8504 if ( this.value !== value ) {
8505 this.value = value;
8506 this.emit( 'change', this.value );
8507 }
8508 return this;
8509 };
8510
8511 /**
8512 * Clean up incoming value.
8513 *
8514 * Ensures value is a string, and converts undefined and null to empty string.
8515 *
8516 * @private
8517 * @param {string} value Original value
8518 * @return {string} Cleaned up value
8519 */
8520 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8521 if ( value === undefined || value === null ) {
8522 return '';
8523 } else if ( this.inputFilter ) {
8524 return this.inputFilter( String( value ) );
8525 } else {
8526 return String( value );
8527 }
8528 };
8529
8530 /**
8531 * @inheritdoc
8532 */
8533 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8534 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8535 if ( this.$input ) {
8536 this.$input.prop( 'disabled', this.isDisabled() );
8537 }
8538 return this;
8539 };
8540
8541 /**
8542 * Set the 'id' attribute of the `<input>` element.
8543 *
8544 * @param {string} id
8545 * @chainable
8546 */
8547 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8548 this.$input.attr( 'id', id );
8549 return this;
8550 };
8551
8552 /**
8553 * @inheritdoc
8554 */
8555 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8556 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8557 if ( state.value !== undefined && state.value !== this.getValue() ) {
8558 this.setValue( state.value );
8559 }
8560 if ( state.focus ) {
8561 this.focus();
8562 }
8563 };
8564
8565 /**
8566 * Data widget intended for creating 'hidden'-type inputs.
8567 *
8568 * @class
8569 * @extends OO.ui.Widget
8570 *
8571 * @constructor
8572 * @param {Object} [config] Configuration options
8573 * @cfg {string} [value=''] The value of the input.
8574 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8575 */
8576 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8577 // Configuration initialization
8578 config = $.extend( { value: '', name: '' }, config );
8579
8580 // Parent constructor
8581 OO.ui.HiddenInputWidget.parent.call( this, config );
8582
8583 // Initialization
8584 this.$element.attr( {
8585 type: 'hidden',
8586 value: config.value,
8587 name: config.name
8588 } );
8589 this.$element.removeAttr( 'aria-disabled' );
8590 };
8591
8592 /* Setup */
8593
8594 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8595
8596 /* Static Properties */
8597
8598 /**
8599 * @static
8600 * @inheritdoc
8601 */
8602 OO.ui.HiddenInputWidget.static.tagName = 'input';
8603
8604 /**
8605 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8606 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8607 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8608 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8609 * [OOjs UI documentation on MediaWiki] [1] for more information.
8610 *
8611 * @example
8612 * // A ButtonInputWidget rendered as an HTML button, the default.
8613 * var button = new OO.ui.ButtonInputWidget( {
8614 * label: 'Input button',
8615 * icon: 'check',
8616 * value: 'check'
8617 * } );
8618 * $( 'body' ).append( button.$element );
8619 *
8620 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8621 *
8622 * @class
8623 * @extends OO.ui.InputWidget
8624 * @mixins OO.ui.mixin.ButtonElement
8625 * @mixins OO.ui.mixin.IconElement
8626 * @mixins OO.ui.mixin.IndicatorElement
8627 * @mixins OO.ui.mixin.LabelElement
8628 * @mixins OO.ui.mixin.TitledElement
8629 *
8630 * @constructor
8631 * @param {Object} [config] Configuration options
8632 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8633 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8634 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8635 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8636 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8637 */
8638 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8639 // Configuration initialization
8640 config = $.extend( { type: 'button', useInputTag: false }, config );
8641
8642 // See InputWidget#reusePreInfuseDOM about config.$input
8643 if ( config.$input ) {
8644 config.$input.empty();
8645 }
8646
8647 // Properties (must be set before parent constructor, which calls #setValue)
8648 this.useInputTag = config.useInputTag;
8649
8650 // Parent constructor
8651 OO.ui.ButtonInputWidget.parent.call( this, config );
8652
8653 // Mixin constructors
8654 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8655 OO.ui.mixin.IconElement.call( this, config );
8656 OO.ui.mixin.IndicatorElement.call( this, config );
8657 OO.ui.mixin.LabelElement.call( this, config );
8658 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8659
8660 // Initialization
8661 if ( !config.useInputTag ) {
8662 this.$input.append( this.$icon, this.$label, this.$indicator );
8663 }
8664 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8665 };
8666
8667 /* Setup */
8668
8669 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8670 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8671 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8672 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8673 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8674 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8675
8676 /* Static Properties */
8677
8678 /**
8679 * @static
8680 * @inheritdoc
8681 */
8682 OO.ui.ButtonInputWidget.static.tagName = 'span';
8683
8684 /* Methods */
8685
8686 /**
8687 * @inheritdoc
8688 * @protected
8689 */
8690 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8691 var type;
8692 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8693 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8694 };
8695
8696 /**
8697 * Set label value.
8698 *
8699 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8700 *
8701 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8702 * text, or `null` for no label
8703 * @chainable
8704 */
8705 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8706 if ( typeof label === 'function' ) {
8707 label = OO.ui.resolveMsg( label );
8708 }
8709
8710 if ( this.useInputTag ) {
8711 // Discard non-plaintext labels
8712 if ( typeof label !== 'string' ) {
8713 label = '';
8714 }
8715
8716 this.$input.val( label );
8717 }
8718
8719 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8720 };
8721
8722 /**
8723 * Set the value of the input.
8724 *
8725 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8726 * they do not support {@link #value values}.
8727 *
8728 * @param {string} value New value
8729 * @chainable
8730 */
8731 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8732 if ( !this.useInputTag ) {
8733 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8734 }
8735 return this;
8736 };
8737
8738 /**
8739 * @inheritdoc
8740 */
8741 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8742 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8743 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
8744 return null;
8745 };
8746
8747 /**
8748 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
8749 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
8750 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
8751 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
8752 *
8753 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
8754 *
8755 * @example
8756 * // An example of selected, unselected, and disabled checkbox inputs
8757 * var checkbox1=new OO.ui.CheckboxInputWidget( {
8758 * value: 'a',
8759 * selected: true
8760 * } );
8761 * var checkbox2=new OO.ui.CheckboxInputWidget( {
8762 * value: 'b'
8763 * } );
8764 * var checkbox3=new OO.ui.CheckboxInputWidget( {
8765 * value:'c',
8766 * disabled: true
8767 * } );
8768 * // Create a fieldset layout with fields for each checkbox.
8769 * var fieldset = new OO.ui.FieldsetLayout( {
8770 * label: 'Checkboxes'
8771 * } );
8772 * fieldset.addItems( [
8773 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
8774 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
8775 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
8776 * ] );
8777 * $( 'body' ).append( fieldset.$element );
8778 *
8779 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8780 *
8781 * @class
8782 * @extends OO.ui.InputWidget
8783 *
8784 * @constructor
8785 * @param {Object} [config] Configuration options
8786 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
8787 */
8788 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
8789 // Configuration initialization
8790 config = config || {};
8791
8792 // Parent constructor
8793 OO.ui.CheckboxInputWidget.parent.call( this, config );
8794
8795 // Initialization
8796 this.$element
8797 .addClass( 'oo-ui-checkboxInputWidget' )
8798 // Required for pretty styling in WikimediaUI theme
8799 .append( $( '<span>' ) );
8800 this.setSelected( config.selected !== undefined ? config.selected : false );
8801 };
8802
8803 /* Setup */
8804
8805 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
8806
8807 /* Static Properties */
8808
8809 /**
8810 * @static
8811 * @inheritdoc
8812 */
8813 OO.ui.CheckboxInputWidget.static.tagName = 'span';
8814
8815 /* Static Methods */
8816
8817 /**
8818 * @inheritdoc
8819 */
8820 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
8821 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
8822 state.checked = config.$input.prop( 'checked' );
8823 return state;
8824 };
8825
8826 /* Methods */
8827
8828 /**
8829 * @inheritdoc
8830 * @protected
8831 */
8832 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
8833 return $( '<input>' ).attr( 'type', 'checkbox' );
8834 };
8835
8836 /**
8837 * @inheritdoc
8838 */
8839 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
8840 var widget = this;
8841 if ( !this.isDisabled() ) {
8842 // Allow the stack to clear so the value will be updated
8843 setTimeout( function () {
8844 widget.setSelected( widget.$input.prop( 'checked' ) );
8845 } );
8846 }
8847 };
8848
8849 /**
8850 * Set selection state of this checkbox.
8851 *
8852 * @param {boolean} state `true` for selected
8853 * @chainable
8854 */
8855 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
8856 state = !!state;
8857 if ( this.selected !== state ) {
8858 this.selected = state;
8859 this.$input.prop( 'checked', this.selected );
8860 this.emit( 'change', this.selected );
8861 }
8862 return this;
8863 };
8864
8865 /**
8866 * Check if this checkbox is selected.
8867 *
8868 * @return {boolean} Checkbox is selected
8869 */
8870 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
8871 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8872 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8873 var selected = this.$input.prop( 'checked' );
8874 if ( this.selected !== selected ) {
8875 this.setSelected( selected );
8876 }
8877 return this.selected;
8878 };
8879
8880 /**
8881 * @inheritdoc
8882 */
8883 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
8884 if ( !this.isDisabled() ) {
8885 this.$input.click();
8886 }
8887 this.focus();
8888 };
8889
8890 /**
8891 * @inheritdoc
8892 */
8893 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
8894 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8895 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
8896 this.setSelected( state.checked );
8897 }
8898 };
8899
8900 /**
8901 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
8902 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
8903 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
8904 * more information about input widgets.
8905 *
8906 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
8907 * are no options. If no `value` configuration option is provided, the first option is selected.
8908 * If you need a state representing no value (no option being selected), use a DropdownWidget.
8909 *
8910 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
8911 *
8912 * @example
8913 * // Example: A DropdownInputWidget with three options
8914 * var dropdownInput = new OO.ui.DropdownInputWidget( {
8915 * options: [
8916 * { data: 'a', label: 'First' },
8917 * { data: 'b', label: 'Second'},
8918 * { data: 'c', label: 'Third' }
8919 * ]
8920 * } );
8921 * $( 'body' ).append( dropdownInput.$element );
8922 *
8923 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8924 *
8925 * @class
8926 * @extends OO.ui.InputWidget
8927 * @mixins OO.ui.mixin.TitledElement
8928 *
8929 * @constructor
8930 * @param {Object} [config] Configuration options
8931 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
8932 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
8933 */
8934 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
8935 // Configuration initialization
8936 config = config || {};
8937
8938 // See InputWidget#reusePreInfuseDOM about config.$input
8939 if ( config.$input ) {
8940 config.$input.addClass( 'oo-ui-element-hidden' );
8941 }
8942
8943 // Properties (must be done before parent constructor which calls #setDisabled)
8944 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
8945
8946 // Parent constructor
8947 OO.ui.DropdownInputWidget.parent.call( this, config );
8948
8949 // Mixin constructors
8950 OO.ui.mixin.TitledElement.call( this, config );
8951
8952 // Events
8953 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
8954
8955 // Initialization
8956 this.setOptions( config.options || [] );
8957 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
8958 // widget has no valid options when it happens.
8959 this.setValue( config.value );
8960 this.$element
8961 .addClass( 'oo-ui-dropdownInputWidget' )
8962 .append( this.dropdownWidget.$element );
8963 this.setTabIndexedElement( null );
8964 };
8965
8966 /* Setup */
8967
8968 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
8969 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
8970
8971 /* Methods */
8972
8973 /**
8974 * @inheritdoc
8975 * @protected
8976 */
8977 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
8978 return $( '<input>' ).attr( 'type', 'hidden' );
8979 };
8980
8981 /**
8982 * Handles menu select events.
8983 *
8984 * @private
8985 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
8986 */
8987 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
8988 this.setValue( item ? item.getData() : '' );
8989 };
8990
8991 /**
8992 * @inheritdoc
8993 */
8994 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
8995 var selected;
8996 value = this.cleanUpValue( value );
8997 // Only allow setting values that are actually present in the dropdown
8998 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
8999 this.dropdownWidget.getMenu().findFirstSelectableItem();
9000 this.dropdownWidget.getMenu().selectItem( selected );
9001 value = selected ? selected.getData() : '';
9002 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9003 return this;
9004 };
9005
9006 /**
9007 * @inheritdoc
9008 */
9009 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9010 this.dropdownWidget.setDisabled( state );
9011 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9012 return this;
9013 };
9014
9015 /**
9016 * Set the options available for this input.
9017 *
9018 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9019 * @chainable
9020 */
9021 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9022 var
9023 value = this.getValue(),
9024 widget = this;
9025
9026 // Rebuild the dropdown menu
9027 this.dropdownWidget.getMenu()
9028 .clearItems()
9029 .addItems( options.map( function ( opt ) {
9030 var optValue = widget.cleanUpValue( opt.data );
9031
9032 if ( opt.optgroup === undefined ) {
9033 return new OO.ui.MenuOptionWidget( {
9034 data: optValue,
9035 label: opt.label !== undefined ? opt.label : optValue
9036 } );
9037 } else {
9038 return new OO.ui.MenuSectionOptionWidget( {
9039 label: opt.optgroup
9040 } );
9041 }
9042 } ) );
9043
9044 // Restore the previous value, or reset to something sensible
9045 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9046 // Previous value is still available, ensure consistency with the dropdown
9047 this.setValue( value );
9048 } else {
9049 // No longer valid, reset
9050 if ( options.length ) {
9051 this.setValue( options[ 0 ].data );
9052 }
9053 }
9054
9055 return this;
9056 };
9057
9058 /**
9059 * @inheritdoc
9060 */
9061 OO.ui.DropdownInputWidget.prototype.focus = function () {
9062 this.dropdownWidget.focus();
9063 return this;
9064 };
9065
9066 /**
9067 * @inheritdoc
9068 */
9069 OO.ui.DropdownInputWidget.prototype.blur = function () {
9070 this.dropdownWidget.blur();
9071 return this;
9072 };
9073
9074 /**
9075 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9076 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9077 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9078 * please see the [OOjs UI documentation on MediaWiki][1].
9079 *
9080 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9081 *
9082 * @example
9083 * // An example of selected, unselected, and disabled radio inputs
9084 * var radio1 = new OO.ui.RadioInputWidget( {
9085 * value: 'a',
9086 * selected: true
9087 * } );
9088 * var radio2 = new OO.ui.RadioInputWidget( {
9089 * value: 'b'
9090 * } );
9091 * var radio3 = new OO.ui.RadioInputWidget( {
9092 * value: 'c',
9093 * disabled: true
9094 * } );
9095 * // Create a fieldset layout with fields for each radio button.
9096 * var fieldset = new OO.ui.FieldsetLayout( {
9097 * label: 'Radio inputs'
9098 * } );
9099 * fieldset.addItems( [
9100 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9101 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9102 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9103 * ] );
9104 * $( 'body' ).append( fieldset.$element );
9105 *
9106 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9107 *
9108 * @class
9109 * @extends OO.ui.InputWidget
9110 *
9111 * @constructor
9112 * @param {Object} [config] Configuration options
9113 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9114 */
9115 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9116 // Configuration initialization
9117 config = config || {};
9118
9119 // Parent constructor
9120 OO.ui.RadioInputWidget.parent.call( this, config );
9121
9122 // Initialization
9123 this.$element
9124 .addClass( 'oo-ui-radioInputWidget' )
9125 // Required for pretty styling in WikimediaUI theme
9126 .append( $( '<span>' ) );
9127 this.setSelected( config.selected !== undefined ? config.selected : false );
9128 };
9129
9130 /* Setup */
9131
9132 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9133
9134 /* Static Properties */
9135
9136 /**
9137 * @static
9138 * @inheritdoc
9139 */
9140 OO.ui.RadioInputWidget.static.tagName = 'span';
9141
9142 /* Static Methods */
9143
9144 /**
9145 * @inheritdoc
9146 */
9147 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9148 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9149 state.checked = config.$input.prop( 'checked' );
9150 return state;
9151 };
9152
9153 /* Methods */
9154
9155 /**
9156 * @inheritdoc
9157 * @protected
9158 */
9159 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9160 return $( '<input>' ).attr( 'type', 'radio' );
9161 };
9162
9163 /**
9164 * @inheritdoc
9165 */
9166 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9167 // RadioInputWidget doesn't track its state.
9168 };
9169
9170 /**
9171 * Set selection state of this radio button.
9172 *
9173 * @param {boolean} state `true` for selected
9174 * @chainable
9175 */
9176 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9177 // RadioInputWidget doesn't track its state.
9178 this.$input.prop( 'checked', state );
9179 return this;
9180 };
9181
9182 /**
9183 * Check if this radio button is selected.
9184 *
9185 * @return {boolean} Radio is selected
9186 */
9187 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9188 return this.$input.prop( 'checked' );
9189 };
9190
9191 /**
9192 * @inheritdoc
9193 */
9194 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9195 if ( !this.isDisabled() ) {
9196 this.$input.click();
9197 }
9198 this.focus();
9199 };
9200
9201 /**
9202 * @inheritdoc
9203 */
9204 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9205 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9206 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9207 this.setSelected( state.checked );
9208 }
9209 };
9210
9211 /**
9212 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9213 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9214 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9215 * more information about input widgets.
9216 *
9217 * This and OO.ui.DropdownInputWidget support the same configuration options.
9218 *
9219 * @example
9220 * // Example: A RadioSelectInputWidget with three options
9221 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9222 * options: [
9223 * { data: 'a', label: 'First' },
9224 * { data: 'b', label: 'Second'},
9225 * { data: 'c', label: 'Third' }
9226 * ]
9227 * } );
9228 * $( 'body' ).append( radioSelectInput.$element );
9229 *
9230 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9231 *
9232 * @class
9233 * @extends OO.ui.InputWidget
9234 *
9235 * @constructor
9236 * @param {Object} [config] Configuration options
9237 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9238 */
9239 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9240 // Configuration initialization
9241 config = config || {};
9242
9243 // Properties (must be done before parent constructor which calls #setDisabled)
9244 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9245
9246 // Parent constructor
9247 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9248
9249 // Events
9250 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9251
9252 // Initialization
9253 this.setOptions( config.options || [] );
9254 this.$element
9255 .addClass( 'oo-ui-radioSelectInputWidget' )
9256 .append( this.radioSelectWidget.$element );
9257 this.setTabIndexedElement( null );
9258 };
9259
9260 /* Setup */
9261
9262 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9263
9264 /* Static Methods */
9265
9266 /**
9267 * @inheritdoc
9268 */
9269 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9270 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9271 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9272 return state;
9273 };
9274
9275 /**
9276 * @inheritdoc
9277 */
9278 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9279 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9280 // Cannot reuse the `<input type=radio>` set
9281 delete config.$input;
9282 return config;
9283 };
9284
9285 /* Methods */
9286
9287 /**
9288 * @inheritdoc
9289 * @protected
9290 */
9291 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9292 return $( '<input>' ).attr( 'type', 'hidden' );
9293 };
9294
9295 /**
9296 * Handles menu select events.
9297 *
9298 * @private
9299 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9300 */
9301 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9302 this.setValue( item.getData() );
9303 };
9304
9305 /**
9306 * @inheritdoc
9307 */
9308 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9309 value = this.cleanUpValue( value );
9310 this.radioSelectWidget.selectItemByData( value );
9311 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9312 return this;
9313 };
9314
9315 /**
9316 * @inheritdoc
9317 */
9318 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9319 this.radioSelectWidget.setDisabled( state );
9320 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9321 return this;
9322 };
9323
9324 /**
9325 * Set the options available for this input.
9326 *
9327 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9328 * @chainable
9329 */
9330 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9331 var
9332 value = this.getValue(),
9333 widget = this;
9334
9335 // Rebuild the radioSelect menu
9336 this.radioSelectWidget
9337 .clearItems()
9338 .addItems( options.map( function ( opt ) {
9339 var optValue = widget.cleanUpValue( opt.data );
9340 return new OO.ui.RadioOptionWidget( {
9341 data: optValue,
9342 label: opt.label !== undefined ? opt.label : optValue
9343 } );
9344 } ) );
9345
9346 // Restore the previous value, or reset to something sensible
9347 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9348 // Previous value is still available, ensure consistency with the radioSelect
9349 this.setValue( value );
9350 } else {
9351 // No longer valid, reset
9352 if ( options.length ) {
9353 this.setValue( options[ 0 ].data );
9354 }
9355 }
9356
9357 return this;
9358 };
9359
9360 /**
9361 * @inheritdoc
9362 */
9363 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9364 this.radioSelectWidget.focus();
9365 return this;
9366 };
9367
9368 /**
9369 * @inheritdoc
9370 */
9371 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9372 this.radioSelectWidget.blur();
9373 return this;
9374 };
9375
9376 /**
9377 * CheckboxMultiselectInputWidget is a
9378 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9379 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9380 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9381 * more information about input widgets.
9382 *
9383 * @example
9384 * // Example: A CheckboxMultiselectInputWidget with three options
9385 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9386 * options: [
9387 * { data: 'a', label: 'First' },
9388 * { data: 'b', label: 'Second'},
9389 * { data: 'c', label: 'Third' }
9390 * ]
9391 * } );
9392 * $( 'body' ).append( multiselectInput.$element );
9393 *
9394 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9395 *
9396 * @class
9397 * @extends OO.ui.InputWidget
9398 *
9399 * @constructor
9400 * @param {Object} [config] Configuration options
9401 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9402 */
9403 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9404 // Configuration initialization
9405 config = config || {};
9406
9407 // Properties (must be done before parent constructor which calls #setDisabled)
9408 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9409
9410 // Parent constructor
9411 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9412
9413 // Properties
9414 this.inputName = config.name;
9415
9416 // Initialization
9417 this.$element
9418 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9419 .append( this.checkboxMultiselectWidget.$element );
9420 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9421 this.$input.detach();
9422 this.setOptions( config.options || [] );
9423 // Have to repeat this from parent, as we need options to be set up for this to make sense
9424 this.setValue( config.value );
9425
9426 // setValue when checkboxMultiselectWidget changes
9427 this.checkboxMultiselectWidget.on( 'change', function () {
9428 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9429 }.bind( this ) );
9430 };
9431
9432 /* Setup */
9433
9434 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9435
9436 /* Static Methods */
9437
9438 /**
9439 * @inheritdoc
9440 */
9441 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9442 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9443 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9444 .toArray().map( function ( el ) { return el.value; } );
9445 return state;
9446 };
9447
9448 /**
9449 * @inheritdoc
9450 */
9451 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9452 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9453 // Cannot reuse the `<input type=checkbox>` set
9454 delete config.$input;
9455 return config;
9456 };
9457
9458 /* Methods */
9459
9460 /**
9461 * @inheritdoc
9462 * @protected
9463 */
9464 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9465 // Actually unused
9466 return $( '<unused>' );
9467 };
9468
9469 /**
9470 * @inheritdoc
9471 */
9472 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9473 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9474 .toArray().map( function ( el ) { return el.value; } );
9475 if ( this.value !== value ) {
9476 this.setValue( value );
9477 }
9478 return this.value;
9479 };
9480
9481 /**
9482 * @inheritdoc
9483 */
9484 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9485 value = this.cleanUpValue( value );
9486 this.checkboxMultiselectWidget.selectItemsByData( value );
9487 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9488 return this;
9489 };
9490
9491 /**
9492 * Clean up incoming value.
9493 *
9494 * @param {string[]} value Original value
9495 * @return {string[]} Cleaned up value
9496 */
9497 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9498 var i, singleValue,
9499 cleanValue = [];
9500 if ( !Array.isArray( value ) ) {
9501 return cleanValue;
9502 }
9503 for ( i = 0; i < value.length; i++ ) {
9504 singleValue =
9505 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9506 // Remove options that we don't have here
9507 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9508 continue;
9509 }
9510 cleanValue.push( singleValue );
9511 }
9512 return cleanValue;
9513 };
9514
9515 /**
9516 * @inheritdoc
9517 */
9518 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9519 this.checkboxMultiselectWidget.setDisabled( state );
9520 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9521 return this;
9522 };
9523
9524 /**
9525 * Set the options available for this input.
9526 *
9527 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9528 * @chainable
9529 */
9530 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9531 var widget = this;
9532
9533 // Rebuild the checkboxMultiselectWidget menu
9534 this.checkboxMultiselectWidget
9535 .clearItems()
9536 .addItems( options.map( function ( opt ) {
9537 var optValue, item, optDisabled;
9538 optValue =
9539 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9540 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9541 item = new OO.ui.CheckboxMultioptionWidget( {
9542 data: optValue,
9543 label: opt.label !== undefined ? opt.label : optValue,
9544 disabled: optDisabled
9545 } );
9546 // Set the 'name' and 'value' for form submission
9547 item.checkbox.$input.attr( 'name', widget.inputName );
9548 item.checkbox.setValue( optValue );
9549 return item;
9550 } ) );
9551
9552 // Re-set the value, checking the checkboxes as needed.
9553 // This will also get rid of any stale options that we just removed.
9554 this.setValue( this.getValue() );
9555
9556 return this;
9557 };
9558
9559 /**
9560 * @inheritdoc
9561 */
9562 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9563 this.checkboxMultiselectWidget.focus();
9564 return this;
9565 };
9566
9567 /**
9568 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9569 * size of the field as well as its presentation. In addition, these widgets can be configured
9570 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9571 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9572 * which modifies incoming values rather than validating them.
9573 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9574 *
9575 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9576 *
9577 * @example
9578 * // Example of a text input widget
9579 * var textInput = new OO.ui.TextInputWidget( {
9580 * value: 'Text input'
9581 * } )
9582 * $( 'body' ).append( textInput.$element );
9583 *
9584 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9585 *
9586 * @class
9587 * @extends OO.ui.InputWidget
9588 * @mixins OO.ui.mixin.IconElement
9589 * @mixins OO.ui.mixin.IndicatorElement
9590 * @mixins OO.ui.mixin.PendingElement
9591 * @mixins OO.ui.mixin.LabelElement
9592 *
9593 * @constructor
9594 * @param {Object} [config] Configuration options
9595 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9596 * 'email', 'url' or 'number'.
9597 * @cfg {string} [placeholder] Placeholder text
9598 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9599 * instruct the browser to focus this widget.
9600 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9601 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9602 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9603 * the value or placeholder text: `'before'` or `'after'`
9604 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9605 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9606 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9607 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9608 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9609 * value for it to be considered valid; when Function, a function receiving the value as parameter
9610 * that must return true, or promise resolving to true, for it to be considered valid.
9611 */
9612 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9613 // Configuration initialization
9614 config = $.extend( {
9615 type: 'text',
9616 labelPosition: 'after'
9617 }, config );
9618
9619 if ( config.multiline ) {
9620 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9621 return new OO.ui.MultilineTextInputWidget( config );
9622 }
9623
9624 // Parent constructor
9625 OO.ui.TextInputWidget.parent.call( this, config );
9626
9627 // Mixin constructors
9628 OO.ui.mixin.IconElement.call( this, config );
9629 OO.ui.mixin.IndicatorElement.call( this, config );
9630 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9631 OO.ui.mixin.LabelElement.call( this, config );
9632
9633 // Properties
9634 this.type = this.getSaneType( config );
9635 this.readOnly = false;
9636 this.required = false;
9637 this.validate = null;
9638 this.styleHeight = null;
9639 this.scrollWidth = null;
9640
9641 this.setValidation( config.validate );
9642 this.setLabelPosition( config.labelPosition );
9643
9644 // Events
9645 this.$input.on( {
9646 keypress: this.onKeyPress.bind( this ),
9647 blur: this.onBlur.bind( this ),
9648 focus: this.onFocus.bind( this )
9649 } );
9650 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9651 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9652 this.on( 'labelChange', this.updatePosition.bind( this ) );
9653 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9654
9655 // Initialization
9656 this.$element
9657 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9658 .append( this.$icon, this.$indicator );
9659 this.setReadOnly( !!config.readOnly );
9660 this.setRequired( !!config.required );
9661 if ( config.placeholder !== undefined ) {
9662 this.$input.attr( 'placeholder', config.placeholder );
9663 }
9664 if ( config.maxLength !== undefined ) {
9665 this.$input.attr( 'maxlength', config.maxLength );
9666 }
9667 if ( config.autofocus ) {
9668 this.$input.attr( 'autofocus', 'autofocus' );
9669 }
9670 if ( config.autocomplete === false ) {
9671 this.$input.attr( 'autocomplete', 'off' );
9672 // Turning off autocompletion also disables "form caching" when the user navigates to a
9673 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9674 $( window ).on( {
9675 beforeunload: function () {
9676 this.$input.removeAttr( 'autocomplete' );
9677 }.bind( this ),
9678 pageshow: function () {
9679 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9680 // whole page... it shouldn't hurt, though.
9681 this.$input.attr( 'autocomplete', 'off' );
9682 }.bind( this )
9683 } );
9684 }
9685 if ( this.label ) {
9686 this.isWaitingToBeAttached = true;
9687 this.installParentChangeDetector();
9688 }
9689 };
9690
9691 /* Setup */
9692
9693 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9694 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9695 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9696 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9697 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9698
9699 /* Static Properties */
9700
9701 OO.ui.TextInputWidget.static.validationPatterns = {
9702 'non-empty': /.+/,
9703 integer: /^\d+$/
9704 };
9705
9706 /* Static Methods */
9707
9708 /**
9709 * @inheritdoc
9710 */
9711 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9712 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9713 return state;
9714 };
9715
9716 /* Events */
9717
9718 /**
9719 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9720 *
9721 * @event enter
9722 */
9723
9724 /* Methods */
9725
9726 /**
9727 * Handle icon mouse down events.
9728 *
9729 * @private
9730 * @param {jQuery.Event} e Mouse down event
9731 */
9732 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
9733 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9734 this.focus();
9735 return false;
9736 }
9737 };
9738
9739 /**
9740 * Handle indicator mouse down events.
9741 *
9742 * @private
9743 * @param {jQuery.Event} e Mouse down event
9744 */
9745 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
9746 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9747 this.focus();
9748 return false;
9749 }
9750 };
9751
9752 /**
9753 * Handle key press events.
9754 *
9755 * @private
9756 * @param {jQuery.Event} e Key press event
9757 * @fires enter If enter key is pressed
9758 */
9759 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
9760 if ( e.which === OO.ui.Keys.ENTER ) {
9761 this.emit( 'enter', e );
9762 }
9763 };
9764
9765 /**
9766 * Handle blur events.
9767 *
9768 * @private
9769 * @param {jQuery.Event} e Blur event
9770 */
9771 OO.ui.TextInputWidget.prototype.onBlur = function () {
9772 this.setValidityFlag();
9773 };
9774
9775 /**
9776 * Handle focus events.
9777 *
9778 * @private
9779 * @param {jQuery.Event} e Focus event
9780 */
9781 OO.ui.TextInputWidget.prototype.onFocus = function () {
9782 if ( this.isWaitingToBeAttached ) {
9783 // If we've received focus, then we must be attached to the document, and if
9784 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
9785 this.onElementAttach();
9786 }
9787 this.setValidityFlag( true );
9788 };
9789
9790 /**
9791 * Handle element attach events.
9792 *
9793 * @private
9794 * @param {jQuery.Event} e Element attach event
9795 */
9796 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
9797 this.isWaitingToBeAttached = false;
9798 // Any previously calculated size is now probably invalid if we reattached elsewhere
9799 this.valCache = null;
9800 this.positionLabel();
9801 };
9802
9803 /**
9804 * Handle debounced change events.
9805 *
9806 * @param {string} value
9807 * @private
9808 */
9809 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
9810 this.setValidityFlag();
9811 };
9812
9813 /**
9814 * Check if the input is {@link #readOnly read-only}.
9815 *
9816 * @return {boolean}
9817 */
9818 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
9819 return this.readOnly;
9820 };
9821
9822 /**
9823 * Set the {@link #readOnly read-only} state of the input.
9824 *
9825 * @param {boolean} state Make input read-only
9826 * @chainable
9827 */
9828 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
9829 this.readOnly = !!state;
9830 this.$input.prop( 'readOnly', this.readOnly );
9831 return this;
9832 };
9833
9834 /**
9835 * Check if the input is {@link #required required}.
9836 *
9837 * @return {boolean}
9838 */
9839 OO.ui.TextInputWidget.prototype.isRequired = function () {
9840 return this.required;
9841 };
9842
9843 /**
9844 * Set the {@link #required required} state of the input.
9845 *
9846 * @param {boolean} state Make input required
9847 * @chainable
9848 */
9849 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
9850 this.required = !!state;
9851 if ( this.required ) {
9852 this.$input
9853 .prop( 'required', true )
9854 .attr( 'aria-required', 'true' );
9855 if ( this.getIndicator() === null ) {
9856 this.setIndicator( 'required' );
9857 }
9858 } else {
9859 this.$input
9860 .prop( 'required', false )
9861 .removeAttr( 'aria-required' );
9862 if ( this.getIndicator() === 'required' ) {
9863 this.setIndicator( null );
9864 }
9865 }
9866 return this;
9867 };
9868
9869 /**
9870 * Support function for making #onElementAttach work across browsers.
9871 *
9872 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
9873 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
9874 *
9875 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
9876 * first time that the element gets attached to the documented.
9877 */
9878 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
9879 var mutationObserver, onRemove, topmostNode, fakeParentNode,
9880 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
9881 widget = this;
9882
9883 if ( MutationObserver ) {
9884 // The new way. If only it wasn't so ugly.
9885
9886 if ( this.isElementAttached() ) {
9887 // Widget is attached already, do nothing. This breaks the functionality of this function when
9888 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
9889 // would require observation of the whole document, which would hurt performance of other,
9890 // more important code.
9891 return;
9892 }
9893
9894 // Find topmost node in the tree
9895 topmostNode = this.$element[ 0 ];
9896 while ( topmostNode.parentNode ) {
9897 topmostNode = topmostNode.parentNode;
9898 }
9899
9900 // We have no way to detect the $element being attached somewhere without observing the entire
9901 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
9902 // parent node of $element, and instead detect when $element is removed from it (and thus
9903 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
9904 // doesn't get attached, we end up back here and create the parent.
9905
9906 mutationObserver = new MutationObserver( function ( mutations ) {
9907 var i, j, removedNodes;
9908 for ( i = 0; i < mutations.length; i++ ) {
9909 removedNodes = mutations[ i ].removedNodes;
9910 for ( j = 0; j < removedNodes.length; j++ ) {
9911 if ( removedNodes[ j ] === topmostNode ) {
9912 setTimeout( onRemove, 0 );
9913 return;
9914 }
9915 }
9916 }
9917 } );
9918
9919 onRemove = function () {
9920 // If the node was attached somewhere else, report it
9921 if ( widget.isElementAttached() ) {
9922 widget.onElementAttach();
9923 }
9924 mutationObserver.disconnect();
9925 widget.installParentChangeDetector();
9926 };
9927
9928 // Create a fake parent and observe it
9929 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
9930 mutationObserver.observe( fakeParentNode, { childList: true } );
9931 } else {
9932 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
9933 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
9934 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
9935 }
9936 };
9937
9938 /**
9939 * @inheritdoc
9940 * @protected
9941 */
9942 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
9943 if ( this.getSaneType( config ) === 'number' ) {
9944 return $( '<input>' )
9945 .attr( 'step', 'any' )
9946 .attr( 'type', 'number' );
9947 } else {
9948 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
9949 }
9950 };
9951
9952 /**
9953 * Get sanitized value for 'type' for given config.
9954 *
9955 * @param {Object} config Configuration options
9956 * @return {string|null}
9957 * @protected
9958 */
9959 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
9960 var allowedTypes = [
9961 'text',
9962 'password',
9963 'email',
9964 'url',
9965 'number'
9966 ];
9967 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
9968 };
9969
9970 /**
9971 * Focus the input and select a specified range within the text.
9972 *
9973 * @param {number} from Select from offset
9974 * @param {number} [to] Select to offset, defaults to from
9975 * @chainable
9976 */
9977 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
9978 var isBackwards, start, end,
9979 input = this.$input[ 0 ];
9980
9981 to = to || from;
9982
9983 isBackwards = to < from;
9984 start = isBackwards ? to : from;
9985 end = isBackwards ? from : to;
9986
9987 this.focus();
9988
9989 try {
9990 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
9991 } catch ( e ) {
9992 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
9993 // Rather than expensively check if the input is attached every time, just check
9994 // if it was the cause of an error being thrown. If not, rethrow the error.
9995 if ( this.getElementDocument().body.contains( input ) ) {
9996 throw e;
9997 }
9998 }
9999 return this;
10000 };
10001
10002 /**
10003 * Get an object describing the current selection range in a directional manner
10004 *
10005 * @return {Object} Object containing 'from' and 'to' offsets
10006 */
10007 OO.ui.TextInputWidget.prototype.getRange = function () {
10008 var input = this.$input[ 0 ],
10009 start = input.selectionStart,
10010 end = input.selectionEnd,
10011 isBackwards = input.selectionDirection === 'backward';
10012
10013 return {
10014 from: isBackwards ? end : start,
10015 to: isBackwards ? start : end
10016 };
10017 };
10018
10019 /**
10020 * Get the length of the text input value.
10021 *
10022 * This could differ from the length of #getValue if the
10023 * value gets filtered
10024 *
10025 * @return {number} Input length
10026 */
10027 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10028 return this.$input[ 0 ].value.length;
10029 };
10030
10031 /**
10032 * Focus the input and select the entire text.
10033 *
10034 * @chainable
10035 */
10036 OO.ui.TextInputWidget.prototype.select = function () {
10037 return this.selectRange( 0, this.getInputLength() );
10038 };
10039
10040 /**
10041 * Focus the input and move the cursor to the start.
10042 *
10043 * @chainable
10044 */
10045 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10046 return this.selectRange( 0 );
10047 };
10048
10049 /**
10050 * Focus the input and move the cursor to the end.
10051 *
10052 * @chainable
10053 */
10054 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10055 return this.selectRange( this.getInputLength() );
10056 };
10057
10058 /**
10059 * Insert new content into the input.
10060 *
10061 * @param {string} content Content to be inserted
10062 * @chainable
10063 */
10064 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10065 var start, end,
10066 range = this.getRange(),
10067 value = this.getValue();
10068
10069 start = Math.min( range.from, range.to );
10070 end = Math.max( range.from, range.to );
10071
10072 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10073 this.selectRange( start + content.length );
10074 return this;
10075 };
10076
10077 /**
10078 * Insert new content either side of a selection.
10079 *
10080 * @param {string} pre Content to be inserted before the selection
10081 * @param {string} post Content to be inserted after the selection
10082 * @chainable
10083 */
10084 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10085 var start, end,
10086 range = this.getRange(),
10087 offset = pre.length;
10088
10089 start = Math.min( range.from, range.to );
10090 end = Math.max( range.from, range.to );
10091
10092 this.selectRange( start ).insertContent( pre );
10093 this.selectRange( offset + end ).insertContent( post );
10094
10095 this.selectRange( offset + start, offset + end );
10096 return this;
10097 };
10098
10099 /**
10100 * Set the validation pattern.
10101 *
10102 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10103 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10104 * value must contain only numbers).
10105 *
10106 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10107 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10108 */
10109 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10110 if ( validate instanceof RegExp || validate instanceof Function ) {
10111 this.validate = validate;
10112 } else {
10113 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10114 }
10115 };
10116
10117 /**
10118 * Sets the 'invalid' flag appropriately.
10119 *
10120 * @param {boolean} [isValid] Optionally override validation result
10121 */
10122 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10123 var widget = this,
10124 setFlag = function ( valid ) {
10125 if ( !valid ) {
10126 widget.$input.attr( 'aria-invalid', 'true' );
10127 } else {
10128 widget.$input.removeAttr( 'aria-invalid' );
10129 }
10130 widget.setFlags( { invalid: !valid } );
10131 };
10132
10133 if ( isValid !== undefined ) {
10134 setFlag( isValid );
10135 } else {
10136 this.getValidity().then( function () {
10137 setFlag( true );
10138 }, function () {
10139 setFlag( false );
10140 } );
10141 }
10142 };
10143
10144 /**
10145 * Get the validity of current value.
10146 *
10147 * This method returns a promise that resolves if the value is valid and rejects if
10148 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10149 *
10150 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10151 */
10152 OO.ui.TextInputWidget.prototype.getValidity = function () {
10153 var result;
10154
10155 function rejectOrResolve( valid ) {
10156 if ( valid ) {
10157 return $.Deferred().resolve().promise();
10158 } else {
10159 return $.Deferred().reject().promise();
10160 }
10161 }
10162
10163 // Check browser validity and reject if it is invalid
10164 if (
10165 this.$input[ 0 ].checkValidity !== undefined &&
10166 this.$input[ 0 ].checkValidity() === false
10167 ) {
10168 return rejectOrResolve( false );
10169 }
10170
10171 // Run our checks if the browser thinks the field is valid
10172 if ( this.validate instanceof Function ) {
10173 result = this.validate( this.getValue() );
10174 if ( result && $.isFunction( result.promise ) ) {
10175 return result.promise().then( function ( valid ) {
10176 return rejectOrResolve( valid );
10177 } );
10178 } else {
10179 return rejectOrResolve( result );
10180 }
10181 } else {
10182 return rejectOrResolve( this.getValue().match( this.validate ) );
10183 }
10184 };
10185
10186 /**
10187 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10188 *
10189 * @param {string} labelPosition Label position, 'before' or 'after'
10190 * @chainable
10191 */
10192 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10193 this.labelPosition = labelPosition;
10194 if ( this.label ) {
10195 // If there is no label and we only change the position, #updatePosition is a no-op,
10196 // but it takes really a lot of work to do nothing.
10197 this.updatePosition();
10198 }
10199 return this;
10200 };
10201
10202 /**
10203 * Update the position of the inline label.
10204 *
10205 * This method is called by #setLabelPosition, and can also be called on its own if
10206 * something causes the label to be mispositioned.
10207 *
10208 * @chainable
10209 */
10210 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10211 var after = this.labelPosition === 'after';
10212
10213 this.$element
10214 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10215 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10216
10217 this.valCache = null;
10218 this.scrollWidth = null;
10219 this.positionLabel();
10220
10221 return this;
10222 };
10223
10224 /**
10225 * Position the label by setting the correct padding on the input.
10226 *
10227 * @private
10228 * @chainable
10229 */
10230 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10231 var after, rtl, property, newCss;
10232
10233 if ( this.isWaitingToBeAttached ) {
10234 // #onElementAttach will be called soon, which calls this method
10235 return this;
10236 }
10237
10238 newCss = {
10239 'padding-right': '',
10240 'padding-left': ''
10241 };
10242
10243 if ( this.label ) {
10244 this.$element.append( this.$label );
10245 } else {
10246 this.$label.detach();
10247 // Clear old values if present
10248 this.$input.css( newCss );
10249 return;
10250 }
10251
10252 after = this.labelPosition === 'after';
10253 rtl = this.$element.css( 'direction' ) === 'rtl';
10254 property = after === rtl ? 'padding-left' : 'padding-right';
10255
10256 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10257 // We have to clear the padding on the other side, in case the element direction changed
10258 this.$input.css( newCss );
10259
10260 return this;
10261 };
10262
10263 /**
10264 * @inheritdoc
10265 */
10266 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10267 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10268 if ( state.scrollTop !== undefined ) {
10269 this.$input.scrollTop( state.scrollTop );
10270 }
10271 };
10272
10273 /**
10274 * @class
10275 * @extends OO.ui.TextInputWidget
10276 *
10277 * @constructor
10278 * @param {Object} [config] Configuration options
10279 */
10280 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10281 config = $.extend( {
10282 icon: 'search'
10283 }, config );
10284
10285 // Parent constructor
10286 OO.ui.SearchInputWidget.parent.call( this, config );
10287
10288 // Events
10289 this.connect( this, {
10290 change: 'onChange'
10291 } );
10292
10293 // Initialization
10294 this.updateSearchIndicator();
10295 this.connect( this, {
10296 disable: 'onDisable'
10297 } );
10298 };
10299
10300 /* Setup */
10301
10302 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10303
10304 /* Methods */
10305
10306 /**
10307 * @inheritdoc
10308 * @protected
10309 */
10310 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10311 return 'search';
10312 };
10313
10314 /**
10315 * @inheritdoc
10316 */
10317 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10318 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10319 // Clear the text field
10320 this.setValue( '' );
10321 this.focus();
10322 return false;
10323 }
10324 };
10325
10326 /**
10327 * Update the 'clear' indicator displayed on type: 'search' text
10328 * fields, hiding it when the field is already empty or when it's not
10329 * editable.
10330 */
10331 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10332 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10333 this.setIndicator( null );
10334 } else {
10335 this.setIndicator( 'clear' );
10336 }
10337 };
10338
10339 /**
10340 * Handle change events.
10341 *
10342 * @private
10343 */
10344 OO.ui.SearchInputWidget.prototype.onChange = function () {
10345 this.updateSearchIndicator();
10346 };
10347
10348 /**
10349 * Handle disable events.
10350 *
10351 * @param {boolean} disabled Element is disabled
10352 * @private
10353 */
10354 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10355 this.updateSearchIndicator();
10356 };
10357
10358 /**
10359 * @inheritdoc
10360 */
10361 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10362 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10363 this.updateSearchIndicator();
10364 return this;
10365 };
10366
10367 /**
10368 * @class
10369 * @extends OO.ui.TextInputWidget
10370 *
10371 * @constructor
10372 * @param {Object} [config] Configuration options
10373 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10374 * specifies minimum number of rows to display.
10375 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10376 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10377 * Use the #maxRows config to specify a maximum number of displayed rows.
10378 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10379 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10380 */
10381 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10382 config = $.extend( {
10383 type: 'text'
10384 }, config );
10385 config.multiline = false;
10386 // Parent constructor
10387 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10388
10389 // Properties
10390 this.multiline = true;
10391 this.autosize = !!config.autosize;
10392 this.minRows = config.rows !== undefined ? config.rows : '';
10393 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10394
10395 // Clone for resizing
10396 if ( this.autosize ) {
10397 this.$clone = this.$input
10398 .clone()
10399 .insertAfter( this.$input )
10400 .attr( 'aria-hidden', 'true' )
10401 .addClass( 'oo-ui-element-hidden' );
10402 }
10403
10404 // Events
10405 this.connect( this, {
10406 change: 'onChange'
10407 } );
10408
10409 // Initialization
10410 if ( this.multiline && config.rows ) {
10411 this.$input.attr( 'rows', config.rows );
10412 }
10413 if ( this.autosize ) {
10414 this.isWaitingToBeAttached = true;
10415 this.installParentChangeDetector();
10416 }
10417 };
10418
10419 /* Setup */
10420
10421 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10422
10423 /* Static Methods */
10424
10425 /**
10426 * @inheritdoc
10427 */
10428 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10429 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10430 state.scrollTop = config.$input.scrollTop();
10431 return state;
10432 };
10433
10434 /* Methods */
10435
10436 /**
10437 * @inheritdoc
10438 */
10439 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10440 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10441 this.adjustSize();
10442 };
10443
10444 /**
10445 * Handle change events.
10446 *
10447 * @private
10448 */
10449 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10450 this.adjustSize();
10451 };
10452
10453 /**
10454 * @inheritdoc
10455 */
10456 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10457 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10458 this.adjustSize();
10459 };
10460
10461 /**
10462 * Override TextInputWidget so it doesn't emit the 'enter' event.
10463 *
10464 * @private
10465 * @param {jQuery.Event} e Key press event
10466 */
10467 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10468 return;
10469 };
10470
10471 /**
10472 * Automatically adjust the size of the text input.
10473 *
10474 * This only affects multiline inputs that are {@link #autosize autosized}.
10475 *
10476 * @chainable
10477 * @fires resize
10478 */
10479 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10480 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10481 idealHeight, newHeight, scrollWidth, property;
10482
10483 if ( this.$input.val() !== this.valCache ) {
10484 if ( this.autosize ) {
10485 this.$clone
10486 .val( this.$input.val() )
10487 .attr( 'rows', this.minRows )
10488 // Set inline height property to 0 to measure scroll height
10489 .css( 'height', 0 );
10490
10491 this.$clone.removeClass( 'oo-ui-element-hidden' );
10492
10493 this.valCache = this.$input.val();
10494
10495 scrollHeight = this.$clone[ 0 ].scrollHeight;
10496
10497 // Remove inline height property to measure natural heights
10498 this.$clone.css( 'height', '' );
10499 innerHeight = this.$clone.innerHeight();
10500 outerHeight = this.$clone.outerHeight();
10501
10502 // Measure max rows height
10503 this.$clone
10504 .attr( 'rows', this.maxRows )
10505 .css( 'height', 'auto' )
10506 .val( '' );
10507 maxInnerHeight = this.$clone.innerHeight();
10508
10509 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10510 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10511 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10512 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10513
10514 this.$clone.addClass( 'oo-ui-element-hidden' );
10515
10516 // Only apply inline height when expansion beyond natural height is needed
10517 // Use the difference between the inner and outer height as a buffer
10518 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10519 if ( newHeight !== this.styleHeight ) {
10520 this.$input.css( 'height', newHeight );
10521 this.styleHeight = newHeight;
10522 this.emit( 'resize' );
10523 }
10524 }
10525 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10526 if ( scrollWidth !== this.scrollWidth ) {
10527 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10528 // Reset
10529 this.$label.css( { right: '', left: '' } );
10530 this.$indicator.css( { right: '', left: '' } );
10531
10532 if ( scrollWidth ) {
10533 this.$indicator.css( property, scrollWidth );
10534 if ( this.labelPosition === 'after' ) {
10535 this.$label.css( property, scrollWidth );
10536 }
10537 }
10538
10539 this.scrollWidth = scrollWidth;
10540 this.positionLabel();
10541 }
10542 }
10543 return this;
10544 };
10545
10546 /**
10547 * @inheritdoc
10548 * @protected
10549 */
10550 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10551 return $( '<textarea>' );
10552 };
10553
10554 /**
10555 * Check if the input supports multiple lines.
10556 *
10557 * @return {boolean}
10558 */
10559 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10560 return !!this.multiline;
10561 };
10562
10563 /**
10564 * Check if the input automatically adjusts its size.
10565 *
10566 * @return {boolean}
10567 */
10568 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10569 return !!this.autosize;
10570 };
10571
10572 /**
10573 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10574 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10575 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10576 *
10577 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10578 * option, that option will appear to be selected.
10579 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10580 * input field.
10581 *
10582 * After the user chooses an option, its `data` will be used as a new value for the widget.
10583 * A `label` also can be specified for each option: if given, it will be shown instead of the
10584 * `data` in the dropdown menu.
10585 *
10586 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10587 *
10588 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10589 *
10590 * @example
10591 * // Example: A ComboBoxInputWidget.
10592 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10593 * value: 'Option 1',
10594 * options: [
10595 * { data: 'Option 1' },
10596 * { data: 'Option 2' },
10597 * { data: 'Option 3' }
10598 * ]
10599 * } );
10600 * $( 'body' ).append( comboBox.$element );
10601 *
10602 * @example
10603 * // Example: A ComboBoxInputWidget with additional option labels.
10604 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10605 * value: 'Option 1',
10606 * options: [
10607 * {
10608 * data: 'Option 1',
10609 * label: 'Option One'
10610 * },
10611 * {
10612 * data: 'Option 2',
10613 * label: 'Option Two'
10614 * },
10615 * {
10616 * data: 'Option 3',
10617 * label: 'Option Three'
10618 * }
10619 * ]
10620 * } );
10621 * $( 'body' ).append( comboBox.$element );
10622 *
10623 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10624 *
10625 * @class
10626 * @extends OO.ui.TextInputWidget
10627 *
10628 * @constructor
10629 * @param {Object} [config] Configuration options
10630 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10631 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10632 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10633 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10634 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10635 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10636 */
10637 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10638 // Configuration initialization
10639 config = $.extend( {
10640 autocomplete: false
10641 }, config );
10642
10643 // ComboBoxInputWidget shouldn't support `multiline`
10644 config.multiline = false;
10645
10646 // See InputWidget#reusePreInfuseDOM about `config.$input`
10647 if ( config.$input ) {
10648 config.$input.removeAttr( 'list' );
10649 }
10650
10651 // Parent constructor
10652 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10653
10654 // Properties
10655 this.$overlay = config.$overlay || this.$element;
10656 this.dropdownButton = new OO.ui.ButtonWidget( {
10657 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10658 indicator: 'down',
10659 disabled: this.disabled
10660 } );
10661 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10662 {
10663 widget: this,
10664 input: this,
10665 $floatableContainer: this.$element,
10666 disabled: this.isDisabled()
10667 },
10668 config.menu
10669 ) );
10670
10671 // Events
10672 this.connect( this, {
10673 change: 'onInputChange',
10674 enter: 'onInputEnter'
10675 } );
10676 this.dropdownButton.connect( this, {
10677 click: 'onDropdownButtonClick'
10678 } );
10679 this.menu.connect( this, {
10680 choose: 'onMenuChoose',
10681 add: 'onMenuItemsChange',
10682 remove: 'onMenuItemsChange'
10683 } );
10684
10685 // Initialization
10686 this.$input.attr( {
10687 role: 'combobox',
10688 'aria-owns': this.menu.getElementId(),
10689 'aria-autocomplete': 'list'
10690 } );
10691 // Do not override options set via config.menu.items
10692 if ( config.options !== undefined ) {
10693 this.setOptions( config.options );
10694 }
10695 this.$field = $( '<div>' )
10696 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10697 .append( this.$input, this.dropdownButton.$element );
10698 this.$element
10699 .addClass( 'oo-ui-comboBoxInputWidget' )
10700 .append( this.$field );
10701 this.$overlay.append( this.menu.$element );
10702 this.onMenuItemsChange();
10703 };
10704
10705 /* Setup */
10706
10707 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10708
10709 /* Methods */
10710
10711 /**
10712 * Get the combobox's menu.
10713 *
10714 * @return {OO.ui.MenuSelectWidget} Menu widget
10715 */
10716 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10717 return this.menu;
10718 };
10719
10720 /**
10721 * Get the combobox's text input widget.
10722 *
10723 * @return {OO.ui.TextInputWidget} Text input widget
10724 */
10725 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10726 return this;
10727 };
10728
10729 /**
10730 * Handle input change events.
10731 *
10732 * @private
10733 * @param {string} value New value
10734 */
10735 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
10736 var match = this.menu.getItemFromData( value );
10737
10738 this.menu.selectItem( match );
10739 if ( this.menu.findHighlightedItem() ) {
10740 this.menu.highlightItem( match );
10741 }
10742
10743 if ( !this.isDisabled() ) {
10744 this.menu.toggle( true );
10745 }
10746 };
10747
10748 /**
10749 * Handle input enter events.
10750 *
10751 * @private
10752 */
10753 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
10754 if ( !this.isDisabled() ) {
10755 this.menu.toggle( false );
10756 }
10757 };
10758
10759 /**
10760 * Handle button click events.
10761 *
10762 * @private
10763 */
10764 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
10765 this.menu.toggle();
10766 this.focus();
10767 };
10768
10769 /**
10770 * Handle menu choose events.
10771 *
10772 * @private
10773 * @param {OO.ui.OptionWidget} item Chosen item
10774 */
10775 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
10776 this.setValue( item.getData() );
10777 };
10778
10779 /**
10780 * Handle menu item change events.
10781 *
10782 * @private
10783 */
10784 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
10785 var match = this.menu.getItemFromData( this.getValue() );
10786 this.menu.selectItem( match );
10787 if ( this.menu.findHighlightedItem() ) {
10788 this.menu.highlightItem( match );
10789 }
10790 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
10791 };
10792
10793 /**
10794 * @inheritdoc
10795 */
10796 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
10797 // Parent method
10798 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
10799
10800 if ( this.dropdownButton ) {
10801 this.dropdownButton.setDisabled( this.isDisabled() );
10802 }
10803 if ( this.menu ) {
10804 this.menu.setDisabled( this.isDisabled() );
10805 }
10806
10807 return this;
10808 };
10809
10810 /**
10811 * Set the options available for this input.
10812 *
10813 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10814 * @chainable
10815 */
10816 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
10817 this.getMenu()
10818 .clearItems()
10819 .addItems( options.map( function ( opt ) {
10820 return new OO.ui.MenuOptionWidget( {
10821 data: opt.data,
10822 label: opt.label !== undefined ? opt.label : opt.data
10823 } );
10824 } ) );
10825
10826 return this;
10827 };
10828
10829 /**
10830 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
10831 * which is a widget that is specified by reference before any optional configuration settings.
10832 *
10833 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
10834 *
10835 * - **left**: The label is placed before the field-widget and aligned with the left margin.
10836 * A left-alignment is used for forms with many fields.
10837 * - **right**: The label is placed before the field-widget and aligned to the right margin.
10838 * A right-alignment is used for long but familiar forms which users tab through,
10839 * verifying the current field with a quick glance at the label.
10840 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
10841 * that users fill out from top to bottom.
10842 * - **inline**: The label is placed after the field-widget and aligned to the left.
10843 * An inline-alignment is best used with checkboxes or radio buttons.
10844 *
10845 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
10846 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
10847 *
10848 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
10849 *
10850 * @class
10851 * @extends OO.ui.Layout
10852 * @mixins OO.ui.mixin.LabelElement
10853 * @mixins OO.ui.mixin.TitledElement
10854 *
10855 * @constructor
10856 * @param {OO.ui.Widget} fieldWidget Field widget
10857 * @param {Object} [config] Configuration options
10858 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
10859 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
10860 * The array may contain strings or OO.ui.HtmlSnippet instances.
10861 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
10862 * The array may contain strings or OO.ui.HtmlSnippet instances.
10863 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
10864 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
10865 * For important messages, you are advised to use `notices`, as they are always shown.
10866 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
10867 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10868 *
10869 * @throws {Error} An error is thrown if no widget is specified
10870 */
10871 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
10872 // Allow passing positional parameters inside the config object
10873 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
10874 config = fieldWidget;
10875 fieldWidget = config.fieldWidget;
10876 }
10877
10878 // Make sure we have required constructor arguments
10879 if ( fieldWidget === undefined ) {
10880 throw new Error( 'Widget not found' );
10881 }
10882
10883 // Configuration initialization
10884 config = $.extend( { align: 'left' }, config );
10885
10886 // Parent constructor
10887 OO.ui.FieldLayout.parent.call( this, config );
10888
10889 // Mixin constructors
10890 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
10891 $label: $( '<label>' )
10892 } ) );
10893 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
10894
10895 // Properties
10896 this.fieldWidget = fieldWidget;
10897 this.errors = [];
10898 this.notices = [];
10899 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
10900 this.$messages = $( '<ul>' );
10901 this.$header = $( '<span>' );
10902 this.$body = $( '<div>' );
10903 this.align = null;
10904 if ( config.help ) {
10905 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
10906 $overlay: config.$overlay,
10907 popup: {
10908 padded: true
10909 },
10910 classes: [ 'oo-ui-fieldLayout-help' ],
10911 framed: false,
10912 icon: 'info'
10913 } );
10914 if ( config.help instanceof OO.ui.HtmlSnippet ) {
10915 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
10916 } else {
10917 this.popupButtonWidget.getPopup().$body.text( config.help );
10918 }
10919 this.$help = this.popupButtonWidget.$element;
10920 } else {
10921 this.$help = $( [] );
10922 }
10923
10924 // Events
10925 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
10926
10927 // Initialization
10928 if ( config.help ) {
10929 // Set the 'aria-describedby' attribute on the fieldWidget
10930 // Preference given to an input or a button
10931 (
10932 this.fieldWidget.$input ||
10933 this.fieldWidget.$button ||
10934 this.fieldWidget.$element
10935 ).attr(
10936 'aria-describedby',
10937 this.popupButtonWidget.getPopup().getBodyId()
10938 );
10939 }
10940 if ( this.fieldWidget.getInputId() ) {
10941 this.$label.attr( 'for', this.fieldWidget.getInputId() );
10942 } else {
10943 this.$label.on( 'click', function () {
10944 this.fieldWidget.simulateLabelClick();
10945 return false;
10946 }.bind( this ) );
10947 }
10948 this.$element
10949 .addClass( 'oo-ui-fieldLayout' )
10950 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
10951 .append( this.$body );
10952 this.$body.addClass( 'oo-ui-fieldLayout-body' );
10953 this.$header.addClass( 'oo-ui-fieldLayout-header' );
10954 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
10955 this.$field
10956 .addClass( 'oo-ui-fieldLayout-field' )
10957 .append( this.fieldWidget.$element );
10958
10959 this.setErrors( config.errors || [] );
10960 this.setNotices( config.notices || [] );
10961 this.setAlignment( config.align );
10962 // Call this again to take into account the widget's accessKey
10963 this.updateTitle();
10964 };
10965
10966 /* Setup */
10967
10968 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
10969 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
10970 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
10971
10972 /* Methods */
10973
10974 /**
10975 * Handle field disable events.
10976 *
10977 * @private
10978 * @param {boolean} value Field is disabled
10979 */
10980 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
10981 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
10982 };
10983
10984 /**
10985 * Get the widget contained by the field.
10986 *
10987 * @return {OO.ui.Widget} Field widget
10988 */
10989 OO.ui.FieldLayout.prototype.getField = function () {
10990 return this.fieldWidget;
10991 };
10992
10993 /**
10994 * Return `true` if the given field widget can be used with `'inline'` alignment (see
10995 * #setAlignment). Return `false` if it can't or if this can't be determined.
10996 *
10997 * @return {boolean}
10998 */
10999 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11000 // This is very simplistic, but should be good enough.
11001 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11002 };
11003
11004 /**
11005 * @protected
11006 * @param {string} kind 'error' or 'notice'
11007 * @param {string|OO.ui.HtmlSnippet} text
11008 * @return {jQuery}
11009 */
11010 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11011 var $listItem, $icon, message;
11012 $listItem = $( '<li>' );
11013 if ( kind === 'error' ) {
11014 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11015 $listItem.attr( 'role', 'alert' );
11016 } else if ( kind === 'notice' ) {
11017 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
11018 } else {
11019 $icon = '';
11020 }
11021 message = new OO.ui.LabelWidget( { label: text } );
11022 $listItem
11023 .append( $icon, message.$element )
11024 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11025 return $listItem;
11026 };
11027
11028 /**
11029 * Set the field alignment mode.
11030 *
11031 * @private
11032 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11033 * @chainable
11034 */
11035 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11036 if ( value !== this.align ) {
11037 // Default to 'left'
11038 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11039 value = 'left';
11040 }
11041 // Validate
11042 if ( value === 'inline' && !this.isFieldInline() ) {
11043 value = 'top';
11044 }
11045 // Reorder elements
11046 if ( value === 'top' ) {
11047 this.$header.append( this.$label, this.$help );
11048 this.$body.append( this.$header, this.$field );
11049 } else if ( value === 'inline' ) {
11050 this.$header.append( this.$label, this.$help );
11051 this.$body.append( this.$field, this.$header );
11052 } else {
11053 this.$header.append( this.$label );
11054 this.$body.append( this.$header, this.$help, this.$field );
11055 }
11056 // Set classes. The following classes can be used here:
11057 // * oo-ui-fieldLayout-align-left
11058 // * oo-ui-fieldLayout-align-right
11059 // * oo-ui-fieldLayout-align-top
11060 // * oo-ui-fieldLayout-align-inline
11061 if ( this.align ) {
11062 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11063 }
11064 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11065 this.align = value;
11066 }
11067
11068 return this;
11069 };
11070
11071 /**
11072 * Set the list of error messages.
11073 *
11074 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11075 * The array may contain strings or OO.ui.HtmlSnippet instances.
11076 * @chainable
11077 */
11078 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11079 this.errors = errors.slice();
11080 this.updateMessages();
11081 return this;
11082 };
11083
11084 /**
11085 * Set the list of notice messages.
11086 *
11087 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11088 * The array may contain strings or OO.ui.HtmlSnippet instances.
11089 * @chainable
11090 */
11091 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11092 this.notices = notices.slice();
11093 this.updateMessages();
11094 return this;
11095 };
11096
11097 /**
11098 * Update the rendering of error and notice messages.
11099 *
11100 * @private
11101 */
11102 OO.ui.FieldLayout.prototype.updateMessages = function () {
11103 var i;
11104 this.$messages.empty();
11105
11106 if ( this.errors.length || this.notices.length ) {
11107 this.$body.after( this.$messages );
11108 } else {
11109 this.$messages.remove();
11110 return;
11111 }
11112
11113 for ( i = 0; i < this.notices.length; i++ ) {
11114 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11115 }
11116 for ( i = 0; i < this.errors.length; i++ ) {
11117 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11118 }
11119 };
11120
11121 /**
11122 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11123 * (This is a bit of a hack.)
11124 *
11125 * @protected
11126 * @param {string} title Tooltip label for 'title' attribute
11127 * @return {string}
11128 */
11129 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11130 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11131 return this.fieldWidget.formatTitleWithAccessKey( title );
11132 }
11133 return title;
11134 };
11135
11136 /**
11137 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11138 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11139 * is required and is specified before any optional configuration settings.
11140 *
11141 * Labels can be aligned in one of four ways:
11142 *
11143 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11144 * A left-alignment is used for forms with many fields.
11145 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11146 * A right-alignment is used for long but familiar forms which users tab through,
11147 * verifying the current field with a quick glance at the label.
11148 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11149 * that users fill out from top to bottom.
11150 * - **inline**: The label is placed after the field-widget and aligned to the left.
11151 * An inline-alignment is best used with checkboxes or radio buttons.
11152 *
11153 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11154 * text is specified.
11155 *
11156 * @example
11157 * // Example of an ActionFieldLayout
11158 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11159 * new OO.ui.TextInputWidget( {
11160 * placeholder: 'Field widget'
11161 * } ),
11162 * new OO.ui.ButtonWidget( {
11163 * label: 'Button'
11164 * } ),
11165 * {
11166 * label: 'An ActionFieldLayout. This label is aligned top',
11167 * align: 'top',
11168 * help: 'This is help text'
11169 * }
11170 * );
11171 *
11172 * $( 'body' ).append( actionFieldLayout.$element );
11173 *
11174 * @class
11175 * @extends OO.ui.FieldLayout
11176 *
11177 * @constructor
11178 * @param {OO.ui.Widget} fieldWidget Field widget
11179 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11180 * @param {Object} config
11181 */
11182 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11183 // Allow passing positional parameters inside the config object
11184 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11185 config = fieldWidget;
11186 fieldWidget = config.fieldWidget;
11187 buttonWidget = config.buttonWidget;
11188 }
11189
11190 // Parent constructor
11191 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11192
11193 // Properties
11194 this.buttonWidget = buttonWidget;
11195 this.$button = $( '<span>' );
11196 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11197
11198 // Initialization
11199 this.$element
11200 .addClass( 'oo-ui-actionFieldLayout' );
11201 this.$button
11202 .addClass( 'oo-ui-actionFieldLayout-button' )
11203 .append( this.buttonWidget.$element );
11204 this.$input
11205 .addClass( 'oo-ui-actionFieldLayout-input' )
11206 .append( this.fieldWidget.$element );
11207 this.$field
11208 .append( this.$input, this.$button );
11209 };
11210
11211 /* Setup */
11212
11213 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11214
11215 /**
11216 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11217 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11218 * configured with a label as well. For more information and examples,
11219 * please see the [OOjs UI documentation on MediaWiki][1].
11220 *
11221 * @example
11222 * // Example of a fieldset layout
11223 * var input1 = new OO.ui.TextInputWidget( {
11224 * placeholder: 'A text input field'
11225 * } );
11226 *
11227 * var input2 = new OO.ui.TextInputWidget( {
11228 * placeholder: 'A text input field'
11229 * } );
11230 *
11231 * var fieldset = new OO.ui.FieldsetLayout( {
11232 * label: 'Example of a fieldset layout'
11233 * } );
11234 *
11235 * fieldset.addItems( [
11236 * new OO.ui.FieldLayout( input1, {
11237 * label: 'Field One'
11238 * } ),
11239 * new OO.ui.FieldLayout( input2, {
11240 * label: 'Field Two'
11241 * } )
11242 * ] );
11243 * $( 'body' ).append( fieldset.$element );
11244 *
11245 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11246 *
11247 * @class
11248 * @extends OO.ui.Layout
11249 * @mixins OO.ui.mixin.IconElement
11250 * @mixins OO.ui.mixin.LabelElement
11251 * @mixins OO.ui.mixin.GroupElement
11252 *
11253 * @constructor
11254 * @param {Object} [config] Configuration options
11255 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11256 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11257 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11258 * For important messages, you are advised to use `notices`, as they are always shown.
11259 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11260 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11261 */
11262 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11263 // Configuration initialization
11264 config = config || {};
11265
11266 // Parent constructor
11267 OO.ui.FieldsetLayout.parent.call( this, config );
11268
11269 // Mixin constructors
11270 OO.ui.mixin.IconElement.call( this, config );
11271 OO.ui.mixin.LabelElement.call( this, config );
11272 OO.ui.mixin.GroupElement.call( this, config );
11273
11274 // Properties
11275 this.$header = $( '<legend>' );
11276 if ( config.help ) {
11277 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11278 $overlay: config.$overlay,
11279 popup: {
11280 padded: true
11281 },
11282 classes: [ 'oo-ui-fieldsetLayout-help' ],
11283 framed: false,
11284 icon: 'info'
11285 } );
11286 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11287 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11288 } else {
11289 this.popupButtonWidget.getPopup().$body.text( config.help );
11290 }
11291 this.$help = this.popupButtonWidget.$element;
11292 } else {
11293 this.$help = $( [] );
11294 }
11295
11296 // Initialization
11297 this.$header
11298 .addClass( 'oo-ui-fieldsetLayout-header' )
11299 .append( this.$icon, this.$label, this.$help );
11300 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11301 this.$element
11302 .addClass( 'oo-ui-fieldsetLayout' )
11303 .prepend( this.$header, this.$group );
11304 if ( Array.isArray( config.items ) ) {
11305 this.addItems( config.items );
11306 }
11307 };
11308
11309 /* Setup */
11310
11311 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11312 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11313 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11314 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11315
11316 /* Static Properties */
11317
11318 /**
11319 * @static
11320 * @inheritdoc
11321 */
11322 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11323
11324 /**
11325 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11326 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11327 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11328 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11329 *
11330 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11331 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11332 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11333 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11334 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11335 * often have simplified APIs to match the capabilities of HTML forms.
11336 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11337 *
11338 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11339 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11340 *
11341 * @example
11342 * // Example of a form layout that wraps a fieldset layout
11343 * var input1 = new OO.ui.TextInputWidget( {
11344 * placeholder: 'Username'
11345 * } );
11346 * var input2 = new OO.ui.TextInputWidget( {
11347 * placeholder: 'Password',
11348 * type: 'password'
11349 * } );
11350 * var submit = new OO.ui.ButtonInputWidget( {
11351 * label: 'Submit'
11352 * } );
11353 *
11354 * var fieldset = new OO.ui.FieldsetLayout( {
11355 * label: 'A form layout'
11356 * } );
11357 * fieldset.addItems( [
11358 * new OO.ui.FieldLayout( input1, {
11359 * label: 'Username',
11360 * align: 'top'
11361 * } ),
11362 * new OO.ui.FieldLayout( input2, {
11363 * label: 'Password',
11364 * align: 'top'
11365 * } ),
11366 * new OO.ui.FieldLayout( submit )
11367 * ] );
11368 * var form = new OO.ui.FormLayout( {
11369 * items: [ fieldset ],
11370 * action: '/api/formhandler',
11371 * method: 'get'
11372 * } )
11373 * $( 'body' ).append( form.$element );
11374 *
11375 * @class
11376 * @extends OO.ui.Layout
11377 * @mixins OO.ui.mixin.GroupElement
11378 *
11379 * @constructor
11380 * @param {Object} [config] Configuration options
11381 * @cfg {string} [method] HTML form `method` attribute
11382 * @cfg {string} [action] HTML form `action` attribute
11383 * @cfg {string} [enctype] HTML form `enctype` attribute
11384 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11385 */
11386 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11387 var action;
11388
11389 // Configuration initialization
11390 config = config || {};
11391
11392 // Parent constructor
11393 OO.ui.FormLayout.parent.call( this, config );
11394
11395 // Mixin constructors
11396 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11397
11398 // Events
11399 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11400
11401 // Make sure the action is safe
11402 action = config.action;
11403 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11404 action = './' + action;
11405 }
11406
11407 // Initialization
11408 this.$element
11409 .addClass( 'oo-ui-formLayout' )
11410 .attr( {
11411 method: config.method,
11412 action: action,
11413 enctype: config.enctype
11414 } );
11415 if ( Array.isArray( config.items ) ) {
11416 this.addItems( config.items );
11417 }
11418 };
11419
11420 /* Setup */
11421
11422 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11423 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11424
11425 /* Events */
11426
11427 /**
11428 * A 'submit' event is emitted when the form is submitted.
11429 *
11430 * @event submit
11431 */
11432
11433 /* Static Properties */
11434
11435 /**
11436 * @static
11437 * @inheritdoc
11438 */
11439 OO.ui.FormLayout.static.tagName = 'form';
11440
11441 /* Methods */
11442
11443 /**
11444 * Handle form submit events.
11445 *
11446 * @private
11447 * @param {jQuery.Event} e Submit event
11448 * @fires submit
11449 */
11450 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11451 if ( this.emit( 'submit' ) ) {
11452 return false;
11453 }
11454 };
11455
11456 /**
11457 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11458 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11459 *
11460 * @example
11461 * // Example of a panel layout
11462 * var panel = new OO.ui.PanelLayout( {
11463 * expanded: false,
11464 * framed: true,
11465 * padded: true,
11466 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11467 * } );
11468 * $( 'body' ).append( panel.$element );
11469 *
11470 * @class
11471 * @extends OO.ui.Layout
11472 *
11473 * @constructor
11474 * @param {Object} [config] Configuration options
11475 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11476 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11477 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11478 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11479 */
11480 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11481 // Configuration initialization
11482 config = $.extend( {
11483 scrollable: false,
11484 padded: false,
11485 expanded: true,
11486 framed: false
11487 }, config );
11488
11489 // Parent constructor
11490 OO.ui.PanelLayout.parent.call( this, config );
11491
11492 // Initialization
11493 this.$element.addClass( 'oo-ui-panelLayout' );
11494 if ( config.scrollable ) {
11495 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11496 }
11497 if ( config.padded ) {
11498 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11499 }
11500 if ( config.expanded ) {
11501 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11502 }
11503 if ( config.framed ) {
11504 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11505 }
11506 };
11507
11508 /* Setup */
11509
11510 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11511
11512 /* Methods */
11513
11514 /**
11515 * Focus the panel layout
11516 *
11517 * The default implementation just focuses the first focusable element in the panel
11518 */
11519 OO.ui.PanelLayout.prototype.focus = function () {
11520 OO.ui.findFocusable( this.$element ).focus();
11521 };
11522
11523 /**
11524 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11525 * items), with small margins between them. Convenient when you need to put a number of block-level
11526 * widgets on a single line next to each other.
11527 *
11528 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11529 *
11530 * @example
11531 * // HorizontalLayout with a text input and a label
11532 * var layout = new OO.ui.HorizontalLayout( {
11533 * items: [
11534 * new OO.ui.LabelWidget( { label: 'Label' } ),
11535 * new OO.ui.TextInputWidget( { value: 'Text' } )
11536 * ]
11537 * } );
11538 * $( 'body' ).append( layout.$element );
11539 *
11540 * @class
11541 * @extends OO.ui.Layout
11542 * @mixins OO.ui.mixin.GroupElement
11543 *
11544 * @constructor
11545 * @param {Object} [config] Configuration options
11546 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11547 */
11548 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11549 // Configuration initialization
11550 config = config || {};
11551
11552 // Parent constructor
11553 OO.ui.HorizontalLayout.parent.call( this, config );
11554
11555 // Mixin constructors
11556 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11557
11558 // Initialization
11559 this.$element.addClass( 'oo-ui-horizontalLayout' );
11560 if ( Array.isArray( config.items ) ) {
11561 this.addItems( config.items );
11562 }
11563 };
11564
11565 /* Setup */
11566
11567 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11568 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11569
11570 }( OO ) );
11571
11572 //# sourceMappingURL=oojs-ui-core.js.map