Merge "Skin: Make skins aware of their registered skin name"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.23.3
3 * https://www.mediawiki.org/wiki/OOjs_UI
4 *
5 * Copyright 2011–2017 OOjs UI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2017-10-04T01:20:41Z
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 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2957 * @return {jQuery} Text with the first match of the query
2958 * sub-string wrapped in highlighted span
2959 */
2960 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2961 var i, tLen, qLen,
2962 offset = -1,
2963 $result = $( '<span>' );
2964
2965 if ( compare ) {
2966 tLen = text.length;
2967 qLen = query.length;
2968 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2969 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2970 offset = i;
2971 }
2972 }
2973 } else {
2974 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2975 }
2976
2977 if ( !query.length || offset === -1 ) {
2978 $result.text( text );
2979 } else {
2980 $result.append(
2981 document.createTextNode( text.slice( 0, offset ) ),
2982 $( '<span>' )
2983 .addClass( 'oo-ui-labelElement-label-highlight' )
2984 .text( text.slice( offset, offset + query.length ) ),
2985 document.createTextNode( text.slice( offset + query.length ) )
2986 );
2987 }
2988 return $result.contents();
2989 };
2990
2991 /* Methods */
2992
2993 /**
2994 * Set the label element.
2995 *
2996 * If an element is already set, it will be cleaned up before setting up the new element.
2997 *
2998 * @param {jQuery} $label Element to use as label
2999 */
3000 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3001 if ( this.$label ) {
3002 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3003 }
3004
3005 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3006 this.setLabelContent( this.label );
3007 };
3008
3009 /**
3010 * Set the label.
3011 *
3012 * An empty string will result in the label being hidden. A string containing only whitespace will
3013 * be converted to a single `&nbsp;`.
3014 *
3015 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3016 * text; or null for no label
3017 * @chainable
3018 */
3019 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3020 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3021 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3022
3023 if ( this.label !== label ) {
3024 if ( this.$label ) {
3025 this.setLabelContent( label );
3026 }
3027 this.label = label;
3028 this.emit( 'labelChange' );
3029 }
3030
3031 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3032
3033 return this;
3034 };
3035
3036 /**
3037 * Set the label as plain text with a highlighted query
3038 *
3039 * @param {string} text Text label to set
3040 * @param {string} query Substring of text to highlight
3041 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3042 * @chainable
3043 */
3044 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3045 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3046 };
3047
3048 /**
3049 * Get the label.
3050 *
3051 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3052 * text; or null for no label
3053 */
3054 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3055 return this.label;
3056 };
3057
3058 /**
3059 * Set the content of the label.
3060 *
3061 * Do not call this method until after the label element has been set by #setLabelElement.
3062 *
3063 * @private
3064 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3065 * text; or null for no label
3066 */
3067 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3068 if ( typeof label === 'string' ) {
3069 if ( label.match( /^\s*$/ ) ) {
3070 // Convert whitespace only string to a single non-breaking space
3071 this.$label.html( '&nbsp;' );
3072 } else {
3073 this.$label.text( label );
3074 }
3075 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3076 this.$label.html( label.toString() );
3077 } else if ( label instanceof jQuery ) {
3078 this.$label.empty().append( label );
3079 } else {
3080 this.$label.empty();
3081 }
3082 };
3083
3084 /**
3085 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3086 * additional functionality to an element created by another class. The class provides
3087 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3088 * which are used to customize the look and feel of a widget to better describe its
3089 * importance and functionality.
3090 *
3091 * The library currently contains the following styling flags for general use:
3092 *
3093 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3094 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3095 * - **constructive**: Constructive styling is deprecated since v0.23.2 and equivalent to progressive.
3096 *
3097 * The flags affect the appearance of the buttons:
3098 *
3099 * @example
3100 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3101 * var button1 = new OO.ui.ButtonWidget( {
3102 * label: 'Progressive',
3103 * flags: 'progressive'
3104 * } );
3105 * var button2 = new OO.ui.ButtonWidget( {
3106 * label: 'Destructive',
3107 * flags: 'destructive'
3108 * } );
3109 * $( 'body' ).append( button1.$element, button2.$element );
3110 *
3111 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3112 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
3113 *
3114 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3115 *
3116 * @abstract
3117 * @class
3118 *
3119 * @constructor
3120 * @param {Object} [config] Configuration options
3121 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3122 * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
3123 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3124 * @cfg {jQuery} [$flagged] The flagged element. By default,
3125 * the flagged functionality is applied to the element created by the class ($element).
3126 * If a different element is specified, the flagged functionality will be applied to it instead.
3127 */
3128 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3129 // Configuration initialization
3130 config = config || {};
3131
3132 // Properties
3133 this.flags = {};
3134 this.$flagged = null;
3135
3136 // Initialization
3137 this.setFlags( config.flags );
3138 this.setFlaggedElement( config.$flagged || this.$element );
3139 };
3140
3141 /* Events */
3142
3143 /**
3144 * @event flag
3145 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3146 * parameter contains the name of each modified flag and indicates whether it was
3147 * added or removed.
3148 *
3149 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3150 * that the flag was added, `false` that the flag was removed.
3151 */
3152
3153 /* Methods */
3154
3155 /**
3156 * Set the flagged element.
3157 *
3158 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3159 * If an element is already set, the method will remove the mixin’s effect on that element.
3160 *
3161 * @param {jQuery} $flagged Element that should be flagged
3162 */
3163 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3164 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3165 return 'oo-ui-flaggedElement-' + flag;
3166 } ).join( ' ' );
3167
3168 if ( this.$flagged ) {
3169 this.$flagged.removeClass( classNames );
3170 }
3171
3172 this.$flagged = $flagged.addClass( classNames );
3173 };
3174
3175 /**
3176 * Check if the specified flag is set.
3177 *
3178 * @param {string} flag Name of flag
3179 * @return {boolean} The flag is set
3180 */
3181 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3182 // This may be called before the constructor, thus before this.flags is set
3183 return this.flags && ( flag in this.flags );
3184 };
3185
3186 /**
3187 * Get the names of all flags set.
3188 *
3189 * @return {string[]} Flag names
3190 */
3191 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3192 // This may be called before the constructor, thus before this.flags is set
3193 return Object.keys( this.flags || {} );
3194 };
3195
3196 /**
3197 * Clear all flags.
3198 *
3199 * @chainable
3200 * @fires flag
3201 */
3202 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3203 var flag, className,
3204 changes = {},
3205 remove = [],
3206 classPrefix = 'oo-ui-flaggedElement-';
3207
3208 for ( flag in this.flags ) {
3209 className = classPrefix + flag;
3210 changes[ flag ] = false;
3211 delete this.flags[ flag ];
3212 remove.push( className );
3213 }
3214
3215 if ( this.$flagged ) {
3216 this.$flagged.removeClass( remove.join( ' ' ) );
3217 }
3218
3219 this.updateThemeClasses();
3220 this.emit( 'flag', changes );
3221
3222 return this;
3223 };
3224
3225 /**
3226 * Add one or more flags.
3227 *
3228 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3229 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3230 * be added (`true`) or removed (`false`).
3231 * @chainable
3232 * @fires flag
3233 */
3234 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3235 var i, len, flag, className,
3236 changes = {},
3237 add = [],
3238 remove = [],
3239 classPrefix = 'oo-ui-flaggedElement-';
3240
3241 if ( typeof flags === 'string' ) {
3242 className = classPrefix + flags;
3243 // Set
3244 if ( !this.flags[ flags ] ) {
3245 this.flags[ flags ] = true;
3246 add.push( className );
3247 }
3248 } else if ( Array.isArray( flags ) ) {
3249 for ( i = 0, len = flags.length; i < len; i++ ) {
3250 flag = flags[ i ];
3251 className = classPrefix + flag;
3252 // Set
3253 if ( !this.flags[ flag ] ) {
3254 changes[ flag ] = true;
3255 this.flags[ flag ] = true;
3256 add.push( className );
3257 }
3258 }
3259 } else if ( OO.isPlainObject( flags ) ) {
3260 for ( flag in flags ) {
3261 className = classPrefix + flag;
3262 if ( flags[ flag ] ) {
3263 // Set
3264 if ( !this.flags[ flag ] ) {
3265 changes[ flag ] = true;
3266 this.flags[ flag ] = true;
3267 add.push( className );
3268 }
3269 } else {
3270 // Remove
3271 if ( this.flags[ flag ] ) {
3272 changes[ flag ] = false;
3273 delete this.flags[ flag ];
3274 remove.push( className );
3275 }
3276 }
3277 }
3278 }
3279
3280 if ( this.$flagged ) {
3281 this.$flagged
3282 .addClass( add.join( ' ' ) )
3283 .removeClass( remove.join( ' ' ) );
3284 }
3285
3286 this.updateThemeClasses();
3287 this.emit( 'flag', changes );
3288
3289 return this;
3290 };
3291
3292 /**
3293 * TitledElement is mixed into other classes to provide a `title` attribute.
3294 * Titles are rendered by the browser and are made visible when the user moves
3295 * the mouse over the element. Titles are not visible on touch devices.
3296 *
3297 * @example
3298 * // TitledElement provides a 'title' attribute to the
3299 * // ButtonWidget class
3300 * var button = new OO.ui.ButtonWidget( {
3301 * label: 'Button with Title',
3302 * title: 'I am a button'
3303 * } );
3304 * $( 'body' ).append( button.$element );
3305 *
3306 * @abstract
3307 * @class
3308 *
3309 * @constructor
3310 * @param {Object} [config] Configuration options
3311 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3312 * If this config is omitted, the title functionality is applied to $element, the
3313 * element created by the class.
3314 * @cfg {string|Function} [title] The title text or a function that returns text. If
3315 * this config is omitted, the value of the {@link #static-title static title} property is used.
3316 */
3317 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3318 // Configuration initialization
3319 config = config || {};
3320
3321 // Properties
3322 this.$titled = null;
3323 this.title = null;
3324
3325 // Initialization
3326 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3327 this.setTitledElement( config.$titled || this.$element );
3328 };
3329
3330 /* Setup */
3331
3332 OO.initClass( OO.ui.mixin.TitledElement );
3333
3334 /* Static Properties */
3335
3336 /**
3337 * The title text, a function that returns text, or `null` for no title. The value of the static property
3338 * is overridden if the #title config option is used.
3339 *
3340 * @static
3341 * @inheritable
3342 * @property {string|Function|null}
3343 */
3344 OO.ui.mixin.TitledElement.static.title = null;
3345
3346 /* Methods */
3347
3348 /**
3349 * Set the titled element.
3350 *
3351 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3352 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3353 *
3354 * @param {jQuery} $titled Element that should use the 'titled' functionality
3355 */
3356 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3357 if ( this.$titled ) {
3358 this.$titled.removeAttr( 'title' );
3359 }
3360
3361 this.$titled = $titled;
3362 if ( this.title ) {
3363 this.updateTitle();
3364 }
3365 };
3366
3367 /**
3368 * Set title.
3369 *
3370 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3371 * @chainable
3372 */
3373 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3374 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3375 title = ( typeof title === 'string' && title.length ) ? title : null;
3376
3377 if ( this.title !== title ) {
3378 this.title = title;
3379 this.updateTitle();
3380 }
3381
3382 return this;
3383 };
3384
3385 /**
3386 * Update the title attribute, in case of changes to title or accessKey.
3387 *
3388 * @protected
3389 * @chainable
3390 */
3391 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3392 var title = this.getTitle();
3393 if ( this.$titled ) {
3394 if ( title !== null ) {
3395 // Only if this is an AccessKeyedElement
3396 if ( this.formatTitleWithAccessKey ) {
3397 title = this.formatTitleWithAccessKey( title );
3398 }
3399 this.$titled.attr( 'title', title );
3400 } else {
3401 this.$titled.removeAttr( 'title' );
3402 }
3403 }
3404 return this;
3405 };
3406
3407 /**
3408 * Get title.
3409 *
3410 * @return {string} Title string
3411 */
3412 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3413 return this.title;
3414 };
3415
3416 /**
3417 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3418 * Accesskeys allow an user to go to a specific element by using
3419 * a shortcut combination of a browser specific keys + the key
3420 * set to the field.
3421 *
3422 * @example
3423 * // AccessKeyedElement provides an 'accesskey' attribute to the
3424 * // ButtonWidget class
3425 * var button = new OO.ui.ButtonWidget( {
3426 * label: 'Button with Accesskey',
3427 * accessKey: 'k'
3428 * } );
3429 * $( 'body' ).append( button.$element );
3430 *
3431 * @abstract
3432 * @class
3433 *
3434 * @constructor
3435 * @param {Object} [config] Configuration options
3436 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3437 * If this config is omitted, the accesskey functionality is applied to $element, the
3438 * element created by the class.
3439 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3440 * this config is omitted, no accesskey will be added.
3441 */
3442 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3443 // Configuration initialization
3444 config = config || {};
3445
3446 // Properties
3447 this.$accessKeyed = null;
3448 this.accessKey = null;
3449
3450 // Initialization
3451 this.setAccessKey( config.accessKey || null );
3452 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3453
3454 // If this is also a TitledElement and it initialized before we did, we may have
3455 // to update the title with the access key
3456 if ( this.updateTitle ) {
3457 this.updateTitle();
3458 }
3459 };
3460
3461 /* Setup */
3462
3463 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3464
3465 /* Static Properties */
3466
3467 /**
3468 * The access key, a function that returns a key, or `null` for no accesskey.
3469 *
3470 * @static
3471 * @inheritable
3472 * @property {string|Function|null}
3473 */
3474 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3475
3476 /* Methods */
3477
3478 /**
3479 * Set the accesskeyed element.
3480 *
3481 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3482 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3483 *
3484 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3485 */
3486 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3487 if ( this.$accessKeyed ) {
3488 this.$accessKeyed.removeAttr( 'accesskey' );
3489 }
3490
3491 this.$accessKeyed = $accessKeyed;
3492 if ( this.accessKey ) {
3493 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3494 }
3495 };
3496
3497 /**
3498 * Set accesskey.
3499 *
3500 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3501 * @chainable
3502 */
3503 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3504 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3505
3506 if ( this.accessKey !== accessKey ) {
3507 if ( this.$accessKeyed ) {
3508 if ( accessKey !== null ) {
3509 this.$accessKeyed.attr( 'accesskey', accessKey );
3510 } else {
3511 this.$accessKeyed.removeAttr( 'accesskey' );
3512 }
3513 }
3514 this.accessKey = accessKey;
3515
3516 // Only if this is a TitledElement
3517 if ( this.updateTitle ) {
3518 this.updateTitle();
3519 }
3520 }
3521
3522 return this;
3523 };
3524
3525 /**
3526 * Get accesskey.
3527 *
3528 * @return {string} accessKey string
3529 */
3530 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3531 return this.accessKey;
3532 };
3533
3534 /**
3535 * Add information about the access key to the element's tooltip label.
3536 * (This is only public for hacky usage in FieldLayout.)
3537 *
3538 * @param {string} title Tooltip label for `title` attribute
3539 * @return {string}
3540 */
3541 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3542 var accessKey;
3543
3544 if ( !this.$accessKeyed ) {
3545 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3546 return title;
3547 }
3548 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3549 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3550 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3551 } else {
3552 accessKey = this.getAccessKey();
3553 }
3554 if ( accessKey ) {
3555 title += ' [' + accessKey + ']';
3556 }
3557 return title;
3558 };
3559
3560 /**
3561 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3562 * feels, and functionality can be customized via the class’s configuration options
3563 * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information
3564 * and examples.
3565 *
3566 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches
3567 *
3568 * @example
3569 * // A button widget
3570 * var button = new OO.ui.ButtonWidget( {
3571 * label: 'Button with Icon',
3572 * icon: 'trash',
3573 * iconTitle: 'Remove'
3574 * } );
3575 * $( 'body' ).append( button.$element );
3576 *
3577 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3578 *
3579 * @class
3580 * @extends OO.ui.Widget
3581 * @mixins OO.ui.mixin.ButtonElement
3582 * @mixins OO.ui.mixin.IconElement
3583 * @mixins OO.ui.mixin.IndicatorElement
3584 * @mixins OO.ui.mixin.LabelElement
3585 * @mixins OO.ui.mixin.TitledElement
3586 * @mixins OO.ui.mixin.FlaggedElement
3587 * @mixins OO.ui.mixin.TabIndexedElement
3588 * @mixins OO.ui.mixin.AccessKeyedElement
3589 *
3590 * @constructor
3591 * @param {Object} [config] Configuration options
3592 * @cfg {boolean} [active=false] Whether button should be shown as active
3593 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3594 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3595 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3596 */
3597 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3598 // Configuration initialization
3599 config = config || {};
3600
3601 // Parent constructor
3602 OO.ui.ButtonWidget.parent.call( this, config );
3603
3604 // Mixin constructors
3605 OO.ui.mixin.ButtonElement.call( this, config );
3606 OO.ui.mixin.IconElement.call( this, config );
3607 OO.ui.mixin.IndicatorElement.call( this, config );
3608 OO.ui.mixin.LabelElement.call( this, config );
3609 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3610 OO.ui.mixin.FlaggedElement.call( this, config );
3611 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3612 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3613
3614 // Properties
3615 this.href = null;
3616 this.target = null;
3617 this.noFollow = false;
3618
3619 // Events
3620 this.connect( this, { disable: 'onDisable' } );
3621
3622 // Initialization
3623 this.$button.append( this.$icon, this.$label, this.$indicator );
3624 this.$element
3625 .addClass( 'oo-ui-buttonWidget' )
3626 .append( this.$button );
3627 this.setActive( config.active );
3628 this.setHref( config.href );
3629 this.setTarget( config.target );
3630 this.setNoFollow( config.noFollow );
3631 };
3632
3633 /* Setup */
3634
3635 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3636 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3637 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3638 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3639 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3640 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3641 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3642 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3643 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3644
3645 /* Static Properties */
3646
3647 /**
3648 * @static
3649 * @inheritdoc
3650 */
3651 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3652
3653 /**
3654 * @static
3655 * @inheritdoc
3656 */
3657 OO.ui.ButtonWidget.static.tagName = 'span';
3658
3659 /* Methods */
3660
3661 /**
3662 * Get hyperlink location.
3663 *
3664 * @return {string} Hyperlink location
3665 */
3666 OO.ui.ButtonWidget.prototype.getHref = function () {
3667 return this.href;
3668 };
3669
3670 /**
3671 * Get hyperlink target.
3672 *
3673 * @return {string} Hyperlink target
3674 */
3675 OO.ui.ButtonWidget.prototype.getTarget = function () {
3676 return this.target;
3677 };
3678
3679 /**
3680 * Get search engine traversal hint.
3681 *
3682 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3683 */
3684 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3685 return this.noFollow;
3686 };
3687
3688 /**
3689 * Set hyperlink location.
3690 *
3691 * @param {string|null} href Hyperlink location, null to remove
3692 */
3693 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3694 href = typeof href === 'string' ? href : null;
3695 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3696 href = './' + href;
3697 }
3698
3699 if ( href !== this.href ) {
3700 this.href = href;
3701 this.updateHref();
3702 }
3703
3704 return this;
3705 };
3706
3707 /**
3708 * Update the `href` attribute, in case of changes to href or
3709 * disabled state.
3710 *
3711 * @private
3712 * @chainable
3713 */
3714 OO.ui.ButtonWidget.prototype.updateHref = function () {
3715 if ( this.href !== null && !this.isDisabled() ) {
3716 this.$button.attr( 'href', this.href );
3717 } else {
3718 this.$button.removeAttr( 'href' );
3719 }
3720
3721 return this;
3722 };
3723
3724 /**
3725 * Handle disable events.
3726 *
3727 * @private
3728 * @param {boolean} disabled Element is disabled
3729 */
3730 OO.ui.ButtonWidget.prototype.onDisable = function () {
3731 this.updateHref();
3732 };
3733
3734 /**
3735 * Set hyperlink target.
3736 *
3737 * @param {string|null} target Hyperlink target, null to remove
3738 */
3739 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3740 target = typeof target === 'string' ? target : null;
3741
3742 if ( target !== this.target ) {
3743 this.target = target;
3744 if ( target !== null ) {
3745 this.$button.attr( 'target', target );
3746 } else {
3747 this.$button.removeAttr( 'target' );
3748 }
3749 }
3750
3751 return this;
3752 };
3753
3754 /**
3755 * Set search engine traversal hint.
3756 *
3757 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3758 */
3759 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3760 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3761
3762 if ( noFollow !== this.noFollow ) {
3763 this.noFollow = noFollow;
3764 if ( noFollow ) {
3765 this.$button.attr( 'rel', 'nofollow' );
3766 } else {
3767 this.$button.removeAttr( 'rel' );
3768 }
3769 }
3770
3771 return this;
3772 };
3773
3774 // Override method visibility hints from ButtonElement
3775 /**
3776 * @method setActive
3777 * @inheritdoc
3778 */
3779 /**
3780 * @method isActive
3781 * @inheritdoc
3782 */
3783
3784 /**
3785 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3786 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3787 * removed, and cleared from the group.
3788 *
3789 * @example
3790 * // Example: A ButtonGroupWidget with two buttons
3791 * var button1 = new OO.ui.PopupButtonWidget( {
3792 * label: 'Select a category',
3793 * icon: 'menu',
3794 * popup: {
3795 * $content: $( '<p>List of categories...</p>' ),
3796 * padded: true,
3797 * align: 'left'
3798 * }
3799 * } );
3800 * var button2 = new OO.ui.ButtonWidget( {
3801 * label: 'Add item'
3802 * });
3803 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3804 * items: [button1, button2]
3805 * } );
3806 * $( 'body' ).append( buttonGroup.$element );
3807 *
3808 * @class
3809 * @extends OO.ui.Widget
3810 * @mixins OO.ui.mixin.GroupElement
3811 *
3812 * @constructor
3813 * @param {Object} [config] Configuration options
3814 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3815 */
3816 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3817 // Configuration initialization
3818 config = config || {};
3819
3820 // Parent constructor
3821 OO.ui.ButtonGroupWidget.parent.call( this, config );
3822
3823 // Mixin constructors
3824 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3825
3826 // Initialization
3827 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3828 if ( Array.isArray( config.items ) ) {
3829 this.addItems( config.items );
3830 }
3831 };
3832
3833 /* Setup */
3834
3835 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3836 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3837
3838 /* Static Properties */
3839
3840 /**
3841 * @static
3842 * @inheritdoc
3843 */
3844 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3845
3846 /* Methods */
3847
3848 /**
3849 * Focus the widget
3850 *
3851 * @chainable
3852 */
3853 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3854 if ( !this.isDisabled() ) {
3855 if ( this.items[ 0 ] ) {
3856 this.items[ 0 ].focus();
3857 }
3858 }
3859 return this;
3860 };
3861
3862 /**
3863 * @inheritdoc
3864 */
3865 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3866 this.focus();
3867 };
3868
3869 /**
3870 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3871 * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
3872 * for a list of icons included in the library.
3873 *
3874 * @example
3875 * // An icon widget with a label
3876 * var myIcon = new OO.ui.IconWidget( {
3877 * icon: 'help',
3878 * iconTitle: 'Help'
3879 * } );
3880 * // Create a label.
3881 * var iconLabel = new OO.ui.LabelWidget( {
3882 * label: 'Help'
3883 * } );
3884 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3885 *
3886 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
3887 *
3888 * @class
3889 * @extends OO.ui.Widget
3890 * @mixins OO.ui.mixin.IconElement
3891 * @mixins OO.ui.mixin.TitledElement
3892 * @mixins OO.ui.mixin.FlaggedElement
3893 *
3894 * @constructor
3895 * @param {Object} [config] Configuration options
3896 */
3897 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3898 // Configuration initialization
3899 config = config || {};
3900
3901 // Parent constructor
3902 OO.ui.IconWidget.parent.call( this, config );
3903
3904 // Mixin constructors
3905 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3906 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3907 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3908
3909 // Initialization
3910 this.$element.addClass( 'oo-ui-iconWidget' );
3911 };
3912
3913 /* Setup */
3914
3915 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3916 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3917 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3918 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3919
3920 /* Static Properties */
3921
3922 /**
3923 * @static
3924 * @inheritdoc
3925 */
3926 OO.ui.IconWidget.static.tagName = 'span';
3927
3928 /**
3929 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3930 * attention to the status of an item or to clarify the function of a control. For a list of
3931 * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
3932 *
3933 * @example
3934 * // Example of an indicator widget
3935 * var indicator1 = new OO.ui.IndicatorWidget( {
3936 * indicator: 'alert'
3937 * } );
3938 *
3939 * // Create a fieldset layout to add a label
3940 * var fieldset = new OO.ui.FieldsetLayout();
3941 * fieldset.addItems( [
3942 * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
3943 * ] );
3944 * $( 'body' ).append( fieldset.$element );
3945 *
3946 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3947 *
3948 * @class
3949 * @extends OO.ui.Widget
3950 * @mixins OO.ui.mixin.IndicatorElement
3951 * @mixins OO.ui.mixin.TitledElement
3952 *
3953 * @constructor
3954 * @param {Object} [config] Configuration options
3955 */
3956 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
3957 // Configuration initialization
3958 config = config || {};
3959
3960 // Parent constructor
3961 OO.ui.IndicatorWidget.parent.call( this, config );
3962
3963 // Mixin constructors
3964 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
3965 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3966
3967 // Initialization
3968 this.$element.addClass( 'oo-ui-indicatorWidget' );
3969 };
3970
3971 /* Setup */
3972
3973 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
3974 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
3975 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
3976
3977 /* Static Properties */
3978
3979 /**
3980 * @static
3981 * @inheritdoc
3982 */
3983 OO.ui.IndicatorWidget.static.tagName = 'span';
3984
3985 /**
3986 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
3987 * be configured with a `label` option that is set to a string, a label node, or a function:
3988 *
3989 * - String: a plaintext string
3990 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
3991 * label that includes a link or special styling, such as a gray color or additional graphical elements.
3992 * - Function: a function that will produce a string in the future. Functions are used
3993 * in cases where the value of the label is not currently defined.
3994 *
3995 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
3996 * will come into focus when the label is clicked.
3997 *
3998 * @example
3999 * // Examples of LabelWidgets
4000 * var label1 = new OO.ui.LabelWidget( {
4001 * label: 'plaintext label'
4002 * } );
4003 * var label2 = new OO.ui.LabelWidget( {
4004 * label: $( '<a href="default.html">jQuery label</a>' )
4005 * } );
4006 * // Create a fieldset layout with fields for each example
4007 * var fieldset = new OO.ui.FieldsetLayout();
4008 * fieldset.addItems( [
4009 * new OO.ui.FieldLayout( label1 ),
4010 * new OO.ui.FieldLayout( label2 )
4011 * ] );
4012 * $( 'body' ).append( fieldset.$element );
4013 *
4014 * @class
4015 * @extends OO.ui.Widget
4016 * @mixins OO.ui.mixin.LabelElement
4017 * @mixins OO.ui.mixin.TitledElement
4018 *
4019 * @constructor
4020 * @param {Object} [config] Configuration options
4021 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4022 * Clicking the label will focus the specified input field.
4023 */
4024 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4025 // Configuration initialization
4026 config = config || {};
4027
4028 // Parent constructor
4029 OO.ui.LabelWidget.parent.call( this, config );
4030
4031 // Mixin constructors
4032 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4033 OO.ui.mixin.TitledElement.call( this, config );
4034
4035 // Properties
4036 this.input = config.input;
4037
4038 // Initialization
4039 if ( this.input ) {
4040 if ( this.input.getInputId() ) {
4041 this.$element.attr( 'for', this.input.getInputId() );
4042 } else {
4043 this.$label.on( 'click', function () {
4044 this.input.simulateLabelClick();
4045 return false;
4046 }.bind( this ) );
4047 }
4048 }
4049 this.$element.addClass( 'oo-ui-labelWidget' );
4050 };
4051
4052 /* Setup */
4053
4054 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4055 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4056 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4057
4058 /* Static Properties */
4059
4060 /**
4061 * @static
4062 * @inheritdoc
4063 */
4064 OO.ui.LabelWidget.static.tagName = 'label';
4065
4066 /**
4067 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4068 * and that they should wait before proceeding. The pending state is visually represented with a pending
4069 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4070 * field of a {@link OO.ui.TextInputWidget text input widget}.
4071 *
4072 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4073 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4074 * in process dialogs.
4075 *
4076 * @example
4077 * function MessageDialog( config ) {
4078 * MessageDialog.parent.call( this, config );
4079 * }
4080 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4081 *
4082 * MessageDialog.static.name = 'myMessageDialog';
4083 * MessageDialog.static.actions = [
4084 * { action: 'save', label: 'Done', flags: 'primary' },
4085 * { label: 'Cancel', flags: 'safe' }
4086 * ];
4087 *
4088 * MessageDialog.prototype.initialize = function () {
4089 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4090 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4091 * 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>' );
4092 * this.$body.append( this.content.$element );
4093 * };
4094 * MessageDialog.prototype.getBodyHeight = function () {
4095 * return 100;
4096 * }
4097 * MessageDialog.prototype.getActionProcess = function ( action ) {
4098 * var dialog = this;
4099 * if ( action === 'save' ) {
4100 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4101 * return new OO.ui.Process()
4102 * .next( 1000 )
4103 * .next( function () {
4104 * dialog.getActions().get({actions: 'save'})[0].popPending();
4105 * } );
4106 * }
4107 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4108 * };
4109 *
4110 * var windowManager = new OO.ui.WindowManager();
4111 * $( 'body' ).append( windowManager.$element );
4112 *
4113 * var dialog = new MessageDialog();
4114 * windowManager.addWindows( [ dialog ] );
4115 * windowManager.openWindow( dialog );
4116 *
4117 * @abstract
4118 * @class
4119 *
4120 * @constructor
4121 * @param {Object} [config] Configuration options
4122 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4123 */
4124 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4125 // Configuration initialization
4126 config = config || {};
4127
4128 // Properties
4129 this.pending = 0;
4130 this.$pending = null;
4131
4132 // Initialisation
4133 this.setPendingElement( config.$pending || this.$element );
4134 };
4135
4136 /* Setup */
4137
4138 OO.initClass( OO.ui.mixin.PendingElement );
4139
4140 /* Methods */
4141
4142 /**
4143 * Set the pending element (and clean up any existing one).
4144 *
4145 * @param {jQuery} $pending The element to set to pending.
4146 */
4147 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4148 if ( this.$pending ) {
4149 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4150 }
4151
4152 this.$pending = $pending;
4153 if ( this.pending > 0 ) {
4154 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4155 }
4156 };
4157
4158 /**
4159 * Check if an element is pending.
4160 *
4161 * @return {boolean} Element is pending
4162 */
4163 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4164 return !!this.pending;
4165 };
4166
4167 /**
4168 * Increase the pending counter. The pending state will remain active until the counter is zero
4169 * (i.e., the number of calls to #pushPending and #popPending is the same).
4170 *
4171 * @chainable
4172 */
4173 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4174 if ( this.pending === 0 ) {
4175 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4176 this.updateThemeClasses();
4177 }
4178 this.pending++;
4179
4180 return this;
4181 };
4182
4183 /**
4184 * Decrease the pending counter. The pending state will remain active until the counter is zero
4185 * (i.e., the number of calls to #pushPending and #popPending is the same).
4186 *
4187 * @chainable
4188 */
4189 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4190 if ( this.pending === 1 ) {
4191 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4192 this.updateThemeClasses();
4193 }
4194 this.pending = Math.max( 0, this.pending - 1 );
4195
4196 return this;
4197 };
4198
4199 /**
4200 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4201 * in the document (for example, in an OO.ui.Window's $overlay).
4202 *
4203 * The elements's position is automatically calculated and maintained when window is resized or the
4204 * page is scrolled. If you reposition the container manually, you have to call #position to make
4205 * sure the element is still placed correctly.
4206 *
4207 * As positioning is only possible when both the element and the container are attached to the DOM
4208 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4209 * the #toggle method to display a floating popup, for example.
4210 *
4211 * @abstract
4212 * @class
4213 *
4214 * @constructor
4215 * @param {Object} [config] Configuration options
4216 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4217 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4218 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4219 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4220 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4221 * 'top': Align the top edge with $floatableContainer's top edge
4222 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4223 * 'center': Vertically align the center with $floatableContainer's center
4224 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4225 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4226 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4227 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4228 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4229 * 'center': Horizontally align the center with $floatableContainer's center
4230 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4231 * is out of view
4232 */
4233 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4234 // Configuration initialization
4235 config = config || {};
4236
4237 // Properties
4238 this.$floatable = null;
4239 this.$floatableContainer = null;
4240 this.$floatableWindow = null;
4241 this.$floatableClosestScrollable = null;
4242 this.onFloatableScrollHandler = this.position.bind( this );
4243 this.onFloatableWindowResizeHandler = this.position.bind( this );
4244
4245 // Initialization
4246 this.setFloatableContainer( config.$floatableContainer );
4247 this.setFloatableElement( config.$floatable || this.$element );
4248 this.setVerticalPosition( config.verticalPosition || 'below' );
4249 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4250 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4251 };
4252
4253 /* Methods */
4254
4255 /**
4256 * Set floatable element.
4257 *
4258 * If an element is already set, it will be cleaned up before setting up the new element.
4259 *
4260 * @param {jQuery} $floatable Element to make floatable
4261 */
4262 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4263 if ( this.$floatable ) {
4264 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4265 this.$floatable.css( { left: '', top: '' } );
4266 }
4267
4268 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4269 this.position();
4270 };
4271
4272 /**
4273 * Set floatable container.
4274 *
4275 * The element will be positioned relative to the specified container.
4276 *
4277 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4278 */
4279 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4280 this.$floatableContainer = $floatableContainer;
4281 if ( this.$floatable ) {
4282 this.position();
4283 }
4284 };
4285
4286 /**
4287 * Change how the element is positioned vertically.
4288 *
4289 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4290 */
4291 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4292 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4293 throw new Error( 'Invalid value for vertical position: ' + position );
4294 }
4295 if ( this.verticalPosition !== position ) {
4296 this.verticalPosition = position;
4297 if ( this.$floatable ) {
4298 this.position();
4299 }
4300 }
4301 };
4302
4303 /**
4304 * Change how the element is positioned horizontally.
4305 *
4306 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4307 */
4308 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4309 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4310 throw new Error( 'Invalid value for horizontal position: ' + position );
4311 }
4312 if ( this.horizontalPosition !== position ) {
4313 this.horizontalPosition = position;
4314 if ( this.$floatable ) {
4315 this.position();
4316 }
4317 }
4318 };
4319
4320 /**
4321 * Toggle positioning.
4322 *
4323 * Do not turn positioning on until after the element is attached to the DOM and visible.
4324 *
4325 * @param {boolean} [positioning] Enable positioning, omit to toggle
4326 * @chainable
4327 */
4328 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4329 var closestScrollableOfContainer;
4330
4331 if ( !this.$floatable || !this.$floatableContainer ) {
4332 return this;
4333 }
4334
4335 positioning = positioning === undefined ? !this.positioning : !!positioning;
4336
4337 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4338 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4339 this.warnedUnattached = true;
4340 }
4341
4342 if ( this.positioning !== positioning ) {
4343 this.positioning = positioning;
4344
4345 this.needsCustomPosition =
4346 this.verticalPostion !== 'below' ||
4347 this.horizontalPosition !== 'start' ||
4348 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4349
4350 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4351 // If the scrollable is the root, we have to listen to scroll events
4352 // on the window because of browser inconsistencies.
4353 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4354 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4355 }
4356
4357 if ( positioning ) {
4358 this.$floatableWindow = $( this.getElementWindow() );
4359 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4360
4361 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4362 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4363
4364 // Initial position after visible
4365 this.position();
4366 } else {
4367 if ( this.$floatableWindow ) {
4368 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4369 this.$floatableWindow = null;
4370 }
4371
4372 if ( this.$floatableClosestScrollable ) {
4373 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4374 this.$floatableClosestScrollable = null;
4375 }
4376
4377 this.$floatable.css( { left: '', right: '', top: '' } );
4378 }
4379 }
4380
4381 return this;
4382 };
4383
4384 /**
4385 * Check whether the bottom edge of the given element is within the viewport of the given container.
4386 *
4387 * @private
4388 * @param {jQuery} $element
4389 * @param {jQuery} $container
4390 * @return {boolean}
4391 */
4392 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4393 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4394 startEdgeInBounds, endEdgeInBounds,
4395 direction = $element.css( 'direction' );
4396
4397 elemRect = $element[ 0 ].getBoundingClientRect();
4398 if ( $container[ 0 ] === window ) {
4399 contRect = {
4400 top: 0,
4401 left: 0,
4402 right: document.documentElement.clientWidth,
4403 bottom: document.documentElement.clientHeight
4404 };
4405 } else {
4406 contRect = $container[ 0 ].getBoundingClientRect();
4407 }
4408
4409 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4410 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4411 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4412 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4413 if ( direction === 'rtl' ) {
4414 startEdgeInBounds = rightEdgeInBounds;
4415 endEdgeInBounds = leftEdgeInBounds;
4416 } else {
4417 startEdgeInBounds = leftEdgeInBounds;
4418 endEdgeInBounds = rightEdgeInBounds;
4419 }
4420
4421 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4422 return false;
4423 }
4424 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4425 return false;
4426 }
4427 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4428 return false;
4429 }
4430 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4431 return false;
4432 }
4433
4434 // The other positioning values are all about being inside the container,
4435 // so in those cases all we care about is that any part of the container is visible.
4436 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4437 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4438 };
4439
4440 /**
4441 * Position the floatable below its container.
4442 *
4443 * This should only be done when both of them are attached to the DOM and visible.
4444 *
4445 * @chainable
4446 */
4447 OO.ui.mixin.FloatableElement.prototype.position = function () {
4448 if ( !this.positioning ) {
4449 return this;
4450 }
4451
4452 if ( !(
4453 // To continue, some things need to be true:
4454 // The element must actually be in the DOM
4455 this.isElementAttached() && (
4456 // The closest scrollable is the current window
4457 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4458 // OR is an element in the element's DOM
4459 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4460 )
4461 ) ) {
4462 // Abort early if important parts of the widget are no longer attached to the DOM
4463 return this;
4464 }
4465
4466 if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
4467 this.$floatable.addClass( 'oo-ui-element-hidden' );
4468 return this;
4469 } else {
4470 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4471 }
4472
4473 if ( !this.needsCustomPosition ) {
4474 return this;
4475 }
4476
4477 this.$floatable.css( this.computePosition() );
4478
4479 // We updated the position, so re-evaluate the clipping state.
4480 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4481 // will not notice the need to update itself.)
4482 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4483 // it not listen to the right events in the right places?
4484 if ( this.clip ) {
4485 this.clip();
4486 }
4487
4488 return this;
4489 };
4490
4491 /**
4492 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4493 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4494 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4495 *
4496 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4497 */
4498 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4499 var isBody, scrollableX, scrollableY, containerPos,
4500 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4501 newPos = { top: '', left: '', bottom: '', right: '' },
4502 direction = this.$floatableContainer.css( 'direction' ),
4503 $offsetParent = this.$floatable.offsetParent();
4504
4505 if ( $offsetParent.is( 'html' ) ) {
4506 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4507 // <html> element, but they do work on the <body>
4508 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4509 }
4510 isBody = $offsetParent.is( 'body' );
4511 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4512 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4513
4514 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4515 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4516 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4517 // or if it isn't scrollable
4518 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4519 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4520
4521 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4522 // if the <body> has a margin
4523 containerPos = isBody ?
4524 this.$floatableContainer.offset() :
4525 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4526 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4527 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4528 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4529 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4530
4531 if ( this.verticalPosition === 'below' ) {
4532 newPos.top = containerPos.bottom;
4533 } else if ( this.verticalPosition === 'above' ) {
4534 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4535 } else if ( this.verticalPosition === 'top' ) {
4536 newPos.top = containerPos.top;
4537 } else if ( this.verticalPosition === 'bottom' ) {
4538 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4539 } else if ( this.verticalPosition === 'center' ) {
4540 newPos.top = containerPos.top +
4541 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4542 }
4543
4544 if ( this.horizontalPosition === 'before' ) {
4545 newPos.end = containerPos.start;
4546 } else if ( this.horizontalPosition === 'after' ) {
4547 newPos.start = containerPos.end;
4548 } else if ( this.horizontalPosition === 'start' ) {
4549 newPos.start = containerPos.start;
4550 } else if ( this.horizontalPosition === 'end' ) {
4551 newPos.end = containerPos.end;
4552 } else if ( this.horizontalPosition === 'center' ) {
4553 newPos.left = containerPos.left +
4554 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4555 }
4556
4557 if ( newPos.start !== undefined ) {
4558 if ( direction === 'rtl' ) {
4559 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4560 } else {
4561 newPos.left = newPos.start;
4562 }
4563 delete newPos.start;
4564 }
4565 if ( newPos.end !== undefined ) {
4566 if ( direction === 'rtl' ) {
4567 newPos.left = newPos.end;
4568 } else {
4569 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4570 }
4571 delete newPos.end;
4572 }
4573
4574 // Account for scroll position
4575 if ( newPos.top !== '' ) {
4576 newPos.top += scrollTop;
4577 }
4578 if ( newPos.bottom !== '' ) {
4579 newPos.bottom -= scrollTop;
4580 }
4581 if ( newPos.left !== '' ) {
4582 newPos.left += scrollLeft;
4583 }
4584 if ( newPos.right !== '' ) {
4585 newPos.right -= scrollLeft;
4586 }
4587
4588 // Account for scrollbar gutter
4589 if ( newPos.bottom !== '' ) {
4590 newPos.bottom -= horizScrollbarHeight;
4591 }
4592 if ( direction === 'rtl' ) {
4593 if ( newPos.left !== '' ) {
4594 newPos.left -= vertScrollbarWidth;
4595 }
4596 } else {
4597 if ( newPos.right !== '' ) {
4598 newPos.right -= vertScrollbarWidth;
4599 }
4600 }
4601
4602 return newPos;
4603 };
4604
4605 /**
4606 * Element that can be automatically clipped to visible boundaries.
4607 *
4608 * Whenever the element's natural height changes, you have to call
4609 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4610 * clipping correctly.
4611 *
4612 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4613 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4614 * then #$clippable will be given a fixed reduced height and/or width and will be made
4615 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4616 * but you can build a static footer by setting #$clippableContainer to an element that contains
4617 * #$clippable and the footer.
4618 *
4619 * @abstract
4620 * @class
4621 *
4622 * @constructor
4623 * @param {Object} [config] Configuration options
4624 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4625 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4626 * omit to use #$clippable
4627 */
4628 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4629 // Configuration initialization
4630 config = config || {};
4631
4632 // Properties
4633 this.$clippable = null;
4634 this.$clippableContainer = null;
4635 this.clipping = false;
4636 this.clippedHorizontally = false;
4637 this.clippedVertically = false;
4638 this.$clippableScrollableContainer = null;
4639 this.$clippableScroller = null;
4640 this.$clippableWindow = null;
4641 this.idealWidth = null;
4642 this.idealHeight = null;
4643 this.onClippableScrollHandler = this.clip.bind( this );
4644 this.onClippableWindowResizeHandler = this.clip.bind( this );
4645
4646 // Initialization
4647 if ( config.$clippableContainer ) {
4648 this.setClippableContainer( config.$clippableContainer );
4649 }
4650 this.setClippableElement( config.$clippable || this.$element );
4651 };
4652
4653 /* Methods */
4654
4655 /**
4656 * Set clippable element.
4657 *
4658 * If an element is already set, it will be cleaned up before setting up the new element.
4659 *
4660 * @param {jQuery} $clippable Element to make clippable
4661 */
4662 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4663 if ( this.$clippable ) {
4664 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4665 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4666 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4667 }
4668
4669 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4670 this.clip();
4671 };
4672
4673 /**
4674 * Set clippable container.
4675 *
4676 * This is the container that will be measured when deciding whether to clip. When clipping,
4677 * #$clippable will be resized in order to keep the clippable container fully visible.
4678 *
4679 * If the clippable container is unset, #$clippable will be used.
4680 *
4681 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4682 */
4683 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4684 this.$clippableContainer = $clippableContainer;
4685 if ( this.$clippable ) {
4686 this.clip();
4687 }
4688 };
4689
4690 /**
4691 * Toggle clipping.
4692 *
4693 * Do not turn clipping on until after the element is attached to the DOM and visible.
4694 *
4695 * @param {boolean} [clipping] Enable clipping, omit to toggle
4696 * @chainable
4697 */
4698 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4699 clipping = clipping === undefined ? !this.clipping : !!clipping;
4700
4701 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4702 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4703 this.warnedUnattached = true;
4704 }
4705
4706 if ( this.clipping !== clipping ) {
4707 this.clipping = clipping;
4708 if ( clipping ) {
4709 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4710 // If the clippable container is the root, we have to listen to scroll events and check
4711 // jQuery.scrollTop on the window because of browser inconsistencies
4712 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4713 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4714 this.$clippableScrollableContainer;
4715 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4716 this.$clippableWindow = $( this.getElementWindow() )
4717 .on( 'resize', this.onClippableWindowResizeHandler );
4718 // Initial clip after visible
4719 this.clip();
4720 } else {
4721 this.$clippable.css( {
4722 width: '',
4723 height: '',
4724 maxWidth: '',
4725 maxHeight: '',
4726 overflowX: '',
4727 overflowY: ''
4728 } );
4729 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4730
4731 this.$clippableScrollableContainer = null;
4732 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4733 this.$clippableScroller = null;
4734 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4735 this.$clippableWindow = null;
4736 }
4737 }
4738
4739 return this;
4740 };
4741
4742 /**
4743 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4744 *
4745 * @return {boolean} Element will be clipped to the visible area
4746 */
4747 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4748 return this.clipping;
4749 };
4750
4751 /**
4752 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4753 *
4754 * @return {boolean} Part of the element is being clipped
4755 */
4756 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4757 return this.clippedHorizontally || this.clippedVertically;
4758 };
4759
4760 /**
4761 * Check if the right of the element is being clipped by the nearest scrollable container.
4762 *
4763 * @return {boolean} Part of the element is being clipped
4764 */
4765 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4766 return this.clippedHorizontally;
4767 };
4768
4769 /**
4770 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4771 *
4772 * @return {boolean} Part of the element is being clipped
4773 */
4774 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4775 return this.clippedVertically;
4776 };
4777
4778 /**
4779 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4780 *
4781 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4782 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4783 */
4784 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4785 this.idealWidth = width;
4786 this.idealHeight = height;
4787
4788 if ( !this.clipping ) {
4789 // Update dimensions
4790 this.$clippable.css( { width: width, height: height } );
4791 }
4792 // While clipping, idealWidth and idealHeight are not considered
4793 };
4794
4795 /**
4796 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4797 * when the element's natural height changes.
4798 *
4799 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4800 * overlapped by, the visible area of the nearest scrollable container.
4801 *
4802 * Because calling clip() when the natural height changes isn't always possible, we also set
4803 * max-height when the element isn't being clipped. This means that if the element tries to grow
4804 * beyond the edge, something reasonable will happen before clip() is called.
4805 *
4806 * @chainable
4807 */
4808 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4809 var $container, extraHeight, extraWidth, ccOffset,
4810 $scrollableContainer, scOffset, scHeight, scWidth,
4811 ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
4812 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4813 naturalWidth, naturalHeight, clipWidth, clipHeight,
4814 buffer = 7; // Chosen by fair dice roll
4815
4816 if ( !this.clipping ) {
4817 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4818 return this;
4819 }
4820
4821 $container = this.$clippableContainer || this.$clippable;
4822 extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
4823 extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
4824 ccOffset = $container.offset();
4825 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4826 $scrollableContainer = this.$clippableWindow;
4827 scOffset = { top: 0, left: 0 };
4828 } else {
4829 $scrollableContainer = this.$clippableScrollableContainer;
4830 scOffset = $scrollableContainer.offset();
4831 }
4832 scHeight = $scrollableContainer.innerHeight() - buffer;
4833 scWidth = $scrollableContainer.innerWidth() - buffer;
4834 ccWidth = $container.outerWidth() + buffer;
4835 scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
4836 scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
4837 scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
4838 desiredWidth = ccOffset.left < 0 ?
4839 ccWidth + ccOffset.left :
4840 ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
4841 desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
4842 // It should never be desirable to exceed the dimensions of the browser viewport... right?
4843 desiredWidth = Math.min( desiredWidth, document.documentElement.clientWidth );
4844 desiredHeight = Math.min( desiredHeight, document.documentElement.clientHeight );
4845 allotedWidth = Math.ceil( desiredWidth - extraWidth );
4846 allotedHeight = Math.ceil( desiredHeight - extraHeight );
4847 naturalWidth = this.$clippable.prop( 'scrollWidth' );
4848 naturalHeight = this.$clippable.prop( 'scrollHeight' );
4849 clipWidth = allotedWidth < naturalWidth;
4850 clipHeight = allotedHeight < naturalHeight;
4851
4852 if ( clipWidth ) {
4853 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4854 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4855 this.$clippable.css( 'overflowX', 'scroll' );
4856 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4857 this.$clippable.css( {
4858 width: Math.max( 0, allotedWidth ),
4859 maxWidth: ''
4860 } );
4861 } else {
4862 this.$clippable.css( {
4863 overflowX: '',
4864 width: this.idealWidth || '',
4865 maxWidth: Math.max( 0, allotedWidth )
4866 } );
4867 }
4868 if ( clipHeight ) {
4869 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4870 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4871 this.$clippable.css( 'overflowY', 'scroll' );
4872 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4873 this.$clippable.css( {
4874 height: Math.max( 0, allotedHeight ),
4875 maxHeight: ''
4876 } );
4877 } else {
4878 this.$clippable.css( {
4879 overflowY: '',
4880 height: this.idealHeight || '',
4881 maxHeight: Math.max( 0, allotedHeight )
4882 } );
4883 }
4884
4885 // If we stopped clipping in at least one of the dimensions
4886 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
4887 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4888 }
4889
4890 this.clippedHorizontally = clipWidth;
4891 this.clippedVertically = clipHeight;
4892
4893 return this;
4894 };
4895
4896 /**
4897 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
4898 * By default, each popup has an anchor that points toward its origin.
4899 * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
4900 *
4901 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
4902 *
4903 * @example
4904 * // A popup widget.
4905 * var popup = new OO.ui.PopupWidget( {
4906 * $content: $( '<p>Hi there!</p>' ),
4907 * padded: true,
4908 * width: 300
4909 * } );
4910 *
4911 * $( 'body' ).append( popup.$element );
4912 * // To display the popup, toggle the visibility to 'true'.
4913 * popup.toggle( true );
4914 *
4915 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
4916 *
4917 * @class
4918 * @extends OO.ui.Widget
4919 * @mixins OO.ui.mixin.LabelElement
4920 * @mixins OO.ui.mixin.ClippableElement
4921 * @mixins OO.ui.mixin.FloatableElement
4922 *
4923 * @constructor
4924 * @param {Object} [config] Configuration options
4925 * @cfg {number} [width=320] Width of popup in pixels
4926 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
4927 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
4928 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
4929 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
4930 * of $floatableContainer
4931 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
4932 * of $floatableContainer
4933 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
4934 * endwards (right/left) to the vertical center of $floatableContainer
4935 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
4936 * startwards (left/right) to the vertical center of $floatableContainer
4937 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
4938 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
4939 * as possible while still keeping the anchor within the popup;
4940 * if position is before/after, move the popup as far downwards as possible.
4941 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
4942 * as possible while still keeping the anchor within the popup;
4943 * if position in before/after, move the popup as far upwards as possible.
4944 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
4945 * of the popup with the center of $floatableContainer.
4946 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
4947 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
4948 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
4949 * See the [OOjs UI docs on MediaWiki][3] for an example.
4950 * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
4951 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
4952 * @cfg {jQuery} [$content] Content to append to the popup's body
4953 * @cfg {jQuery} [$footer] Content to append to the popup's footer
4954 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
4955 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
4956 * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
4957 * for an example.
4958 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
4959 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
4960 * button.
4961 * @cfg {boolean} [padded=false] Add padding to the popup's body
4962 */
4963 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
4964 // Configuration initialization
4965 config = config || {};
4966
4967 // Parent constructor
4968 OO.ui.PopupWidget.parent.call( this, config );
4969
4970 // Properties (must be set before ClippableElement constructor call)
4971 this.$body = $( '<div>' );
4972 this.$popup = $( '<div>' );
4973
4974 // Mixin constructors
4975 OO.ui.mixin.LabelElement.call( this, config );
4976 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
4977 $clippable: this.$body,
4978 $clippableContainer: this.$popup
4979 } ) );
4980 OO.ui.mixin.FloatableElement.call( this, config );
4981
4982 // Properties
4983 this.$anchor = $( '<div>' );
4984 // If undefined, will be computed lazily in computePosition()
4985 this.$container = config.$container;
4986 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
4987 this.autoClose = !!config.autoClose;
4988 this.$autoCloseIgnore = config.$autoCloseIgnore;
4989 this.transitionTimeout = null;
4990 this.anchored = false;
4991 this.width = config.width !== undefined ? config.width : 320;
4992 this.height = config.height !== undefined ? config.height : null;
4993 this.onMouseDownHandler = this.onMouseDown.bind( this );
4994 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
4995
4996 // Initialization
4997 this.toggleAnchor( config.anchor === undefined || config.anchor );
4998 this.setAlignment( config.align || 'center' );
4999 this.setPosition( config.position || 'below' );
5000 this.$body.addClass( 'oo-ui-popupWidget-body' );
5001 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5002 this.$popup
5003 .addClass( 'oo-ui-popupWidget-popup' )
5004 .append( this.$body );
5005 this.$element
5006 .addClass( 'oo-ui-popupWidget' )
5007 .append( this.$popup, this.$anchor );
5008 // Move content, which was added to #$element by OO.ui.Widget, to the body
5009 // FIXME This is gross, we should use '$body' or something for the config
5010 if ( config.$content instanceof jQuery ) {
5011 this.$body.append( config.$content );
5012 }
5013
5014 if ( config.padded ) {
5015 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5016 }
5017
5018 if ( config.head ) {
5019 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5020 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5021 this.$head = $( '<div>' )
5022 .addClass( 'oo-ui-popupWidget-head' )
5023 .append( this.$label, this.closeButton.$element );
5024 this.$popup.prepend( this.$head );
5025 }
5026
5027 if ( config.$footer ) {
5028 this.$footer = $( '<div>' )
5029 .addClass( 'oo-ui-popupWidget-footer' )
5030 .append( config.$footer );
5031 this.$popup.append( this.$footer );
5032 }
5033
5034 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5035 // that reference properties not initialized at that time of parent class construction
5036 // TODO: Find a better way to handle post-constructor setup
5037 this.visible = false;
5038 this.$element.addClass( 'oo-ui-element-hidden' );
5039 };
5040
5041 /* Setup */
5042
5043 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5044 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5045 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5046 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5047
5048 /* Events */
5049
5050 /**
5051 * @event ready
5052 *
5053 * The popup is ready: it is visible and has been positioned and clipped.
5054 */
5055
5056 /* Methods */
5057
5058 /**
5059 * Handles mouse down events.
5060 *
5061 * @private
5062 * @param {MouseEvent} e Mouse down event
5063 */
5064 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5065 if (
5066 this.isVisible() &&
5067 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5068 ) {
5069 this.toggle( false );
5070 }
5071 };
5072
5073 /**
5074 * Bind mouse down listener.
5075 *
5076 * @private
5077 */
5078 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5079 // Capture clicks outside popup
5080 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5081 };
5082
5083 /**
5084 * Handles close button click events.
5085 *
5086 * @private
5087 */
5088 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5089 if ( this.isVisible() ) {
5090 this.toggle( false );
5091 }
5092 };
5093
5094 /**
5095 * Unbind mouse down listener.
5096 *
5097 * @private
5098 */
5099 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5100 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5101 };
5102
5103 /**
5104 * Handles key down events.
5105 *
5106 * @private
5107 * @param {KeyboardEvent} e Key down event
5108 */
5109 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5110 if (
5111 e.which === OO.ui.Keys.ESCAPE &&
5112 this.isVisible()
5113 ) {
5114 this.toggle( false );
5115 e.preventDefault();
5116 e.stopPropagation();
5117 }
5118 };
5119
5120 /**
5121 * Bind key down listener.
5122 *
5123 * @private
5124 */
5125 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5126 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5127 };
5128
5129 /**
5130 * Unbind key down listener.
5131 *
5132 * @private
5133 */
5134 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5135 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5136 };
5137
5138 /**
5139 * Show, hide, or toggle the visibility of the anchor.
5140 *
5141 * @param {boolean} [show] Show anchor, omit to toggle
5142 */
5143 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5144 show = show === undefined ? !this.anchored : !!show;
5145
5146 if ( this.anchored !== show ) {
5147 if ( show ) {
5148 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5149 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5150 } else {
5151 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5152 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5153 }
5154 this.anchored = show;
5155 }
5156 };
5157 /**
5158 * Change which edge the anchor appears on.
5159 *
5160 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5161 */
5162 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5163 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5164 throw new Error( 'Invalid value for edge: ' + edge );
5165 }
5166 if ( this.anchorEdge !== null ) {
5167 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5168 }
5169 this.anchorEdge = edge;
5170 if ( this.anchored ) {
5171 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5172 }
5173 };
5174
5175 /**
5176 * Check if the anchor is visible.
5177 *
5178 * @return {boolean} Anchor is visible
5179 */
5180 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5181 return this.anchored;
5182 };
5183
5184 /**
5185 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5186 * `.toggle( true )` after its #$element is attached to the DOM.
5187 *
5188 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5189 * it in the right place and with the right dimensions only work correctly while it is attached.
5190 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5191 * strictly enforced, so currently it only generates a warning in the browser console.
5192 *
5193 * @fires ready
5194 * @inheritdoc
5195 */
5196 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5197 var change;
5198 show = show === undefined ? !this.isVisible() : !!show;
5199
5200 change = show !== this.isVisible();
5201
5202 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5203 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5204 this.warnedUnattached = true;
5205 }
5206 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5207 // Fall back to the parent node if the floatableContainer is not set
5208 this.setFloatableContainer( this.$element.parent() );
5209 }
5210
5211 // Parent method
5212 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5213
5214 if ( change ) {
5215 this.togglePositioning( show && !!this.$floatableContainer );
5216
5217 if ( show ) {
5218 if ( this.autoClose ) {
5219 this.bindMouseDownListener();
5220 this.bindKeyDownListener();
5221 }
5222 this.updateDimensions();
5223 this.toggleClipping( true );
5224 this.emit( 'ready' );
5225 } else {
5226 this.toggleClipping( false );
5227 if ( this.autoClose ) {
5228 this.unbindMouseDownListener();
5229 this.unbindKeyDownListener();
5230 }
5231 }
5232 }
5233
5234 return this;
5235 };
5236
5237 /**
5238 * Set the size of the popup.
5239 *
5240 * Changing the size may also change the popup's position depending on the alignment.
5241 *
5242 * @param {number} width Width in pixels
5243 * @param {number} height Height in pixels
5244 * @param {boolean} [transition=false] Use a smooth transition
5245 * @chainable
5246 */
5247 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5248 this.width = width;
5249 this.height = height !== undefined ? height : null;
5250 if ( this.isVisible() ) {
5251 this.updateDimensions( transition );
5252 }
5253 };
5254
5255 /**
5256 * Update the size and position.
5257 *
5258 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5259 * be called automatically.
5260 *
5261 * @param {boolean} [transition=false] Use a smooth transition
5262 * @chainable
5263 */
5264 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5265 var widget = this;
5266
5267 // Prevent transition from being interrupted
5268 clearTimeout( this.transitionTimeout );
5269 if ( transition ) {
5270 // Enable transition
5271 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5272 }
5273
5274 this.position();
5275
5276 if ( transition ) {
5277 // Prevent transitioning after transition is complete
5278 this.transitionTimeout = setTimeout( function () {
5279 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5280 }, 200 );
5281 } else {
5282 // Prevent transitioning immediately
5283 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5284 }
5285 };
5286
5287 /**
5288 * @inheritdoc
5289 */
5290 OO.ui.PopupWidget.prototype.computePosition = function () {
5291 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5292 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5293 offsetParentPos, containerPos,
5294 popupPos = {},
5295 anchorCss = { left: '', right: '', top: '', bottom: '' },
5296 alignMap = {
5297 ltr: {
5298 'force-left': 'backwards',
5299 'force-right': 'forwards'
5300 },
5301 rtl: {
5302 'force-left': 'forwards',
5303 'force-right': 'backwards'
5304 }
5305 },
5306 anchorEdgeMap = {
5307 above: 'bottom',
5308 below: 'top',
5309 before: 'end',
5310 after: 'start'
5311 },
5312 hPosMap = {
5313 forwards: 'start',
5314 center: 'center',
5315 backwards: this.anchored ? 'before' : 'end'
5316 },
5317 vPosMap = {
5318 forwards: 'top',
5319 center: 'center',
5320 backwards: 'bottom'
5321 };
5322
5323 if ( !this.$container ) {
5324 // Lazy-initialize $container if not specified in constructor
5325 this.$container = $( this.getClosestScrollableElementContainer() );
5326 }
5327 direction = this.$container.css( 'direction' );
5328
5329 // Set height and width before we do anything else, since it might cause our measurements
5330 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5331 this.$popup.css( {
5332 width: this.width,
5333 height: this.height !== null ? this.height : 'auto'
5334 } );
5335
5336 align = alignMap[ direction ][ this.align ] || this.align;
5337 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5338 vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
5339 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5340 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5341 near = vertical ? 'top' : 'left';
5342 far = vertical ? 'bottom' : 'right';
5343 sizeProp = vertical ? 'Height' : 'Width';
5344 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5345
5346 this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
5347 this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
5348 this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
5349
5350 // Parent method
5351 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5352 // Find out which property FloatableElement used for positioning, and adjust that value
5353 positionProp = vertical ?
5354 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5355 ( parentPosition.left !== '' ? 'left' : 'right' );
5356
5357 // Figure out where the near and far edges of the popup and $floatableContainer are
5358 floatablePos = this.$floatableContainer.offset();
5359 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5360 // Measure where the offsetParent is and compute our position based on that and parentPosition
5361 offsetParentPos = this.$element.offsetParent().offset();
5362
5363 if ( positionProp === near ) {
5364 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5365 popupPos[ far ] = popupPos[ near ] + popupSize;
5366 } else {
5367 popupPos[ far ] = offsetParentPos[ near ] +
5368 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5369 popupPos[ near ] = popupPos[ far ] - popupSize;
5370 }
5371
5372 if ( this.anchored ) {
5373 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5374 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5375 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5376
5377 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5378 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5379 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5380 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5381 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5382 // Not enough space for the anchor on the start side; pull the popup startwards
5383 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5384 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5385 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5386 // Not enough space for the anchor on the end side; pull the popup endwards
5387 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5388 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5389 } else {
5390 positionAdjustment = 0;
5391 }
5392 } else {
5393 positionAdjustment = 0;
5394 }
5395
5396 // Check if the popup will go beyond the edge of this.$container
5397 containerPos = this.$container.offset();
5398 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5399 // Take into account how much the popup will move because of the adjustments we're going to make
5400 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5401 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5402 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5403 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5404 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5405 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5406 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5407 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5408 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5409 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5410 }
5411
5412 if ( this.anchored ) {
5413 // Adjust anchorOffset for positionAdjustment
5414 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5415
5416 // Position the anchor
5417 anchorCss[ start ] = anchorOffset;
5418 this.$anchor.css( anchorCss );
5419 }
5420
5421 // Move the popup if needed
5422 parentPosition[ positionProp ] += positionAdjustment;
5423
5424 return parentPosition;
5425 };
5426
5427 /**
5428 * Set popup alignment
5429 *
5430 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5431 * `backwards` or `forwards`.
5432 */
5433 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5434 // Validate alignment
5435 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5436 this.align = align;
5437 } else {
5438 this.align = 'center';
5439 }
5440 this.position();
5441 };
5442
5443 /**
5444 * Get popup alignment
5445 *
5446 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5447 * `backwards` or `forwards`.
5448 */
5449 OO.ui.PopupWidget.prototype.getAlignment = function () {
5450 return this.align;
5451 };
5452
5453 /**
5454 * Change the positioning of the popup.
5455 *
5456 * @param {string} position 'above', 'below', 'before' or 'after'
5457 */
5458 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5459 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5460 position = 'below';
5461 }
5462 this.popupPosition = position;
5463 this.position();
5464 };
5465
5466 /**
5467 * Get popup positioning.
5468 *
5469 * @return {string} 'above', 'below', 'before' or 'after'
5470 */
5471 OO.ui.PopupWidget.prototype.getPosition = function () {
5472 return this.popupPosition;
5473 };
5474
5475 /**
5476 * Get an ID of the body element, this can be used as the
5477 * `aria-describedby` attribute for an input field.
5478 *
5479 * @return {string} The ID of the body element
5480 */
5481 OO.ui.PopupWidget.prototype.getBodyId = function () {
5482 var id = this.$body.attr( 'id' );
5483 if ( id === undefined ) {
5484 id = OO.ui.generateElementId();
5485 this.$body.attr( 'id', id );
5486 }
5487 return id;
5488 };
5489
5490 /**
5491 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5492 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5493 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5494 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5495 *
5496 * @abstract
5497 * @class
5498 *
5499 * @constructor
5500 * @param {Object} [config] Configuration options
5501 * @cfg {Object} [popup] Configuration to pass to popup
5502 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5503 */
5504 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5505 // Configuration initialization
5506 config = config || {};
5507
5508 // Properties
5509 this.popup = new OO.ui.PopupWidget( $.extend(
5510 {
5511 autoClose: true,
5512 $floatableContainer: this.$element
5513 },
5514 config.popup,
5515 {
5516 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5517 }
5518 ) );
5519 };
5520
5521 /* Methods */
5522
5523 /**
5524 * Get popup.
5525 *
5526 * @return {OO.ui.PopupWidget} Popup widget
5527 */
5528 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5529 return this.popup;
5530 };
5531
5532 /**
5533 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5534 * which is used to display additional information or options.
5535 *
5536 * @example
5537 * // Example of a popup button.
5538 * var popupButton = new OO.ui.PopupButtonWidget( {
5539 * label: 'Popup button with options',
5540 * icon: 'menu',
5541 * popup: {
5542 * $content: $( '<p>Additional options here.</p>' ),
5543 * padded: true,
5544 * align: 'force-left'
5545 * }
5546 * } );
5547 * // Append the button to the DOM.
5548 * $( 'body' ).append( popupButton.$element );
5549 *
5550 * @class
5551 * @extends OO.ui.ButtonWidget
5552 * @mixins OO.ui.mixin.PopupElement
5553 *
5554 * @constructor
5555 * @param {Object} [config] Configuration options
5556 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5557 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5558 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5559 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
5560 */
5561 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5562 // Configuration initialization
5563 config = config || {};
5564
5565 // Parent constructor
5566 OO.ui.PopupButtonWidget.parent.call( this, config );
5567
5568 // Mixin constructors
5569 OO.ui.mixin.PopupElement.call( this, config );
5570
5571 // Properties
5572 this.$overlay = config.$overlay || this.$element;
5573
5574 // Events
5575 this.connect( this, { click: 'onAction' } );
5576
5577 // Initialization
5578 this.$element
5579 .addClass( 'oo-ui-popupButtonWidget' )
5580 .attr( 'aria-haspopup', 'true' );
5581 this.popup.$element
5582 .addClass( 'oo-ui-popupButtonWidget-popup' )
5583 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5584 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5585 this.$overlay.append( this.popup.$element );
5586 };
5587
5588 /* Setup */
5589
5590 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5591 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5592
5593 /* Methods */
5594
5595 /**
5596 * Handle the button action being triggered.
5597 *
5598 * @private
5599 */
5600 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5601 this.popup.toggle();
5602 };
5603
5604 /**
5605 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5606 *
5607 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5608 *
5609 * @private
5610 * @abstract
5611 * @class
5612 * @mixins OO.ui.mixin.GroupElement
5613 *
5614 * @constructor
5615 * @param {Object} [config] Configuration options
5616 */
5617 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5618 // Mixin constructors
5619 OO.ui.mixin.GroupElement.call( this, config );
5620 };
5621
5622 /* Setup */
5623
5624 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5625
5626 /* Methods */
5627
5628 /**
5629 * Set the disabled state of the widget.
5630 *
5631 * This will also update the disabled state of child widgets.
5632 *
5633 * @param {boolean} disabled Disable widget
5634 * @chainable
5635 */
5636 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5637 var i, len;
5638
5639 // Parent method
5640 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5641 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5642
5643 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5644 if ( this.items ) {
5645 for ( i = 0, len = this.items.length; i < len; i++ ) {
5646 this.items[ i ].updateDisabled();
5647 }
5648 }
5649
5650 return this;
5651 };
5652
5653 /**
5654 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5655 *
5656 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5657 * allows bidirectional communication.
5658 *
5659 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5660 *
5661 * @private
5662 * @abstract
5663 * @class
5664 *
5665 * @constructor
5666 */
5667 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5668 //
5669 };
5670
5671 /* Methods */
5672
5673 /**
5674 * Check if widget is disabled.
5675 *
5676 * Checks parent if present, making disabled state inheritable.
5677 *
5678 * @return {boolean} Widget is disabled
5679 */
5680 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5681 return this.disabled ||
5682 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5683 };
5684
5685 /**
5686 * Set group element is in.
5687 *
5688 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5689 * @chainable
5690 */
5691 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5692 // Parent method
5693 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5694 OO.ui.Element.prototype.setElementGroup.call( this, group );
5695
5696 // Initialize item disabled states
5697 this.updateDisabled();
5698
5699 return this;
5700 };
5701
5702 /**
5703 * OptionWidgets are special elements that can be selected and configured with data. The
5704 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5705 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5706 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
5707 *
5708 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5709 *
5710 * @class
5711 * @extends OO.ui.Widget
5712 * @mixins OO.ui.mixin.ItemWidget
5713 * @mixins OO.ui.mixin.LabelElement
5714 * @mixins OO.ui.mixin.FlaggedElement
5715 * @mixins OO.ui.mixin.AccessKeyedElement
5716 *
5717 * @constructor
5718 * @param {Object} [config] Configuration options
5719 */
5720 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5721 // Configuration initialization
5722 config = config || {};
5723
5724 // Parent constructor
5725 OO.ui.OptionWidget.parent.call( this, config );
5726
5727 // Mixin constructors
5728 OO.ui.mixin.ItemWidget.call( this );
5729 OO.ui.mixin.LabelElement.call( this, config );
5730 OO.ui.mixin.FlaggedElement.call( this, config );
5731 OO.ui.mixin.AccessKeyedElement.call( this, config );
5732
5733 // Properties
5734 this.selected = false;
5735 this.highlighted = false;
5736 this.pressed = false;
5737
5738 // Initialization
5739 this.$element
5740 .data( 'oo-ui-optionWidget', this )
5741 // Allow programmatic focussing (and by accesskey), but not tabbing
5742 .attr( 'tabindex', '-1' )
5743 .attr( 'role', 'option' )
5744 .attr( 'aria-selected', 'false' )
5745 .addClass( 'oo-ui-optionWidget' )
5746 .append( this.$label );
5747 };
5748
5749 /* Setup */
5750
5751 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
5752 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
5753 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
5754 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
5755 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
5756
5757 /* Static Properties */
5758
5759 /**
5760 * Whether this option can be selected. See #setSelected.
5761 *
5762 * @static
5763 * @inheritable
5764 * @property {boolean}
5765 */
5766 OO.ui.OptionWidget.static.selectable = true;
5767
5768 /**
5769 * Whether this option can be highlighted. See #setHighlighted.
5770 *
5771 * @static
5772 * @inheritable
5773 * @property {boolean}
5774 */
5775 OO.ui.OptionWidget.static.highlightable = true;
5776
5777 /**
5778 * Whether this option can be pressed. See #setPressed.
5779 *
5780 * @static
5781 * @inheritable
5782 * @property {boolean}
5783 */
5784 OO.ui.OptionWidget.static.pressable = true;
5785
5786 /**
5787 * Whether this option will be scrolled into view when it is selected.
5788 *
5789 * @static
5790 * @inheritable
5791 * @property {boolean}
5792 */
5793 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
5794
5795 /* Methods */
5796
5797 /**
5798 * Check if the option can be selected.
5799 *
5800 * @return {boolean} Item is selectable
5801 */
5802 OO.ui.OptionWidget.prototype.isSelectable = function () {
5803 return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
5804 };
5805
5806 /**
5807 * Check if the option can be highlighted. A highlight indicates that the option
5808 * may be selected when a user presses enter or clicks. Disabled items cannot
5809 * be highlighted.
5810 *
5811 * @return {boolean} Item is highlightable
5812 */
5813 OO.ui.OptionWidget.prototype.isHighlightable = function () {
5814 return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
5815 };
5816
5817 /**
5818 * Check if the option can be pressed. The pressed state occurs when a user mouses
5819 * down on an item, but has not yet let go of the mouse.
5820 *
5821 * @return {boolean} Item is pressable
5822 */
5823 OO.ui.OptionWidget.prototype.isPressable = function () {
5824 return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
5825 };
5826
5827 /**
5828 * Check if the option is selected.
5829 *
5830 * @return {boolean} Item is selected
5831 */
5832 OO.ui.OptionWidget.prototype.isSelected = function () {
5833 return this.selected;
5834 };
5835
5836 /**
5837 * Check if the option is highlighted. A highlight indicates that the
5838 * item may be selected when a user presses enter or clicks.
5839 *
5840 * @return {boolean} Item is highlighted
5841 */
5842 OO.ui.OptionWidget.prototype.isHighlighted = function () {
5843 return this.highlighted;
5844 };
5845
5846 /**
5847 * Check if the option is pressed. The pressed state occurs when a user mouses
5848 * down on an item, but has not yet let go of the mouse. The item may appear
5849 * selected, but it will not be selected until the user releases the mouse.
5850 *
5851 * @return {boolean} Item is pressed
5852 */
5853 OO.ui.OptionWidget.prototype.isPressed = function () {
5854 return this.pressed;
5855 };
5856
5857 /**
5858 * Set the option’s selected state. In general, all modifications to the selection
5859 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
5860 * method instead of this method.
5861 *
5862 * @param {boolean} [state=false] Select option
5863 * @chainable
5864 */
5865 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
5866 if ( this.constructor.static.selectable ) {
5867 this.selected = !!state;
5868 this.$element
5869 .toggleClass( 'oo-ui-optionWidget-selected', state )
5870 .attr( 'aria-selected', state.toString() );
5871 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
5872 this.scrollElementIntoView();
5873 }
5874 this.updateThemeClasses();
5875 }
5876 return this;
5877 };
5878
5879 /**
5880 * Set the option’s highlighted state. In general, all programmatic
5881 * modifications to the highlight should be handled by the
5882 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
5883 * method instead of this method.
5884 *
5885 * @param {boolean} [state=false] Highlight option
5886 * @chainable
5887 */
5888 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
5889 if ( this.constructor.static.highlightable ) {
5890 this.highlighted = !!state;
5891 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
5892 this.updateThemeClasses();
5893 }
5894 return this;
5895 };
5896
5897 /**
5898 * Set the option’s pressed state. In general, all
5899 * programmatic modifications to the pressed state should be handled by the
5900 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
5901 * method instead of this method.
5902 *
5903 * @param {boolean} [state=false] Press option
5904 * @chainable
5905 */
5906 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
5907 if ( this.constructor.static.pressable ) {
5908 this.pressed = !!state;
5909 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
5910 this.updateThemeClasses();
5911 }
5912 return this;
5913 };
5914
5915 /**
5916 * Get text to match search strings against.
5917 *
5918 * The default implementation returns the label text, but subclasses
5919 * can override this to provide more complex behavior.
5920 *
5921 * @return {string|boolean} String to match search string against
5922 */
5923 OO.ui.OptionWidget.prototype.getMatchText = function () {
5924 var label = this.getLabel();
5925 return typeof label === 'string' ? label : this.$label.text();
5926 };
5927
5928 /**
5929 * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
5930 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
5931 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
5932 * menu selects}.
5933 *
5934 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
5935 * information, please see the [OOjs UI documentation on MediaWiki][1].
5936 *
5937 * @example
5938 * // Example of a select widget with three options
5939 * var select = new OO.ui.SelectWidget( {
5940 * items: [
5941 * new OO.ui.OptionWidget( {
5942 * data: 'a',
5943 * label: 'Option One',
5944 * } ),
5945 * new OO.ui.OptionWidget( {
5946 * data: 'b',
5947 * label: 'Option Two',
5948 * } ),
5949 * new OO.ui.OptionWidget( {
5950 * data: 'c',
5951 * label: 'Option Three',
5952 * } )
5953 * ]
5954 * } );
5955 * $( 'body' ).append( select.$element );
5956 *
5957 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5958 *
5959 * @abstract
5960 * @class
5961 * @extends OO.ui.Widget
5962 * @mixins OO.ui.mixin.GroupWidget
5963 *
5964 * @constructor
5965 * @param {Object} [config] Configuration options
5966 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
5967 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
5968 * the [OOjs UI documentation on MediaWiki] [2] for examples.
5969 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5970 */
5971 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
5972 // Configuration initialization
5973 config = config || {};
5974
5975 // Parent constructor
5976 OO.ui.SelectWidget.parent.call( this, config );
5977
5978 // Mixin constructors
5979 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
5980
5981 // Properties
5982 this.pressed = false;
5983 this.selecting = null;
5984 this.onMouseUpHandler = this.onMouseUp.bind( this );
5985 this.onMouseMoveHandler = this.onMouseMove.bind( this );
5986 this.onKeyDownHandler = this.onKeyDown.bind( this );
5987 this.onKeyPressHandler = this.onKeyPress.bind( this );
5988 this.keyPressBuffer = '';
5989 this.keyPressBufferTimer = null;
5990 this.blockMouseOverEvents = 0;
5991
5992 // Events
5993 this.connect( this, {
5994 toggle: 'onToggle'
5995 } );
5996 this.$element.on( {
5997 focusin: this.onFocus.bind( this ),
5998 mousedown: this.onMouseDown.bind( this ),
5999 mouseover: this.onMouseOver.bind( this ),
6000 mouseleave: this.onMouseLeave.bind( this )
6001 } );
6002
6003 // Initialization
6004 this.$element
6005 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6006 .attr( 'role', 'listbox' );
6007 this.setFocusOwner( this.$element );
6008 if ( Array.isArray( config.items ) ) {
6009 this.addItems( config.items );
6010 }
6011 };
6012
6013 /* Setup */
6014
6015 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6016 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6017
6018 /* Events */
6019
6020 /**
6021 * @event highlight
6022 *
6023 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6024 *
6025 * @param {OO.ui.OptionWidget|null} item Highlighted item
6026 */
6027
6028 /**
6029 * @event press
6030 *
6031 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6032 * pressed state of an option.
6033 *
6034 * @param {OO.ui.OptionWidget|null} item Pressed item
6035 */
6036
6037 /**
6038 * @event select
6039 *
6040 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6041 *
6042 * @param {OO.ui.OptionWidget|null} item Selected item
6043 */
6044
6045 /**
6046 * @event choose
6047 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6048 * @param {OO.ui.OptionWidget} item Chosen item
6049 */
6050
6051 /**
6052 * @event add
6053 *
6054 * An `add` event is emitted when options are added to the select with the #addItems method.
6055 *
6056 * @param {OO.ui.OptionWidget[]} items Added items
6057 * @param {number} index Index of insertion point
6058 */
6059
6060 /**
6061 * @event remove
6062 *
6063 * A `remove` event is emitted when options are removed from the select with the #clearItems
6064 * or #removeItems methods.
6065 *
6066 * @param {OO.ui.OptionWidget[]} items Removed items
6067 */
6068
6069 /* Methods */
6070
6071 /**
6072 * Handle focus events
6073 *
6074 * @private
6075 * @param {jQuery.Event} event
6076 */
6077 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6078 var item;
6079 if ( event.target === this.$element[ 0 ] ) {
6080 // This widget was focussed, e.g. by the user tabbing to it.
6081 // The styles for focus state depend on one of the items being selected.
6082 if ( !this.getSelectedItem() ) {
6083 item = this.findFirstSelectableItem();
6084 }
6085 } else {
6086 if ( event.target.tabIndex === -1 ) {
6087 // One of the options got focussed (and the event bubbled up here).
6088 // They can't be tabbed to, but they can be activated using accesskeys.
6089 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6090 item = this.findTargetItem( event );
6091 } else {
6092 // There is something actually user-focusable in one of the labels of the options, and the
6093 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6094 return;
6095 }
6096 }
6097
6098 if ( item ) {
6099 if ( item.constructor.static.highlightable ) {
6100 this.highlightItem( item );
6101 } else {
6102 this.selectItem( item );
6103 }
6104 }
6105
6106 if ( event.target !== this.$element[ 0 ] ) {
6107 this.$focusOwner.focus();
6108 }
6109 };
6110
6111 /**
6112 * Handle mouse down events.
6113 *
6114 * @private
6115 * @param {jQuery.Event} e Mouse down event
6116 */
6117 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6118 var item;
6119
6120 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6121 this.togglePressed( true );
6122 item = this.findTargetItem( e );
6123 if ( item && item.isSelectable() ) {
6124 this.pressItem( item );
6125 this.selecting = item;
6126 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6127 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6128 }
6129 }
6130 return false;
6131 };
6132
6133 /**
6134 * Handle mouse up events.
6135 *
6136 * @private
6137 * @param {MouseEvent} e Mouse up event
6138 */
6139 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6140 var item;
6141
6142 this.togglePressed( false );
6143 if ( !this.selecting ) {
6144 item = this.findTargetItem( e );
6145 if ( item && item.isSelectable() ) {
6146 this.selecting = item;
6147 }
6148 }
6149 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6150 this.pressItem( null );
6151 this.chooseItem( this.selecting );
6152 this.selecting = null;
6153 }
6154
6155 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6156 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6157
6158 return false;
6159 };
6160
6161 /**
6162 * Handle mouse move events.
6163 *
6164 * @private
6165 * @param {MouseEvent} e Mouse move event
6166 */
6167 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6168 var item;
6169
6170 if ( !this.isDisabled() && this.pressed ) {
6171 item = this.findTargetItem( e );
6172 if ( item && item !== this.selecting && item.isSelectable() ) {
6173 this.pressItem( item );
6174 this.selecting = item;
6175 }
6176 }
6177 };
6178
6179 /**
6180 * Handle mouse over events.
6181 *
6182 * @private
6183 * @param {jQuery.Event} e Mouse over event
6184 */
6185 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6186 var item;
6187 if ( this.blockMouseOverEvents ) {
6188 return;
6189 }
6190 if ( !this.isDisabled() ) {
6191 item = this.findTargetItem( e );
6192 this.highlightItem( item && item.isHighlightable() ? item : null );
6193 }
6194 return false;
6195 };
6196
6197 /**
6198 * Handle mouse leave events.
6199 *
6200 * @private
6201 * @param {jQuery.Event} e Mouse over event
6202 */
6203 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6204 if ( !this.isDisabled() ) {
6205 this.highlightItem( null );
6206 }
6207 return false;
6208 };
6209
6210 /**
6211 * Handle key down events.
6212 *
6213 * @protected
6214 * @param {KeyboardEvent} e Key down event
6215 */
6216 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6217 var nextItem,
6218 handled = false,
6219 currentItem = this.findHighlightedItem() || this.getSelectedItem();
6220
6221 if ( !this.isDisabled() && this.isVisible() ) {
6222 switch ( e.keyCode ) {
6223 case OO.ui.Keys.ENTER:
6224 if ( currentItem && currentItem.constructor.static.highlightable ) {
6225 // Was only highlighted, now let's select it. No-op if already selected.
6226 this.chooseItem( currentItem );
6227 handled = true;
6228 }
6229 break;
6230 case OO.ui.Keys.UP:
6231 case OO.ui.Keys.LEFT:
6232 this.clearKeyPressBuffer();
6233 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6234 handled = true;
6235 break;
6236 case OO.ui.Keys.DOWN:
6237 case OO.ui.Keys.RIGHT:
6238 this.clearKeyPressBuffer();
6239 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6240 handled = true;
6241 break;
6242 case OO.ui.Keys.ESCAPE:
6243 case OO.ui.Keys.TAB:
6244 if ( currentItem && currentItem.constructor.static.highlightable ) {
6245 currentItem.setHighlighted( false );
6246 }
6247 this.unbindKeyDownListener();
6248 this.unbindKeyPressListener();
6249 // Don't prevent tabbing away / defocusing
6250 handled = false;
6251 break;
6252 }
6253
6254 if ( nextItem ) {
6255 if ( nextItem.constructor.static.highlightable ) {
6256 this.highlightItem( nextItem );
6257 } else {
6258 this.chooseItem( nextItem );
6259 }
6260 this.scrollItemIntoView( nextItem );
6261 }
6262
6263 if ( handled ) {
6264 e.preventDefault();
6265 e.stopPropagation();
6266 }
6267 }
6268 };
6269
6270 /**
6271 * Bind key down listener.
6272 *
6273 * @protected
6274 */
6275 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6276 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6277 };
6278
6279 /**
6280 * Unbind key down listener.
6281 *
6282 * @protected
6283 */
6284 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6285 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6286 };
6287
6288 /**
6289 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6290 *
6291 * @param {OO.ui.OptionWidget} item Item to scroll into view
6292 */
6293 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6294 var widget = this;
6295 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6296 // and around 100-150 ms after it is finished.
6297 this.blockMouseOverEvents++;
6298 item.scrollElementIntoView().done( function () {
6299 setTimeout( function () {
6300 widget.blockMouseOverEvents--;
6301 }, 200 );
6302 } );
6303 };
6304
6305 /**
6306 * Clear the key-press buffer
6307 *
6308 * @protected
6309 */
6310 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6311 if ( this.keyPressBufferTimer ) {
6312 clearTimeout( this.keyPressBufferTimer );
6313 this.keyPressBufferTimer = null;
6314 }
6315 this.keyPressBuffer = '';
6316 };
6317
6318 /**
6319 * Handle key press events.
6320 *
6321 * @protected
6322 * @param {KeyboardEvent} e Key press event
6323 */
6324 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6325 var c, filter, item;
6326
6327 if ( !e.charCode ) {
6328 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6329 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6330 return false;
6331 }
6332 return;
6333 }
6334 if ( String.fromCodePoint ) {
6335 c = String.fromCodePoint( e.charCode );
6336 } else {
6337 c = String.fromCharCode( e.charCode );
6338 }
6339
6340 if ( this.keyPressBufferTimer ) {
6341 clearTimeout( this.keyPressBufferTimer );
6342 }
6343 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6344
6345 item = this.findHighlightedItem() || this.getSelectedItem();
6346
6347 if ( this.keyPressBuffer === c ) {
6348 // Common (if weird) special case: typing "xxxx" will cycle through all
6349 // the items beginning with "x".
6350 if ( item ) {
6351 item = this.findRelativeSelectableItem( item, 1 );
6352 }
6353 } else {
6354 this.keyPressBuffer += c;
6355 }
6356
6357 filter = this.getItemMatcher( this.keyPressBuffer, false );
6358 if ( !item || !filter( item ) ) {
6359 item = this.findRelativeSelectableItem( item, 1, filter );
6360 }
6361 if ( item ) {
6362 if ( this.isVisible() && item.constructor.static.highlightable ) {
6363 this.highlightItem( item );
6364 } else {
6365 this.chooseItem( item );
6366 }
6367 this.scrollItemIntoView( item );
6368 }
6369
6370 e.preventDefault();
6371 e.stopPropagation();
6372 };
6373
6374 /**
6375 * Get a matcher for the specific string
6376 *
6377 * @protected
6378 * @param {string} s String to match against items
6379 * @param {boolean} [exact=false] Only accept exact matches
6380 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6381 */
6382 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6383 var re;
6384
6385 if ( s.normalize ) {
6386 s = s.normalize();
6387 }
6388 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6389 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6390 if ( exact ) {
6391 re += '\\s*$';
6392 }
6393 re = new RegExp( re, 'i' );
6394 return function ( item ) {
6395 var matchText = item.getMatchText();
6396 if ( matchText.normalize ) {
6397 matchText = matchText.normalize();
6398 }
6399 return re.test( matchText );
6400 };
6401 };
6402
6403 /**
6404 * Bind key press listener.
6405 *
6406 * @protected
6407 */
6408 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6409 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6410 };
6411
6412 /**
6413 * Unbind key down listener.
6414 *
6415 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6416 * implementation.
6417 *
6418 * @protected
6419 */
6420 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6421 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6422 this.clearKeyPressBuffer();
6423 };
6424
6425 /**
6426 * Visibility change handler
6427 *
6428 * @protected
6429 * @param {boolean} visible
6430 */
6431 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6432 if ( !visible ) {
6433 this.clearKeyPressBuffer();
6434 }
6435 };
6436
6437 /**
6438 * Get the closest item to a jQuery.Event.
6439 *
6440 * @private
6441 * @param {jQuery.Event} e
6442 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6443 */
6444 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6445 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6446 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6447 return null;
6448 }
6449 return $option.data( 'oo-ui-optionWidget' ) || null;
6450 };
6451
6452 /**
6453 * Get selected item.
6454 *
6455 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6456 */
6457 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6458 var i, len;
6459
6460 for ( i = 0, len = this.items.length; i < len; i++ ) {
6461 if ( this.items[ i ].isSelected() ) {
6462 return this.items[ i ];
6463 }
6464 }
6465 return null;
6466 };
6467
6468 /**
6469 * Find highlighted item.
6470 *
6471 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6472 */
6473 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6474 var i, len;
6475
6476 for ( i = 0, len = this.items.length; i < len; i++ ) {
6477 if ( this.items[ i ].isHighlighted() ) {
6478 return this.items[ i ];
6479 }
6480 }
6481 return null;
6482 };
6483
6484 /**
6485 * Get highlighted item.
6486 *
6487 * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead.
6488 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6489 */
6490 OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
6491 OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' );
6492 return this.findHighlightedItem();
6493 };
6494
6495 /**
6496 * Toggle pressed state.
6497 *
6498 * Press is a state that occurs when a user mouses down on an item, but
6499 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6500 * until the user releases the mouse.
6501 *
6502 * @param {boolean} pressed An option is being pressed
6503 */
6504 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6505 if ( pressed === undefined ) {
6506 pressed = !this.pressed;
6507 }
6508 if ( pressed !== this.pressed ) {
6509 this.$element
6510 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6511 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6512 this.pressed = pressed;
6513 }
6514 };
6515
6516 /**
6517 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6518 * and any existing highlight will be removed. The highlight is mutually exclusive.
6519 *
6520 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6521 * @fires highlight
6522 * @chainable
6523 */
6524 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6525 var i, len, highlighted,
6526 changed = false;
6527
6528 for ( i = 0, len = this.items.length; i < len; i++ ) {
6529 highlighted = this.items[ i ] === item;
6530 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6531 this.items[ i ].setHighlighted( highlighted );
6532 changed = true;
6533 }
6534 }
6535 if ( changed ) {
6536 if ( item ) {
6537 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6538 } else {
6539 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6540 }
6541 this.emit( 'highlight', item );
6542 }
6543
6544 return this;
6545 };
6546
6547 /**
6548 * Fetch an item by its label.
6549 *
6550 * @param {string} label Label of the item to select.
6551 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6552 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6553 */
6554 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6555 var i, item, found,
6556 len = this.items.length,
6557 filter = this.getItemMatcher( label, true );
6558
6559 for ( i = 0; i < len; i++ ) {
6560 item = this.items[ i ];
6561 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6562 return item;
6563 }
6564 }
6565
6566 if ( prefix ) {
6567 found = null;
6568 filter = this.getItemMatcher( label, false );
6569 for ( i = 0; i < len; i++ ) {
6570 item = this.items[ i ];
6571 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6572 if ( found ) {
6573 return null;
6574 }
6575 found = item;
6576 }
6577 }
6578 if ( found ) {
6579 return found;
6580 }
6581 }
6582
6583 return null;
6584 };
6585
6586 /**
6587 * Programmatically select an option by its label. If the item does not exist,
6588 * all options will be deselected.
6589 *
6590 * @param {string} [label] Label of the item to select.
6591 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6592 * @fires select
6593 * @chainable
6594 */
6595 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6596 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6597 if ( label === undefined || !itemFromLabel ) {
6598 return this.selectItem();
6599 }
6600 return this.selectItem( itemFromLabel );
6601 };
6602
6603 /**
6604 * Programmatically select an option by its data. If the `data` parameter is omitted,
6605 * or if the item does not exist, all options will be deselected.
6606 *
6607 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6608 * @fires select
6609 * @chainable
6610 */
6611 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6612 var itemFromData = this.getItemFromData( data );
6613 if ( data === undefined || !itemFromData ) {
6614 return this.selectItem();
6615 }
6616 return this.selectItem( itemFromData );
6617 };
6618
6619 /**
6620 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6621 * all options will be deselected.
6622 *
6623 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6624 * @fires select
6625 * @chainable
6626 */
6627 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6628 var i, len, selected,
6629 changed = false;
6630
6631 for ( i = 0, len = this.items.length; i < len; i++ ) {
6632 selected = this.items[ i ] === item;
6633 if ( this.items[ i ].isSelected() !== selected ) {
6634 this.items[ i ].setSelected( selected );
6635 changed = true;
6636 }
6637 }
6638 if ( changed ) {
6639 if ( item && !item.constructor.static.highlightable ) {
6640 if ( item ) {
6641 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6642 } else {
6643 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6644 }
6645 }
6646 this.emit( 'select', item );
6647 }
6648
6649 return this;
6650 };
6651
6652 /**
6653 * Press an item.
6654 *
6655 * Press is a state that occurs when a user mouses down on an item, but has not
6656 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6657 * releases the mouse.
6658 *
6659 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6660 * @fires press
6661 * @chainable
6662 */
6663 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6664 var i, len, pressed,
6665 changed = false;
6666
6667 for ( i = 0, len = this.items.length; i < len; i++ ) {
6668 pressed = this.items[ i ] === item;
6669 if ( this.items[ i ].isPressed() !== pressed ) {
6670 this.items[ i ].setPressed( pressed );
6671 changed = true;
6672 }
6673 }
6674 if ( changed ) {
6675 this.emit( 'press', item );
6676 }
6677
6678 return this;
6679 };
6680
6681 /**
6682 * Choose an item.
6683 *
6684 * Note that ‘choose’ should never be modified programmatically. A user can choose
6685 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6686 * use the #selectItem method.
6687 *
6688 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6689 * when users choose an item with the keyboard or mouse.
6690 *
6691 * @param {OO.ui.OptionWidget} item Item to choose
6692 * @fires choose
6693 * @chainable
6694 */
6695 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6696 if ( item ) {
6697 this.selectItem( item );
6698 this.emit( 'choose', item );
6699 }
6700
6701 return this;
6702 };
6703
6704 /**
6705 * Find an option by its position relative to the specified item (or to the start of the option array,
6706 * if item is `null`). The direction in which to search through the option array is specified with a
6707 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6708 * `null` if there are no options in the array.
6709 *
6710 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6711 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6712 * @param {Function} [filter] Only consider items for which this function returns
6713 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6714 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6715 */
6716 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6717 var currentIndex, nextIndex, i,
6718 increase = direction > 0 ? 1 : -1,
6719 len = this.items.length;
6720
6721 if ( item instanceof OO.ui.OptionWidget ) {
6722 currentIndex = this.items.indexOf( item );
6723 nextIndex = ( currentIndex + increase + len ) % len;
6724 } else {
6725 // If no item is selected and moving forward, start at the beginning.
6726 // If moving backward, start at the end.
6727 nextIndex = direction > 0 ? 0 : len - 1;
6728 }
6729
6730 for ( i = 0; i < len; i++ ) {
6731 item = this.items[ nextIndex ];
6732 if (
6733 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6734 ( !filter || filter( item ) )
6735 ) {
6736 return item;
6737 }
6738 nextIndex = ( nextIndex + increase + len ) % len;
6739 }
6740 return null;
6741 };
6742
6743 /**
6744 * Get an option by its position relative to the specified item (or to the start of the option array,
6745 * if item is `null`). The direction in which to search through the option array is specified with a
6746 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6747 * `null` if there are no options in the array.
6748 *
6749 * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead
6750 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6751 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6752 * @param {Function} [filter] Only consider items for which this function returns
6753 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6754 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6755 */
6756 OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
6757 OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' );
6758 return this.findRelativeSelectableItem( item, direction, filter );
6759 };
6760
6761 /**
6762 * Find the next selectable item or `null` if there are no selectable items.
6763 * Disabled options and menu-section markers and breaks are not selectable.
6764 *
6765 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6766 */
6767 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
6768 return this.findRelativeSelectableItem( null, 1 );
6769 };
6770
6771 /**
6772 * Get the next selectable item or `null` if there are no selectable items.
6773 * Disabled options and menu-section markers and breaks are not selectable.
6774 *
6775 * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead.
6776 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6777 */
6778 OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
6779 OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' );
6780 return this.findFirstSelectableItem();
6781 };
6782
6783 /**
6784 * Add an array of options to the select. Optionally, an index number can be used to
6785 * specify an insertion point.
6786 *
6787 * @param {OO.ui.OptionWidget[]} items Items to add
6788 * @param {number} [index] Index to insert items after
6789 * @fires add
6790 * @chainable
6791 */
6792 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
6793 // Mixin method
6794 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
6795
6796 // Always provide an index, even if it was omitted
6797 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
6798
6799 return this;
6800 };
6801
6802 /**
6803 * Remove the specified array of options from the select. Options will be detached
6804 * from the DOM, not removed, so they can be reused later. To remove all options from
6805 * the select, you may wish to use the #clearItems method instead.
6806 *
6807 * @param {OO.ui.OptionWidget[]} items Items to remove
6808 * @fires remove
6809 * @chainable
6810 */
6811 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
6812 var i, len, item;
6813
6814 // Deselect items being removed
6815 for ( i = 0, len = items.length; i < len; i++ ) {
6816 item = items[ i ];
6817 if ( item.isSelected() ) {
6818 this.selectItem( null );
6819 }
6820 }
6821
6822 // Mixin method
6823 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
6824
6825 this.emit( 'remove', items );
6826
6827 return this;
6828 };
6829
6830 /**
6831 * Clear all options from the select. Options will be detached from the DOM, not removed,
6832 * so that they can be reused later. To remove a subset of options from the select, use
6833 * the #removeItems method.
6834 *
6835 * @fires remove
6836 * @chainable
6837 */
6838 OO.ui.SelectWidget.prototype.clearItems = function () {
6839 var items = this.items.slice();
6840
6841 // Mixin method
6842 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
6843
6844 // Clear selection
6845 this.selectItem( null );
6846
6847 this.emit( 'remove', items );
6848
6849 return this;
6850 };
6851
6852 /**
6853 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
6854 *
6855 * Currently this is just used to set `aria-activedescendant` on it.
6856 *
6857 * @protected
6858 * @param {jQuery} $focusOwner
6859 */
6860 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
6861 this.$focusOwner = $focusOwner;
6862 };
6863
6864 /**
6865 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
6866 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
6867 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
6868 * options. For more information about options and selects, please see the
6869 * [OOjs UI documentation on MediaWiki][1].
6870 *
6871 * @example
6872 * // Decorated options in a select widget
6873 * var select = new OO.ui.SelectWidget( {
6874 * items: [
6875 * new OO.ui.DecoratedOptionWidget( {
6876 * data: 'a',
6877 * label: 'Option with icon',
6878 * icon: 'help'
6879 * } ),
6880 * new OO.ui.DecoratedOptionWidget( {
6881 * data: 'b',
6882 * label: 'Option with indicator',
6883 * indicator: 'next'
6884 * } )
6885 * ]
6886 * } );
6887 * $( 'body' ).append( select.$element );
6888 *
6889 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6890 *
6891 * @class
6892 * @extends OO.ui.OptionWidget
6893 * @mixins OO.ui.mixin.IconElement
6894 * @mixins OO.ui.mixin.IndicatorElement
6895 *
6896 * @constructor
6897 * @param {Object} [config] Configuration options
6898 */
6899 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
6900 // Parent constructor
6901 OO.ui.DecoratedOptionWidget.parent.call( this, config );
6902
6903 // Mixin constructors
6904 OO.ui.mixin.IconElement.call( this, config );
6905 OO.ui.mixin.IndicatorElement.call( this, config );
6906
6907 // Initialization
6908 this.$element
6909 .addClass( 'oo-ui-decoratedOptionWidget' )
6910 .prepend( this.$icon )
6911 .append( this.$indicator );
6912 };
6913
6914 /* Setup */
6915
6916 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
6917 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
6918 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
6919
6920 /**
6921 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
6922 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
6923 * the [OOjs UI documentation on MediaWiki] [1] for more information.
6924 *
6925 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
6926 *
6927 * @class
6928 * @extends OO.ui.DecoratedOptionWidget
6929 *
6930 * @constructor
6931 * @param {Object} [config] Configuration options
6932 */
6933 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
6934 // Parent constructor
6935 OO.ui.MenuOptionWidget.parent.call( this, config );
6936
6937 // Initialization
6938 this.$element.addClass( 'oo-ui-menuOptionWidget' );
6939 };
6940
6941 /* Setup */
6942
6943 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
6944
6945 /* Static Properties */
6946
6947 /**
6948 * @static
6949 * @inheritdoc
6950 */
6951 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
6952
6953 /**
6954 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
6955 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
6956 *
6957 * @example
6958 * var myDropdown = new OO.ui.DropdownWidget( {
6959 * menu: {
6960 * items: [
6961 * new OO.ui.MenuSectionOptionWidget( {
6962 * label: 'Dogs'
6963 * } ),
6964 * new OO.ui.MenuOptionWidget( {
6965 * data: 'corgi',
6966 * label: 'Welsh Corgi'
6967 * } ),
6968 * new OO.ui.MenuOptionWidget( {
6969 * data: 'poodle',
6970 * label: 'Standard Poodle'
6971 * } ),
6972 * new OO.ui.MenuSectionOptionWidget( {
6973 * label: 'Cats'
6974 * } ),
6975 * new OO.ui.MenuOptionWidget( {
6976 * data: 'lion',
6977 * label: 'Lion'
6978 * } )
6979 * ]
6980 * }
6981 * } );
6982 * $( 'body' ).append( myDropdown.$element );
6983 *
6984 * @class
6985 * @extends OO.ui.DecoratedOptionWidget
6986 *
6987 * @constructor
6988 * @param {Object} [config] Configuration options
6989 */
6990 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
6991 // Parent constructor
6992 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
6993
6994 // Initialization
6995 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
6996 .removeAttr( 'role aria-selected' );
6997 };
6998
6999 /* Setup */
7000
7001 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7002
7003 /* Static Properties */
7004
7005 /**
7006 * @static
7007 * @inheritdoc
7008 */
7009 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7010
7011 /**
7012 * @static
7013 * @inheritdoc
7014 */
7015 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7016
7017 /**
7018 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7019 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7020 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7021 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7022 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7023 * and customized to be opened, closed, and displayed as needed.
7024 *
7025 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7026 * mouse outside the menu.
7027 *
7028 * Menus also have support for keyboard interaction:
7029 *
7030 * - Enter/Return key: choose and select a menu option
7031 * - Up-arrow key: highlight the previous menu option
7032 * - Down-arrow key: highlight the next menu option
7033 * - Esc key: hide the menu
7034 *
7035 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7036 *
7037 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7038 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7039 *
7040 * @class
7041 * @extends OO.ui.SelectWidget
7042 * @mixins OO.ui.mixin.ClippableElement
7043 * @mixins OO.ui.mixin.FloatableElement
7044 *
7045 * @constructor
7046 * @param {Object} [config] Configuration options
7047 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7048 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7049 * and {@link OO.ui.mixin.LookupElement LookupElement}
7050 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7051 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7052 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7053 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7054 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7055 * that button, unless the button (or its parent widget) is passed in here.
7056 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7057 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7058 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7059 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7060 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7061 * @cfg {number} [width] Width of the menu
7062 */
7063 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7064 // Configuration initialization
7065 config = config || {};
7066
7067 // Parent constructor
7068 OO.ui.MenuSelectWidget.parent.call( this, config );
7069
7070 // Mixin constructors
7071 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7072 OO.ui.mixin.FloatableElement.call( this, config );
7073
7074 // Properties
7075 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7076 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7077 this.filterFromInput = !!config.filterFromInput;
7078 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7079 this.$widget = config.widget ? config.widget.$element : null;
7080 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7081 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7082 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7083 this.highlightOnFilter = !!config.highlightOnFilter;
7084 this.width = config.width;
7085
7086 // Initialization
7087 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7088 if ( config.widget ) {
7089 this.setFocusOwner( config.widget.$tabIndexed );
7090 }
7091
7092 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7093 // that reference properties not initialized at that time of parent class construction
7094 // TODO: Find a better way to handle post-constructor setup
7095 this.visible = false;
7096 this.$element.addClass( 'oo-ui-element-hidden' );
7097 };
7098
7099 /* Setup */
7100
7101 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7102 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7103 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7104
7105 /* Events */
7106
7107 /**
7108 * @event ready
7109 *
7110 * The menu is ready: it is visible and has been positioned and clipped.
7111 */
7112
7113 /* Methods */
7114
7115 /**
7116 * Handles document mouse down events.
7117 *
7118 * @protected
7119 * @param {MouseEvent} e Mouse down event
7120 */
7121 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7122 if (
7123 this.isVisible() &&
7124 !OO.ui.contains(
7125 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7126 e.target,
7127 true
7128 )
7129 ) {
7130 this.toggle( false );
7131 }
7132 };
7133
7134 /**
7135 * @inheritdoc
7136 */
7137 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7138 var currentItem = this.findHighlightedItem() || this.getSelectedItem();
7139
7140 if ( !this.isDisabled() && this.isVisible() ) {
7141 switch ( e.keyCode ) {
7142 case OO.ui.Keys.LEFT:
7143 case OO.ui.Keys.RIGHT:
7144 // Do nothing if a text field is associated, arrow keys will be handled natively
7145 if ( !this.$input ) {
7146 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7147 }
7148 break;
7149 case OO.ui.Keys.ESCAPE:
7150 case OO.ui.Keys.TAB:
7151 if ( currentItem ) {
7152 currentItem.setHighlighted( false );
7153 }
7154 this.toggle( false );
7155 // Don't prevent tabbing away, prevent defocusing
7156 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7157 e.preventDefault();
7158 e.stopPropagation();
7159 }
7160 break;
7161 default:
7162 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7163 return;
7164 }
7165 }
7166 };
7167
7168 /**
7169 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7170 * or after items were added/removed (always).
7171 *
7172 * @protected
7173 */
7174 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7175 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7176 firstItemFound = false,
7177 anyVisible = false,
7178 len = this.items.length,
7179 showAll = !this.isVisible(),
7180 exactMatch = false;
7181
7182 if ( this.$input && this.filterFromInput ) {
7183 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7184 exactFilter = this.getItemMatcher( this.$input.val(), true );
7185
7186 // Hide non-matching options, and also hide section headers if all options
7187 // in their section are hidden.
7188 for ( i = 0; i < len; i++ ) {
7189 item = this.items[ i ];
7190 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7191 if ( section ) {
7192 // If the previous section was empty, hide its header
7193 section.toggle( showAll || !sectionEmpty );
7194 }
7195 section = item;
7196 sectionEmpty = true;
7197 } else if ( item instanceof OO.ui.OptionWidget ) {
7198 visible = showAll || filter( item );
7199 exactMatch = exactMatch || exactFilter( item );
7200 anyVisible = anyVisible || visible;
7201 sectionEmpty = sectionEmpty && !visible;
7202 item.toggle( visible );
7203 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7204 // Highlight the first item in the list
7205 this.highlightItem( item );
7206 firstItemFound = true;
7207 }
7208 }
7209 }
7210 // Process the final section
7211 if ( section ) {
7212 section.toggle( showAll || !sectionEmpty );
7213 }
7214
7215 if ( anyVisible && this.items.length && !exactMatch ) {
7216 this.scrollItemIntoView( this.items[ 0 ] );
7217 }
7218
7219 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7220 }
7221
7222 // Reevaluate clipping
7223 this.clip();
7224 };
7225
7226 /**
7227 * @inheritdoc
7228 */
7229 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7230 if ( this.$input ) {
7231 this.$input.on( 'keydown', this.onKeyDownHandler );
7232 } else {
7233 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7234 }
7235 };
7236
7237 /**
7238 * @inheritdoc
7239 */
7240 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7241 if ( this.$input ) {
7242 this.$input.off( 'keydown', this.onKeyDownHandler );
7243 } else {
7244 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7245 }
7246 };
7247
7248 /**
7249 * @inheritdoc
7250 */
7251 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7252 if ( this.$input ) {
7253 if ( this.filterFromInput ) {
7254 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7255 this.updateItemVisibility();
7256 }
7257 } else {
7258 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7259 }
7260 };
7261
7262 /**
7263 * @inheritdoc
7264 */
7265 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7266 if ( this.$input ) {
7267 if ( this.filterFromInput ) {
7268 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7269 this.updateItemVisibility();
7270 }
7271 } else {
7272 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7273 }
7274 };
7275
7276 /**
7277 * Choose an item.
7278 *
7279 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7280 *
7281 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7282 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7283 *
7284 * @param {OO.ui.OptionWidget} item Item to choose
7285 * @chainable
7286 */
7287 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7288 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7289 if ( this.hideOnChoose ) {
7290 this.toggle( false );
7291 }
7292 return this;
7293 };
7294
7295 /**
7296 * @inheritdoc
7297 */
7298 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7299 // Parent method
7300 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7301
7302 this.updateItemVisibility();
7303
7304 return this;
7305 };
7306
7307 /**
7308 * @inheritdoc
7309 */
7310 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7311 // Parent method
7312 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7313
7314 this.updateItemVisibility();
7315
7316 return this;
7317 };
7318
7319 /**
7320 * @inheritdoc
7321 */
7322 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7323 // Parent method
7324 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7325
7326 this.updateItemVisibility();
7327
7328 return this;
7329 };
7330
7331 /**
7332 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7333 * `.toggle( true )` after its #$element is attached to the DOM.
7334 *
7335 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7336 * it in the right place and with the right dimensions only work correctly while it is attached.
7337 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7338 * strictly enforced, so currently it only generates a warning in the browser console.
7339 *
7340 * @fires ready
7341 * @inheritdoc
7342 */
7343 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7344 var change;
7345
7346 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7347 change = visible !== this.isVisible();
7348
7349 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7350 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7351 this.warnedUnattached = true;
7352 }
7353
7354 if ( change && visible && ( this.width || this.$floatableContainer ) ) {
7355 this.setIdealSize( this.width || this.$floatableContainer.width() );
7356 }
7357
7358 // Parent method
7359 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7360
7361 if ( change ) {
7362 if ( visible ) {
7363 this.bindKeyDownListener();
7364 this.bindKeyPressListener();
7365
7366 this.togglePositioning( !!this.$floatableContainer );
7367 this.toggleClipping( true );
7368
7369 this.$focusOwner.attr( 'aria-expanded', 'true' );
7370
7371 if ( this.getSelectedItem() ) {
7372 this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
7373 this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
7374 }
7375
7376 // Auto-hide
7377 if ( this.autoHide ) {
7378 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7379 }
7380
7381 this.emit( 'ready' );
7382 } else {
7383 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7384 this.unbindKeyDownListener();
7385 this.unbindKeyPressListener();
7386 this.$focusOwner.attr( 'aria-expanded', 'false' );
7387 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7388 this.togglePositioning( false );
7389 this.toggleClipping( false );
7390 }
7391 }
7392
7393 return this;
7394 };
7395
7396 /**
7397 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7398 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7399 * users can interact with it.
7400 *
7401 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7402 * OO.ui.DropdownInputWidget instead.
7403 *
7404 * @example
7405 * // Example: A DropdownWidget with a menu that contains three options
7406 * var dropDown = new OO.ui.DropdownWidget( {
7407 * label: 'Dropdown menu: Select a menu option',
7408 * menu: {
7409 * items: [
7410 * new OO.ui.MenuOptionWidget( {
7411 * data: 'a',
7412 * label: 'First'
7413 * } ),
7414 * new OO.ui.MenuOptionWidget( {
7415 * data: 'b',
7416 * label: 'Second'
7417 * } ),
7418 * new OO.ui.MenuOptionWidget( {
7419 * data: 'c',
7420 * label: 'Third'
7421 * } )
7422 * ]
7423 * }
7424 * } );
7425 *
7426 * $( 'body' ).append( dropDown.$element );
7427 *
7428 * dropDown.getMenu().selectItemByData( 'b' );
7429 *
7430 * dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
7431 *
7432 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
7433 *
7434 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7435 *
7436 * @class
7437 * @extends OO.ui.Widget
7438 * @mixins OO.ui.mixin.IconElement
7439 * @mixins OO.ui.mixin.IndicatorElement
7440 * @mixins OO.ui.mixin.LabelElement
7441 * @mixins OO.ui.mixin.TitledElement
7442 * @mixins OO.ui.mixin.TabIndexedElement
7443 *
7444 * @constructor
7445 * @param {Object} [config] Configuration options
7446 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7447 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7448 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7449 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7450 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
7451 */
7452 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7453 // Configuration initialization
7454 config = $.extend( { indicator: 'down' }, config );
7455
7456 // Parent constructor
7457 OO.ui.DropdownWidget.parent.call( this, config );
7458
7459 // Properties (must be set before TabIndexedElement constructor call)
7460 this.$handle = this.$( '<span>' );
7461 this.$overlay = config.$overlay || this.$element;
7462
7463 // Mixin constructors
7464 OO.ui.mixin.IconElement.call( this, config );
7465 OO.ui.mixin.IndicatorElement.call( this, config );
7466 OO.ui.mixin.LabelElement.call( this, config );
7467 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7468 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7469
7470 // Properties
7471 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7472 widget: this,
7473 $floatableContainer: this.$element
7474 }, config.menu ) );
7475
7476 // Events
7477 this.$handle.on( {
7478 click: this.onClick.bind( this ),
7479 keydown: this.onKeyDown.bind( this ),
7480 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7481 keypress: this.menu.onKeyPressHandler,
7482 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7483 } );
7484 this.menu.connect( this, {
7485 select: 'onMenuSelect',
7486 toggle: 'onMenuToggle'
7487 } );
7488
7489 // Initialization
7490 this.$handle
7491 .addClass( 'oo-ui-dropdownWidget-handle' )
7492 .attr( {
7493 role: 'combobox',
7494 'aria-owns': this.menu.getElementId(),
7495 'aria-autocomplete': 'list'
7496 } )
7497 .append( this.$icon, this.$label, this.$indicator );
7498 this.$element
7499 .addClass( 'oo-ui-dropdownWidget' )
7500 .append( this.$handle );
7501 this.$overlay.append( this.menu.$element );
7502 };
7503
7504 /* Setup */
7505
7506 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7507 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7508 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7509 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7510 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7511 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7512
7513 /* Methods */
7514
7515 /**
7516 * Get the menu.
7517 *
7518 * @return {OO.ui.MenuSelectWidget} Menu of widget
7519 */
7520 OO.ui.DropdownWidget.prototype.getMenu = function () {
7521 return this.menu;
7522 };
7523
7524 /**
7525 * Handles menu select events.
7526 *
7527 * @private
7528 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7529 */
7530 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7531 var selectedLabel;
7532
7533 if ( !item ) {
7534 this.setLabel( null );
7535 return;
7536 }
7537
7538 selectedLabel = item.getLabel();
7539
7540 // If the label is a DOM element, clone it, because setLabel will append() it
7541 if ( selectedLabel instanceof jQuery ) {
7542 selectedLabel = selectedLabel.clone();
7543 }
7544
7545 this.setLabel( selectedLabel );
7546 };
7547
7548 /**
7549 * Handle menu toggle events.
7550 *
7551 * @private
7552 * @param {boolean} isVisible Menu toggle event
7553 */
7554 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7555 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7556 this.$handle.attr(
7557 'aria-expanded',
7558 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7559 );
7560 };
7561
7562 /**
7563 * Handle mouse click events.
7564 *
7565 * @private
7566 * @param {jQuery.Event} e Mouse click event
7567 */
7568 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7569 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7570 this.menu.toggle();
7571 }
7572 return false;
7573 };
7574
7575 /**
7576 * Handle key down events.
7577 *
7578 * @private
7579 * @param {jQuery.Event} e Key down event
7580 */
7581 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7582 if (
7583 !this.isDisabled() &&
7584 (
7585 e.which === OO.ui.Keys.ENTER ||
7586 (
7587 !this.menu.isVisible() &&
7588 (
7589 e.which === OO.ui.Keys.SPACE ||
7590 e.which === OO.ui.Keys.UP ||
7591 e.which === OO.ui.Keys.DOWN
7592 )
7593 )
7594 )
7595 ) {
7596 this.menu.toggle();
7597 return false;
7598 }
7599 };
7600
7601 /**
7602 * RadioOptionWidget is an option widget that looks like a radio button.
7603 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7604 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7605 *
7606 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7607 *
7608 * @class
7609 * @extends OO.ui.OptionWidget
7610 *
7611 * @constructor
7612 * @param {Object} [config] Configuration options
7613 */
7614 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7615 // Configuration initialization
7616 config = config || {};
7617
7618 // Properties (must be done before parent constructor which calls #setDisabled)
7619 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7620
7621 // Parent constructor
7622 OO.ui.RadioOptionWidget.parent.call( this, config );
7623
7624 // Initialization
7625 // Remove implicit role, we're handling it ourselves
7626 this.radio.$input.attr( 'role', 'presentation' );
7627 this.$element
7628 .addClass( 'oo-ui-radioOptionWidget' )
7629 .attr( 'role', 'radio' )
7630 .attr( 'aria-checked', 'false' )
7631 .removeAttr( 'aria-selected' )
7632 .prepend( this.radio.$element );
7633 };
7634
7635 /* Setup */
7636
7637 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7638
7639 /* Static Properties */
7640
7641 /**
7642 * @static
7643 * @inheritdoc
7644 */
7645 OO.ui.RadioOptionWidget.static.highlightable = false;
7646
7647 /**
7648 * @static
7649 * @inheritdoc
7650 */
7651 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7652
7653 /**
7654 * @static
7655 * @inheritdoc
7656 */
7657 OO.ui.RadioOptionWidget.static.pressable = false;
7658
7659 /**
7660 * @static
7661 * @inheritdoc
7662 */
7663 OO.ui.RadioOptionWidget.static.tagName = 'label';
7664
7665 /* Methods */
7666
7667 /**
7668 * @inheritdoc
7669 */
7670 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7671 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7672
7673 this.radio.setSelected( state );
7674 this.$element
7675 .attr( 'aria-checked', state.toString() )
7676 .removeAttr( 'aria-selected' );
7677
7678 return this;
7679 };
7680
7681 /**
7682 * @inheritdoc
7683 */
7684 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7685 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7686
7687 this.radio.setDisabled( this.isDisabled() );
7688
7689 return this;
7690 };
7691
7692 /**
7693 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7694 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7695 * an interface for adding, removing and selecting options.
7696 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7697 *
7698 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7699 * OO.ui.RadioSelectInputWidget instead.
7700 *
7701 * @example
7702 * // A RadioSelectWidget with RadioOptions.
7703 * var option1 = new OO.ui.RadioOptionWidget( {
7704 * data: 'a',
7705 * label: 'Selected radio option'
7706 * } );
7707 *
7708 * var option2 = new OO.ui.RadioOptionWidget( {
7709 * data: 'b',
7710 * label: 'Unselected radio option'
7711 * } );
7712 *
7713 * var radioSelect=new OO.ui.RadioSelectWidget( {
7714 * items: [ option1, option2 ]
7715 * } );
7716 *
7717 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7718 * radioSelect.selectItem( option1 );
7719 *
7720 * $( 'body' ).append( radioSelect.$element );
7721 *
7722 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7723
7724 *
7725 * @class
7726 * @extends OO.ui.SelectWidget
7727 * @mixins OO.ui.mixin.TabIndexedElement
7728 *
7729 * @constructor
7730 * @param {Object} [config] Configuration options
7731 */
7732 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7733 // Parent constructor
7734 OO.ui.RadioSelectWidget.parent.call( this, config );
7735
7736 // Mixin constructors
7737 OO.ui.mixin.TabIndexedElement.call( this, config );
7738
7739 // Events
7740 this.$element.on( {
7741 focus: this.bindKeyDownListener.bind( this ),
7742 blur: this.unbindKeyDownListener.bind( this )
7743 } );
7744
7745 // Initialization
7746 this.$element
7747 .addClass( 'oo-ui-radioSelectWidget' )
7748 .attr( 'role', 'radiogroup' );
7749 };
7750
7751 /* Setup */
7752
7753 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
7754 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
7755
7756 /**
7757 * MultioptionWidgets are special elements that can be selected and configured with data. The
7758 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
7759 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
7760 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
7761 *
7762 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
7763 *
7764 * @class
7765 * @extends OO.ui.Widget
7766 * @mixins OO.ui.mixin.ItemWidget
7767 * @mixins OO.ui.mixin.LabelElement
7768 *
7769 * @constructor
7770 * @param {Object} [config] Configuration options
7771 * @cfg {boolean} [selected=false] Whether the option is initially selected
7772 */
7773 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
7774 // Configuration initialization
7775 config = config || {};
7776
7777 // Parent constructor
7778 OO.ui.MultioptionWidget.parent.call( this, config );
7779
7780 // Mixin constructors
7781 OO.ui.mixin.ItemWidget.call( this );
7782 OO.ui.mixin.LabelElement.call( this, config );
7783
7784 // Properties
7785 this.selected = null;
7786
7787 // Initialization
7788 this.$element
7789 .addClass( 'oo-ui-multioptionWidget' )
7790 .append( this.$label );
7791 this.setSelected( config.selected );
7792 };
7793
7794 /* Setup */
7795
7796 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
7797 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
7798 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
7799
7800 /* Events */
7801
7802 /**
7803 * @event change
7804 *
7805 * A change event is emitted when the selected state of the option changes.
7806 *
7807 * @param {boolean} selected Whether the option is now selected
7808 */
7809
7810 /* Methods */
7811
7812 /**
7813 * Check if the option is selected.
7814 *
7815 * @return {boolean} Item is selected
7816 */
7817 OO.ui.MultioptionWidget.prototype.isSelected = function () {
7818 return this.selected;
7819 };
7820
7821 /**
7822 * Set the option’s selected state. In general, all modifications to the selection
7823 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
7824 * method instead of this method.
7825 *
7826 * @param {boolean} [state=false] Select option
7827 * @chainable
7828 */
7829 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
7830 state = !!state;
7831 if ( this.selected !== state ) {
7832 this.selected = state;
7833 this.emit( 'change', state );
7834 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
7835 }
7836 return this;
7837 };
7838
7839 /**
7840 * MultiselectWidget allows selecting multiple options from a list.
7841 *
7842 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
7843 *
7844 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7845 *
7846 * @class
7847 * @abstract
7848 * @extends OO.ui.Widget
7849 * @mixins OO.ui.mixin.GroupWidget
7850 *
7851 * @constructor
7852 * @param {Object} [config] Configuration options
7853 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
7854 */
7855 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
7856 // Parent constructor
7857 OO.ui.MultiselectWidget.parent.call( this, config );
7858
7859 // Configuration initialization
7860 config = config || {};
7861
7862 // Mixin constructors
7863 OO.ui.mixin.GroupWidget.call( this, config );
7864
7865 // Events
7866 this.aggregate( { change: 'select' } );
7867 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
7868 // by GroupElement only when items are added/removed
7869 this.connect( this, { select: [ 'emit', 'change' ] } );
7870
7871 // Initialization
7872 if ( config.items ) {
7873 this.addItems( config.items );
7874 }
7875 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
7876 this.$element.addClass( 'oo-ui-multiselectWidget' )
7877 .append( this.$group );
7878 };
7879
7880 /* Setup */
7881
7882 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
7883 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
7884
7885 /* Events */
7886
7887 /**
7888 * @event change
7889 *
7890 * A change event is emitted when the set of items changes, or an item is selected or deselected.
7891 */
7892
7893 /**
7894 * @event select
7895 *
7896 * A select event is emitted when an item is selected or deselected.
7897 */
7898
7899 /* Methods */
7900
7901 /**
7902 * Get options that are selected.
7903 *
7904 * @return {OO.ui.MultioptionWidget[]} Selected options
7905 */
7906 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
7907 return this.items.filter( function ( item ) {
7908 return item.isSelected();
7909 } );
7910 };
7911
7912 /**
7913 * Get the data of options that are selected.
7914 *
7915 * @return {Object[]|string[]} Values of selected options
7916 */
7917 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
7918 return this.getSelectedItems().map( function ( item ) {
7919 return item.data;
7920 } );
7921 };
7922
7923 /**
7924 * Select options by reference. Options not mentioned in the `items` array will be deselected.
7925 *
7926 * @param {OO.ui.MultioptionWidget[]} items Items to select
7927 * @chainable
7928 */
7929 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
7930 this.items.forEach( function ( item ) {
7931 var selected = items.indexOf( item ) !== -1;
7932 item.setSelected( selected );
7933 } );
7934 return this;
7935 };
7936
7937 /**
7938 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
7939 *
7940 * @param {Object[]|string[]} datas Values of items to select
7941 * @chainable
7942 */
7943 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
7944 var items,
7945 widget = this;
7946 items = datas.map( function ( data ) {
7947 return widget.getItemFromData( data );
7948 } );
7949 this.selectItems( items );
7950 return this;
7951 };
7952
7953 /**
7954 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
7955 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
7956 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7957 *
7958 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7959 *
7960 * @class
7961 * @extends OO.ui.MultioptionWidget
7962 *
7963 * @constructor
7964 * @param {Object} [config] Configuration options
7965 */
7966 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
7967 // Configuration initialization
7968 config = config || {};
7969
7970 // Properties (must be done before parent constructor which calls #setDisabled)
7971 this.checkbox = new OO.ui.CheckboxInputWidget();
7972
7973 // Parent constructor
7974 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
7975
7976 // Events
7977 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
7978 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
7979
7980 // Initialization
7981 this.$element
7982 .addClass( 'oo-ui-checkboxMultioptionWidget' )
7983 .prepend( this.checkbox.$element );
7984 };
7985
7986 /* Setup */
7987
7988 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
7989
7990 /* Static Properties */
7991
7992 /**
7993 * @static
7994 * @inheritdoc
7995 */
7996 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
7997
7998 /* Methods */
7999
8000 /**
8001 * Handle checkbox selected state change.
8002 *
8003 * @private
8004 */
8005 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8006 this.setSelected( this.checkbox.isSelected() );
8007 };
8008
8009 /**
8010 * @inheritdoc
8011 */
8012 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8013 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8014 this.checkbox.setSelected( state );
8015 return this;
8016 };
8017
8018 /**
8019 * @inheritdoc
8020 */
8021 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8022 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8023 this.checkbox.setDisabled( this.isDisabled() );
8024 return this;
8025 };
8026
8027 /**
8028 * Focus the widget.
8029 */
8030 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8031 this.checkbox.focus();
8032 };
8033
8034 /**
8035 * Handle key down events.
8036 *
8037 * @protected
8038 * @param {jQuery.Event} e
8039 */
8040 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8041 var
8042 element = this.getElementGroup(),
8043 nextItem;
8044
8045 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8046 nextItem = element.getRelativeFocusableItem( this, -1 );
8047 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8048 nextItem = element.getRelativeFocusableItem( this, 1 );
8049 }
8050
8051 if ( nextItem ) {
8052 e.preventDefault();
8053 nextItem.focus();
8054 }
8055 };
8056
8057 /**
8058 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8059 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8060 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8061 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8062 *
8063 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8064 * OO.ui.CheckboxMultiselectInputWidget instead.
8065 *
8066 * @example
8067 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8068 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8069 * data: 'a',
8070 * selected: true,
8071 * label: 'Selected checkbox'
8072 * } );
8073 *
8074 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8075 * data: 'b',
8076 * label: 'Unselected checkbox'
8077 * } );
8078 *
8079 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8080 * items: [ option1, option2 ]
8081 * } );
8082 *
8083 * $( 'body' ).append( multiselect.$element );
8084 *
8085 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8086 *
8087 * @class
8088 * @extends OO.ui.MultiselectWidget
8089 *
8090 * @constructor
8091 * @param {Object} [config] Configuration options
8092 */
8093 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8094 // Parent constructor
8095 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8096
8097 // Properties
8098 this.$lastClicked = null;
8099
8100 // Events
8101 this.$group.on( 'click', this.onClick.bind( this ) );
8102
8103 // Initialization
8104 this.$element
8105 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8106 };
8107
8108 /* Setup */
8109
8110 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8111
8112 /* Methods */
8113
8114 /**
8115 * Get an option by its position relative to the specified item (or to the start of the option array,
8116 * if item is `null`). The direction in which to search through the option array is specified with a
8117 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8118 * `null` if there are no options in the array.
8119 *
8120 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8121 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8122 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8123 */
8124 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8125 var currentIndex, nextIndex, i,
8126 increase = direction > 0 ? 1 : -1,
8127 len = this.items.length;
8128
8129 if ( item ) {
8130 currentIndex = this.items.indexOf( item );
8131 nextIndex = ( currentIndex + increase + len ) % len;
8132 } else {
8133 // If no item is selected and moving forward, start at the beginning.
8134 // If moving backward, start at the end.
8135 nextIndex = direction > 0 ? 0 : len - 1;
8136 }
8137
8138 for ( i = 0; i < len; i++ ) {
8139 item = this.items[ nextIndex ];
8140 if ( item && !item.isDisabled() ) {
8141 return item;
8142 }
8143 nextIndex = ( nextIndex + increase + len ) % len;
8144 }
8145 return null;
8146 };
8147
8148 /**
8149 * Handle click events on checkboxes.
8150 *
8151 * @param {jQuery.Event} e
8152 */
8153 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8154 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8155 $lastClicked = this.$lastClicked,
8156 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8157 .not( '.oo-ui-widget-disabled' );
8158
8159 // Allow selecting multiple options at once by Shift-clicking them
8160 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8161 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8162 lastClickedIndex = $options.index( $lastClicked );
8163 nowClickedIndex = $options.index( $nowClicked );
8164 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8165 // browser. In either case we don't need custom handling.
8166 if ( nowClickedIndex !== lastClickedIndex ) {
8167 items = this.items;
8168 wasSelected = items[ nowClickedIndex ].isSelected();
8169 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8170
8171 // This depends on the DOM order of the items and the order of the .items array being the same.
8172 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8173 if ( !items[ i ].isDisabled() ) {
8174 items[ i ].setSelected( !wasSelected );
8175 }
8176 }
8177 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8178 // handling first, then set our value. The order in which events happen is different for
8179 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8180 // non-click actions that change the checkboxes.
8181 e.preventDefault();
8182 setTimeout( function () {
8183 if ( !items[ nowClickedIndex ].isDisabled() ) {
8184 items[ nowClickedIndex ].setSelected( !wasSelected );
8185 }
8186 } );
8187 }
8188 }
8189
8190 if ( $nowClicked.length ) {
8191 this.$lastClicked = $nowClicked;
8192 }
8193 };
8194
8195 /**
8196 * Focus the widget
8197 *
8198 * @chainable
8199 */
8200 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8201 var item;
8202 if ( !this.isDisabled() ) {
8203 item = this.getRelativeFocusableItem( null, 1 );
8204 if ( item ) {
8205 item.focus();
8206 }
8207 }
8208 return this;
8209 };
8210
8211 /**
8212 * @inheritdoc
8213 */
8214 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8215 this.focus();
8216 };
8217
8218 /**
8219 * Progress bars visually display the status of an operation, such as a download,
8220 * and can be either determinate or indeterminate:
8221 *
8222 * - **determinate** process bars show the percent of an operation that is complete.
8223 *
8224 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8225 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8226 * not use percentages.
8227 *
8228 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8229 *
8230 * @example
8231 * // Examples of determinate and indeterminate progress bars.
8232 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8233 * progress: 33
8234 * } );
8235 * var progressBar2 = new OO.ui.ProgressBarWidget();
8236 *
8237 * // Create a FieldsetLayout to layout progress bars
8238 * var fieldset = new OO.ui.FieldsetLayout;
8239 * fieldset.addItems( [
8240 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8241 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8242 * ] );
8243 * $( 'body' ).append( fieldset.$element );
8244 *
8245 * @class
8246 * @extends OO.ui.Widget
8247 *
8248 * @constructor
8249 * @param {Object} [config] Configuration options
8250 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8251 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8252 * By default, the progress bar is indeterminate.
8253 */
8254 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8255 // Configuration initialization
8256 config = config || {};
8257
8258 // Parent constructor
8259 OO.ui.ProgressBarWidget.parent.call( this, config );
8260
8261 // Properties
8262 this.$bar = $( '<div>' );
8263 this.progress = null;
8264
8265 // Initialization
8266 this.setProgress( config.progress !== undefined ? config.progress : false );
8267 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8268 this.$element
8269 .attr( {
8270 role: 'progressbar',
8271 'aria-valuemin': 0,
8272 'aria-valuemax': 100
8273 } )
8274 .addClass( 'oo-ui-progressBarWidget' )
8275 .append( this.$bar );
8276 };
8277
8278 /* Setup */
8279
8280 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8281
8282 /* Static Properties */
8283
8284 /**
8285 * @static
8286 * @inheritdoc
8287 */
8288 OO.ui.ProgressBarWidget.static.tagName = 'div';
8289
8290 /* Methods */
8291
8292 /**
8293 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8294 *
8295 * @return {number|boolean} Progress percent
8296 */
8297 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8298 return this.progress;
8299 };
8300
8301 /**
8302 * Set the percent of the process completed or `false` for an indeterminate process.
8303 *
8304 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8305 */
8306 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8307 this.progress = progress;
8308
8309 if ( progress !== false ) {
8310 this.$bar.css( 'width', this.progress + '%' );
8311 this.$element.attr( 'aria-valuenow', this.progress );
8312 } else {
8313 this.$bar.css( 'width', '' );
8314 this.$element.removeAttr( 'aria-valuenow' );
8315 }
8316 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8317 };
8318
8319 /**
8320 * InputWidget is the base class for all input widgets, which
8321 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8322 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8323 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8324 *
8325 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8326 *
8327 * @abstract
8328 * @class
8329 * @extends OO.ui.Widget
8330 * @mixins OO.ui.mixin.FlaggedElement
8331 * @mixins OO.ui.mixin.TabIndexedElement
8332 * @mixins OO.ui.mixin.TitledElement
8333 * @mixins OO.ui.mixin.AccessKeyedElement
8334 *
8335 * @constructor
8336 * @param {Object} [config] Configuration options
8337 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8338 * @cfg {string} [value=''] The value of the input.
8339 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8340 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8341 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8342 * before it is accepted.
8343 */
8344 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8345 // Configuration initialization
8346 config = config || {};
8347
8348 // Parent constructor
8349 OO.ui.InputWidget.parent.call( this, config );
8350
8351 // Properties
8352 // See #reusePreInfuseDOM about config.$input
8353 this.$input = config.$input || this.getInputElement( config );
8354 this.value = '';
8355 this.inputFilter = config.inputFilter;
8356
8357 // Mixin constructors
8358 OO.ui.mixin.FlaggedElement.call( this, config );
8359 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8360 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8361 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8362
8363 // Events
8364 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8365
8366 // Initialization
8367 this.$input
8368 .addClass( 'oo-ui-inputWidget-input' )
8369 .attr( 'name', config.name )
8370 .prop( 'disabled', this.isDisabled() );
8371 this.$element
8372 .addClass( 'oo-ui-inputWidget' )
8373 .append( this.$input );
8374 this.setValue( config.value );
8375 if ( config.dir ) {
8376 this.setDir( config.dir );
8377 }
8378 if ( config.inputId !== undefined ) {
8379 this.setInputId( config.inputId );
8380 }
8381 };
8382
8383 /* Setup */
8384
8385 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8386 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8387 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8388 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8389 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8390
8391 /* Static Methods */
8392
8393 /**
8394 * @inheritdoc
8395 */
8396 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8397 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8398 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8399 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8400 return config;
8401 };
8402
8403 /**
8404 * @inheritdoc
8405 */
8406 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8407 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8408 if ( config.$input && config.$input.length ) {
8409 state.value = config.$input.val();
8410 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8411 state.focus = config.$input.is( ':focus' );
8412 }
8413 return state;
8414 };
8415
8416 /* Events */
8417
8418 /**
8419 * @event change
8420 *
8421 * A change event is emitted when the value of the input changes.
8422 *
8423 * @param {string} value
8424 */
8425
8426 /* Methods */
8427
8428 /**
8429 * Get input element.
8430 *
8431 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8432 * different circumstances. The element must have a `value` property (like form elements).
8433 *
8434 * @protected
8435 * @param {Object} config Configuration options
8436 * @return {jQuery} Input element
8437 */
8438 OO.ui.InputWidget.prototype.getInputElement = function () {
8439 return $( '<input>' );
8440 };
8441
8442 /**
8443 * Handle potentially value-changing events.
8444 *
8445 * @private
8446 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8447 */
8448 OO.ui.InputWidget.prototype.onEdit = function () {
8449 var widget = this;
8450 if ( !this.isDisabled() ) {
8451 // Allow the stack to clear so the value will be updated
8452 setTimeout( function () {
8453 widget.setValue( widget.$input.val() );
8454 } );
8455 }
8456 };
8457
8458 /**
8459 * Get the value of the input.
8460 *
8461 * @return {string} Input value
8462 */
8463 OO.ui.InputWidget.prototype.getValue = function () {
8464 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8465 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8466 var value = this.$input.val();
8467 if ( this.value !== value ) {
8468 this.setValue( value );
8469 }
8470 return this.value;
8471 };
8472
8473 /**
8474 * Set the directionality of the input.
8475 *
8476 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8477 * @chainable
8478 */
8479 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8480 this.$input.prop( 'dir', dir );
8481 return this;
8482 };
8483
8484 /**
8485 * Set the value of the input.
8486 *
8487 * @param {string} value New value
8488 * @fires change
8489 * @chainable
8490 */
8491 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8492 value = this.cleanUpValue( value );
8493 // Update the DOM if it has changed. Note that with cleanUpValue, it
8494 // is possible for the DOM value to change without this.value changing.
8495 if ( this.$input.val() !== value ) {
8496 this.$input.val( value );
8497 }
8498 if ( this.value !== value ) {
8499 this.value = value;
8500 this.emit( 'change', this.value );
8501 }
8502 return this;
8503 };
8504
8505 /**
8506 * Clean up incoming value.
8507 *
8508 * Ensures value is a string, and converts undefined and null to empty string.
8509 *
8510 * @private
8511 * @param {string} value Original value
8512 * @return {string} Cleaned up value
8513 */
8514 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8515 if ( value === undefined || value === null ) {
8516 return '';
8517 } else if ( this.inputFilter ) {
8518 return this.inputFilter( String( value ) );
8519 } else {
8520 return String( value );
8521 }
8522 };
8523
8524 /**
8525 * @inheritdoc
8526 */
8527 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8528 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8529 if ( this.$input ) {
8530 this.$input.prop( 'disabled', this.isDisabled() );
8531 }
8532 return this;
8533 };
8534
8535 /**
8536 * Set the 'id' attribute of the `<input>` element.
8537 *
8538 * @param {string} id
8539 * @chainable
8540 */
8541 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8542 this.$input.attr( 'id', id );
8543 return this;
8544 };
8545
8546 /**
8547 * @inheritdoc
8548 */
8549 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8550 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8551 if ( state.value !== undefined && state.value !== this.getValue() ) {
8552 this.setValue( state.value );
8553 }
8554 if ( state.focus ) {
8555 this.focus();
8556 }
8557 };
8558
8559 /**
8560 * Data widget intended for creating 'hidden'-type inputs.
8561 *
8562 * @class
8563 * @extends OO.ui.Widget
8564 *
8565 * @constructor
8566 * @param {Object} [config] Configuration options
8567 * @cfg {string} [value=''] The value of the input.
8568 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8569 */
8570 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8571 // Configuration initialization
8572 config = $.extend( { value: '', name: '' }, config );
8573
8574 // Parent constructor
8575 OO.ui.HiddenInputWidget.parent.call( this, config );
8576
8577 // Initialization
8578 this.$element.attr( {
8579 type: 'hidden',
8580 value: config.value,
8581 name: config.name
8582 } );
8583 this.$element.removeAttr( 'aria-disabled' );
8584 };
8585
8586 /* Setup */
8587
8588 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8589
8590 /* Static Properties */
8591
8592 /**
8593 * @static
8594 * @inheritdoc
8595 */
8596 OO.ui.HiddenInputWidget.static.tagName = 'input';
8597
8598 /**
8599 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8600 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8601 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8602 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8603 * [OOjs UI documentation on MediaWiki] [1] for more information.
8604 *
8605 * @example
8606 * // A ButtonInputWidget rendered as an HTML button, the default.
8607 * var button = new OO.ui.ButtonInputWidget( {
8608 * label: 'Input button',
8609 * icon: 'check',
8610 * value: 'check'
8611 * } );
8612 * $( 'body' ).append( button.$element );
8613 *
8614 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8615 *
8616 * @class
8617 * @extends OO.ui.InputWidget
8618 * @mixins OO.ui.mixin.ButtonElement
8619 * @mixins OO.ui.mixin.IconElement
8620 * @mixins OO.ui.mixin.IndicatorElement
8621 * @mixins OO.ui.mixin.LabelElement
8622 * @mixins OO.ui.mixin.TitledElement
8623 *
8624 * @constructor
8625 * @param {Object} [config] Configuration options
8626 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8627 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8628 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8629 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8630 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8631 */
8632 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8633 // Configuration initialization
8634 config = $.extend( { type: 'button', useInputTag: false }, config );
8635
8636 // See InputWidget#reusePreInfuseDOM about config.$input
8637 if ( config.$input ) {
8638 config.$input.empty();
8639 }
8640
8641 // Properties (must be set before parent constructor, which calls #setValue)
8642 this.useInputTag = config.useInputTag;
8643
8644 // Parent constructor
8645 OO.ui.ButtonInputWidget.parent.call( this, config );
8646
8647 // Mixin constructors
8648 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8649 OO.ui.mixin.IconElement.call( this, config );
8650 OO.ui.mixin.IndicatorElement.call( this, config );
8651 OO.ui.mixin.LabelElement.call( this, config );
8652 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8653
8654 // Initialization
8655 if ( !config.useInputTag ) {
8656 this.$input.append( this.$icon, this.$label, this.$indicator );
8657 }
8658 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8659 };
8660
8661 /* Setup */
8662
8663 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8664 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8665 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8666 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8667 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8668 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8669
8670 /* Static Properties */
8671
8672 /**
8673 * @static
8674 * @inheritdoc
8675 */
8676 OO.ui.ButtonInputWidget.static.tagName = 'span';
8677
8678 /* Methods */
8679
8680 /**
8681 * @inheritdoc
8682 * @protected
8683 */
8684 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8685 var type;
8686 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8687 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8688 };
8689
8690 /**
8691 * Set label value.
8692 *
8693 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8694 *
8695 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8696 * text, or `null` for no label
8697 * @chainable
8698 */
8699 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8700 if ( typeof label === 'function' ) {
8701 label = OO.ui.resolveMsg( label );
8702 }
8703
8704 if ( this.useInputTag ) {
8705 // Discard non-plaintext labels
8706 if ( typeof label !== 'string' ) {
8707 label = '';
8708 }
8709
8710 this.$input.val( label );
8711 }
8712
8713 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8714 };
8715
8716 /**
8717 * Set the value of the input.
8718 *
8719 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8720 * they do not support {@link #value values}.
8721 *
8722 * @param {string} value New value
8723 * @chainable
8724 */
8725 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8726 if ( !this.useInputTag ) {
8727 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8728 }
8729 return this;
8730 };
8731
8732 /**
8733 * @inheritdoc
8734 */
8735 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8736 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8737 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
8738 return null;
8739 };
8740
8741 /**
8742 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
8743 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
8744 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
8745 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
8746 *
8747 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
8748 *
8749 * @example
8750 * // An example of selected, unselected, and disabled checkbox inputs
8751 * var checkbox1=new OO.ui.CheckboxInputWidget( {
8752 * value: 'a',
8753 * selected: true
8754 * } );
8755 * var checkbox2=new OO.ui.CheckboxInputWidget( {
8756 * value: 'b'
8757 * } );
8758 * var checkbox3=new OO.ui.CheckboxInputWidget( {
8759 * value:'c',
8760 * disabled: true
8761 * } );
8762 * // Create a fieldset layout with fields for each checkbox.
8763 * var fieldset = new OO.ui.FieldsetLayout( {
8764 * label: 'Checkboxes'
8765 * } );
8766 * fieldset.addItems( [
8767 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
8768 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
8769 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
8770 * ] );
8771 * $( 'body' ).append( fieldset.$element );
8772 *
8773 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8774 *
8775 * @class
8776 * @extends OO.ui.InputWidget
8777 *
8778 * @constructor
8779 * @param {Object} [config] Configuration options
8780 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
8781 */
8782 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
8783 // Configuration initialization
8784 config = config || {};
8785
8786 // Parent constructor
8787 OO.ui.CheckboxInputWidget.parent.call( this, config );
8788
8789 // Initialization
8790 this.$element
8791 .addClass( 'oo-ui-checkboxInputWidget' )
8792 // Required for pretty styling in WikimediaUI theme
8793 .append( $( '<span>' ) );
8794 this.setSelected( config.selected !== undefined ? config.selected : false );
8795 };
8796
8797 /* Setup */
8798
8799 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
8800
8801 /* Static Properties */
8802
8803 /**
8804 * @static
8805 * @inheritdoc
8806 */
8807 OO.ui.CheckboxInputWidget.static.tagName = 'span';
8808
8809 /* Static Methods */
8810
8811 /**
8812 * @inheritdoc
8813 */
8814 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
8815 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
8816 state.checked = config.$input.prop( 'checked' );
8817 return state;
8818 };
8819
8820 /* Methods */
8821
8822 /**
8823 * @inheritdoc
8824 * @protected
8825 */
8826 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
8827 return $( '<input>' ).attr( 'type', 'checkbox' );
8828 };
8829
8830 /**
8831 * @inheritdoc
8832 */
8833 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
8834 var widget = this;
8835 if ( !this.isDisabled() ) {
8836 // Allow the stack to clear so the value will be updated
8837 setTimeout( function () {
8838 widget.setSelected( widget.$input.prop( 'checked' ) );
8839 } );
8840 }
8841 };
8842
8843 /**
8844 * Set selection state of this checkbox.
8845 *
8846 * @param {boolean} state `true` for selected
8847 * @chainable
8848 */
8849 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
8850 state = !!state;
8851 if ( this.selected !== state ) {
8852 this.selected = state;
8853 this.$input.prop( 'checked', this.selected );
8854 this.emit( 'change', this.selected );
8855 }
8856 return this;
8857 };
8858
8859 /**
8860 * Check if this checkbox is selected.
8861 *
8862 * @return {boolean} Checkbox is selected
8863 */
8864 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
8865 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8866 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8867 var selected = this.$input.prop( 'checked' );
8868 if ( this.selected !== selected ) {
8869 this.setSelected( selected );
8870 }
8871 return this.selected;
8872 };
8873
8874 /**
8875 * @inheritdoc
8876 */
8877 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
8878 if ( !this.isDisabled() ) {
8879 this.$input.click();
8880 }
8881 this.focus();
8882 };
8883
8884 /**
8885 * @inheritdoc
8886 */
8887 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
8888 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8889 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
8890 this.setSelected( state.checked );
8891 }
8892 };
8893
8894 /**
8895 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
8896 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
8897 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
8898 * more information about input widgets.
8899 *
8900 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
8901 * are no options. If no `value` configuration option is provided, the first option is selected.
8902 * If you need a state representing no value (no option being selected), use a DropdownWidget.
8903 *
8904 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
8905 *
8906 * @example
8907 * // Example: A DropdownInputWidget with three options
8908 * var dropdownInput = new OO.ui.DropdownInputWidget( {
8909 * options: [
8910 * { data: 'a', label: 'First' },
8911 * { data: 'b', label: 'Second'},
8912 * { data: 'c', label: 'Third' }
8913 * ]
8914 * } );
8915 * $( 'body' ).append( dropdownInput.$element );
8916 *
8917 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8918 *
8919 * @class
8920 * @extends OO.ui.InputWidget
8921 * @mixins OO.ui.mixin.TitledElement
8922 *
8923 * @constructor
8924 * @param {Object} [config] Configuration options
8925 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
8926 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
8927 */
8928 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
8929 // Configuration initialization
8930 config = config || {};
8931
8932 // See InputWidget#reusePreInfuseDOM about config.$input
8933 if ( config.$input ) {
8934 config.$input.addClass( 'oo-ui-element-hidden' );
8935 }
8936
8937 // Properties (must be done before parent constructor which calls #setDisabled)
8938 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
8939
8940 // Parent constructor
8941 OO.ui.DropdownInputWidget.parent.call( this, config );
8942
8943 // Mixin constructors
8944 OO.ui.mixin.TitledElement.call( this, config );
8945
8946 // Events
8947 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
8948
8949 // Initialization
8950 this.setOptions( config.options || [] );
8951 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
8952 // widget has no valid options when it happens.
8953 this.setValue( config.value );
8954 this.$element
8955 .addClass( 'oo-ui-dropdownInputWidget' )
8956 .append( this.dropdownWidget.$element );
8957 this.setTabIndexedElement( null );
8958 };
8959
8960 /* Setup */
8961
8962 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
8963 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
8964
8965 /* Methods */
8966
8967 /**
8968 * @inheritdoc
8969 * @protected
8970 */
8971 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
8972 return $( '<input>' ).attr( 'type', 'hidden' );
8973 };
8974
8975 /**
8976 * Handles menu select events.
8977 *
8978 * @private
8979 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
8980 */
8981 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
8982 this.setValue( item ? item.getData() : '' );
8983 };
8984
8985 /**
8986 * @inheritdoc
8987 */
8988 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
8989 var selected;
8990 value = this.cleanUpValue( value );
8991 // Only allow setting values that are actually present in the dropdown
8992 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
8993 this.dropdownWidget.getMenu().findFirstSelectableItem();
8994 this.dropdownWidget.getMenu().selectItem( selected );
8995 value = selected ? selected.getData() : '';
8996 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
8997 return this;
8998 };
8999
9000 /**
9001 * @inheritdoc
9002 */
9003 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9004 this.dropdownWidget.setDisabled( state );
9005 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9006 return this;
9007 };
9008
9009 /**
9010 * Set the options available for this input.
9011 *
9012 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9013 * @chainable
9014 */
9015 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9016 var
9017 value = this.getValue(),
9018 widget = this;
9019
9020 // Rebuild the dropdown menu
9021 this.dropdownWidget.getMenu()
9022 .clearItems()
9023 .addItems( options.map( function ( opt ) {
9024 var optValue = widget.cleanUpValue( opt.data );
9025
9026 if ( opt.optgroup === undefined ) {
9027 return new OO.ui.MenuOptionWidget( {
9028 data: optValue,
9029 label: opt.label !== undefined ? opt.label : optValue
9030 } );
9031 } else {
9032 return new OO.ui.MenuSectionOptionWidget( {
9033 label: opt.optgroup
9034 } );
9035 }
9036 } ) );
9037
9038 // Restore the previous value, or reset to something sensible
9039 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9040 // Previous value is still available, ensure consistency with the dropdown
9041 this.setValue( value );
9042 } else {
9043 // No longer valid, reset
9044 if ( options.length ) {
9045 this.setValue( options[ 0 ].data );
9046 }
9047 }
9048
9049 return this;
9050 };
9051
9052 /**
9053 * @inheritdoc
9054 */
9055 OO.ui.DropdownInputWidget.prototype.focus = function () {
9056 this.dropdownWidget.focus();
9057 return this;
9058 };
9059
9060 /**
9061 * @inheritdoc
9062 */
9063 OO.ui.DropdownInputWidget.prototype.blur = function () {
9064 this.dropdownWidget.blur();
9065 return this;
9066 };
9067
9068 /**
9069 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9070 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9071 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9072 * please see the [OOjs UI documentation on MediaWiki][1].
9073 *
9074 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9075 *
9076 * @example
9077 * // An example of selected, unselected, and disabled radio inputs
9078 * var radio1 = new OO.ui.RadioInputWidget( {
9079 * value: 'a',
9080 * selected: true
9081 * } );
9082 * var radio2 = new OO.ui.RadioInputWidget( {
9083 * value: 'b'
9084 * } );
9085 * var radio3 = new OO.ui.RadioInputWidget( {
9086 * value: 'c',
9087 * disabled: true
9088 * } );
9089 * // Create a fieldset layout with fields for each radio button.
9090 * var fieldset = new OO.ui.FieldsetLayout( {
9091 * label: 'Radio inputs'
9092 * } );
9093 * fieldset.addItems( [
9094 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9095 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9096 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9097 * ] );
9098 * $( 'body' ).append( fieldset.$element );
9099 *
9100 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9101 *
9102 * @class
9103 * @extends OO.ui.InputWidget
9104 *
9105 * @constructor
9106 * @param {Object} [config] Configuration options
9107 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9108 */
9109 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9110 // Configuration initialization
9111 config = config || {};
9112
9113 // Parent constructor
9114 OO.ui.RadioInputWidget.parent.call( this, config );
9115
9116 // Initialization
9117 this.$element
9118 .addClass( 'oo-ui-radioInputWidget' )
9119 // Required for pretty styling in WikimediaUI theme
9120 .append( $( '<span>' ) );
9121 this.setSelected( config.selected !== undefined ? config.selected : false );
9122 };
9123
9124 /* Setup */
9125
9126 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9127
9128 /* Static Properties */
9129
9130 /**
9131 * @static
9132 * @inheritdoc
9133 */
9134 OO.ui.RadioInputWidget.static.tagName = 'span';
9135
9136 /* Static Methods */
9137
9138 /**
9139 * @inheritdoc
9140 */
9141 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9142 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9143 state.checked = config.$input.prop( 'checked' );
9144 return state;
9145 };
9146
9147 /* Methods */
9148
9149 /**
9150 * @inheritdoc
9151 * @protected
9152 */
9153 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9154 return $( '<input>' ).attr( 'type', 'radio' );
9155 };
9156
9157 /**
9158 * @inheritdoc
9159 */
9160 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9161 // RadioInputWidget doesn't track its state.
9162 };
9163
9164 /**
9165 * Set selection state of this radio button.
9166 *
9167 * @param {boolean} state `true` for selected
9168 * @chainable
9169 */
9170 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9171 // RadioInputWidget doesn't track its state.
9172 this.$input.prop( 'checked', state );
9173 return this;
9174 };
9175
9176 /**
9177 * Check if this radio button is selected.
9178 *
9179 * @return {boolean} Radio is selected
9180 */
9181 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9182 return this.$input.prop( 'checked' );
9183 };
9184
9185 /**
9186 * @inheritdoc
9187 */
9188 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9189 if ( !this.isDisabled() ) {
9190 this.$input.click();
9191 }
9192 this.focus();
9193 };
9194
9195 /**
9196 * @inheritdoc
9197 */
9198 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9199 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9200 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9201 this.setSelected( state.checked );
9202 }
9203 };
9204
9205 /**
9206 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9207 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9208 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9209 * more information about input widgets.
9210 *
9211 * This and OO.ui.DropdownInputWidget support the same configuration options.
9212 *
9213 * @example
9214 * // Example: A RadioSelectInputWidget with three options
9215 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9216 * options: [
9217 * { data: 'a', label: 'First' },
9218 * { data: 'b', label: 'Second'},
9219 * { data: 'c', label: 'Third' }
9220 * ]
9221 * } );
9222 * $( 'body' ).append( radioSelectInput.$element );
9223 *
9224 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9225 *
9226 * @class
9227 * @extends OO.ui.InputWidget
9228 *
9229 * @constructor
9230 * @param {Object} [config] Configuration options
9231 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9232 */
9233 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9234 // Configuration initialization
9235 config = config || {};
9236
9237 // Properties (must be done before parent constructor which calls #setDisabled)
9238 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9239
9240 // Parent constructor
9241 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9242
9243 // Events
9244 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9245
9246 // Initialization
9247 this.setOptions( config.options || [] );
9248 this.$element
9249 .addClass( 'oo-ui-radioSelectInputWidget' )
9250 .append( this.radioSelectWidget.$element );
9251 this.setTabIndexedElement( null );
9252 };
9253
9254 /* Setup */
9255
9256 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9257
9258 /* Static Methods */
9259
9260 /**
9261 * @inheritdoc
9262 */
9263 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9264 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9265 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9266 return state;
9267 };
9268
9269 /**
9270 * @inheritdoc
9271 */
9272 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9273 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9274 // Cannot reuse the `<input type=radio>` set
9275 delete config.$input;
9276 return config;
9277 };
9278
9279 /* Methods */
9280
9281 /**
9282 * @inheritdoc
9283 * @protected
9284 */
9285 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9286 return $( '<input>' ).attr( 'type', 'hidden' );
9287 };
9288
9289 /**
9290 * Handles menu select events.
9291 *
9292 * @private
9293 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9294 */
9295 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9296 this.setValue( item.getData() );
9297 };
9298
9299 /**
9300 * @inheritdoc
9301 */
9302 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9303 value = this.cleanUpValue( value );
9304 this.radioSelectWidget.selectItemByData( value );
9305 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9306 return this;
9307 };
9308
9309 /**
9310 * @inheritdoc
9311 */
9312 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9313 this.radioSelectWidget.setDisabled( state );
9314 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9315 return this;
9316 };
9317
9318 /**
9319 * Set the options available for this input.
9320 *
9321 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9322 * @chainable
9323 */
9324 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9325 var
9326 value = this.getValue(),
9327 widget = this;
9328
9329 // Rebuild the radioSelect menu
9330 this.radioSelectWidget
9331 .clearItems()
9332 .addItems( options.map( function ( opt ) {
9333 var optValue = widget.cleanUpValue( opt.data );
9334 return new OO.ui.RadioOptionWidget( {
9335 data: optValue,
9336 label: opt.label !== undefined ? opt.label : optValue
9337 } );
9338 } ) );
9339
9340 // Restore the previous value, or reset to something sensible
9341 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9342 // Previous value is still available, ensure consistency with the radioSelect
9343 this.setValue( value );
9344 } else {
9345 // No longer valid, reset
9346 if ( options.length ) {
9347 this.setValue( options[ 0 ].data );
9348 }
9349 }
9350
9351 return this;
9352 };
9353
9354 /**
9355 * @inheritdoc
9356 */
9357 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9358 this.radioSelectWidget.focus();
9359 return this;
9360 };
9361
9362 /**
9363 * @inheritdoc
9364 */
9365 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9366 this.radioSelectWidget.blur();
9367 return this;
9368 };
9369
9370 /**
9371 * CheckboxMultiselectInputWidget is a
9372 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9373 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9374 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9375 * more information about input widgets.
9376 *
9377 * @example
9378 * // Example: A CheckboxMultiselectInputWidget with three options
9379 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9380 * options: [
9381 * { data: 'a', label: 'First' },
9382 * { data: 'b', label: 'Second'},
9383 * { data: 'c', label: 'Third' }
9384 * ]
9385 * } );
9386 * $( 'body' ).append( multiselectInput.$element );
9387 *
9388 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9389 *
9390 * @class
9391 * @extends OO.ui.InputWidget
9392 *
9393 * @constructor
9394 * @param {Object} [config] Configuration options
9395 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9396 */
9397 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9398 // Configuration initialization
9399 config = config || {};
9400
9401 // Properties (must be done before parent constructor which calls #setDisabled)
9402 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9403
9404 // Parent constructor
9405 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9406
9407 // Properties
9408 this.inputName = config.name;
9409
9410 // Initialization
9411 this.$element
9412 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9413 .append( this.checkboxMultiselectWidget.$element );
9414 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9415 this.$input.detach();
9416 this.setOptions( config.options || [] );
9417 // Have to repeat this from parent, as we need options to be set up for this to make sense
9418 this.setValue( config.value );
9419
9420 // setValue when checkboxMultiselectWidget changes
9421 this.checkboxMultiselectWidget.on( 'change', function () {
9422 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9423 }.bind( this ) );
9424 };
9425
9426 /* Setup */
9427
9428 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9429
9430 /* Static Methods */
9431
9432 /**
9433 * @inheritdoc
9434 */
9435 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9436 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9437 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9438 .toArray().map( function ( el ) { return el.value; } );
9439 return state;
9440 };
9441
9442 /**
9443 * @inheritdoc
9444 */
9445 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9446 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9447 // Cannot reuse the `<input type=checkbox>` set
9448 delete config.$input;
9449 return config;
9450 };
9451
9452 /* Methods */
9453
9454 /**
9455 * @inheritdoc
9456 * @protected
9457 */
9458 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9459 // Actually unused
9460 return $( '<unused>' );
9461 };
9462
9463 /**
9464 * @inheritdoc
9465 */
9466 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9467 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9468 .toArray().map( function ( el ) { return el.value; } );
9469 if ( this.value !== value ) {
9470 this.setValue( value );
9471 }
9472 return this.value;
9473 };
9474
9475 /**
9476 * @inheritdoc
9477 */
9478 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9479 value = this.cleanUpValue( value );
9480 this.checkboxMultiselectWidget.selectItemsByData( value );
9481 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9482 return this;
9483 };
9484
9485 /**
9486 * Clean up incoming value.
9487 *
9488 * @param {string[]} value Original value
9489 * @return {string[]} Cleaned up value
9490 */
9491 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9492 var i, singleValue,
9493 cleanValue = [];
9494 if ( !Array.isArray( value ) ) {
9495 return cleanValue;
9496 }
9497 for ( i = 0; i < value.length; i++ ) {
9498 singleValue =
9499 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9500 // Remove options that we don't have here
9501 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9502 continue;
9503 }
9504 cleanValue.push( singleValue );
9505 }
9506 return cleanValue;
9507 };
9508
9509 /**
9510 * @inheritdoc
9511 */
9512 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9513 this.checkboxMultiselectWidget.setDisabled( state );
9514 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9515 return this;
9516 };
9517
9518 /**
9519 * Set the options available for this input.
9520 *
9521 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9522 * @chainable
9523 */
9524 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9525 var widget = this;
9526
9527 // Rebuild the checkboxMultiselectWidget menu
9528 this.checkboxMultiselectWidget
9529 .clearItems()
9530 .addItems( options.map( function ( opt ) {
9531 var optValue, item, optDisabled;
9532 optValue =
9533 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9534 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9535 item = new OO.ui.CheckboxMultioptionWidget( {
9536 data: optValue,
9537 label: opt.label !== undefined ? opt.label : optValue,
9538 disabled: optDisabled
9539 } );
9540 // Set the 'name' and 'value' for form submission
9541 item.checkbox.$input.attr( 'name', widget.inputName );
9542 item.checkbox.setValue( optValue );
9543 return item;
9544 } ) );
9545
9546 // Re-set the value, checking the checkboxes as needed.
9547 // This will also get rid of any stale options that we just removed.
9548 this.setValue( this.getValue() );
9549
9550 return this;
9551 };
9552
9553 /**
9554 * @inheritdoc
9555 */
9556 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9557 this.checkboxMultiselectWidget.focus();
9558 return this;
9559 };
9560
9561 /**
9562 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9563 * size of the field as well as its presentation. In addition, these widgets can be configured
9564 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9565 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9566 * which modifies incoming values rather than validating them.
9567 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9568 *
9569 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9570 *
9571 * @example
9572 * // Example of a text input widget
9573 * var textInput = new OO.ui.TextInputWidget( {
9574 * value: 'Text input'
9575 * } )
9576 * $( 'body' ).append( textInput.$element );
9577 *
9578 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9579 *
9580 * @class
9581 * @extends OO.ui.InputWidget
9582 * @mixins OO.ui.mixin.IconElement
9583 * @mixins OO.ui.mixin.IndicatorElement
9584 * @mixins OO.ui.mixin.PendingElement
9585 * @mixins OO.ui.mixin.LabelElement
9586 *
9587 * @constructor
9588 * @param {Object} [config] Configuration options
9589 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9590 * 'email', 'url' or 'number'.
9591 * @cfg {string} [placeholder] Placeholder text
9592 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9593 * instruct the browser to focus this widget.
9594 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9595 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9596 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9597 * the value or placeholder text: `'before'` or `'after'`
9598 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9599 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9600 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9601 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9602 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9603 * value for it to be considered valid; when Function, a function receiving the value as parameter
9604 * that must return true, or promise resolving to true, for it to be considered valid.
9605 */
9606 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9607 // Configuration initialization
9608 config = $.extend( {
9609 type: 'text',
9610 labelPosition: 'after'
9611 }, config );
9612
9613 if ( config.multiline ) {
9614 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9615 return new OO.ui.MultilineTextInputWidget( config );
9616 }
9617
9618 // Parent constructor
9619 OO.ui.TextInputWidget.parent.call( this, config );
9620
9621 // Mixin constructors
9622 OO.ui.mixin.IconElement.call( this, config );
9623 OO.ui.mixin.IndicatorElement.call( this, config );
9624 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9625 OO.ui.mixin.LabelElement.call( this, config );
9626
9627 // Properties
9628 this.type = this.getSaneType( config );
9629 this.readOnly = false;
9630 this.required = false;
9631 this.validate = null;
9632 this.styleHeight = null;
9633 this.scrollWidth = null;
9634
9635 this.setValidation( config.validate );
9636 this.setLabelPosition( config.labelPosition );
9637
9638 // Events
9639 this.$input.on( {
9640 keypress: this.onKeyPress.bind( this ),
9641 blur: this.onBlur.bind( this ),
9642 focus: this.onFocus.bind( this )
9643 } );
9644 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9645 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9646 this.on( 'labelChange', this.updatePosition.bind( this ) );
9647 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9648
9649 // Initialization
9650 this.$element
9651 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9652 .append( this.$icon, this.$indicator );
9653 this.setReadOnly( !!config.readOnly );
9654 this.setRequired( !!config.required );
9655 if ( config.placeholder !== undefined ) {
9656 this.$input.attr( 'placeholder', config.placeholder );
9657 }
9658 if ( config.maxLength !== undefined ) {
9659 this.$input.attr( 'maxlength', config.maxLength );
9660 }
9661 if ( config.autofocus ) {
9662 this.$input.attr( 'autofocus', 'autofocus' );
9663 }
9664 if ( config.autocomplete === false ) {
9665 this.$input.attr( 'autocomplete', 'off' );
9666 // Turning off autocompletion also disables "form caching" when the user navigates to a
9667 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9668 $( window ).on( {
9669 beforeunload: function () {
9670 this.$input.removeAttr( 'autocomplete' );
9671 }.bind( this ),
9672 pageshow: function () {
9673 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9674 // whole page... it shouldn't hurt, though.
9675 this.$input.attr( 'autocomplete', 'off' );
9676 }.bind( this )
9677 } );
9678 }
9679 if ( this.label ) {
9680 this.isWaitingToBeAttached = true;
9681 this.installParentChangeDetector();
9682 }
9683 };
9684
9685 /* Setup */
9686
9687 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9688 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9689 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9690 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9691 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9692
9693 /* Static Properties */
9694
9695 OO.ui.TextInputWidget.static.validationPatterns = {
9696 'non-empty': /.+/,
9697 integer: /^\d+$/
9698 };
9699
9700 /* Static Methods */
9701
9702 /**
9703 * @inheritdoc
9704 */
9705 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9706 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9707 return state;
9708 };
9709
9710 /* Events */
9711
9712 /**
9713 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9714 *
9715 * @event enter
9716 */
9717
9718 /* Methods */
9719
9720 /**
9721 * Handle icon mouse down events.
9722 *
9723 * @private
9724 * @param {jQuery.Event} e Mouse down event
9725 */
9726 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
9727 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9728 this.focus();
9729 return false;
9730 }
9731 };
9732
9733 /**
9734 * Handle indicator mouse down events.
9735 *
9736 * @private
9737 * @param {jQuery.Event} e Mouse down event
9738 */
9739 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
9740 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9741 this.focus();
9742 return false;
9743 }
9744 };
9745
9746 /**
9747 * Handle key press events.
9748 *
9749 * @private
9750 * @param {jQuery.Event} e Key press event
9751 * @fires enter If enter key is pressed
9752 */
9753 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
9754 if ( e.which === OO.ui.Keys.ENTER ) {
9755 this.emit( 'enter', e );
9756 }
9757 };
9758
9759 /**
9760 * Handle blur events.
9761 *
9762 * @private
9763 * @param {jQuery.Event} e Blur event
9764 */
9765 OO.ui.TextInputWidget.prototype.onBlur = function () {
9766 this.setValidityFlag();
9767 };
9768
9769 /**
9770 * Handle focus events.
9771 *
9772 * @private
9773 * @param {jQuery.Event} e Focus event
9774 */
9775 OO.ui.TextInputWidget.prototype.onFocus = function () {
9776 if ( this.isWaitingToBeAttached ) {
9777 // If we've received focus, then we must be attached to the document, and if
9778 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
9779 this.onElementAttach();
9780 }
9781 this.setValidityFlag( true );
9782 };
9783
9784 /**
9785 * Handle element attach events.
9786 *
9787 * @private
9788 * @param {jQuery.Event} e Element attach event
9789 */
9790 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
9791 this.isWaitingToBeAttached = false;
9792 // Any previously calculated size is now probably invalid if we reattached elsewhere
9793 this.valCache = null;
9794 this.positionLabel();
9795 };
9796
9797 /**
9798 * Handle debounced change events.
9799 *
9800 * @param {string} value
9801 * @private
9802 */
9803 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
9804 this.setValidityFlag();
9805 };
9806
9807 /**
9808 * Check if the input is {@link #readOnly read-only}.
9809 *
9810 * @return {boolean}
9811 */
9812 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
9813 return this.readOnly;
9814 };
9815
9816 /**
9817 * Set the {@link #readOnly read-only} state of the input.
9818 *
9819 * @param {boolean} state Make input read-only
9820 * @chainable
9821 */
9822 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
9823 this.readOnly = !!state;
9824 this.$input.prop( 'readOnly', this.readOnly );
9825 return this;
9826 };
9827
9828 /**
9829 * Check if the input is {@link #required required}.
9830 *
9831 * @return {boolean}
9832 */
9833 OO.ui.TextInputWidget.prototype.isRequired = function () {
9834 return this.required;
9835 };
9836
9837 /**
9838 * Set the {@link #required required} state of the input.
9839 *
9840 * @param {boolean} state Make input required
9841 * @chainable
9842 */
9843 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
9844 this.required = !!state;
9845 if ( this.required ) {
9846 this.$input
9847 .prop( 'required', true )
9848 .attr( 'aria-required', 'true' );
9849 if ( this.getIndicator() === null ) {
9850 this.setIndicator( 'required' );
9851 }
9852 } else {
9853 this.$input
9854 .prop( 'required', false )
9855 .removeAttr( 'aria-required' );
9856 if ( this.getIndicator() === 'required' ) {
9857 this.setIndicator( null );
9858 }
9859 }
9860 return this;
9861 };
9862
9863 /**
9864 * Support function for making #onElementAttach work across browsers.
9865 *
9866 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
9867 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
9868 *
9869 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
9870 * first time that the element gets attached to the documented.
9871 */
9872 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
9873 var mutationObserver, onRemove, topmostNode, fakeParentNode,
9874 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
9875 widget = this;
9876
9877 if ( MutationObserver ) {
9878 // The new way. If only it wasn't so ugly.
9879
9880 if ( this.isElementAttached() ) {
9881 // Widget is attached already, do nothing. This breaks the functionality of this function when
9882 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
9883 // would require observation of the whole document, which would hurt performance of other,
9884 // more important code.
9885 return;
9886 }
9887
9888 // Find topmost node in the tree
9889 topmostNode = this.$element[ 0 ];
9890 while ( topmostNode.parentNode ) {
9891 topmostNode = topmostNode.parentNode;
9892 }
9893
9894 // We have no way to detect the $element being attached somewhere without observing the entire
9895 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
9896 // parent node of $element, and instead detect when $element is removed from it (and thus
9897 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
9898 // doesn't get attached, we end up back here and create the parent.
9899
9900 mutationObserver = new MutationObserver( function ( mutations ) {
9901 var i, j, removedNodes;
9902 for ( i = 0; i < mutations.length; i++ ) {
9903 removedNodes = mutations[ i ].removedNodes;
9904 for ( j = 0; j < removedNodes.length; j++ ) {
9905 if ( removedNodes[ j ] === topmostNode ) {
9906 setTimeout( onRemove, 0 );
9907 return;
9908 }
9909 }
9910 }
9911 } );
9912
9913 onRemove = function () {
9914 // If the node was attached somewhere else, report it
9915 if ( widget.isElementAttached() ) {
9916 widget.onElementAttach();
9917 }
9918 mutationObserver.disconnect();
9919 widget.installParentChangeDetector();
9920 };
9921
9922 // Create a fake parent and observe it
9923 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
9924 mutationObserver.observe( fakeParentNode, { childList: true } );
9925 } else {
9926 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
9927 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
9928 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
9929 }
9930 };
9931
9932 /**
9933 * @inheritdoc
9934 * @protected
9935 */
9936 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
9937 if ( this.getSaneType( config ) === 'number' ) {
9938 return $( '<input>' )
9939 .attr( 'step', 'any' )
9940 .attr( 'type', 'number' );
9941 } else {
9942 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
9943 }
9944 };
9945
9946 /**
9947 * Get sanitized value for 'type' for given config.
9948 *
9949 * @param {Object} config Configuration options
9950 * @return {string|null}
9951 * @private
9952 */
9953 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
9954 var allowedTypes = [
9955 'text',
9956 'password',
9957 'email',
9958 'url',
9959 'number'
9960 ];
9961 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
9962 };
9963
9964 /**
9965 * Focus the input and select a specified range within the text.
9966 *
9967 * @param {number} from Select from offset
9968 * @param {number} [to] Select to offset, defaults to from
9969 * @chainable
9970 */
9971 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
9972 var isBackwards, start, end,
9973 input = this.$input[ 0 ];
9974
9975 to = to || from;
9976
9977 isBackwards = to < from;
9978 start = isBackwards ? to : from;
9979 end = isBackwards ? from : to;
9980
9981 this.focus();
9982
9983 try {
9984 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
9985 } catch ( e ) {
9986 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
9987 // Rather than expensively check if the input is attached every time, just check
9988 // if it was the cause of an error being thrown. If not, rethrow the error.
9989 if ( this.getElementDocument().body.contains( input ) ) {
9990 throw e;
9991 }
9992 }
9993 return this;
9994 };
9995
9996 /**
9997 * Get an object describing the current selection range in a directional manner
9998 *
9999 * @return {Object} Object containing 'from' and 'to' offsets
10000 */
10001 OO.ui.TextInputWidget.prototype.getRange = function () {
10002 var input = this.$input[ 0 ],
10003 start = input.selectionStart,
10004 end = input.selectionEnd,
10005 isBackwards = input.selectionDirection === 'backward';
10006
10007 return {
10008 from: isBackwards ? end : start,
10009 to: isBackwards ? start : end
10010 };
10011 };
10012
10013 /**
10014 * Get the length of the text input value.
10015 *
10016 * This could differ from the length of #getValue if the
10017 * value gets filtered
10018 *
10019 * @return {number} Input length
10020 */
10021 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10022 return this.$input[ 0 ].value.length;
10023 };
10024
10025 /**
10026 * Focus the input and select the entire text.
10027 *
10028 * @chainable
10029 */
10030 OO.ui.TextInputWidget.prototype.select = function () {
10031 return this.selectRange( 0, this.getInputLength() );
10032 };
10033
10034 /**
10035 * Focus the input and move the cursor to the start.
10036 *
10037 * @chainable
10038 */
10039 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10040 return this.selectRange( 0 );
10041 };
10042
10043 /**
10044 * Focus the input and move the cursor to the end.
10045 *
10046 * @chainable
10047 */
10048 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10049 return this.selectRange( this.getInputLength() );
10050 };
10051
10052 /**
10053 * Insert new content into the input.
10054 *
10055 * @param {string} content Content to be inserted
10056 * @chainable
10057 */
10058 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10059 var start, end,
10060 range = this.getRange(),
10061 value = this.getValue();
10062
10063 start = Math.min( range.from, range.to );
10064 end = Math.max( range.from, range.to );
10065
10066 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10067 this.selectRange( start + content.length );
10068 return this;
10069 };
10070
10071 /**
10072 * Insert new content either side of a selection.
10073 *
10074 * @param {string} pre Content to be inserted before the selection
10075 * @param {string} post Content to be inserted after the selection
10076 * @chainable
10077 */
10078 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10079 var start, end,
10080 range = this.getRange(),
10081 offset = pre.length;
10082
10083 start = Math.min( range.from, range.to );
10084 end = Math.max( range.from, range.to );
10085
10086 this.selectRange( start ).insertContent( pre );
10087 this.selectRange( offset + end ).insertContent( post );
10088
10089 this.selectRange( offset + start, offset + end );
10090 return this;
10091 };
10092
10093 /**
10094 * Set the validation pattern.
10095 *
10096 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10097 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10098 * value must contain only numbers).
10099 *
10100 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10101 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10102 */
10103 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10104 if ( validate instanceof RegExp || validate instanceof Function ) {
10105 this.validate = validate;
10106 } else {
10107 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10108 }
10109 };
10110
10111 /**
10112 * Sets the 'invalid' flag appropriately.
10113 *
10114 * @param {boolean} [isValid] Optionally override validation result
10115 */
10116 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10117 var widget = this,
10118 setFlag = function ( valid ) {
10119 if ( !valid ) {
10120 widget.$input.attr( 'aria-invalid', 'true' );
10121 } else {
10122 widget.$input.removeAttr( 'aria-invalid' );
10123 }
10124 widget.setFlags( { invalid: !valid } );
10125 };
10126
10127 if ( isValid !== undefined ) {
10128 setFlag( isValid );
10129 } else {
10130 this.getValidity().then( function () {
10131 setFlag( true );
10132 }, function () {
10133 setFlag( false );
10134 } );
10135 }
10136 };
10137
10138 /**
10139 * Get the validity of current value.
10140 *
10141 * This method returns a promise that resolves if the value is valid and rejects if
10142 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10143 *
10144 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10145 */
10146 OO.ui.TextInputWidget.prototype.getValidity = function () {
10147 var result;
10148
10149 function rejectOrResolve( valid ) {
10150 if ( valid ) {
10151 return $.Deferred().resolve().promise();
10152 } else {
10153 return $.Deferred().reject().promise();
10154 }
10155 }
10156
10157 // Check browser validity and reject if it is invalid
10158 if (
10159 this.$input[ 0 ].checkValidity !== undefined &&
10160 this.$input[ 0 ].checkValidity() === false
10161 ) {
10162 return rejectOrResolve( false );
10163 }
10164
10165 // Run our checks if the browser thinks the field is valid
10166 if ( this.validate instanceof Function ) {
10167 result = this.validate( this.getValue() );
10168 if ( result && $.isFunction( result.promise ) ) {
10169 return result.promise().then( function ( valid ) {
10170 return rejectOrResolve( valid );
10171 } );
10172 } else {
10173 return rejectOrResolve( result );
10174 }
10175 } else {
10176 return rejectOrResolve( this.getValue().match( this.validate ) );
10177 }
10178 };
10179
10180 /**
10181 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10182 *
10183 * @param {string} labelPosition Label position, 'before' or 'after'
10184 * @chainable
10185 */
10186 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10187 this.labelPosition = labelPosition;
10188 if ( this.label ) {
10189 // If there is no label and we only change the position, #updatePosition is a no-op,
10190 // but it takes really a lot of work to do nothing.
10191 this.updatePosition();
10192 }
10193 return this;
10194 };
10195
10196 /**
10197 * Update the position of the inline label.
10198 *
10199 * This method is called by #setLabelPosition, and can also be called on its own if
10200 * something causes the label to be mispositioned.
10201 *
10202 * @chainable
10203 */
10204 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10205 var after = this.labelPosition === 'after';
10206
10207 this.$element
10208 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10209 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10210
10211 this.valCache = null;
10212 this.scrollWidth = null;
10213 this.positionLabel();
10214
10215 return this;
10216 };
10217
10218 /**
10219 * Position the label by setting the correct padding on the input.
10220 *
10221 * @private
10222 * @chainable
10223 */
10224 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10225 var after, rtl, property, newCss;
10226
10227 if ( this.isWaitingToBeAttached ) {
10228 // #onElementAttach will be called soon, which calls this method
10229 return this;
10230 }
10231
10232 newCss = {
10233 'padding-right': '',
10234 'padding-left': ''
10235 };
10236
10237 if ( this.label ) {
10238 this.$element.append( this.$label );
10239 } else {
10240 this.$label.detach();
10241 // Clear old values if present
10242 this.$input.css( newCss );
10243 return;
10244 }
10245
10246 after = this.labelPosition === 'after';
10247 rtl = this.$element.css( 'direction' ) === 'rtl';
10248 property = after === rtl ? 'padding-left' : 'padding-right';
10249
10250 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10251 // We have to clear the padding on the other side, in case the element direction changed
10252 this.$input.css( newCss );
10253
10254 return this;
10255 };
10256
10257 /**
10258 * @inheritdoc
10259 */
10260 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10261 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10262 if ( state.scrollTop !== undefined ) {
10263 this.$input.scrollTop( state.scrollTop );
10264 }
10265 };
10266
10267 /**
10268 * @class
10269 * @extends OO.ui.TextInputWidget
10270 *
10271 * @constructor
10272 * @param {Object} [config] Configuration options
10273 */
10274 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10275 config = $.extend( {
10276 icon: 'search'
10277 }, config );
10278
10279 // Set type to text so that TextInputWidget doesn't
10280 // get stuck in an infinite loop.
10281 config.type = 'text';
10282
10283 // Parent constructor
10284 OO.ui.SearchInputWidget.parent.call( this, config );
10285
10286 // Events
10287 this.connect( this, {
10288 change: 'onChange'
10289 } );
10290
10291 // Initialization
10292 this.$element.addClass( 'oo-ui-textInputWidget-type-search' );
10293 this.updateSearchIndicator();
10294 this.connect( this, {
10295 disable: 'onDisable'
10296 } );
10297 };
10298
10299 /* Setup */
10300
10301 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10302
10303 /* Methods */
10304
10305 /**
10306 * @inheritdoc
10307 * @protected
10308 */
10309 OO.ui.SearchInputWidget.prototype.getInputElement = function () {
10310 return $( '<input>' ).attr( 'type', 'search' );
10311 };
10312
10313 /**
10314 * @inheritdoc
10315 */
10316 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10317 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10318 // Clear the text field
10319 this.setValue( '' );
10320 this.focus();
10321 return false;
10322 }
10323 };
10324
10325 /**
10326 * Update the 'clear' indicator displayed on type: 'search' text
10327 * fields, hiding it when the field is already empty or when it's not
10328 * editable.
10329 */
10330 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10331 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10332 this.setIndicator( null );
10333 } else {
10334 this.setIndicator( 'clear' );
10335 }
10336 };
10337
10338 /**
10339 * Handle change events.
10340 *
10341 * @private
10342 */
10343 OO.ui.SearchInputWidget.prototype.onChange = function () {
10344 this.updateSearchIndicator();
10345 };
10346
10347 /**
10348 * Handle disable events.
10349 *
10350 * @param {boolean} disabled Element is disabled
10351 * @private
10352 */
10353 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10354 this.updateSearchIndicator();
10355 };
10356
10357 /**
10358 * @inheritdoc
10359 */
10360 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10361 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10362 this.updateSearchIndicator();
10363 return this;
10364 };
10365
10366 /**
10367 * @class
10368 * @extends OO.ui.TextInputWidget
10369 *
10370 * @constructor
10371 * @param {Object} [config] Configuration options
10372 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10373 * specifies minimum number of rows to display.
10374 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10375 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10376 * Use the #maxRows config to specify a maximum number of displayed rows.
10377 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10378 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10379 */
10380 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10381 config = $.extend( {
10382 type: 'text'
10383 }, config );
10384 config.multiline = false;
10385 // Parent constructor
10386 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10387
10388 // Properties
10389 this.multiline = true;
10390 this.autosize = !!config.autosize;
10391 this.minRows = config.rows !== undefined ? config.rows : '';
10392 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10393
10394 // Clone for resizing
10395 if ( this.autosize ) {
10396 this.$clone = this.$input
10397 .clone()
10398 .insertAfter( this.$input )
10399 .attr( 'aria-hidden', 'true' )
10400 .addClass( 'oo-ui-element-hidden' );
10401 }
10402
10403 // Events
10404 this.connect( this, {
10405 change: 'onChange'
10406 } );
10407
10408 // Initialization
10409 if ( this.multiline && config.rows ) {
10410 this.$input.attr( 'rows', config.rows );
10411 }
10412 if ( this.autosize ) {
10413 this.isWaitingToBeAttached = true;
10414 this.installParentChangeDetector();
10415 }
10416 };
10417
10418 /* Setup */
10419
10420 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10421
10422 /* Static Methods */
10423
10424 /**
10425 * @inheritdoc
10426 */
10427 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10428 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10429 state.scrollTop = config.$input.scrollTop();
10430 return state;
10431 };
10432
10433 /* Methods */
10434
10435 /**
10436 * @inheritdoc
10437 */
10438 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10439 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10440 this.adjustSize();
10441 };
10442
10443 /**
10444 * Handle change events.
10445 *
10446 * @private
10447 */
10448 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10449 this.adjustSize();
10450 };
10451
10452 /**
10453 * @inheritdoc
10454 */
10455 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10456 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10457 this.adjustSize();
10458 };
10459
10460 /**
10461 * Override TextInputWidget so it doesn't emit the 'enter' event.
10462 *
10463 * @private
10464 * @param {jQuery.Event} e Key press event
10465 */
10466 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10467 return;
10468 };
10469
10470 /**
10471 * Automatically adjust the size of the text input.
10472 *
10473 * This only affects multiline inputs that are {@link #autosize autosized}.
10474 *
10475 * @chainable
10476 * @fires resize
10477 */
10478 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10479 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10480 idealHeight, newHeight, scrollWidth, property;
10481
10482 if ( this.$input.val() !== this.valCache ) {
10483 if ( this.autosize ) {
10484 this.$clone
10485 .val( this.$input.val() )
10486 .attr( 'rows', this.minRows )
10487 // Set inline height property to 0 to measure scroll height
10488 .css( 'height', 0 );
10489
10490 this.$clone.removeClass( 'oo-ui-element-hidden' );
10491
10492 this.valCache = this.$input.val();
10493
10494 scrollHeight = this.$clone[ 0 ].scrollHeight;
10495
10496 // Remove inline height property to measure natural heights
10497 this.$clone.css( 'height', '' );
10498 innerHeight = this.$clone.innerHeight();
10499 outerHeight = this.$clone.outerHeight();
10500
10501 // Measure max rows height
10502 this.$clone
10503 .attr( 'rows', this.maxRows )
10504 .css( 'height', 'auto' )
10505 .val( '' );
10506 maxInnerHeight = this.$clone.innerHeight();
10507
10508 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10509 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10510 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10511 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10512
10513 this.$clone.addClass( 'oo-ui-element-hidden' );
10514
10515 // Only apply inline height when expansion beyond natural height is needed
10516 // Use the difference between the inner and outer height as a buffer
10517 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10518 if ( newHeight !== this.styleHeight ) {
10519 this.$input.css( 'height', newHeight );
10520 this.styleHeight = newHeight;
10521 this.emit( 'resize' );
10522 }
10523 }
10524 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10525 if ( scrollWidth !== this.scrollWidth ) {
10526 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10527 // Reset
10528 this.$label.css( { right: '', left: '' } );
10529 this.$indicator.css( { right: '', left: '' } );
10530
10531 if ( scrollWidth ) {
10532 this.$indicator.css( property, scrollWidth );
10533 if ( this.labelPosition === 'after' ) {
10534 this.$label.css( property, scrollWidth );
10535 }
10536 }
10537
10538 this.scrollWidth = scrollWidth;
10539 this.positionLabel();
10540 }
10541 }
10542 return this;
10543 };
10544
10545 /**
10546 * @inheritdoc
10547 * @protected
10548 */
10549 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10550 return $( '<textarea>' );
10551 };
10552
10553 /**
10554 * Check if the input supports multiple lines.
10555 *
10556 * @return {boolean}
10557 */
10558 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10559 return !!this.multiline;
10560 };
10561
10562 /**
10563 * Check if the input automatically adjusts its size.
10564 *
10565 * @return {boolean}
10566 */
10567 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10568 return !!this.autosize;
10569 };
10570
10571 /**
10572 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10573 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10574 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10575 *
10576 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10577 * option, that option will appear to be selected.
10578 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10579 * input field.
10580 *
10581 * After the user chooses an option, its `data` will be used as a new value for the widget.
10582 * A `label` also can be specified for each option: if given, it will be shown instead of the
10583 * `data` in the dropdown menu.
10584 *
10585 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10586 *
10587 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10588 *
10589 * @example
10590 * // Example: A ComboBoxInputWidget.
10591 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10592 * value: 'Option 1',
10593 * options: [
10594 * { data: 'Option 1' },
10595 * { data: 'Option 2' },
10596 * { data: 'Option 3' }
10597 * ]
10598 * } );
10599 * $( 'body' ).append( comboBox.$element );
10600 *
10601 * @example
10602 * // Example: A ComboBoxInputWidget with additional option labels.
10603 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10604 * value: 'Option 1',
10605 * options: [
10606 * {
10607 * data: 'Option 1',
10608 * label: 'Option One'
10609 * },
10610 * {
10611 * data: 'Option 2',
10612 * label: 'Option Two'
10613 * },
10614 * {
10615 * data: 'Option 3',
10616 * label: 'Option Three'
10617 * }
10618 * ]
10619 * } );
10620 * $( 'body' ).append( comboBox.$element );
10621 *
10622 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10623 *
10624 * @class
10625 * @extends OO.ui.TextInputWidget
10626 *
10627 * @constructor
10628 * @param {Object} [config] Configuration options
10629 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10630 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10631 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10632 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10633 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10634 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10635 */
10636 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10637 // Configuration initialization
10638 config = $.extend( {
10639 autocomplete: false
10640 }, config );
10641
10642 // ComboBoxInputWidget shouldn't support `multiline`
10643 config.multiline = false;
10644
10645 // See InputWidget#reusePreInfuseDOM about `config.$input`
10646 if ( config.$input ) {
10647 config.$input.removeAttr( 'list' );
10648 }
10649
10650 // Parent constructor
10651 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10652
10653 // Properties
10654 this.$overlay = config.$overlay || this.$element;
10655 this.dropdownButton = new OO.ui.ButtonWidget( {
10656 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10657 indicator: 'down',
10658 disabled: this.disabled
10659 } );
10660 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10661 {
10662 widget: this,
10663 input: this,
10664 $floatableContainer: this.$element,
10665 disabled: this.isDisabled()
10666 },
10667 config.menu
10668 ) );
10669
10670 // Events
10671 this.connect( this, {
10672 change: 'onInputChange',
10673 enter: 'onInputEnter'
10674 } );
10675 this.dropdownButton.connect( this, {
10676 click: 'onDropdownButtonClick'
10677 } );
10678 this.menu.connect( this, {
10679 choose: 'onMenuChoose',
10680 add: 'onMenuItemsChange',
10681 remove: 'onMenuItemsChange'
10682 } );
10683
10684 // Initialization
10685 this.$input.attr( {
10686 role: 'combobox',
10687 'aria-owns': this.menu.getElementId(),
10688 'aria-autocomplete': 'list'
10689 } );
10690 // Do not override options set via config.menu.items
10691 if ( config.options !== undefined ) {
10692 this.setOptions( config.options );
10693 }
10694 this.$field = $( '<div>' )
10695 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10696 .append( this.$input, this.dropdownButton.$element );
10697 this.$element
10698 .addClass( 'oo-ui-comboBoxInputWidget' )
10699 .append( this.$field );
10700 this.$overlay.append( this.menu.$element );
10701 this.onMenuItemsChange();
10702 };
10703
10704 /* Setup */
10705
10706 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10707
10708 /* Methods */
10709
10710 /**
10711 * Get the combobox's menu.
10712 *
10713 * @return {OO.ui.MenuSelectWidget} Menu widget
10714 */
10715 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10716 return this.menu;
10717 };
10718
10719 /**
10720 * Get the combobox's text input widget.
10721 *
10722 * @return {OO.ui.TextInputWidget} Text input widget
10723 */
10724 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10725 return this;
10726 };
10727
10728 /**
10729 * Handle input change events.
10730 *
10731 * @private
10732 * @param {string} value New value
10733 */
10734 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
10735 var match = this.menu.getItemFromData( value );
10736
10737 this.menu.selectItem( match );
10738 if ( this.menu.findHighlightedItem() ) {
10739 this.menu.highlightItem( match );
10740 }
10741
10742 if ( !this.isDisabled() ) {
10743 this.menu.toggle( true );
10744 }
10745 };
10746
10747 /**
10748 * Handle input enter events.
10749 *
10750 * @private
10751 */
10752 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
10753 if ( !this.isDisabled() ) {
10754 this.menu.toggle( false );
10755 }
10756 };
10757
10758 /**
10759 * Handle button click events.
10760 *
10761 * @private
10762 */
10763 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
10764 this.menu.toggle();
10765 this.focus();
10766 };
10767
10768 /**
10769 * Handle menu choose events.
10770 *
10771 * @private
10772 * @param {OO.ui.OptionWidget} item Chosen item
10773 */
10774 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
10775 this.setValue( item.getData() );
10776 };
10777
10778 /**
10779 * Handle menu item change events.
10780 *
10781 * @private
10782 */
10783 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
10784 var match = this.menu.getItemFromData( this.getValue() );
10785 this.menu.selectItem( match );
10786 if ( this.menu.findHighlightedItem() ) {
10787 this.menu.highlightItem( match );
10788 }
10789 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
10790 };
10791
10792 /**
10793 * @inheritdoc
10794 */
10795 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
10796 // Parent method
10797 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
10798
10799 if ( this.dropdownButton ) {
10800 this.dropdownButton.setDisabled( this.isDisabled() );
10801 }
10802 if ( this.menu ) {
10803 this.menu.setDisabled( this.isDisabled() );
10804 }
10805
10806 return this;
10807 };
10808
10809 /**
10810 * Set the options available for this input.
10811 *
10812 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10813 * @chainable
10814 */
10815 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
10816 this.getMenu()
10817 .clearItems()
10818 .addItems( options.map( function ( opt ) {
10819 return new OO.ui.MenuOptionWidget( {
10820 data: opt.data,
10821 label: opt.label !== undefined ? opt.label : opt.data
10822 } );
10823 } ) );
10824
10825 return this;
10826 };
10827
10828 /**
10829 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
10830 * which is a widget that is specified by reference before any optional configuration settings.
10831 *
10832 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
10833 *
10834 * - **left**: The label is placed before the field-widget and aligned with the left margin.
10835 * A left-alignment is used for forms with many fields.
10836 * - **right**: The label is placed before the field-widget and aligned to the right margin.
10837 * A right-alignment is used for long but familiar forms which users tab through,
10838 * verifying the current field with a quick glance at the label.
10839 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
10840 * that users fill out from top to bottom.
10841 * - **inline**: The label is placed after the field-widget and aligned to the left.
10842 * An inline-alignment is best used with checkboxes or radio buttons.
10843 *
10844 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
10845 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
10846 *
10847 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
10848 *
10849 * @class
10850 * @extends OO.ui.Layout
10851 * @mixins OO.ui.mixin.LabelElement
10852 * @mixins OO.ui.mixin.TitledElement
10853 *
10854 * @constructor
10855 * @param {OO.ui.Widget} fieldWidget Field widget
10856 * @param {Object} [config] Configuration options
10857 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
10858 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
10859 * The array may contain strings or OO.ui.HtmlSnippet instances.
10860 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
10861 * The array may contain strings or OO.ui.HtmlSnippet instances.
10862 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
10863 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
10864 * For important messages, you are advised to use `notices`, as they are always shown.
10865 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
10866 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10867 *
10868 * @throws {Error} An error is thrown if no widget is specified
10869 */
10870 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
10871 // Allow passing positional parameters inside the config object
10872 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
10873 config = fieldWidget;
10874 fieldWidget = config.fieldWidget;
10875 }
10876
10877 // Make sure we have required constructor arguments
10878 if ( fieldWidget === undefined ) {
10879 throw new Error( 'Widget not found' );
10880 }
10881
10882 // Configuration initialization
10883 config = $.extend( { align: 'left' }, config );
10884
10885 // Parent constructor
10886 OO.ui.FieldLayout.parent.call( this, config );
10887
10888 // Mixin constructors
10889 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
10890 $label: $( '<label>' )
10891 } ) );
10892 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
10893
10894 // Properties
10895 this.fieldWidget = fieldWidget;
10896 this.errors = [];
10897 this.notices = [];
10898 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
10899 this.$messages = $( '<ul>' );
10900 this.$header = $( '<span>' );
10901 this.$body = $( '<div>' );
10902 this.align = null;
10903 if ( config.help ) {
10904 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
10905 $overlay: config.$overlay,
10906 popup: {
10907 padded: true
10908 },
10909 classes: [ 'oo-ui-fieldLayout-help' ],
10910 framed: false,
10911 icon: 'info'
10912 } );
10913 if ( config.help instanceof OO.ui.HtmlSnippet ) {
10914 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
10915 } else {
10916 this.popupButtonWidget.getPopup().$body.text( config.help );
10917 }
10918 this.$help = this.popupButtonWidget.$element;
10919 } else {
10920 this.$help = $( [] );
10921 }
10922
10923 // Events
10924 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
10925
10926 // Initialization
10927 if ( config.help ) {
10928 // Set the 'aria-describedby' attribute on the fieldWidget
10929 // Preference given to an input or a button
10930 (
10931 this.fieldWidget.$input ||
10932 this.fieldWidget.$button ||
10933 this.fieldWidget.$element
10934 ).attr(
10935 'aria-describedby',
10936 this.popupButtonWidget.getPopup().getBodyId()
10937 );
10938 }
10939 if ( this.fieldWidget.getInputId() ) {
10940 this.$label.attr( 'for', this.fieldWidget.getInputId() );
10941 } else {
10942 this.$label.on( 'click', function () {
10943 this.fieldWidget.simulateLabelClick();
10944 return false;
10945 }.bind( this ) );
10946 }
10947 this.$element
10948 .addClass( 'oo-ui-fieldLayout' )
10949 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
10950 .append( this.$body );
10951 this.$body.addClass( 'oo-ui-fieldLayout-body' );
10952 this.$header.addClass( 'oo-ui-fieldLayout-header' );
10953 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
10954 this.$field
10955 .addClass( 'oo-ui-fieldLayout-field' )
10956 .append( this.fieldWidget.$element );
10957
10958 this.setErrors( config.errors || [] );
10959 this.setNotices( config.notices || [] );
10960 this.setAlignment( config.align );
10961 // Call this again to take into account the widget's accessKey
10962 this.updateTitle();
10963 };
10964
10965 /* Setup */
10966
10967 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
10968 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
10969 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
10970
10971 /* Methods */
10972
10973 /**
10974 * Handle field disable events.
10975 *
10976 * @private
10977 * @param {boolean} value Field is disabled
10978 */
10979 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
10980 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
10981 };
10982
10983 /**
10984 * Get the widget contained by the field.
10985 *
10986 * @return {OO.ui.Widget} Field widget
10987 */
10988 OO.ui.FieldLayout.prototype.getField = function () {
10989 return this.fieldWidget;
10990 };
10991
10992 /**
10993 * Return `true` if the given field widget can be used with `'inline'` alignment (see
10994 * #setAlignment). Return `false` if it can't or if this can't be determined.
10995 *
10996 * @return {boolean}
10997 */
10998 OO.ui.FieldLayout.prototype.isFieldInline = function () {
10999 // This is very simplistic, but should be good enough.
11000 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11001 };
11002
11003 /**
11004 * @protected
11005 * @param {string} kind 'error' or 'notice'
11006 * @param {string|OO.ui.HtmlSnippet} text
11007 * @return {jQuery}
11008 */
11009 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11010 var $listItem, $icon, message;
11011 $listItem = $( '<li>' );
11012 if ( kind === 'error' ) {
11013 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11014 $listItem.attr( 'role', 'alert' );
11015 } else if ( kind === 'notice' ) {
11016 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
11017 } else {
11018 $icon = '';
11019 }
11020 message = new OO.ui.LabelWidget( { label: text } );
11021 $listItem
11022 .append( $icon, message.$element )
11023 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11024 return $listItem;
11025 };
11026
11027 /**
11028 * Set the field alignment mode.
11029 *
11030 * @private
11031 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11032 * @chainable
11033 */
11034 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11035 if ( value !== this.align ) {
11036 // Default to 'left'
11037 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11038 value = 'left';
11039 }
11040 // Validate
11041 if ( value === 'inline' && !this.isFieldInline() ) {
11042 value = 'top';
11043 }
11044 // Reorder elements
11045 if ( value === 'top' ) {
11046 this.$header.append( this.$label, this.$help );
11047 this.$body.append( this.$header, this.$field );
11048 } else if ( value === 'inline' ) {
11049 this.$header.append( this.$label, this.$help );
11050 this.$body.append( this.$field, this.$header );
11051 } else {
11052 this.$header.append( this.$label );
11053 this.$body.append( this.$header, this.$help, this.$field );
11054 }
11055 // Set classes. The following classes can be used here:
11056 // * oo-ui-fieldLayout-align-left
11057 // * oo-ui-fieldLayout-align-right
11058 // * oo-ui-fieldLayout-align-top
11059 // * oo-ui-fieldLayout-align-inline
11060 if ( this.align ) {
11061 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11062 }
11063 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11064 this.align = value;
11065 }
11066
11067 return this;
11068 };
11069
11070 /**
11071 * Set the list of error messages.
11072 *
11073 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11074 * The array may contain strings or OO.ui.HtmlSnippet instances.
11075 * @chainable
11076 */
11077 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11078 this.errors = errors.slice();
11079 this.updateMessages();
11080 return this;
11081 };
11082
11083 /**
11084 * Set the list of notice messages.
11085 *
11086 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11087 * The array may contain strings or OO.ui.HtmlSnippet instances.
11088 * @chainable
11089 */
11090 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11091 this.notices = notices.slice();
11092 this.updateMessages();
11093 return this;
11094 };
11095
11096 /**
11097 * Update the rendering of error and notice messages.
11098 *
11099 * @private
11100 */
11101 OO.ui.FieldLayout.prototype.updateMessages = function () {
11102 var i;
11103 this.$messages.empty();
11104
11105 if ( this.errors.length || this.notices.length ) {
11106 this.$body.after( this.$messages );
11107 } else {
11108 this.$messages.remove();
11109 return;
11110 }
11111
11112 for ( i = 0; i < this.notices.length; i++ ) {
11113 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11114 }
11115 for ( i = 0; i < this.errors.length; i++ ) {
11116 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11117 }
11118 };
11119
11120 /**
11121 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11122 * (This is a bit of a hack.)
11123 *
11124 * @protected
11125 * @param {string} title Tooltip label for 'title' attribute
11126 * @return {string}
11127 */
11128 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11129 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11130 return this.fieldWidget.formatTitleWithAccessKey( title );
11131 }
11132 return title;
11133 };
11134
11135 /**
11136 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11137 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11138 * is required and is specified before any optional configuration settings.
11139 *
11140 * Labels can be aligned in one of four ways:
11141 *
11142 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11143 * A left-alignment is used for forms with many fields.
11144 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11145 * A right-alignment is used for long but familiar forms which users tab through,
11146 * verifying the current field with a quick glance at the label.
11147 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11148 * that users fill out from top to bottom.
11149 * - **inline**: The label is placed after the field-widget and aligned to the left.
11150 * An inline-alignment is best used with checkboxes or radio buttons.
11151 *
11152 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11153 * text is specified.
11154 *
11155 * @example
11156 * // Example of an ActionFieldLayout
11157 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11158 * new OO.ui.TextInputWidget( {
11159 * placeholder: 'Field widget'
11160 * } ),
11161 * new OO.ui.ButtonWidget( {
11162 * label: 'Button'
11163 * } ),
11164 * {
11165 * label: 'An ActionFieldLayout. This label is aligned top',
11166 * align: 'top',
11167 * help: 'This is help text'
11168 * }
11169 * );
11170 *
11171 * $( 'body' ).append( actionFieldLayout.$element );
11172 *
11173 * @class
11174 * @extends OO.ui.FieldLayout
11175 *
11176 * @constructor
11177 * @param {OO.ui.Widget} fieldWidget Field widget
11178 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11179 * @param {Object} config
11180 */
11181 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11182 // Allow passing positional parameters inside the config object
11183 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11184 config = fieldWidget;
11185 fieldWidget = config.fieldWidget;
11186 buttonWidget = config.buttonWidget;
11187 }
11188
11189 // Parent constructor
11190 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11191
11192 // Properties
11193 this.buttonWidget = buttonWidget;
11194 this.$button = $( '<span>' );
11195 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11196
11197 // Initialization
11198 this.$element
11199 .addClass( 'oo-ui-actionFieldLayout' );
11200 this.$button
11201 .addClass( 'oo-ui-actionFieldLayout-button' )
11202 .append( this.buttonWidget.$element );
11203 this.$input
11204 .addClass( 'oo-ui-actionFieldLayout-input' )
11205 .append( this.fieldWidget.$element );
11206 this.$field
11207 .append( this.$input, this.$button );
11208 };
11209
11210 /* Setup */
11211
11212 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11213
11214 /**
11215 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11216 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11217 * configured with a label as well. For more information and examples,
11218 * please see the [OOjs UI documentation on MediaWiki][1].
11219 *
11220 * @example
11221 * // Example of a fieldset layout
11222 * var input1 = new OO.ui.TextInputWidget( {
11223 * placeholder: 'A text input field'
11224 * } );
11225 *
11226 * var input2 = new OO.ui.TextInputWidget( {
11227 * placeholder: 'A text input field'
11228 * } );
11229 *
11230 * var fieldset = new OO.ui.FieldsetLayout( {
11231 * label: 'Example of a fieldset layout'
11232 * } );
11233 *
11234 * fieldset.addItems( [
11235 * new OO.ui.FieldLayout( input1, {
11236 * label: 'Field One'
11237 * } ),
11238 * new OO.ui.FieldLayout( input2, {
11239 * label: 'Field Two'
11240 * } )
11241 * ] );
11242 * $( 'body' ).append( fieldset.$element );
11243 *
11244 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11245 *
11246 * @class
11247 * @extends OO.ui.Layout
11248 * @mixins OO.ui.mixin.IconElement
11249 * @mixins OO.ui.mixin.LabelElement
11250 * @mixins OO.ui.mixin.GroupElement
11251 *
11252 * @constructor
11253 * @param {Object} [config] Configuration options
11254 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11255 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11256 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11257 * For important messages, you are advised to use `notices`, as they are always shown.
11258 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11259 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11260 */
11261 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11262 // Configuration initialization
11263 config = config || {};
11264
11265 // Parent constructor
11266 OO.ui.FieldsetLayout.parent.call( this, config );
11267
11268 // Mixin constructors
11269 OO.ui.mixin.IconElement.call( this, config );
11270 OO.ui.mixin.LabelElement.call( this, config );
11271 OO.ui.mixin.GroupElement.call( this, config );
11272
11273 // Properties
11274 this.$header = $( '<legend>' );
11275 if ( config.help ) {
11276 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11277 $overlay: config.$overlay,
11278 popup: {
11279 padded: true
11280 },
11281 classes: [ 'oo-ui-fieldsetLayout-help' ],
11282 framed: false,
11283 icon: 'info'
11284 } );
11285 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11286 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11287 } else {
11288 this.popupButtonWidget.getPopup().$body.text( config.help );
11289 }
11290 this.$help = this.popupButtonWidget.$element;
11291 } else {
11292 this.$help = $( [] );
11293 }
11294
11295 // Initialization
11296 this.$header
11297 .addClass( 'oo-ui-fieldsetLayout-header' )
11298 .append( this.$icon, this.$label, this.$help );
11299 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11300 this.$element
11301 .addClass( 'oo-ui-fieldsetLayout' )
11302 .prepend( this.$header, this.$group );
11303 if ( Array.isArray( config.items ) ) {
11304 this.addItems( config.items );
11305 }
11306 };
11307
11308 /* Setup */
11309
11310 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11311 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11312 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11313 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11314
11315 /* Static Properties */
11316
11317 /**
11318 * @static
11319 * @inheritdoc
11320 */
11321 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11322
11323 /**
11324 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11325 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11326 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11327 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11328 *
11329 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11330 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11331 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11332 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11333 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11334 * often have simplified APIs to match the capabilities of HTML forms.
11335 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11336 *
11337 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11338 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11339 *
11340 * @example
11341 * // Example of a form layout that wraps a fieldset layout
11342 * var input1 = new OO.ui.TextInputWidget( {
11343 * placeholder: 'Username'
11344 * } );
11345 * var input2 = new OO.ui.TextInputWidget( {
11346 * placeholder: 'Password',
11347 * type: 'password'
11348 * } );
11349 * var submit = new OO.ui.ButtonInputWidget( {
11350 * label: 'Submit'
11351 * } );
11352 *
11353 * var fieldset = new OO.ui.FieldsetLayout( {
11354 * label: 'A form layout'
11355 * } );
11356 * fieldset.addItems( [
11357 * new OO.ui.FieldLayout( input1, {
11358 * label: 'Username',
11359 * align: 'top'
11360 * } ),
11361 * new OO.ui.FieldLayout( input2, {
11362 * label: 'Password',
11363 * align: 'top'
11364 * } ),
11365 * new OO.ui.FieldLayout( submit )
11366 * ] );
11367 * var form = new OO.ui.FormLayout( {
11368 * items: [ fieldset ],
11369 * action: '/api/formhandler',
11370 * method: 'get'
11371 * } )
11372 * $( 'body' ).append( form.$element );
11373 *
11374 * @class
11375 * @extends OO.ui.Layout
11376 * @mixins OO.ui.mixin.GroupElement
11377 *
11378 * @constructor
11379 * @param {Object} [config] Configuration options
11380 * @cfg {string} [method] HTML form `method` attribute
11381 * @cfg {string} [action] HTML form `action` attribute
11382 * @cfg {string} [enctype] HTML form `enctype` attribute
11383 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11384 */
11385 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11386 var action;
11387
11388 // Configuration initialization
11389 config = config || {};
11390
11391 // Parent constructor
11392 OO.ui.FormLayout.parent.call( this, config );
11393
11394 // Mixin constructors
11395 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11396
11397 // Events
11398 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11399
11400 // Make sure the action is safe
11401 action = config.action;
11402 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11403 action = './' + action;
11404 }
11405
11406 // Initialization
11407 this.$element
11408 .addClass( 'oo-ui-formLayout' )
11409 .attr( {
11410 method: config.method,
11411 action: action,
11412 enctype: config.enctype
11413 } );
11414 if ( Array.isArray( config.items ) ) {
11415 this.addItems( config.items );
11416 }
11417 };
11418
11419 /* Setup */
11420
11421 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11422 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11423
11424 /* Events */
11425
11426 /**
11427 * A 'submit' event is emitted when the form is submitted.
11428 *
11429 * @event submit
11430 */
11431
11432 /* Static Properties */
11433
11434 /**
11435 * @static
11436 * @inheritdoc
11437 */
11438 OO.ui.FormLayout.static.tagName = 'form';
11439
11440 /* Methods */
11441
11442 /**
11443 * Handle form submit events.
11444 *
11445 * @private
11446 * @param {jQuery.Event} e Submit event
11447 * @fires submit
11448 */
11449 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11450 if ( this.emit( 'submit' ) ) {
11451 return false;
11452 }
11453 };
11454
11455 /**
11456 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11457 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11458 *
11459 * @example
11460 * // Example of a panel layout
11461 * var panel = new OO.ui.PanelLayout( {
11462 * expanded: false,
11463 * framed: true,
11464 * padded: true,
11465 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11466 * } );
11467 * $( 'body' ).append( panel.$element );
11468 *
11469 * @class
11470 * @extends OO.ui.Layout
11471 *
11472 * @constructor
11473 * @param {Object} [config] Configuration options
11474 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11475 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11476 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11477 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11478 */
11479 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11480 // Configuration initialization
11481 config = $.extend( {
11482 scrollable: false,
11483 padded: false,
11484 expanded: true,
11485 framed: false
11486 }, config );
11487
11488 // Parent constructor
11489 OO.ui.PanelLayout.parent.call( this, config );
11490
11491 // Initialization
11492 this.$element.addClass( 'oo-ui-panelLayout' );
11493 if ( config.scrollable ) {
11494 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11495 }
11496 if ( config.padded ) {
11497 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11498 }
11499 if ( config.expanded ) {
11500 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11501 }
11502 if ( config.framed ) {
11503 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11504 }
11505 };
11506
11507 /* Setup */
11508
11509 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11510
11511 /* Methods */
11512
11513 /**
11514 * Focus the panel layout
11515 *
11516 * The default implementation just focuses the first focusable element in the panel
11517 */
11518 OO.ui.PanelLayout.prototype.focus = function () {
11519 OO.ui.findFocusable( this.$element ).focus();
11520 };
11521
11522 /**
11523 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11524 * items), with small margins between them. Convenient when you need to put a number of block-level
11525 * widgets on a single line next to each other.
11526 *
11527 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11528 *
11529 * @example
11530 * // HorizontalLayout with a text input and a label
11531 * var layout = new OO.ui.HorizontalLayout( {
11532 * items: [
11533 * new OO.ui.LabelWidget( { label: 'Label' } ),
11534 * new OO.ui.TextInputWidget( { value: 'Text' } )
11535 * ]
11536 * } );
11537 * $( 'body' ).append( layout.$element );
11538 *
11539 * @class
11540 * @extends OO.ui.Layout
11541 * @mixins OO.ui.mixin.GroupElement
11542 *
11543 * @constructor
11544 * @param {Object} [config] Configuration options
11545 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11546 */
11547 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11548 // Configuration initialization
11549 config = config || {};
11550
11551 // Parent constructor
11552 OO.ui.HorizontalLayout.parent.call( this, config );
11553
11554 // Mixin constructors
11555 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11556
11557 // Initialization
11558 this.$element.addClass( 'oo-ui-horizontalLayout' );
11559 if ( Array.isArray( config.items ) ) {
11560 this.addItems( config.items );
11561 }
11562 };
11563
11564 /* Setup */
11565
11566 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11567 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11568
11569 }( OO ) );
11570
11571 //# sourceMappingURL=oojs-ui-core.js.map