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