Merge "Add attributes parameter to ShowSearchHitTitle"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.24.1
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-31T22:46:35Z
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 e.which === OO.ui.Keys.SPACE &&
7594 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7595 // Space only closes the menu is the user is not typing to search.
7596 this.menu.keyPressBuffer === ''
7597 ) ||
7598 (
7599 !this.menu.isVisible() &&
7600 (
7601 e.which === OO.ui.Keys.UP ||
7602 e.which === OO.ui.Keys.DOWN
7603 )
7604 )
7605 )
7606 ) {
7607 this.menu.toggle();
7608 return false;
7609 }
7610 };
7611
7612 /**
7613 * RadioOptionWidget is an option widget that looks like a radio button.
7614 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7615 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7616 *
7617 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7618 *
7619 * @class
7620 * @extends OO.ui.OptionWidget
7621 *
7622 * @constructor
7623 * @param {Object} [config] Configuration options
7624 */
7625 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7626 // Configuration initialization
7627 config = config || {};
7628
7629 // Properties (must be done before parent constructor which calls #setDisabled)
7630 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7631
7632 // Parent constructor
7633 OO.ui.RadioOptionWidget.parent.call( this, config );
7634
7635 // Initialization
7636 // Remove implicit role, we're handling it ourselves
7637 this.radio.$input.attr( 'role', 'presentation' );
7638 this.$element
7639 .addClass( 'oo-ui-radioOptionWidget' )
7640 .attr( 'role', 'radio' )
7641 .attr( 'aria-checked', 'false' )
7642 .removeAttr( 'aria-selected' )
7643 .prepend( this.radio.$element );
7644 };
7645
7646 /* Setup */
7647
7648 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7649
7650 /* Static Properties */
7651
7652 /**
7653 * @static
7654 * @inheritdoc
7655 */
7656 OO.ui.RadioOptionWidget.static.highlightable = false;
7657
7658 /**
7659 * @static
7660 * @inheritdoc
7661 */
7662 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7663
7664 /**
7665 * @static
7666 * @inheritdoc
7667 */
7668 OO.ui.RadioOptionWidget.static.pressable = false;
7669
7670 /**
7671 * @static
7672 * @inheritdoc
7673 */
7674 OO.ui.RadioOptionWidget.static.tagName = 'label';
7675
7676 /* Methods */
7677
7678 /**
7679 * @inheritdoc
7680 */
7681 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7682 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7683
7684 this.radio.setSelected( state );
7685 this.$element
7686 .attr( 'aria-checked', state.toString() )
7687 .removeAttr( 'aria-selected' );
7688
7689 return this;
7690 };
7691
7692 /**
7693 * @inheritdoc
7694 */
7695 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7696 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7697
7698 this.radio.setDisabled( this.isDisabled() );
7699
7700 return this;
7701 };
7702
7703 /**
7704 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7705 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7706 * an interface for adding, removing and selecting options.
7707 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7708 *
7709 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7710 * OO.ui.RadioSelectInputWidget instead.
7711 *
7712 * @example
7713 * // A RadioSelectWidget with RadioOptions.
7714 * var option1 = new OO.ui.RadioOptionWidget( {
7715 * data: 'a',
7716 * label: 'Selected radio option'
7717 * } );
7718 *
7719 * var option2 = new OO.ui.RadioOptionWidget( {
7720 * data: 'b',
7721 * label: 'Unselected radio option'
7722 * } );
7723 *
7724 * var radioSelect=new OO.ui.RadioSelectWidget( {
7725 * items: [ option1, option2 ]
7726 * } );
7727 *
7728 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7729 * radioSelect.selectItem( option1 );
7730 *
7731 * $( 'body' ).append( radioSelect.$element );
7732 *
7733 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7734
7735 *
7736 * @class
7737 * @extends OO.ui.SelectWidget
7738 * @mixins OO.ui.mixin.TabIndexedElement
7739 *
7740 * @constructor
7741 * @param {Object} [config] Configuration options
7742 */
7743 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7744 // Parent constructor
7745 OO.ui.RadioSelectWidget.parent.call( this, config );
7746
7747 // Mixin constructors
7748 OO.ui.mixin.TabIndexedElement.call( this, config );
7749
7750 // Events
7751 this.$element.on( {
7752 focus: this.bindKeyDownListener.bind( this ),
7753 blur: this.unbindKeyDownListener.bind( this )
7754 } );
7755
7756 // Initialization
7757 this.$element
7758 .addClass( 'oo-ui-radioSelectWidget' )
7759 .attr( 'role', 'radiogroup' );
7760 };
7761
7762 /* Setup */
7763
7764 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
7765 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
7766
7767 /**
7768 * MultioptionWidgets are special elements that can be selected and configured with data. The
7769 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
7770 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
7771 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
7772 *
7773 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
7774 *
7775 * @class
7776 * @extends OO.ui.Widget
7777 * @mixins OO.ui.mixin.ItemWidget
7778 * @mixins OO.ui.mixin.LabelElement
7779 *
7780 * @constructor
7781 * @param {Object} [config] Configuration options
7782 * @cfg {boolean} [selected=false] Whether the option is initially selected
7783 */
7784 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
7785 // Configuration initialization
7786 config = config || {};
7787
7788 // Parent constructor
7789 OO.ui.MultioptionWidget.parent.call( this, config );
7790
7791 // Mixin constructors
7792 OO.ui.mixin.ItemWidget.call( this );
7793 OO.ui.mixin.LabelElement.call( this, config );
7794
7795 // Properties
7796 this.selected = null;
7797
7798 // Initialization
7799 this.$element
7800 .addClass( 'oo-ui-multioptionWidget' )
7801 .append( this.$label );
7802 this.setSelected( config.selected );
7803 };
7804
7805 /* Setup */
7806
7807 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
7808 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
7809 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
7810
7811 /* Events */
7812
7813 /**
7814 * @event change
7815 *
7816 * A change event is emitted when the selected state of the option changes.
7817 *
7818 * @param {boolean} selected Whether the option is now selected
7819 */
7820
7821 /* Methods */
7822
7823 /**
7824 * Check if the option is selected.
7825 *
7826 * @return {boolean} Item is selected
7827 */
7828 OO.ui.MultioptionWidget.prototype.isSelected = function () {
7829 return this.selected;
7830 };
7831
7832 /**
7833 * Set the option’s selected state. In general, all modifications to the selection
7834 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
7835 * method instead of this method.
7836 *
7837 * @param {boolean} [state=false] Select option
7838 * @chainable
7839 */
7840 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
7841 state = !!state;
7842 if ( this.selected !== state ) {
7843 this.selected = state;
7844 this.emit( 'change', state );
7845 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
7846 }
7847 return this;
7848 };
7849
7850 /**
7851 * MultiselectWidget allows selecting multiple options from a list.
7852 *
7853 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
7854 *
7855 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7856 *
7857 * @class
7858 * @abstract
7859 * @extends OO.ui.Widget
7860 * @mixins OO.ui.mixin.GroupWidget
7861 *
7862 * @constructor
7863 * @param {Object} [config] Configuration options
7864 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
7865 */
7866 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
7867 // Parent constructor
7868 OO.ui.MultiselectWidget.parent.call( this, config );
7869
7870 // Configuration initialization
7871 config = config || {};
7872
7873 // Mixin constructors
7874 OO.ui.mixin.GroupWidget.call( this, config );
7875
7876 // Events
7877 this.aggregate( { change: 'select' } );
7878 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
7879 // by GroupElement only when items are added/removed
7880 this.connect( this, { select: [ 'emit', 'change' ] } );
7881
7882 // Initialization
7883 if ( config.items ) {
7884 this.addItems( config.items );
7885 }
7886 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
7887 this.$element.addClass( 'oo-ui-multiselectWidget' )
7888 .append( this.$group );
7889 };
7890
7891 /* Setup */
7892
7893 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
7894 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
7895
7896 /* Events */
7897
7898 /**
7899 * @event change
7900 *
7901 * A change event is emitted when the set of items changes, or an item is selected or deselected.
7902 */
7903
7904 /**
7905 * @event select
7906 *
7907 * A select event is emitted when an item is selected or deselected.
7908 */
7909
7910 /* Methods */
7911
7912 /**
7913 * Get options that are selected.
7914 *
7915 * @return {OO.ui.MultioptionWidget[]} Selected options
7916 */
7917 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
7918 return this.items.filter( function ( item ) {
7919 return item.isSelected();
7920 } );
7921 };
7922
7923 /**
7924 * Get the data of options that are selected.
7925 *
7926 * @return {Object[]|string[]} Values of selected options
7927 */
7928 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
7929 return this.getSelectedItems().map( function ( item ) {
7930 return item.data;
7931 } );
7932 };
7933
7934 /**
7935 * Select options by reference. Options not mentioned in the `items` array will be deselected.
7936 *
7937 * @param {OO.ui.MultioptionWidget[]} items Items to select
7938 * @chainable
7939 */
7940 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
7941 this.items.forEach( function ( item ) {
7942 var selected = items.indexOf( item ) !== -1;
7943 item.setSelected( selected );
7944 } );
7945 return this;
7946 };
7947
7948 /**
7949 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
7950 *
7951 * @param {Object[]|string[]} datas Values of items to select
7952 * @chainable
7953 */
7954 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
7955 var items,
7956 widget = this;
7957 items = datas.map( function ( data ) {
7958 return widget.getItemFromData( data );
7959 } );
7960 this.selectItems( items );
7961 return this;
7962 };
7963
7964 /**
7965 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
7966 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
7967 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7968 *
7969 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7970 *
7971 * @class
7972 * @extends OO.ui.MultioptionWidget
7973 *
7974 * @constructor
7975 * @param {Object} [config] Configuration options
7976 */
7977 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
7978 // Configuration initialization
7979 config = config || {};
7980
7981 // Properties (must be done before parent constructor which calls #setDisabled)
7982 this.checkbox = new OO.ui.CheckboxInputWidget();
7983
7984 // Parent constructor
7985 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
7986
7987 // Events
7988 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
7989 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
7990
7991 // Initialization
7992 this.$element
7993 .addClass( 'oo-ui-checkboxMultioptionWidget' )
7994 .prepend( this.checkbox.$element );
7995 };
7996
7997 /* Setup */
7998
7999 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8000
8001 /* Static Properties */
8002
8003 /**
8004 * @static
8005 * @inheritdoc
8006 */
8007 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8008
8009 /* Methods */
8010
8011 /**
8012 * Handle checkbox selected state change.
8013 *
8014 * @private
8015 */
8016 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8017 this.setSelected( this.checkbox.isSelected() );
8018 };
8019
8020 /**
8021 * @inheritdoc
8022 */
8023 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8024 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8025 this.checkbox.setSelected( state );
8026 return this;
8027 };
8028
8029 /**
8030 * @inheritdoc
8031 */
8032 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8033 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8034 this.checkbox.setDisabled( this.isDisabled() );
8035 return this;
8036 };
8037
8038 /**
8039 * Focus the widget.
8040 */
8041 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8042 this.checkbox.focus();
8043 };
8044
8045 /**
8046 * Handle key down events.
8047 *
8048 * @protected
8049 * @param {jQuery.Event} e
8050 */
8051 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8052 var
8053 element = this.getElementGroup(),
8054 nextItem;
8055
8056 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8057 nextItem = element.getRelativeFocusableItem( this, -1 );
8058 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8059 nextItem = element.getRelativeFocusableItem( this, 1 );
8060 }
8061
8062 if ( nextItem ) {
8063 e.preventDefault();
8064 nextItem.focus();
8065 }
8066 };
8067
8068 /**
8069 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8070 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8071 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8072 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8073 *
8074 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8075 * OO.ui.CheckboxMultiselectInputWidget instead.
8076 *
8077 * @example
8078 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8079 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8080 * data: 'a',
8081 * selected: true,
8082 * label: 'Selected checkbox'
8083 * } );
8084 *
8085 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8086 * data: 'b',
8087 * label: 'Unselected checkbox'
8088 * } );
8089 *
8090 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8091 * items: [ option1, option2 ]
8092 * } );
8093 *
8094 * $( 'body' ).append( multiselect.$element );
8095 *
8096 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8097 *
8098 * @class
8099 * @extends OO.ui.MultiselectWidget
8100 *
8101 * @constructor
8102 * @param {Object} [config] Configuration options
8103 */
8104 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8105 // Parent constructor
8106 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8107
8108 // Properties
8109 this.$lastClicked = null;
8110
8111 // Events
8112 this.$group.on( 'click', this.onClick.bind( this ) );
8113
8114 // Initialization
8115 this.$element
8116 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8117 };
8118
8119 /* Setup */
8120
8121 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8122
8123 /* Methods */
8124
8125 /**
8126 * Get an option by its position relative to the specified item (or to the start of the option array,
8127 * if item is `null`). The direction in which to search through the option array is specified with a
8128 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8129 * `null` if there are no options in the array.
8130 *
8131 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8132 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8133 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8134 */
8135 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8136 var currentIndex, nextIndex, i,
8137 increase = direction > 0 ? 1 : -1,
8138 len = this.items.length;
8139
8140 if ( item ) {
8141 currentIndex = this.items.indexOf( item );
8142 nextIndex = ( currentIndex + increase + len ) % len;
8143 } else {
8144 // If no item is selected and moving forward, start at the beginning.
8145 // If moving backward, start at the end.
8146 nextIndex = direction > 0 ? 0 : len - 1;
8147 }
8148
8149 for ( i = 0; i < len; i++ ) {
8150 item = this.items[ nextIndex ];
8151 if ( item && !item.isDisabled() ) {
8152 return item;
8153 }
8154 nextIndex = ( nextIndex + increase + len ) % len;
8155 }
8156 return null;
8157 };
8158
8159 /**
8160 * Handle click events on checkboxes.
8161 *
8162 * @param {jQuery.Event} e
8163 */
8164 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8165 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8166 $lastClicked = this.$lastClicked,
8167 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8168 .not( '.oo-ui-widget-disabled' );
8169
8170 // Allow selecting multiple options at once by Shift-clicking them
8171 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8172 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8173 lastClickedIndex = $options.index( $lastClicked );
8174 nowClickedIndex = $options.index( $nowClicked );
8175 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8176 // browser. In either case we don't need custom handling.
8177 if ( nowClickedIndex !== lastClickedIndex ) {
8178 items = this.items;
8179 wasSelected = items[ nowClickedIndex ].isSelected();
8180 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8181
8182 // This depends on the DOM order of the items and the order of the .items array being the same.
8183 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8184 if ( !items[ i ].isDisabled() ) {
8185 items[ i ].setSelected( !wasSelected );
8186 }
8187 }
8188 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8189 // handling first, then set our value. The order in which events happen is different for
8190 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8191 // non-click actions that change the checkboxes.
8192 e.preventDefault();
8193 setTimeout( function () {
8194 if ( !items[ nowClickedIndex ].isDisabled() ) {
8195 items[ nowClickedIndex ].setSelected( !wasSelected );
8196 }
8197 } );
8198 }
8199 }
8200
8201 if ( $nowClicked.length ) {
8202 this.$lastClicked = $nowClicked;
8203 }
8204 };
8205
8206 /**
8207 * Focus the widget
8208 *
8209 * @chainable
8210 */
8211 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8212 var item;
8213 if ( !this.isDisabled() ) {
8214 item = this.getRelativeFocusableItem( null, 1 );
8215 if ( item ) {
8216 item.focus();
8217 }
8218 }
8219 return this;
8220 };
8221
8222 /**
8223 * @inheritdoc
8224 */
8225 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8226 this.focus();
8227 };
8228
8229 /**
8230 * Progress bars visually display the status of an operation, such as a download,
8231 * and can be either determinate or indeterminate:
8232 *
8233 * - **determinate** process bars show the percent of an operation that is complete.
8234 *
8235 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8236 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8237 * not use percentages.
8238 *
8239 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8240 *
8241 * @example
8242 * // Examples of determinate and indeterminate progress bars.
8243 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8244 * progress: 33
8245 * } );
8246 * var progressBar2 = new OO.ui.ProgressBarWidget();
8247 *
8248 * // Create a FieldsetLayout to layout progress bars
8249 * var fieldset = new OO.ui.FieldsetLayout;
8250 * fieldset.addItems( [
8251 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8252 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8253 * ] );
8254 * $( 'body' ).append( fieldset.$element );
8255 *
8256 * @class
8257 * @extends OO.ui.Widget
8258 *
8259 * @constructor
8260 * @param {Object} [config] Configuration options
8261 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8262 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8263 * By default, the progress bar is indeterminate.
8264 */
8265 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8266 // Configuration initialization
8267 config = config || {};
8268
8269 // Parent constructor
8270 OO.ui.ProgressBarWidget.parent.call( this, config );
8271
8272 // Properties
8273 this.$bar = $( '<div>' );
8274 this.progress = null;
8275
8276 // Initialization
8277 this.setProgress( config.progress !== undefined ? config.progress : false );
8278 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8279 this.$element
8280 .attr( {
8281 role: 'progressbar',
8282 'aria-valuemin': 0,
8283 'aria-valuemax': 100
8284 } )
8285 .addClass( 'oo-ui-progressBarWidget' )
8286 .append( this.$bar );
8287 };
8288
8289 /* Setup */
8290
8291 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8292
8293 /* Static Properties */
8294
8295 /**
8296 * @static
8297 * @inheritdoc
8298 */
8299 OO.ui.ProgressBarWidget.static.tagName = 'div';
8300
8301 /* Methods */
8302
8303 /**
8304 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8305 *
8306 * @return {number|boolean} Progress percent
8307 */
8308 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8309 return this.progress;
8310 };
8311
8312 /**
8313 * Set the percent of the process completed or `false` for an indeterminate process.
8314 *
8315 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8316 */
8317 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8318 this.progress = progress;
8319
8320 if ( progress !== false ) {
8321 this.$bar.css( 'width', this.progress + '%' );
8322 this.$element.attr( 'aria-valuenow', this.progress );
8323 } else {
8324 this.$bar.css( 'width', '' );
8325 this.$element.removeAttr( 'aria-valuenow' );
8326 }
8327 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8328 };
8329
8330 /**
8331 * InputWidget is the base class for all input widgets, which
8332 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8333 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8334 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8335 *
8336 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8337 *
8338 * @abstract
8339 * @class
8340 * @extends OO.ui.Widget
8341 * @mixins OO.ui.mixin.FlaggedElement
8342 * @mixins OO.ui.mixin.TabIndexedElement
8343 * @mixins OO.ui.mixin.TitledElement
8344 * @mixins OO.ui.mixin.AccessKeyedElement
8345 *
8346 * @constructor
8347 * @param {Object} [config] Configuration options
8348 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8349 * @cfg {string} [value=''] The value of the input.
8350 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8351 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8352 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8353 * before it is accepted.
8354 */
8355 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8356 // Configuration initialization
8357 config = config || {};
8358
8359 // Parent constructor
8360 OO.ui.InputWidget.parent.call( this, config );
8361
8362 // Properties
8363 // See #reusePreInfuseDOM about config.$input
8364 this.$input = config.$input || this.getInputElement( config );
8365 this.value = '';
8366 this.inputFilter = config.inputFilter;
8367
8368 // Mixin constructors
8369 OO.ui.mixin.FlaggedElement.call( this, config );
8370 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8371 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8372 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8373
8374 // Events
8375 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8376
8377 // Initialization
8378 this.$input
8379 .addClass( 'oo-ui-inputWidget-input' )
8380 .attr( 'name', config.name )
8381 .prop( 'disabled', this.isDisabled() );
8382 this.$element
8383 .addClass( 'oo-ui-inputWidget' )
8384 .append( this.$input );
8385 this.setValue( config.value );
8386 if ( config.dir ) {
8387 this.setDir( config.dir );
8388 }
8389 if ( config.inputId !== undefined ) {
8390 this.setInputId( config.inputId );
8391 }
8392 };
8393
8394 /* Setup */
8395
8396 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8397 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8398 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8399 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8400 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8401
8402 /* Static Methods */
8403
8404 /**
8405 * @inheritdoc
8406 */
8407 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8408 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8409 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8410 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8411 return config;
8412 };
8413
8414 /**
8415 * @inheritdoc
8416 */
8417 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8418 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8419 if ( config.$input && config.$input.length ) {
8420 state.value = config.$input.val();
8421 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8422 state.focus = config.$input.is( ':focus' );
8423 }
8424 return state;
8425 };
8426
8427 /* Events */
8428
8429 /**
8430 * @event change
8431 *
8432 * A change event is emitted when the value of the input changes.
8433 *
8434 * @param {string} value
8435 */
8436
8437 /* Methods */
8438
8439 /**
8440 * Get input element.
8441 *
8442 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8443 * different circumstances. The element must have a `value` property (like form elements).
8444 *
8445 * @protected
8446 * @param {Object} config Configuration options
8447 * @return {jQuery} Input element
8448 */
8449 OO.ui.InputWidget.prototype.getInputElement = function () {
8450 return $( '<input>' );
8451 };
8452
8453 /**
8454 * Handle potentially value-changing events.
8455 *
8456 * @private
8457 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8458 */
8459 OO.ui.InputWidget.prototype.onEdit = function () {
8460 var widget = this;
8461 if ( !this.isDisabled() ) {
8462 // Allow the stack to clear so the value will be updated
8463 setTimeout( function () {
8464 widget.setValue( widget.$input.val() );
8465 } );
8466 }
8467 };
8468
8469 /**
8470 * Get the value of the input.
8471 *
8472 * @return {string} Input value
8473 */
8474 OO.ui.InputWidget.prototype.getValue = function () {
8475 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8476 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8477 var value = this.$input.val();
8478 if ( this.value !== value ) {
8479 this.setValue( value );
8480 }
8481 return this.value;
8482 };
8483
8484 /**
8485 * Set the directionality of the input.
8486 *
8487 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8488 * @chainable
8489 */
8490 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8491 this.$input.prop( 'dir', dir );
8492 return this;
8493 };
8494
8495 /**
8496 * Set the value of the input.
8497 *
8498 * @param {string} value New value
8499 * @fires change
8500 * @chainable
8501 */
8502 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8503 value = this.cleanUpValue( value );
8504 // Update the DOM if it has changed. Note that with cleanUpValue, it
8505 // is possible for the DOM value to change without this.value changing.
8506 if ( this.$input.val() !== value ) {
8507 this.$input.val( value );
8508 }
8509 if ( this.value !== value ) {
8510 this.value = value;
8511 this.emit( 'change', this.value );
8512 }
8513 return this;
8514 };
8515
8516 /**
8517 * Clean up incoming value.
8518 *
8519 * Ensures value is a string, and converts undefined and null to empty string.
8520 *
8521 * @private
8522 * @param {string} value Original value
8523 * @return {string} Cleaned up value
8524 */
8525 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8526 if ( value === undefined || value === null ) {
8527 return '';
8528 } else if ( this.inputFilter ) {
8529 return this.inputFilter( String( value ) );
8530 } else {
8531 return String( value );
8532 }
8533 };
8534
8535 /**
8536 * @inheritdoc
8537 */
8538 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8539 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8540 if ( this.$input ) {
8541 this.$input.prop( 'disabled', this.isDisabled() );
8542 }
8543 return this;
8544 };
8545
8546 /**
8547 * Set the 'id' attribute of the `<input>` element.
8548 *
8549 * @param {string} id
8550 * @chainable
8551 */
8552 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8553 this.$input.attr( 'id', id );
8554 return this;
8555 };
8556
8557 /**
8558 * @inheritdoc
8559 */
8560 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8561 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8562 if ( state.value !== undefined && state.value !== this.getValue() ) {
8563 this.setValue( state.value );
8564 }
8565 if ( state.focus ) {
8566 this.focus();
8567 }
8568 };
8569
8570 /**
8571 * Data widget intended for creating 'hidden'-type inputs.
8572 *
8573 * @class
8574 * @extends OO.ui.Widget
8575 *
8576 * @constructor
8577 * @param {Object} [config] Configuration options
8578 * @cfg {string} [value=''] The value of the input.
8579 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8580 */
8581 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8582 // Configuration initialization
8583 config = $.extend( { value: '', name: '' }, config );
8584
8585 // Parent constructor
8586 OO.ui.HiddenInputWidget.parent.call( this, config );
8587
8588 // Initialization
8589 this.$element.attr( {
8590 type: 'hidden',
8591 value: config.value,
8592 name: config.name
8593 } );
8594 this.$element.removeAttr( 'aria-disabled' );
8595 };
8596
8597 /* Setup */
8598
8599 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8600
8601 /* Static Properties */
8602
8603 /**
8604 * @static
8605 * @inheritdoc
8606 */
8607 OO.ui.HiddenInputWidget.static.tagName = 'input';
8608
8609 /**
8610 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8611 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8612 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8613 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8614 * [OOjs UI documentation on MediaWiki] [1] for more information.
8615 *
8616 * @example
8617 * // A ButtonInputWidget rendered as an HTML button, the default.
8618 * var button = new OO.ui.ButtonInputWidget( {
8619 * label: 'Input button',
8620 * icon: 'check',
8621 * value: 'check'
8622 * } );
8623 * $( 'body' ).append( button.$element );
8624 *
8625 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8626 *
8627 * @class
8628 * @extends OO.ui.InputWidget
8629 * @mixins OO.ui.mixin.ButtonElement
8630 * @mixins OO.ui.mixin.IconElement
8631 * @mixins OO.ui.mixin.IndicatorElement
8632 * @mixins OO.ui.mixin.LabelElement
8633 * @mixins OO.ui.mixin.TitledElement
8634 *
8635 * @constructor
8636 * @param {Object} [config] Configuration options
8637 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8638 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8639 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8640 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8641 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8642 */
8643 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8644 // Configuration initialization
8645 config = $.extend( { type: 'button', useInputTag: false }, config );
8646
8647 // See InputWidget#reusePreInfuseDOM about config.$input
8648 if ( config.$input ) {
8649 config.$input.empty();
8650 }
8651
8652 // Properties (must be set before parent constructor, which calls #setValue)
8653 this.useInputTag = config.useInputTag;
8654
8655 // Parent constructor
8656 OO.ui.ButtonInputWidget.parent.call( this, config );
8657
8658 // Mixin constructors
8659 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8660 OO.ui.mixin.IconElement.call( this, config );
8661 OO.ui.mixin.IndicatorElement.call( this, config );
8662 OO.ui.mixin.LabelElement.call( this, config );
8663 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8664
8665 // Initialization
8666 if ( !config.useInputTag ) {
8667 this.$input.append( this.$icon, this.$label, this.$indicator );
8668 }
8669 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8670 };
8671
8672 /* Setup */
8673
8674 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8675 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8676 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8677 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8678 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8679 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8680
8681 /* Static Properties */
8682
8683 /**
8684 * @static
8685 * @inheritdoc
8686 */
8687 OO.ui.ButtonInputWidget.static.tagName = 'span';
8688
8689 /* Methods */
8690
8691 /**
8692 * @inheritdoc
8693 * @protected
8694 */
8695 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8696 var type;
8697 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8698 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8699 };
8700
8701 /**
8702 * Set label value.
8703 *
8704 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8705 *
8706 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8707 * text, or `null` for no label
8708 * @chainable
8709 */
8710 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8711 if ( typeof label === 'function' ) {
8712 label = OO.ui.resolveMsg( label );
8713 }
8714
8715 if ( this.useInputTag ) {
8716 // Discard non-plaintext labels
8717 if ( typeof label !== 'string' ) {
8718 label = '';
8719 }
8720
8721 this.$input.val( label );
8722 }
8723
8724 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8725 };
8726
8727 /**
8728 * Set the value of the input.
8729 *
8730 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8731 * they do not support {@link #value values}.
8732 *
8733 * @param {string} value New value
8734 * @chainable
8735 */
8736 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8737 if ( !this.useInputTag ) {
8738 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8739 }
8740 return this;
8741 };
8742
8743 /**
8744 * @inheritdoc
8745 */
8746 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8747 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8748 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
8749 return null;
8750 };
8751
8752 /**
8753 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
8754 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
8755 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
8756 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
8757 *
8758 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
8759 *
8760 * @example
8761 * // An example of selected, unselected, and disabled checkbox inputs
8762 * var checkbox1=new OO.ui.CheckboxInputWidget( {
8763 * value: 'a',
8764 * selected: true
8765 * } );
8766 * var checkbox2=new OO.ui.CheckboxInputWidget( {
8767 * value: 'b'
8768 * } );
8769 * var checkbox3=new OO.ui.CheckboxInputWidget( {
8770 * value:'c',
8771 * disabled: true
8772 * } );
8773 * // Create a fieldset layout with fields for each checkbox.
8774 * var fieldset = new OO.ui.FieldsetLayout( {
8775 * label: 'Checkboxes'
8776 * } );
8777 * fieldset.addItems( [
8778 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
8779 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
8780 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
8781 * ] );
8782 * $( 'body' ).append( fieldset.$element );
8783 *
8784 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8785 *
8786 * @class
8787 * @extends OO.ui.InputWidget
8788 *
8789 * @constructor
8790 * @param {Object} [config] Configuration options
8791 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
8792 */
8793 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
8794 // Configuration initialization
8795 config = config || {};
8796
8797 // Parent constructor
8798 OO.ui.CheckboxInputWidget.parent.call( this, config );
8799
8800 // Initialization
8801 this.$element
8802 .addClass( 'oo-ui-checkboxInputWidget' )
8803 // Required for pretty styling in WikimediaUI theme
8804 .append( $( '<span>' ) );
8805 this.setSelected( config.selected !== undefined ? config.selected : false );
8806 };
8807
8808 /* Setup */
8809
8810 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
8811
8812 /* Static Properties */
8813
8814 /**
8815 * @static
8816 * @inheritdoc
8817 */
8818 OO.ui.CheckboxInputWidget.static.tagName = 'span';
8819
8820 /* Static Methods */
8821
8822 /**
8823 * @inheritdoc
8824 */
8825 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
8826 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
8827 state.checked = config.$input.prop( 'checked' );
8828 return state;
8829 };
8830
8831 /* Methods */
8832
8833 /**
8834 * @inheritdoc
8835 * @protected
8836 */
8837 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
8838 return $( '<input>' ).attr( 'type', 'checkbox' );
8839 };
8840
8841 /**
8842 * @inheritdoc
8843 */
8844 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
8845 var widget = this;
8846 if ( !this.isDisabled() ) {
8847 // Allow the stack to clear so the value will be updated
8848 setTimeout( function () {
8849 widget.setSelected( widget.$input.prop( 'checked' ) );
8850 } );
8851 }
8852 };
8853
8854 /**
8855 * Set selection state of this checkbox.
8856 *
8857 * @param {boolean} state `true` for selected
8858 * @chainable
8859 */
8860 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
8861 state = !!state;
8862 if ( this.selected !== state ) {
8863 this.selected = state;
8864 this.$input.prop( 'checked', this.selected );
8865 this.emit( 'change', this.selected );
8866 }
8867 return this;
8868 };
8869
8870 /**
8871 * Check if this checkbox is selected.
8872 *
8873 * @return {boolean} Checkbox is selected
8874 */
8875 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
8876 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8877 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8878 var selected = this.$input.prop( 'checked' );
8879 if ( this.selected !== selected ) {
8880 this.setSelected( selected );
8881 }
8882 return this.selected;
8883 };
8884
8885 /**
8886 * @inheritdoc
8887 */
8888 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
8889 if ( !this.isDisabled() ) {
8890 this.$input.click();
8891 }
8892 this.focus();
8893 };
8894
8895 /**
8896 * @inheritdoc
8897 */
8898 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
8899 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8900 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
8901 this.setSelected( state.checked );
8902 }
8903 };
8904
8905 /**
8906 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
8907 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
8908 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
8909 * more information about input widgets.
8910 *
8911 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
8912 * are no options. If no `value` configuration option is provided, the first option is selected.
8913 * If you need a state representing no value (no option being selected), use a DropdownWidget.
8914 *
8915 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
8916 *
8917 * @example
8918 * // Example: A DropdownInputWidget with three options
8919 * var dropdownInput = new OO.ui.DropdownInputWidget( {
8920 * options: [
8921 * { data: 'a', label: 'First' },
8922 * { data: 'b', label: 'Second'},
8923 * { data: 'c', label: 'Third' }
8924 * ]
8925 * } );
8926 * $( 'body' ).append( dropdownInput.$element );
8927 *
8928 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8929 *
8930 * @class
8931 * @extends OO.ui.InputWidget
8932 * @mixins OO.ui.mixin.TitledElement
8933 *
8934 * @constructor
8935 * @param {Object} [config] Configuration options
8936 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
8937 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
8938 */
8939 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
8940 // Configuration initialization
8941 config = config || {};
8942
8943 // See InputWidget#reusePreInfuseDOM about config.$input
8944 if ( config.$input ) {
8945 config.$input.addClass( 'oo-ui-element-hidden' );
8946 }
8947
8948 // Properties (must be done before parent constructor which calls #setDisabled)
8949 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
8950
8951 // Parent constructor
8952 OO.ui.DropdownInputWidget.parent.call( this, config );
8953
8954 // Mixin constructors
8955 OO.ui.mixin.TitledElement.call( this, config );
8956
8957 // Events
8958 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
8959
8960 // Initialization
8961 this.setOptions( config.options || [] );
8962 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
8963 // widget has no valid options when it happens.
8964 this.setValue( config.value );
8965 this.$element
8966 .addClass( 'oo-ui-dropdownInputWidget' )
8967 .append( this.dropdownWidget.$element );
8968 this.setTabIndexedElement( null );
8969 };
8970
8971 /* Setup */
8972
8973 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
8974 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
8975
8976 /* Methods */
8977
8978 /**
8979 * @inheritdoc
8980 * @protected
8981 */
8982 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
8983 return $( '<input>' ).attr( 'type', 'hidden' );
8984 };
8985
8986 /**
8987 * Handles menu select events.
8988 *
8989 * @private
8990 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
8991 */
8992 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
8993 this.setValue( item ? item.getData() : '' );
8994 };
8995
8996 /**
8997 * @inheritdoc
8998 */
8999 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9000 var selected;
9001 value = this.cleanUpValue( value );
9002 // Only allow setting values that are actually present in the dropdown
9003 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
9004 this.dropdownWidget.getMenu().findFirstSelectableItem();
9005 this.dropdownWidget.getMenu().selectItem( selected );
9006 value = selected ? selected.getData() : '';
9007 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9008 return this;
9009 };
9010
9011 /**
9012 * @inheritdoc
9013 */
9014 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9015 this.dropdownWidget.setDisabled( state );
9016 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9017 return this;
9018 };
9019
9020 /**
9021 * Set the options available for this input.
9022 *
9023 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9024 * @chainable
9025 */
9026 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9027 var
9028 value = this.getValue(),
9029 widget = this;
9030
9031 // Rebuild the dropdown menu
9032 this.dropdownWidget.getMenu()
9033 .clearItems()
9034 .addItems( options.map( function ( opt ) {
9035 var optValue = widget.cleanUpValue( opt.data );
9036
9037 if ( opt.optgroup === undefined ) {
9038 return new OO.ui.MenuOptionWidget( {
9039 data: optValue,
9040 label: opt.label !== undefined ? opt.label : optValue
9041 } );
9042 } else {
9043 return new OO.ui.MenuSectionOptionWidget( {
9044 label: opt.optgroup
9045 } );
9046 }
9047 } ) );
9048
9049 // Restore the previous value, or reset to something sensible
9050 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9051 // Previous value is still available, ensure consistency with the dropdown
9052 this.setValue( value );
9053 } else {
9054 // No longer valid, reset
9055 if ( options.length ) {
9056 this.setValue( options[ 0 ].data );
9057 }
9058 }
9059
9060 return this;
9061 };
9062
9063 /**
9064 * @inheritdoc
9065 */
9066 OO.ui.DropdownInputWidget.prototype.focus = function () {
9067 this.dropdownWidget.focus();
9068 return this;
9069 };
9070
9071 /**
9072 * @inheritdoc
9073 */
9074 OO.ui.DropdownInputWidget.prototype.blur = function () {
9075 this.dropdownWidget.blur();
9076 return this;
9077 };
9078
9079 /**
9080 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9081 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9082 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9083 * please see the [OOjs UI documentation on MediaWiki][1].
9084 *
9085 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9086 *
9087 * @example
9088 * // An example of selected, unselected, and disabled radio inputs
9089 * var radio1 = new OO.ui.RadioInputWidget( {
9090 * value: 'a',
9091 * selected: true
9092 * } );
9093 * var radio2 = new OO.ui.RadioInputWidget( {
9094 * value: 'b'
9095 * } );
9096 * var radio3 = new OO.ui.RadioInputWidget( {
9097 * value: 'c',
9098 * disabled: true
9099 * } );
9100 * // Create a fieldset layout with fields for each radio button.
9101 * var fieldset = new OO.ui.FieldsetLayout( {
9102 * label: 'Radio inputs'
9103 * } );
9104 * fieldset.addItems( [
9105 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9106 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9107 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9108 * ] );
9109 * $( 'body' ).append( fieldset.$element );
9110 *
9111 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9112 *
9113 * @class
9114 * @extends OO.ui.InputWidget
9115 *
9116 * @constructor
9117 * @param {Object} [config] Configuration options
9118 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9119 */
9120 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9121 // Configuration initialization
9122 config = config || {};
9123
9124 // Parent constructor
9125 OO.ui.RadioInputWidget.parent.call( this, config );
9126
9127 // Initialization
9128 this.$element
9129 .addClass( 'oo-ui-radioInputWidget' )
9130 // Required for pretty styling in WikimediaUI theme
9131 .append( $( '<span>' ) );
9132 this.setSelected( config.selected !== undefined ? config.selected : false );
9133 };
9134
9135 /* Setup */
9136
9137 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9138
9139 /* Static Properties */
9140
9141 /**
9142 * @static
9143 * @inheritdoc
9144 */
9145 OO.ui.RadioInputWidget.static.tagName = 'span';
9146
9147 /* Static Methods */
9148
9149 /**
9150 * @inheritdoc
9151 */
9152 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9153 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9154 state.checked = config.$input.prop( 'checked' );
9155 return state;
9156 };
9157
9158 /* Methods */
9159
9160 /**
9161 * @inheritdoc
9162 * @protected
9163 */
9164 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9165 return $( '<input>' ).attr( 'type', 'radio' );
9166 };
9167
9168 /**
9169 * @inheritdoc
9170 */
9171 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9172 // RadioInputWidget doesn't track its state.
9173 };
9174
9175 /**
9176 * Set selection state of this radio button.
9177 *
9178 * @param {boolean} state `true` for selected
9179 * @chainable
9180 */
9181 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9182 // RadioInputWidget doesn't track its state.
9183 this.$input.prop( 'checked', state );
9184 return this;
9185 };
9186
9187 /**
9188 * Check if this radio button is selected.
9189 *
9190 * @return {boolean} Radio is selected
9191 */
9192 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9193 return this.$input.prop( 'checked' );
9194 };
9195
9196 /**
9197 * @inheritdoc
9198 */
9199 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9200 if ( !this.isDisabled() ) {
9201 this.$input.click();
9202 }
9203 this.focus();
9204 };
9205
9206 /**
9207 * @inheritdoc
9208 */
9209 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9210 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9211 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9212 this.setSelected( state.checked );
9213 }
9214 };
9215
9216 /**
9217 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9218 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9219 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9220 * more information about input widgets.
9221 *
9222 * This and OO.ui.DropdownInputWidget support the same configuration options.
9223 *
9224 * @example
9225 * // Example: A RadioSelectInputWidget with three options
9226 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9227 * options: [
9228 * { data: 'a', label: 'First' },
9229 * { data: 'b', label: 'Second'},
9230 * { data: 'c', label: 'Third' }
9231 * ]
9232 * } );
9233 * $( 'body' ).append( radioSelectInput.$element );
9234 *
9235 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9236 *
9237 * @class
9238 * @extends OO.ui.InputWidget
9239 *
9240 * @constructor
9241 * @param {Object} [config] Configuration options
9242 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9243 */
9244 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9245 // Configuration initialization
9246 config = config || {};
9247
9248 // Properties (must be done before parent constructor which calls #setDisabled)
9249 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9250
9251 // Parent constructor
9252 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9253
9254 // Events
9255 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9256
9257 // Initialization
9258 this.setOptions( config.options || [] );
9259 this.$element
9260 .addClass( 'oo-ui-radioSelectInputWidget' )
9261 .append( this.radioSelectWidget.$element );
9262 this.setTabIndexedElement( null );
9263 };
9264
9265 /* Setup */
9266
9267 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9268
9269 /* Static Methods */
9270
9271 /**
9272 * @inheritdoc
9273 */
9274 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9275 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9276 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9277 return state;
9278 };
9279
9280 /**
9281 * @inheritdoc
9282 */
9283 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9284 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9285 // Cannot reuse the `<input type=radio>` set
9286 delete config.$input;
9287 return config;
9288 };
9289
9290 /* Methods */
9291
9292 /**
9293 * @inheritdoc
9294 * @protected
9295 */
9296 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9297 return $( '<input>' ).attr( 'type', 'hidden' );
9298 };
9299
9300 /**
9301 * Handles menu select events.
9302 *
9303 * @private
9304 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9305 */
9306 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9307 this.setValue( item.getData() );
9308 };
9309
9310 /**
9311 * @inheritdoc
9312 */
9313 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9314 value = this.cleanUpValue( value );
9315 this.radioSelectWidget.selectItemByData( value );
9316 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9317 return this;
9318 };
9319
9320 /**
9321 * @inheritdoc
9322 */
9323 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9324 this.radioSelectWidget.setDisabled( state );
9325 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9326 return this;
9327 };
9328
9329 /**
9330 * Set the options available for this input.
9331 *
9332 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9333 * @chainable
9334 */
9335 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9336 var
9337 value = this.getValue(),
9338 widget = this;
9339
9340 // Rebuild the radioSelect menu
9341 this.radioSelectWidget
9342 .clearItems()
9343 .addItems( options.map( function ( opt ) {
9344 var optValue = widget.cleanUpValue( opt.data );
9345 return new OO.ui.RadioOptionWidget( {
9346 data: optValue,
9347 label: opt.label !== undefined ? opt.label : optValue
9348 } );
9349 } ) );
9350
9351 // Restore the previous value, or reset to something sensible
9352 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9353 // Previous value is still available, ensure consistency with the radioSelect
9354 this.setValue( value );
9355 } else {
9356 // No longer valid, reset
9357 if ( options.length ) {
9358 this.setValue( options[ 0 ].data );
9359 }
9360 }
9361
9362 return this;
9363 };
9364
9365 /**
9366 * @inheritdoc
9367 */
9368 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9369 this.radioSelectWidget.focus();
9370 return this;
9371 };
9372
9373 /**
9374 * @inheritdoc
9375 */
9376 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9377 this.radioSelectWidget.blur();
9378 return this;
9379 };
9380
9381 /**
9382 * CheckboxMultiselectInputWidget is a
9383 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9384 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9385 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9386 * more information about input widgets.
9387 *
9388 * @example
9389 * // Example: A CheckboxMultiselectInputWidget with three options
9390 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9391 * options: [
9392 * { data: 'a', label: 'First' },
9393 * { data: 'b', label: 'Second'},
9394 * { data: 'c', label: 'Third' }
9395 * ]
9396 * } );
9397 * $( 'body' ).append( multiselectInput.$element );
9398 *
9399 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9400 *
9401 * @class
9402 * @extends OO.ui.InputWidget
9403 *
9404 * @constructor
9405 * @param {Object} [config] Configuration options
9406 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9407 */
9408 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9409 // Configuration initialization
9410 config = config || {};
9411
9412 // Properties (must be done before parent constructor which calls #setDisabled)
9413 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9414
9415 // Parent constructor
9416 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9417
9418 // Properties
9419 this.inputName = config.name;
9420
9421 // Initialization
9422 this.$element
9423 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9424 .append( this.checkboxMultiselectWidget.$element );
9425 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9426 this.$input.detach();
9427 this.setOptions( config.options || [] );
9428 // Have to repeat this from parent, as we need options to be set up for this to make sense
9429 this.setValue( config.value );
9430
9431 // setValue when checkboxMultiselectWidget changes
9432 this.checkboxMultiselectWidget.on( 'change', function () {
9433 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9434 }.bind( this ) );
9435 };
9436
9437 /* Setup */
9438
9439 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9440
9441 /* Static Methods */
9442
9443 /**
9444 * @inheritdoc
9445 */
9446 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9447 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9448 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9449 .toArray().map( function ( el ) { return el.value; } );
9450 return state;
9451 };
9452
9453 /**
9454 * @inheritdoc
9455 */
9456 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9457 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9458 // Cannot reuse the `<input type=checkbox>` set
9459 delete config.$input;
9460 return config;
9461 };
9462
9463 /* Methods */
9464
9465 /**
9466 * @inheritdoc
9467 * @protected
9468 */
9469 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9470 // Actually unused
9471 return $( '<unused>' );
9472 };
9473
9474 /**
9475 * @inheritdoc
9476 */
9477 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9478 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9479 .toArray().map( function ( el ) { return el.value; } );
9480 if ( this.value !== value ) {
9481 this.setValue( value );
9482 }
9483 return this.value;
9484 };
9485
9486 /**
9487 * @inheritdoc
9488 */
9489 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9490 value = this.cleanUpValue( value );
9491 this.checkboxMultiselectWidget.selectItemsByData( value );
9492 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9493 return this;
9494 };
9495
9496 /**
9497 * Clean up incoming value.
9498 *
9499 * @param {string[]} value Original value
9500 * @return {string[]} Cleaned up value
9501 */
9502 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9503 var i, singleValue,
9504 cleanValue = [];
9505 if ( !Array.isArray( value ) ) {
9506 return cleanValue;
9507 }
9508 for ( i = 0; i < value.length; i++ ) {
9509 singleValue =
9510 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9511 // Remove options that we don't have here
9512 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9513 continue;
9514 }
9515 cleanValue.push( singleValue );
9516 }
9517 return cleanValue;
9518 };
9519
9520 /**
9521 * @inheritdoc
9522 */
9523 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9524 this.checkboxMultiselectWidget.setDisabled( state );
9525 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9526 return this;
9527 };
9528
9529 /**
9530 * Set the options available for this input.
9531 *
9532 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9533 * @chainable
9534 */
9535 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9536 var widget = this;
9537
9538 // Rebuild the checkboxMultiselectWidget menu
9539 this.checkboxMultiselectWidget
9540 .clearItems()
9541 .addItems( options.map( function ( opt ) {
9542 var optValue, item, optDisabled;
9543 optValue =
9544 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9545 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9546 item = new OO.ui.CheckboxMultioptionWidget( {
9547 data: optValue,
9548 label: opt.label !== undefined ? opt.label : optValue,
9549 disabled: optDisabled
9550 } );
9551 // Set the 'name' and 'value' for form submission
9552 item.checkbox.$input.attr( 'name', widget.inputName );
9553 item.checkbox.setValue( optValue );
9554 return item;
9555 } ) );
9556
9557 // Re-set the value, checking the checkboxes as needed.
9558 // This will also get rid of any stale options that we just removed.
9559 this.setValue( this.getValue() );
9560
9561 return this;
9562 };
9563
9564 /**
9565 * @inheritdoc
9566 */
9567 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9568 this.checkboxMultiselectWidget.focus();
9569 return this;
9570 };
9571
9572 /**
9573 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9574 * size of the field as well as its presentation. In addition, these widgets can be configured
9575 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9576 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9577 * which modifies incoming values rather than validating them.
9578 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9579 *
9580 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9581 *
9582 * @example
9583 * // Example of a text input widget
9584 * var textInput = new OO.ui.TextInputWidget( {
9585 * value: 'Text input'
9586 * } )
9587 * $( 'body' ).append( textInput.$element );
9588 *
9589 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9590 *
9591 * @class
9592 * @extends OO.ui.InputWidget
9593 * @mixins OO.ui.mixin.IconElement
9594 * @mixins OO.ui.mixin.IndicatorElement
9595 * @mixins OO.ui.mixin.PendingElement
9596 * @mixins OO.ui.mixin.LabelElement
9597 *
9598 * @constructor
9599 * @param {Object} [config] Configuration options
9600 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9601 * 'email', 'url' or 'number'.
9602 * @cfg {string} [placeholder] Placeholder text
9603 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9604 * instruct the browser to focus this widget.
9605 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9606 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9607 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9608 * the value or placeholder text: `'before'` or `'after'`
9609 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9610 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9611 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9612 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9613 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9614 * value for it to be considered valid; when Function, a function receiving the value as parameter
9615 * that must return true, or promise resolving to true, for it to be considered valid.
9616 */
9617 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9618 // Configuration initialization
9619 config = $.extend( {
9620 type: 'text',
9621 labelPosition: 'after'
9622 }, config );
9623
9624 if ( config.multiline ) {
9625 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9626 return new OO.ui.MultilineTextInputWidget( config );
9627 }
9628
9629 // Parent constructor
9630 OO.ui.TextInputWidget.parent.call( this, config );
9631
9632 // Mixin constructors
9633 OO.ui.mixin.IconElement.call( this, config );
9634 OO.ui.mixin.IndicatorElement.call( this, config );
9635 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9636 OO.ui.mixin.LabelElement.call( this, config );
9637
9638 // Properties
9639 this.type = this.getSaneType( config );
9640 this.readOnly = false;
9641 this.required = false;
9642 this.validate = null;
9643 this.styleHeight = null;
9644 this.scrollWidth = null;
9645
9646 this.setValidation( config.validate );
9647 this.setLabelPosition( config.labelPosition );
9648
9649 // Events
9650 this.$input.on( {
9651 keypress: this.onKeyPress.bind( this ),
9652 blur: this.onBlur.bind( this ),
9653 focus: this.onFocus.bind( this )
9654 } );
9655 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9656 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9657 this.on( 'labelChange', this.updatePosition.bind( this ) );
9658 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9659
9660 // Initialization
9661 this.$element
9662 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9663 .append( this.$icon, this.$indicator );
9664 this.setReadOnly( !!config.readOnly );
9665 this.setRequired( !!config.required );
9666 if ( config.placeholder !== undefined ) {
9667 this.$input.attr( 'placeholder', config.placeholder );
9668 }
9669 if ( config.maxLength !== undefined ) {
9670 this.$input.attr( 'maxlength', config.maxLength );
9671 }
9672 if ( config.autofocus ) {
9673 this.$input.attr( 'autofocus', 'autofocus' );
9674 }
9675 if ( config.autocomplete === false ) {
9676 this.$input.attr( 'autocomplete', 'off' );
9677 // Turning off autocompletion also disables "form caching" when the user navigates to a
9678 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9679 $( window ).on( {
9680 beforeunload: function () {
9681 this.$input.removeAttr( 'autocomplete' );
9682 }.bind( this ),
9683 pageshow: function () {
9684 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9685 // whole page... it shouldn't hurt, though.
9686 this.$input.attr( 'autocomplete', 'off' );
9687 }.bind( this )
9688 } );
9689 }
9690 if ( this.label ) {
9691 this.isWaitingToBeAttached = true;
9692 this.installParentChangeDetector();
9693 }
9694 };
9695
9696 /* Setup */
9697
9698 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9699 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9700 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9701 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9702 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9703
9704 /* Static Properties */
9705
9706 OO.ui.TextInputWidget.static.validationPatterns = {
9707 'non-empty': /.+/,
9708 integer: /^\d+$/
9709 };
9710
9711 /* Static Methods */
9712
9713 /**
9714 * @inheritdoc
9715 */
9716 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9717 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9718 return state;
9719 };
9720
9721 /* Events */
9722
9723 /**
9724 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9725 *
9726 * @event enter
9727 */
9728
9729 /* Methods */
9730
9731 /**
9732 * Handle icon mouse down events.
9733 *
9734 * @private
9735 * @param {jQuery.Event} e Mouse down event
9736 */
9737 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
9738 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9739 this.focus();
9740 return false;
9741 }
9742 };
9743
9744 /**
9745 * Handle indicator mouse down events.
9746 *
9747 * @private
9748 * @param {jQuery.Event} e Mouse down event
9749 */
9750 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
9751 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9752 this.focus();
9753 return false;
9754 }
9755 };
9756
9757 /**
9758 * Handle key press events.
9759 *
9760 * @private
9761 * @param {jQuery.Event} e Key press event
9762 * @fires enter If enter key is pressed
9763 */
9764 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
9765 if ( e.which === OO.ui.Keys.ENTER ) {
9766 this.emit( 'enter', e );
9767 }
9768 };
9769
9770 /**
9771 * Handle blur events.
9772 *
9773 * @private
9774 * @param {jQuery.Event} e Blur event
9775 */
9776 OO.ui.TextInputWidget.prototype.onBlur = function () {
9777 this.setValidityFlag();
9778 };
9779
9780 /**
9781 * Handle focus events.
9782 *
9783 * @private
9784 * @param {jQuery.Event} e Focus event
9785 */
9786 OO.ui.TextInputWidget.prototype.onFocus = function () {
9787 if ( this.isWaitingToBeAttached ) {
9788 // If we've received focus, then we must be attached to the document, and if
9789 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
9790 this.onElementAttach();
9791 }
9792 this.setValidityFlag( true );
9793 };
9794
9795 /**
9796 * Handle element attach events.
9797 *
9798 * @private
9799 * @param {jQuery.Event} e Element attach event
9800 */
9801 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
9802 this.isWaitingToBeAttached = false;
9803 // Any previously calculated size is now probably invalid if we reattached elsewhere
9804 this.valCache = null;
9805 this.positionLabel();
9806 };
9807
9808 /**
9809 * Handle debounced change events.
9810 *
9811 * @param {string} value
9812 * @private
9813 */
9814 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
9815 this.setValidityFlag();
9816 };
9817
9818 /**
9819 * Check if the input is {@link #readOnly read-only}.
9820 *
9821 * @return {boolean}
9822 */
9823 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
9824 return this.readOnly;
9825 };
9826
9827 /**
9828 * Set the {@link #readOnly read-only} state of the input.
9829 *
9830 * @param {boolean} state Make input read-only
9831 * @chainable
9832 */
9833 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
9834 this.readOnly = !!state;
9835 this.$input.prop( 'readOnly', this.readOnly );
9836 return this;
9837 };
9838
9839 /**
9840 * Check if the input is {@link #required required}.
9841 *
9842 * @return {boolean}
9843 */
9844 OO.ui.TextInputWidget.prototype.isRequired = function () {
9845 return this.required;
9846 };
9847
9848 /**
9849 * Set the {@link #required required} state of the input.
9850 *
9851 * @param {boolean} state Make input required
9852 * @chainable
9853 */
9854 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
9855 this.required = !!state;
9856 if ( this.required ) {
9857 this.$input
9858 .prop( 'required', true )
9859 .attr( 'aria-required', 'true' );
9860 if ( this.getIndicator() === null ) {
9861 this.setIndicator( 'required' );
9862 }
9863 } else {
9864 this.$input
9865 .prop( 'required', false )
9866 .removeAttr( 'aria-required' );
9867 if ( this.getIndicator() === 'required' ) {
9868 this.setIndicator( null );
9869 }
9870 }
9871 return this;
9872 };
9873
9874 /**
9875 * Support function for making #onElementAttach work across browsers.
9876 *
9877 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
9878 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
9879 *
9880 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
9881 * first time that the element gets attached to the documented.
9882 */
9883 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
9884 var mutationObserver, onRemove, topmostNode, fakeParentNode,
9885 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
9886 widget = this;
9887
9888 if ( MutationObserver ) {
9889 // The new way. If only it wasn't so ugly.
9890
9891 if ( this.isElementAttached() ) {
9892 // Widget is attached already, do nothing. This breaks the functionality of this function when
9893 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
9894 // would require observation of the whole document, which would hurt performance of other,
9895 // more important code.
9896 return;
9897 }
9898
9899 // Find topmost node in the tree
9900 topmostNode = this.$element[ 0 ];
9901 while ( topmostNode.parentNode ) {
9902 topmostNode = topmostNode.parentNode;
9903 }
9904
9905 // We have no way to detect the $element being attached somewhere without observing the entire
9906 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
9907 // parent node of $element, and instead detect when $element is removed from it (and thus
9908 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
9909 // doesn't get attached, we end up back here and create the parent.
9910
9911 mutationObserver = new MutationObserver( function ( mutations ) {
9912 var i, j, removedNodes;
9913 for ( i = 0; i < mutations.length; i++ ) {
9914 removedNodes = mutations[ i ].removedNodes;
9915 for ( j = 0; j < removedNodes.length; j++ ) {
9916 if ( removedNodes[ j ] === topmostNode ) {
9917 setTimeout( onRemove, 0 );
9918 return;
9919 }
9920 }
9921 }
9922 } );
9923
9924 onRemove = function () {
9925 // If the node was attached somewhere else, report it
9926 if ( widget.isElementAttached() ) {
9927 widget.onElementAttach();
9928 }
9929 mutationObserver.disconnect();
9930 widget.installParentChangeDetector();
9931 };
9932
9933 // Create a fake parent and observe it
9934 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
9935 mutationObserver.observe( fakeParentNode, { childList: true } );
9936 } else {
9937 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
9938 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
9939 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
9940 }
9941 };
9942
9943 /**
9944 * @inheritdoc
9945 * @protected
9946 */
9947 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
9948 if ( this.getSaneType( config ) === 'number' ) {
9949 return $( '<input>' )
9950 .attr( 'step', 'any' )
9951 .attr( 'type', 'number' );
9952 } else {
9953 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
9954 }
9955 };
9956
9957 /**
9958 * Get sanitized value for 'type' for given config.
9959 *
9960 * @param {Object} config Configuration options
9961 * @return {string|null}
9962 * @protected
9963 */
9964 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
9965 var allowedTypes = [
9966 'text',
9967 'password',
9968 'email',
9969 'url',
9970 'number'
9971 ];
9972 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
9973 };
9974
9975 /**
9976 * Focus the input and select a specified range within the text.
9977 *
9978 * @param {number} from Select from offset
9979 * @param {number} [to] Select to offset, defaults to from
9980 * @chainable
9981 */
9982 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
9983 var isBackwards, start, end,
9984 input = this.$input[ 0 ];
9985
9986 to = to || from;
9987
9988 isBackwards = to < from;
9989 start = isBackwards ? to : from;
9990 end = isBackwards ? from : to;
9991
9992 this.focus();
9993
9994 try {
9995 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
9996 } catch ( e ) {
9997 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
9998 // Rather than expensively check if the input is attached every time, just check
9999 // if it was the cause of an error being thrown. If not, rethrow the error.
10000 if ( this.getElementDocument().body.contains( input ) ) {
10001 throw e;
10002 }
10003 }
10004 return this;
10005 };
10006
10007 /**
10008 * Get an object describing the current selection range in a directional manner
10009 *
10010 * @return {Object} Object containing 'from' and 'to' offsets
10011 */
10012 OO.ui.TextInputWidget.prototype.getRange = function () {
10013 var input = this.$input[ 0 ],
10014 start = input.selectionStart,
10015 end = input.selectionEnd,
10016 isBackwards = input.selectionDirection === 'backward';
10017
10018 return {
10019 from: isBackwards ? end : start,
10020 to: isBackwards ? start : end
10021 };
10022 };
10023
10024 /**
10025 * Get the length of the text input value.
10026 *
10027 * This could differ from the length of #getValue if the
10028 * value gets filtered
10029 *
10030 * @return {number} Input length
10031 */
10032 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10033 return this.$input[ 0 ].value.length;
10034 };
10035
10036 /**
10037 * Focus the input and select the entire text.
10038 *
10039 * @chainable
10040 */
10041 OO.ui.TextInputWidget.prototype.select = function () {
10042 return this.selectRange( 0, this.getInputLength() );
10043 };
10044
10045 /**
10046 * Focus the input and move the cursor to the start.
10047 *
10048 * @chainable
10049 */
10050 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10051 return this.selectRange( 0 );
10052 };
10053
10054 /**
10055 * Focus the input and move the cursor to the end.
10056 *
10057 * @chainable
10058 */
10059 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10060 return this.selectRange( this.getInputLength() );
10061 };
10062
10063 /**
10064 * Insert new content into the input.
10065 *
10066 * @param {string} content Content to be inserted
10067 * @chainable
10068 */
10069 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10070 var start, end,
10071 range = this.getRange(),
10072 value = this.getValue();
10073
10074 start = Math.min( range.from, range.to );
10075 end = Math.max( range.from, range.to );
10076
10077 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10078 this.selectRange( start + content.length );
10079 return this;
10080 };
10081
10082 /**
10083 * Insert new content either side of a selection.
10084 *
10085 * @param {string} pre Content to be inserted before the selection
10086 * @param {string} post Content to be inserted after the selection
10087 * @chainable
10088 */
10089 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10090 var start, end,
10091 range = this.getRange(),
10092 offset = pre.length;
10093
10094 start = Math.min( range.from, range.to );
10095 end = Math.max( range.from, range.to );
10096
10097 this.selectRange( start ).insertContent( pre );
10098 this.selectRange( offset + end ).insertContent( post );
10099
10100 this.selectRange( offset + start, offset + end );
10101 return this;
10102 };
10103
10104 /**
10105 * Set the validation pattern.
10106 *
10107 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10108 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10109 * value must contain only numbers).
10110 *
10111 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10112 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10113 */
10114 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10115 if ( validate instanceof RegExp || validate instanceof Function ) {
10116 this.validate = validate;
10117 } else {
10118 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10119 }
10120 };
10121
10122 /**
10123 * Sets the 'invalid' flag appropriately.
10124 *
10125 * @param {boolean} [isValid] Optionally override validation result
10126 */
10127 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10128 var widget = this,
10129 setFlag = function ( valid ) {
10130 if ( !valid ) {
10131 widget.$input.attr( 'aria-invalid', 'true' );
10132 } else {
10133 widget.$input.removeAttr( 'aria-invalid' );
10134 }
10135 widget.setFlags( { invalid: !valid } );
10136 };
10137
10138 if ( isValid !== undefined ) {
10139 setFlag( isValid );
10140 } else {
10141 this.getValidity().then( function () {
10142 setFlag( true );
10143 }, function () {
10144 setFlag( false );
10145 } );
10146 }
10147 };
10148
10149 /**
10150 * Get the validity of current value.
10151 *
10152 * This method returns a promise that resolves if the value is valid and rejects if
10153 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10154 *
10155 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10156 */
10157 OO.ui.TextInputWidget.prototype.getValidity = function () {
10158 var result;
10159
10160 function rejectOrResolve( valid ) {
10161 if ( valid ) {
10162 return $.Deferred().resolve().promise();
10163 } else {
10164 return $.Deferred().reject().promise();
10165 }
10166 }
10167
10168 // Check browser validity and reject if it is invalid
10169 if (
10170 this.$input[ 0 ].checkValidity !== undefined &&
10171 this.$input[ 0 ].checkValidity() === false
10172 ) {
10173 return rejectOrResolve( false );
10174 }
10175
10176 // Run our checks if the browser thinks the field is valid
10177 if ( this.validate instanceof Function ) {
10178 result = this.validate( this.getValue() );
10179 if ( result && $.isFunction( result.promise ) ) {
10180 return result.promise().then( function ( valid ) {
10181 return rejectOrResolve( valid );
10182 } );
10183 } else {
10184 return rejectOrResolve( result );
10185 }
10186 } else {
10187 return rejectOrResolve( this.getValue().match( this.validate ) );
10188 }
10189 };
10190
10191 /**
10192 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10193 *
10194 * @param {string} labelPosition Label position, 'before' or 'after'
10195 * @chainable
10196 */
10197 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10198 this.labelPosition = labelPosition;
10199 if ( this.label ) {
10200 // If there is no label and we only change the position, #updatePosition is a no-op,
10201 // but it takes really a lot of work to do nothing.
10202 this.updatePosition();
10203 }
10204 return this;
10205 };
10206
10207 /**
10208 * Update the position of the inline label.
10209 *
10210 * This method is called by #setLabelPosition, and can also be called on its own if
10211 * something causes the label to be mispositioned.
10212 *
10213 * @chainable
10214 */
10215 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10216 var after = this.labelPosition === 'after';
10217
10218 this.$element
10219 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10220 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10221
10222 this.valCache = null;
10223 this.scrollWidth = null;
10224 this.positionLabel();
10225
10226 return this;
10227 };
10228
10229 /**
10230 * Position the label by setting the correct padding on the input.
10231 *
10232 * @private
10233 * @chainable
10234 */
10235 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10236 var after, rtl, property, newCss;
10237
10238 if ( this.isWaitingToBeAttached ) {
10239 // #onElementAttach will be called soon, which calls this method
10240 return this;
10241 }
10242
10243 newCss = {
10244 'padding-right': '',
10245 'padding-left': ''
10246 };
10247
10248 if ( this.label ) {
10249 this.$element.append( this.$label );
10250 } else {
10251 this.$label.detach();
10252 // Clear old values if present
10253 this.$input.css( newCss );
10254 return;
10255 }
10256
10257 after = this.labelPosition === 'after';
10258 rtl = this.$element.css( 'direction' ) === 'rtl';
10259 property = after === rtl ? 'padding-left' : 'padding-right';
10260
10261 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10262 // We have to clear the padding on the other side, in case the element direction changed
10263 this.$input.css( newCss );
10264
10265 return this;
10266 };
10267
10268 /**
10269 * @inheritdoc
10270 */
10271 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10272 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10273 if ( state.scrollTop !== undefined ) {
10274 this.$input.scrollTop( state.scrollTop );
10275 }
10276 };
10277
10278 /**
10279 * @class
10280 * @extends OO.ui.TextInputWidget
10281 *
10282 * @constructor
10283 * @param {Object} [config] Configuration options
10284 */
10285 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10286 config = $.extend( {
10287 icon: 'search'
10288 }, config );
10289
10290 // Parent constructor
10291 OO.ui.SearchInputWidget.parent.call( this, config );
10292
10293 // Events
10294 this.connect( this, {
10295 change: 'onChange'
10296 } );
10297
10298 // Initialization
10299 this.updateSearchIndicator();
10300 this.connect( this, {
10301 disable: 'onDisable'
10302 } );
10303 };
10304
10305 /* Setup */
10306
10307 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10308
10309 /* Methods */
10310
10311 /**
10312 * @inheritdoc
10313 * @protected
10314 */
10315 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10316 return 'search';
10317 };
10318
10319 /**
10320 * @inheritdoc
10321 */
10322 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10323 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10324 // Clear the text field
10325 this.setValue( '' );
10326 this.focus();
10327 return false;
10328 }
10329 };
10330
10331 /**
10332 * Update the 'clear' indicator displayed on type: 'search' text
10333 * fields, hiding it when the field is already empty or when it's not
10334 * editable.
10335 */
10336 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10337 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10338 this.setIndicator( null );
10339 } else {
10340 this.setIndicator( 'clear' );
10341 }
10342 };
10343
10344 /**
10345 * Handle change events.
10346 *
10347 * @private
10348 */
10349 OO.ui.SearchInputWidget.prototype.onChange = function () {
10350 this.updateSearchIndicator();
10351 };
10352
10353 /**
10354 * Handle disable events.
10355 *
10356 * @param {boolean} disabled Element is disabled
10357 * @private
10358 */
10359 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10360 this.updateSearchIndicator();
10361 };
10362
10363 /**
10364 * @inheritdoc
10365 */
10366 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10367 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10368 this.updateSearchIndicator();
10369 return this;
10370 };
10371
10372 /**
10373 * @class
10374 * @extends OO.ui.TextInputWidget
10375 *
10376 * @constructor
10377 * @param {Object} [config] Configuration options
10378 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10379 * specifies minimum number of rows to display.
10380 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10381 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10382 * Use the #maxRows config to specify a maximum number of displayed rows.
10383 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10384 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10385 */
10386 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10387 config = $.extend( {
10388 type: 'text'
10389 }, config );
10390 config.multiline = false;
10391 // Parent constructor
10392 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10393
10394 // Properties
10395 this.multiline = true;
10396 this.autosize = !!config.autosize;
10397 this.minRows = config.rows !== undefined ? config.rows : '';
10398 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10399
10400 // Clone for resizing
10401 if ( this.autosize ) {
10402 this.$clone = this.$input
10403 .clone()
10404 .insertAfter( this.$input )
10405 .attr( 'aria-hidden', 'true' )
10406 .addClass( 'oo-ui-element-hidden' );
10407 }
10408
10409 // Events
10410 this.connect( this, {
10411 change: 'onChange'
10412 } );
10413
10414 // Initialization
10415 if ( this.multiline && config.rows ) {
10416 this.$input.attr( 'rows', config.rows );
10417 }
10418 if ( this.autosize ) {
10419 this.isWaitingToBeAttached = true;
10420 this.installParentChangeDetector();
10421 }
10422 };
10423
10424 /* Setup */
10425
10426 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10427
10428 /* Static Methods */
10429
10430 /**
10431 * @inheritdoc
10432 */
10433 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10434 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10435 state.scrollTop = config.$input.scrollTop();
10436 return state;
10437 };
10438
10439 /* Methods */
10440
10441 /**
10442 * @inheritdoc
10443 */
10444 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10445 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10446 this.adjustSize();
10447 };
10448
10449 /**
10450 * Handle change events.
10451 *
10452 * @private
10453 */
10454 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10455 this.adjustSize();
10456 };
10457
10458 /**
10459 * @inheritdoc
10460 */
10461 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10462 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10463 this.adjustSize();
10464 };
10465
10466 /**
10467 * Override TextInputWidget so it doesn't emit the 'enter' event.
10468 *
10469 * @private
10470 * @param {jQuery.Event} e Key press event
10471 */
10472 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10473 return;
10474 };
10475
10476 /**
10477 * Automatically adjust the size of the text input.
10478 *
10479 * This only affects multiline inputs that are {@link #autosize autosized}.
10480 *
10481 * @chainable
10482 * @fires resize
10483 */
10484 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10485 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10486 idealHeight, newHeight, scrollWidth, property;
10487
10488 if ( this.$input.val() !== this.valCache ) {
10489 if ( this.autosize ) {
10490 this.$clone
10491 .val( this.$input.val() )
10492 .attr( 'rows', this.minRows )
10493 // Set inline height property to 0 to measure scroll height
10494 .css( 'height', 0 );
10495
10496 this.$clone.removeClass( 'oo-ui-element-hidden' );
10497
10498 this.valCache = this.$input.val();
10499
10500 scrollHeight = this.$clone[ 0 ].scrollHeight;
10501
10502 // Remove inline height property to measure natural heights
10503 this.$clone.css( 'height', '' );
10504 innerHeight = this.$clone.innerHeight();
10505 outerHeight = this.$clone.outerHeight();
10506
10507 // Measure max rows height
10508 this.$clone
10509 .attr( 'rows', this.maxRows )
10510 .css( 'height', 'auto' )
10511 .val( '' );
10512 maxInnerHeight = this.$clone.innerHeight();
10513
10514 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10515 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10516 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10517 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10518
10519 this.$clone.addClass( 'oo-ui-element-hidden' );
10520
10521 // Only apply inline height when expansion beyond natural height is needed
10522 // Use the difference between the inner and outer height as a buffer
10523 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10524 if ( newHeight !== this.styleHeight ) {
10525 this.$input.css( 'height', newHeight );
10526 this.styleHeight = newHeight;
10527 this.emit( 'resize' );
10528 }
10529 }
10530 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10531 if ( scrollWidth !== this.scrollWidth ) {
10532 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10533 // Reset
10534 this.$label.css( { right: '', left: '' } );
10535 this.$indicator.css( { right: '', left: '' } );
10536
10537 if ( scrollWidth ) {
10538 this.$indicator.css( property, scrollWidth );
10539 if ( this.labelPosition === 'after' ) {
10540 this.$label.css( property, scrollWidth );
10541 }
10542 }
10543
10544 this.scrollWidth = scrollWidth;
10545 this.positionLabel();
10546 }
10547 }
10548 return this;
10549 };
10550
10551 /**
10552 * @inheritdoc
10553 * @protected
10554 */
10555 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10556 return $( '<textarea>' );
10557 };
10558
10559 /**
10560 * Check if the input supports multiple lines.
10561 *
10562 * @return {boolean}
10563 */
10564 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10565 return !!this.multiline;
10566 };
10567
10568 /**
10569 * Check if the input automatically adjusts its size.
10570 *
10571 * @return {boolean}
10572 */
10573 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10574 return !!this.autosize;
10575 };
10576
10577 /**
10578 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10579 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10580 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10581 *
10582 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10583 * option, that option will appear to be selected.
10584 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10585 * input field.
10586 *
10587 * After the user chooses an option, its `data` will be used as a new value for the widget.
10588 * A `label` also can be specified for each option: if given, it will be shown instead of the
10589 * `data` in the dropdown menu.
10590 *
10591 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10592 *
10593 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10594 *
10595 * @example
10596 * // Example: A ComboBoxInputWidget.
10597 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10598 * value: 'Option 1',
10599 * options: [
10600 * { data: 'Option 1' },
10601 * { data: 'Option 2' },
10602 * { data: 'Option 3' }
10603 * ]
10604 * } );
10605 * $( 'body' ).append( comboBox.$element );
10606 *
10607 * @example
10608 * // Example: A ComboBoxInputWidget with additional option labels.
10609 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10610 * value: 'Option 1',
10611 * options: [
10612 * {
10613 * data: 'Option 1',
10614 * label: 'Option One'
10615 * },
10616 * {
10617 * data: 'Option 2',
10618 * label: 'Option Two'
10619 * },
10620 * {
10621 * data: 'Option 3',
10622 * label: 'Option Three'
10623 * }
10624 * ]
10625 * } );
10626 * $( 'body' ).append( comboBox.$element );
10627 *
10628 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10629 *
10630 * @class
10631 * @extends OO.ui.TextInputWidget
10632 *
10633 * @constructor
10634 * @param {Object} [config] Configuration options
10635 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10636 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10637 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10638 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10639 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10640 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10641 */
10642 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10643 // Configuration initialization
10644 config = $.extend( {
10645 autocomplete: false
10646 }, config );
10647
10648 // ComboBoxInputWidget shouldn't support `multiline`
10649 config.multiline = false;
10650
10651 // See InputWidget#reusePreInfuseDOM about `config.$input`
10652 if ( config.$input ) {
10653 config.$input.removeAttr( 'list' );
10654 }
10655
10656 // Parent constructor
10657 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10658
10659 // Properties
10660 this.$overlay = config.$overlay || this.$element;
10661 this.dropdownButton = new OO.ui.ButtonWidget( {
10662 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10663 indicator: 'down',
10664 disabled: this.disabled
10665 } );
10666 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10667 {
10668 widget: this,
10669 input: this,
10670 $floatableContainer: this.$element,
10671 disabled: this.isDisabled()
10672 },
10673 config.menu
10674 ) );
10675
10676 // Events
10677 this.connect( this, {
10678 change: 'onInputChange',
10679 enter: 'onInputEnter'
10680 } );
10681 this.dropdownButton.connect( this, {
10682 click: 'onDropdownButtonClick'
10683 } );
10684 this.menu.connect( this, {
10685 choose: 'onMenuChoose',
10686 add: 'onMenuItemsChange',
10687 remove: 'onMenuItemsChange',
10688 toggle: 'onMenuToggle'
10689 } );
10690
10691 // Initialization
10692 this.$input.attr( {
10693 role: 'combobox',
10694 'aria-owns': this.menu.getElementId(),
10695 'aria-autocomplete': 'list'
10696 } );
10697 // Do not override options set via config.menu.items
10698 if ( config.options !== undefined ) {
10699 this.setOptions( config.options );
10700 }
10701 this.$field = $( '<div>' )
10702 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10703 .append( this.$input, this.dropdownButton.$element );
10704 this.$element
10705 .addClass( 'oo-ui-comboBoxInputWidget' )
10706 .append( this.$field );
10707 this.$overlay.append( this.menu.$element );
10708 this.onMenuItemsChange();
10709 };
10710
10711 /* Setup */
10712
10713 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10714
10715 /* Methods */
10716
10717 /**
10718 * Get the combobox's menu.
10719 *
10720 * @return {OO.ui.MenuSelectWidget} Menu widget
10721 */
10722 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10723 return this.menu;
10724 };
10725
10726 /**
10727 * Get the combobox's text input widget.
10728 *
10729 * @return {OO.ui.TextInputWidget} Text input widget
10730 */
10731 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10732 return this;
10733 };
10734
10735 /**
10736 * Handle input change events.
10737 *
10738 * @private
10739 * @param {string} value New value
10740 */
10741 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
10742 var match = this.menu.getItemFromData( value );
10743
10744 this.menu.selectItem( match );
10745 if ( this.menu.findHighlightedItem() ) {
10746 this.menu.highlightItem( match );
10747 }
10748
10749 if ( !this.isDisabled() ) {
10750 this.menu.toggle( true );
10751 }
10752 };
10753
10754 /**
10755 * Handle input enter events.
10756 *
10757 * @private
10758 */
10759 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
10760 if ( !this.isDisabled() ) {
10761 this.menu.toggle( false );
10762 }
10763 };
10764
10765 /**
10766 * Handle button click events.
10767 *
10768 * @private
10769 */
10770 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
10771 this.menu.toggle();
10772 this.focus();
10773 };
10774
10775 /**
10776 * Handle menu choose events.
10777 *
10778 * @private
10779 * @param {OO.ui.OptionWidget} item Chosen item
10780 */
10781 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
10782 this.setValue( item.getData() );
10783 };
10784
10785 /**
10786 * Handle menu item change events.
10787 *
10788 * @private
10789 */
10790 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
10791 var match = this.menu.getItemFromData( this.getValue() );
10792 this.menu.selectItem( match );
10793 if ( this.menu.findHighlightedItem() ) {
10794 this.menu.highlightItem( match );
10795 }
10796 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
10797 };
10798
10799 /**
10800 * Handle menu toggle events.
10801 *
10802 * @private
10803 * @param {boolean} isVisible Menu toggle event
10804 */
10805 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
10806 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
10807 };
10808
10809 /**
10810 * @inheritdoc
10811 */
10812 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
10813 // Parent method
10814 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
10815
10816 if ( this.dropdownButton ) {
10817 this.dropdownButton.setDisabled( this.isDisabled() );
10818 }
10819 if ( this.menu ) {
10820 this.menu.setDisabled( this.isDisabled() );
10821 }
10822
10823 return this;
10824 };
10825
10826 /**
10827 * Set the options available for this input.
10828 *
10829 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10830 * @chainable
10831 */
10832 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
10833 this.getMenu()
10834 .clearItems()
10835 .addItems( options.map( function ( opt ) {
10836 return new OO.ui.MenuOptionWidget( {
10837 data: opt.data,
10838 label: opt.label !== undefined ? opt.label : opt.data
10839 } );
10840 } ) );
10841
10842 return this;
10843 };
10844
10845 /**
10846 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
10847 * which is a widget that is specified by reference before any optional configuration settings.
10848 *
10849 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
10850 *
10851 * - **left**: The label is placed before the field-widget and aligned with the left margin.
10852 * A left-alignment is used for forms with many fields.
10853 * - **right**: The label is placed before the field-widget and aligned to the right margin.
10854 * A right-alignment is used for long but familiar forms which users tab through,
10855 * verifying the current field with a quick glance at the label.
10856 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
10857 * that users fill out from top to bottom.
10858 * - **inline**: The label is placed after the field-widget and aligned to the left.
10859 * An inline-alignment is best used with checkboxes or radio buttons.
10860 *
10861 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
10862 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
10863 *
10864 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
10865 *
10866 * @class
10867 * @extends OO.ui.Layout
10868 * @mixins OO.ui.mixin.LabelElement
10869 * @mixins OO.ui.mixin.TitledElement
10870 *
10871 * @constructor
10872 * @param {OO.ui.Widget} fieldWidget Field widget
10873 * @param {Object} [config] Configuration options
10874 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
10875 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
10876 * The array may contain strings or OO.ui.HtmlSnippet instances.
10877 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
10878 * The array may contain strings or OO.ui.HtmlSnippet instances.
10879 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
10880 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
10881 * For important messages, you are advised to use `notices`, as they are always shown.
10882 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
10883 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10884 *
10885 * @throws {Error} An error is thrown if no widget is specified
10886 */
10887 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
10888 // Allow passing positional parameters inside the config object
10889 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
10890 config = fieldWidget;
10891 fieldWidget = config.fieldWidget;
10892 }
10893
10894 // Make sure we have required constructor arguments
10895 if ( fieldWidget === undefined ) {
10896 throw new Error( 'Widget not found' );
10897 }
10898
10899 // Configuration initialization
10900 config = $.extend( { align: 'left' }, config );
10901
10902 // Parent constructor
10903 OO.ui.FieldLayout.parent.call( this, config );
10904
10905 // Mixin constructors
10906 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
10907 $label: $( '<label>' )
10908 } ) );
10909 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
10910
10911 // Properties
10912 this.fieldWidget = fieldWidget;
10913 this.errors = [];
10914 this.notices = [];
10915 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
10916 this.$messages = $( '<ul>' );
10917 this.$header = $( '<span>' );
10918 this.$body = $( '<div>' );
10919 this.align = null;
10920 if ( config.help ) {
10921 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
10922 $overlay: config.$overlay,
10923 popup: {
10924 padded: true
10925 },
10926 classes: [ 'oo-ui-fieldLayout-help' ],
10927 framed: false,
10928 icon: 'info'
10929 } );
10930 if ( config.help instanceof OO.ui.HtmlSnippet ) {
10931 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
10932 } else {
10933 this.popupButtonWidget.getPopup().$body.text( config.help );
10934 }
10935 this.$help = this.popupButtonWidget.$element;
10936 } else {
10937 this.$help = $( [] );
10938 }
10939
10940 // Events
10941 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
10942
10943 // Initialization
10944 if ( config.help ) {
10945 // Set the 'aria-describedby' attribute on the fieldWidget
10946 // Preference given to an input or a button
10947 (
10948 this.fieldWidget.$input ||
10949 this.fieldWidget.$button ||
10950 this.fieldWidget.$element
10951 ).attr(
10952 'aria-describedby',
10953 this.popupButtonWidget.getPopup().getBodyId()
10954 );
10955 }
10956 if ( this.fieldWidget.getInputId() ) {
10957 this.$label.attr( 'for', this.fieldWidget.getInputId() );
10958 } else {
10959 this.$label.on( 'click', function () {
10960 this.fieldWidget.simulateLabelClick();
10961 return false;
10962 }.bind( this ) );
10963 }
10964 this.$element
10965 .addClass( 'oo-ui-fieldLayout' )
10966 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
10967 .append( this.$body );
10968 this.$body.addClass( 'oo-ui-fieldLayout-body' );
10969 this.$header.addClass( 'oo-ui-fieldLayout-header' );
10970 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
10971 this.$field
10972 .addClass( 'oo-ui-fieldLayout-field' )
10973 .append( this.fieldWidget.$element );
10974
10975 this.setErrors( config.errors || [] );
10976 this.setNotices( config.notices || [] );
10977 this.setAlignment( config.align );
10978 // Call this again to take into account the widget's accessKey
10979 this.updateTitle();
10980 };
10981
10982 /* Setup */
10983
10984 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
10985 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
10986 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
10987
10988 /* Methods */
10989
10990 /**
10991 * Handle field disable events.
10992 *
10993 * @private
10994 * @param {boolean} value Field is disabled
10995 */
10996 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
10997 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
10998 };
10999
11000 /**
11001 * Get the widget contained by the field.
11002 *
11003 * @return {OO.ui.Widget} Field widget
11004 */
11005 OO.ui.FieldLayout.prototype.getField = function () {
11006 return this.fieldWidget;
11007 };
11008
11009 /**
11010 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11011 * #setAlignment). Return `false` if it can't or if this can't be determined.
11012 *
11013 * @return {boolean}
11014 */
11015 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11016 // This is very simplistic, but should be good enough.
11017 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11018 };
11019
11020 /**
11021 * @protected
11022 * @param {string} kind 'error' or 'notice'
11023 * @param {string|OO.ui.HtmlSnippet} text
11024 * @return {jQuery}
11025 */
11026 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11027 var $listItem, $icon, message;
11028 $listItem = $( '<li>' );
11029 if ( kind === 'error' ) {
11030 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11031 $listItem.attr( 'role', 'alert' );
11032 } else if ( kind === 'notice' ) {
11033 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
11034 } else {
11035 $icon = '';
11036 }
11037 message = new OO.ui.LabelWidget( { label: text } );
11038 $listItem
11039 .append( $icon, message.$element )
11040 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11041 return $listItem;
11042 };
11043
11044 /**
11045 * Set the field alignment mode.
11046 *
11047 * @private
11048 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11049 * @chainable
11050 */
11051 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11052 if ( value !== this.align ) {
11053 // Default to 'left'
11054 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11055 value = 'left';
11056 }
11057 // Validate
11058 if ( value === 'inline' && !this.isFieldInline() ) {
11059 value = 'top';
11060 }
11061 // Reorder elements
11062 if ( value === 'top' ) {
11063 this.$header.append( this.$label, this.$help );
11064 this.$body.append( this.$header, this.$field );
11065 } else if ( value === 'inline' ) {
11066 this.$header.append( this.$label, this.$help );
11067 this.$body.append( this.$field, this.$header );
11068 } else {
11069 this.$header.append( this.$label );
11070 this.$body.append( this.$header, this.$help, this.$field );
11071 }
11072 // Set classes. The following classes can be used here:
11073 // * oo-ui-fieldLayout-align-left
11074 // * oo-ui-fieldLayout-align-right
11075 // * oo-ui-fieldLayout-align-top
11076 // * oo-ui-fieldLayout-align-inline
11077 if ( this.align ) {
11078 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11079 }
11080 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11081 this.align = value;
11082 }
11083
11084 return this;
11085 };
11086
11087 /**
11088 * Set the list of error messages.
11089 *
11090 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11091 * The array may contain strings or OO.ui.HtmlSnippet instances.
11092 * @chainable
11093 */
11094 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11095 this.errors = errors.slice();
11096 this.updateMessages();
11097 return this;
11098 };
11099
11100 /**
11101 * Set the list of notice messages.
11102 *
11103 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11104 * The array may contain strings or OO.ui.HtmlSnippet instances.
11105 * @chainable
11106 */
11107 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11108 this.notices = notices.slice();
11109 this.updateMessages();
11110 return this;
11111 };
11112
11113 /**
11114 * Update the rendering of error and notice messages.
11115 *
11116 * @private
11117 */
11118 OO.ui.FieldLayout.prototype.updateMessages = function () {
11119 var i;
11120 this.$messages.empty();
11121
11122 if ( this.errors.length || this.notices.length ) {
11123 this.$body.after( this.$messages );
11124 } else {
11125 this.$messages.remove();
11126 return;
11127 }
11128
11129 for ( i = 0; i < this.notices.length; i++ ) {
11130 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11131 }
11132 for ( i = 0; i < this.errors.length; i++ ) {
11133 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11134 }
11135 };
11136
11137 /**
11138 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11139 * (This is a bit of a hack.)
11140 *
11141 * @protected
11142 * @param {string} title Tooltip label for 'title' attribute
11143 * @return {string}
11144 */
11145 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11146 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11147 return this.fieldWidget.formatTitleWithAccessKey( title );
11148 }
11149 return title;
11150 };
11151
11152 /**
11153 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11154 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11155 * is required and is specified before any optional configuration settings.
11156 *
11157 * Labels can be aligned in one of four ways:
11158 *
11159 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11160 * A left-alignment is used for forms with many fields.
11161 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11162 * A right-alignment is used for long but familiar forms which users tab through,
11163 * verifying the current field with a quick glance at the label.
11164 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11165 * that users fill out from top to bottom.
11166 * - **inline**: The label is placed after the field-widget and aligned to the left.
11167 * An inline-alignment is best used with checkboxes or radio buttons.
11168 *
11169 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11170 * text is specified.
11171 *
11172 * @example
11173 * // Example of an ActionFieldLayout
11174 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11175 * new OO.ui.TextInputWidget( {
11176 * placeholder: 'Field widget'
11177 * } ),
11178 * new OO.ui.ButtonWidget( {
11179 * label: 'Button'
11180 * } ),
11181 * {
11182 * label: 'An ActionFieldLayout. This label is aligned top',
11183 * align: 'top',
11184 * help: 'This is help text'
11185 * }
11186 * );
11187 *
11188 * $( 'body' ).append( actionFieldLayout.$element );
11189 *
11190 * @class
11191 * @extends OO.ui.FieldLayout
11192 *
11193 * @constructor
11194 * @param {OO.ui.Widget} fieldWidget Field widget
11195 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11196 * @param {Object} config
11197 */
11198 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11199 // Allow passing positional parameters inside the config object
11200 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11201 config = fieldWidget;
11202 fieldWidget = config.fieldWidget;
11203 buttonWidget = config.buttonWidget;
11204 }
11205
11206 // Parent constructor
11207 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11208
11209 // Properties
11210 this.buttonWidget = buttonWidget;
11211 this.$button = $( '<span>' );
11212 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11213
11214 // Initialization
11215 this.$element
11216 .addClass( 'oo-ui-actionFieldLayout' );
11217 this.$button
11218 .addClass( 'oo-ui-actionFieldLayout-button' )
11219 .append( this.buttonWidget.$element );
11220 this.$input
11221 .addClass( 'oo-ui-actionFieldLayout-input' )
11222 .append( this.fieldWidget.$element );
11223 this.$field
11224 .append( this.$input, this.$button );
11225 };
11226
11227 /* Setup */
11228
11229 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11230
11231 /**
11232 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11233 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11234 * configured with a label as well. For more information and examples,
11235 * please see the [OOjs UI documentation on MediaWiki][1].
11236 *
11237 * @example
11238 * // Example of a fieldset layout
11239 * var input1 = new OO.ui.TextInputWidget( {
11240 * placeholder: 'A text input field'
11241 * } );
11242 *
11243 * var input2 = new OO.ui.TextInputWidget( {
11244 * placeholder: 'A text input field'
11245 * } );
11246 *
11247 * var fieldset = new OO.ui.FieldsetLayout( {
11248 * label: 'Example of a fieldset layout'
11249 * } );
11250 *
11251 * fieldset.addItems( [
11252 * new OO.ui.FieldLayout( input1, {
11253 * label: 'Field One'
11254 * } ),
11255 * new OO.ui.FieldLayout( input2, {
11256 * label: 'Field Two'
11257 * } )
11258 * ] );
11259 * $( 'body' ).append( fieldset.$element );
11260 *
11261 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11262 *
11263 * @class
11264 * @extends OO.ui.Layout
11265 * @mixins OO.ui.mixin.IconElement
11266 * @mixins OO.ui.mixin.LabelElement
11267 * @mixins OO.ui.mixin.GroupElement
11268 *
11269 * @constructor
11270 * @param {Object} [config] Configuration options
11271 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11272 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11273 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11274 * For important messages, you are advised to use `notices`, as they are always shown.
11275 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11276 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11277 */
11278 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11279 // Configuration initialization
11280 config = config || {};
11281
11282 // Parent constructor
11283 OO.ui.FieldsetLayout.parent.call( this, config );
11284
11285 // Mixin constructors
11286 OO.ui.mixin.IconElement.call( this, config );
11287 OO.ui.mixin.LabelElement.call( this, config );
11288 OO.ui.mixin.GroupElement.call( this, config );
11289
11290 // Properties
11291 this.$header = $( '<legend>' );
11292 if ( config.help ) {
11293 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11294 $overlay: config.$overlay,
11295 popup: {
11296 padded: true
11297 },
11298 classes: [ 'oo-ui-fieldsetLayout-help' ],
11299 framed: false,
11300 icon: 'info'
11301 } );
11302 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11303 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11304 } else {
11305 this.popupButtonWidget.getPopup().$body.text( config.help );
11306 }
11307 this.$help = this.popupButtonWidget.$element;
11308 } else {
11309 this.$help = $( [] );
11310 }
11311
11312 // Initialization
11313 this.$header
11314 .addClass( 'oo-ui-fieldsetLayout-header' )
11315 .append( this.$icon, this.$label, this.$help );
11316 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11317 this.$element
11318 .addClass( 'oo-ui-fieldsetLayout' )
11319 .prepend( this.$header, this.$group );
11320 if ( Array.isArray( config.items ) ) {
11321 this.addItems( config.items );
11322 }
11323 };
11324
11325 /* Setup */
11326
11327 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11328 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11329 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11330 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11331
11332 /* Static Properties */
11333
11334 /**
11335 * @static
11336 * @inheritdoc
11337 */
11338 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11339
11340 /**
11341 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11342 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11343 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11344 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11345 *
11346 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11347 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11348 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11349 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11350 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11351 * often have simplified APIs to match the capabilities of HTML forms.
11352 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11353 *
11354 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11355 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11356 *
11357 * @example
11358 * // Example of a form layout that wraps a fieldset layout
11359 * var input1 = new OO.ui.TextInputWidget( {
11360 * placeholder: 'Username'
11361 * } );
11362 * var input2 = new OO.ui.TextInputWidget( {
11363 * placeholder: 'Password',
11364 * type: 'password'
11365 * } );
11366 * var submit = new OO.ui.ButtonInputWidget( {
11367 * label: 'Submit'
11368 * } );
11369 *
11370 * var fieldset = new OO.ui.FieldsetLayout( {
11371 * label: 'A form layout'
11372 * } );
11373 * fieldset.addItems( [
11374 * new OO.ui.FieldLayout( input1, {
11375 * label: 'Username',
11376 * align: 'top'
11377 * } ),
11378 * new OO.ui.FieldLayout( input2, {
11379 * label: 'Password',
11380 * align: 'top'
11381 * } ),
11382 * new OO.ui.FieldLayout( submit )
11383 * ] );
11384 * var form = new OO.ui.FormLayout( {
11385 * items: [ fieldset ],
11386 * action: '/api/formhandler',
11387 * method: 'get'
11388 * } )
11389 * $( 'body' ).append( form.$element );
11390 *
11391 * @class
11392 * @extends OO.ui.Layout
11393 * @mixins OO.ui.mixin.GroupElement
11394 *
11395 * @constructor
11396 * @param {Object} [config] Configuration options
11397 * @cfg {string} [method] HTML form `method` attribute
11398 * @cfg {string} [action] HTML form `action` attribute
11399 * @cfg {string} [enctype] HTML form `enctype` attribute
11400 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11401 */
11402 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11403 var action;
11404
11405 // Configuration initialization
11406 config = config || {};
11407
11408 // Parent constructor
11409 OO.ui.FormLayout.parent.call( this, config );
11410
11411 // Mixin constructors
11412 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11413
11414 // Events
11415 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11416
11417 // Make sure the action is safe
11418 action = config.action;
11419 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11420 action = './' + action;
11421 }
11422
11423 // Initialization
11424 this.$element
11425 .addClass( 'oo-ui-formLayout' )
11426 .attr( {
11427 method: config.method,
11428 action: action,
11429 enctype: config.enctype
11430 } );
11431 if ( Array.isArray( config.items ) ) {
11432 this.addItems( config.items );
11433 }
11434 };
11435
11436 /* Setup */
11437
11438 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11439 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11440
11441 /* Events */
11442
11443 /**
11444 * A 'submit' event is emitted when the form is submitted.
11445 *
11446 * @event submit
11447 */
11448
11449 /* Static Properties */
11450
11451 /**
11452 * @static
11453 * @inheritdoc
11454 */
11455 OO.ui.FormLayout.static.tagName = 'form';
11456
11457 /* Methods */
11458
11459 /**
11460 * Handle form submit events.
11461 *
11462 * @private
11463 * @param {jQuery.Event} e Submit event
11464 * @fires submit
11465 */
11466 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11467 if ( this.emit( 'submit' ) ) {
11468 return false;
11469 }
11470 };
11471
11472 /**
11473 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11474 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11475 *
11476 * @example
11477 * // Example of a panel layout
11478 * var panel = new OO.ui.PanelLayout( {
11479 * expanded: false,
11480 * framed: true,
11481 * padded: true,
11482 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11483 * } );
11484 * $( 'body' ).append( panel.$element );
11485 *
11486 * @class
11487 * @extends OO.ui.Layout
11488 *
11489 * @constructor
11490 * @param {Object} [config] Configuration options
11491 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11492 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11493 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11494 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11495 */
11496 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11497 // Configuration initialization
11498 config = $.extend( {
11499 scrollable: false,
11500 padded: false,
11501 expanded: true,
11502 framed: false
11503 }, config );
11504
11505 // Parent constructor
11506 OO.ui.PanelLayout.parent.call( this, config );
11507
11508 // Initialization
11509 this.$element.addClass( 'oo-ui-panelLayout' );
11510 if ( config.scrollable ) {
11511 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11512 }
11513 if ( config.padded ) {
11514 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11515 }
11516 if ( config.expanded ) {
11517 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11518 }
11519 if ( config.framed ) {
11520 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11521 }
11522 };
11523
11524 /* Setup */
11525
11526 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11527
11528 /* Methods */
11529
11530 /**
11531 * Focus the panel layout
11532 *
11533 * The default implementation just focuses the first focusable element in the panel
11534 */
11535 OO.ui.PanelLayout.prototype.focus = function () {
11536 OO.ui.findFocusable( this.$element ).focus();
11537 };
11538
11539 /**
11540 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11541 * items), with small margins between them. Convenient when you need to put a number of block-level
11542 * widgets on a single line next to each other.
11543 *
11544 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11545 *
11546 * @example
11547 * // HorizontalLayout with a text input and a label
11548 * var layout = new OO.ui.HorizontalLayout( {
11549 * items: [
11550 * new OO.ui.LabelWidget( { label: 'Label' } ),
11551 * new OO.ui.TextInputWidget( { value: 'Text' } )
11552 * ]
11553 * } );
11554 * $( 'body' ).append( layout.$element );
11555 *
11556 * @class
11557 * @extends OO.ui.Layout
11558 * @mixins OO.ui.mixin.GroupElement
11559 *
11560 * @constructor
11561 * @param {Object} [config] Configuration options
11562 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11563 */
11564 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11565 // Configuration initialization
11566 config = config || {};
11567
11568 // Parent constructor
11569 OO.ui.HorizontalLayout.parent.call( this, config );
11570
11571 // Mixin constructors
11572 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11573
11574 // Initialization
11575 this.$element.addClass( 'oo-ui-horizontalLayout' );
11576 if ( Array.isArray( config.items ) ) {
11577 this.addItems( config.items );
11578 }
11579 };
11580
11581 /* Setup */
11582
11583 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11584 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11585
11586 }( OO ) );
11587
11588 //# sourceMappingURL=oojs-ui-core.js.map