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