Merge "Improve "selfmove" message's wording"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.23.1
3 * https://www.mediawiki.org/wiki/OOjs_UI
4 *
5 * Copyright 2011–2017 OOjs UI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2017-09-20T00:31:56Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
15 /**
16 * Namespace for all classes, static methods and static properties.
17 *
18 * @class
19 * @singleton
20 */
21 OO.ui = {};
22
23 OO.ui.bind = $.proxy;
24
25 /**
26 * @property {Object}
27 */
28 OO.ui.Keys = {
29 UNDEFINED: 0,
30 BACKSPACE: 8,
31 DELETE: 46,
32 LEFT: 37,
33 RIGHT: 39,
34 UP: 38,
35 DOWN: 40,
36 ENTER: 13,
37 END: 35,
38 HOME: 36,
39 TAB: 9,
40 PAGEUP: 33,
41 PAGEDOWN: 34,
42 ESCAPE: 27,
43 SHIFT: 16,
44 SPACE: 32
45 };
46
47 /**
48 * Constants for MouseEvent.which
49 *
50 * @property {Object}
51 */
52 OO.ui.MouseButtons = {
53 LEFT: 1,
54 MIDDLE: 2,
55 RIGHT: 3
56 };
57
58 /**
59 * @property {number}
60 * @private
61 */
62 OO.ui.elementId = 0;
63
64 /**
65 * Generate a unique ID for element
66 *
67 * @return {string} ID
68 */
69 OO.ui.generateElementId = function () {
70 OO.ui.elementId++;
71 return 'oojsui-' + OO.ui.elementId;
72 };
73
74 /**
75 * Check if an element is focusable.
76 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 *
78 * @param {jQuery} $element Element to test
79 * @return {boolean} Element is focusable
80 */
81 OO.ui.isFocusableElement = function ( $element ) {
82 var nodeName,
83 element = $element[ 0 ];
84
85 // Anything disabled is not focusable
86 if ( element.disabled ) {
87 return false;
88 }
89
90 // Check if the element is visible
91 if ( !(
92 // This is quicker than calling $element.is( ':visible' )
93 $.expr.pseudos.visible( element ) &&
94 // Check that all parents are visible
95 !$element.parents().addBack().filter( function () {
96 return $.css( this, 'visibility' ) === 'hidden';
97 } ).length
98 ) ) {
99 return false;
100 }
101
102 // Check if the element is ContentEditable, which is the string 'true'
103 if ( element.contentEditable === 'true' ) {
104 return true;
105 }
106
107 // Anything with a non-negative numeric tabIndex is focusable.
108 // Use .prop to avoid browser bugs
109 if ( $element.prop( 'tabIndex' ) >= 0 ) {
110 return true;
111 }
112
113 // Some element types are naturally focusable
114 // (indexOf is much faster than regex in Chrome and about the
115 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
116 nodeName = element.nodeName.toLowerCase();
117 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
118 return true;
119 }
120
121 // Links and areas are focusable if they have an href
122 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
123 return true;
124 }
125
126 return false;
127 };
128
129 /**
130 * Find a focusable child
131 *
132 * @param {jQuery} $container Container to search in
133 * @param {boolean} [backwards] Search backwards
134 * @return {jQuery} Focusable child, or an empty jQuery object if none found
135 */
136 OO.ui.findFocusable = function ( $container, backwards ) {
137 var $focusable = $( [] ),
138 // $focusableCandidates is a superset of things that
139 // could get matched by isFocusableElement
140 $focusableCandidates = $container
141 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142
143 if ( backwards ) {
144 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 }
146
147 $focusableCandidates.each( function () {
148 var $this = $( this );
149 if ( OO.ui.isFocusableElement( $this ) ) {
150 $focusable = $this;
151 return false;
152 }
153 } );
154 return $focusable;
155 };
156
157 /**
158 * Get the user's language and any fallback languages.
159 *
160 * These language codes are used to localize user interface elements in the user's language.
161 *
162 * In environments that provide a localization system, this function should be overridden to
163 * return the user's language(s). The default implementation returns English (en) only.
164 *
165 * @return {string[]} Language codes, in descending order of priority
166 */
167 OO.ui.getUserLanguages = function () {
168 return [ 'en' ];
169 };
170
171 /**
172 * Get a value in an object keyed by language code.
173 *
174 * @param {Object.<string,Mixed>} obj Object keyed by language code
175 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
176 * @param {string} [fallback] Fallback code, used if no matching language can be found
177 * @return {Mixed} Local value
178 */
179 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
180 var i, len, langs;
181
182 // Requested language
183 if ( obj[ lang ] ) {
184 return obj[ lang ];
185 }
186 // Known user language
187 langs = OO.ui.getUserLanguages();
188 for ( i = 0, len = langs.length; i < len; i++ ) {
189 lang = langs[ i ];
190 if ( obj[ lang ] ) {
191 return obj[ lang ];
192 }
193 }
194 // Fallback language
195 if ( obj[ fallback ] ) {
196 return obj[ fallback ];
197 }
198 // First existing language
199 for ( lang in obj ) {
200 return obj[ lang ];
201 }
202
203 return undefined;
204 };
205
206 /**
207 * Check if a node is contained within another node
208 *
209 * Similar to jQuery#contains except a list of containers can be supplied
210 * and a boolean argument allows you to include the container in the match list
211 *
212 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
213 * @param {HTMLElement} contained Node to find
214 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
273 }
274 };
275
276 /**
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
280 *
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @return {OO.ui.Element}
337 * The `OO.ui.Element` corresponding to this (infusable) document node.
338 */
339 OO.ui.infuse = function ( idOrNode ) {
340 return OO.ui.Element.static.infuse( idOrNode );
341 };
342
343 ( function () {
344 /**
345 * Message store for the default implementation of OO.ui.msg
346 *
347 * Environments that provide a localization system should not use this, but should override
348 * OO.ui.msg altogether.
349 *
350 * @private
351 */
352 var messages = {
353 // Tool tip for a button that moves items in a list down one place
354 'ooui-outline-control-move-down': 'Move item down',
355 // Tool tip for a button that moves items in a list up one place
356 'ooui-outline-control-move-up': 'Move item up',
357 // Tool tip for a button that removes items from a list
358 'ooui-outline-control-remove': 'Remove item',
359 // Label for the toolbar group that contains a list of all other available tools
360 'ooui-toolbar-more': 'More',
361 // Label for the fake tool that expands the full list of tools in a toolbar group
362 'ooui-toolgroup-expand': 'More',
363 // Label for the fake tool that collapses the full list of tools in a toolbar group
364 'ooui-toolgroup-collapse': 'Fewer',
365 // Default label for the tooltip for the button that removes a tag item
366 'ooui-item-remove': 'Remove',
367 // Default label for the accept button of a confirmation dialog
368 'ooui-dialog-message-accept': 'OK',
369 // Default label for the reject button of a confirmation dialog
370 'ooui-dialog-message-reject': 'Cancel',
371 // Title for process dialog error description
372 'ooui-dialog-process-error': 'Something went wrong',
373 // Label for process dialog dismiss error button, visible when describing errors
374 'ooui-dialog-process-dismiss': 'Dismiss',
375 // Label for process dialog retry action button, visible when describing only recoverable errors
376 'ooui-dialog-process-retry': 'Try again',
377 // Label for process dialog retry action button, visible when describing only warnings
378 'ooui-dialog-process-continue': 'Continue',
379 // Label for the file selection widget's select file button
380 'ooui-selectfile-button-select': 'Select a file',
381 // Label for the file selection widget if file selection is not supported
382 'ooui-selectfile-not-supported': 'File selection is not supported',
383 // Label for the file selection widget when no file is currently selected
384 'ooui-selectfile-placeholder': 'No file is selected',
385 // Label for the file selection widget's drop target
386 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
387 };
388
389 /**
390 * Get a localized message.
391 *
392 * After the message key, message parameters may optionally be passed. In the default implementation,
393 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
394 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
395 * they support unnamed, ordered message parameters.
396 *
397 * In environments that provide a localization system, this function should be overridden to
398 * return the message translated in the user's language. The default implementation always returns
399 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
400 * follows.
401 *
402 * @example
403 * var i, iLen, button,
404 * messagePath = 'oojs-ui/dist/i18n/',
405 * languages = [ $.i18n().locale, 'ur', 'en' ],
406 * languageMap = {};
407 *
408 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
409 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
410 * }
411 *
412 * $.i18n().load( languageMap ).done( function() {
413 * // Replace the built-in `msg` only once we've loaded the internationalization.
414 * // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
415 * // you put off creating any widgets until this promise is complete, no English
416 * // will be displayed.
417 * OO.ui.msg = $.i18n;
418 *
419 * // A button displaying "OK" in the default locale
420 * button = new OO.ui.ButtonWidget( {
421 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
422 * icon: 'check'
423 * } );
424 * $( 'body' ).append( button.$element );
425 *
426 * // A button displaying "OK" in Urdu
427 * $.i18n().locale = 'ur';
428 * button = new OO.ui.ButtonWidget( {
429 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
430 * icon: 'check'
431 * } );
432 * $( 'body' ).append( button.$element );
433 * } );
434 *
435 * @param {string} key Message key
436 * @param {...Mixed} [params] Message parameters
437 * @return {string} Translated message with parameters substituted
438 */
439 OO.ui.msg = function ( key ) {
440 var message = messages[ key ],
441 params = Array.prototype.slice.call( arguments, 1 );
442 if ( typeof message === 'string' ) {
443 // Perform $1 substitution
444 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
445 var i = parseInt( n, 10 );
446 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
447 } );
448 } else {
449 // Return placeholder if message not found
450 message = '[' + key + ']';
451 }
452 return message;
453 };
454 }() );
455
456 /**
457 * Package a message and arguments for deferred resolution.
458 *
459 * Use this when you are statically specifying a message and the message may not yet be present.
460 *
461 * @param {string} key Message key
462 * @param {...Mixed} [params] Message parameters
463 * @return {Function} Function that returns the resolved message when executed
464 */
465 OO.ui.deferMsg = function () {
466 var args = arguments;
467 return function () {
468 return OO.ui.msg.apply( OO.ui, args );
469 };
470 };
471
472 /**
473 * Resolve a message.
474 *
475 * If the message is a function it will be executed, otherwise it will pass through directly.
476 *
477 * @param {Function|string} msg Deferred message, or message text
478 * @return {string} Resolved message
479 */
480 OO.ui.resolveMsg = function ( msg ) {
481 if ( $.isFunction( msg ) ) {
482 return msg();
483 }
484 return msg;
485 };
486
487 /**
488 * @param {string} url
489 * @return {boolean}
490 */
491 OO.ui.isSafeUrl = function ( url ) {
492 // Keep this function in sync with php/Tag.php
493 var i, protocolWhitelist;
494
495 function stringStartsWith( haystack, needle ) {
496 return haystack.substr( 0, needle.length ) === needle;
497 }
498
499 protocolWhitelist = [
500 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
501 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
502 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
503 ];
504
505 if ( url === '' ) {
506 return true;
507 }
508
509 for ( i = 0; i < protocolWhitelist.length; i++ ) {
510 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
511 return true;
512 }
513 }
514
515 // This matches '//' too
516 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
517 return true;
518 }
519 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
520 return true;
521 }
522
523 return false;
524 };
525
526 /**
527 * Check if the user has a 'mobile' device.
528 *
529 * For our purposes this means the user is primarily using an
530 * on-screen keyboard, touch input instead of a mouse and may
531 * have a physically small display.
532 *
533 * It is left up to implementors to decide how to compute this
534 * so the default implementation always returns false.
535 *
536 * @return {boolean} Use is on a mobile device
537 */
538 OO.ui.isMobile = function () {
539 return false;
540 };
541
542 /*!
543 * Mixin namespace.
544 */
545
546 /**
547 * Namespace for OOjs UI mixins.
548 *
549 * Mixins are named according to the type of object they are intended to
550 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
551 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
552 * is intended to be mixed in to an instance of OO.ui.Widget.
553 *
554 * @class
555 * @singleton
556 */
557 OO.ui.mixin = {};
558
559 /**
560 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
561 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
562 * connected to them and can't be interacted with.
563 *
564 * @abstract
565 * @class
566 *
567 * @constructor
568 * @param {Object} [config] Configuration options
569 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
570 * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
571 * for an example.
572 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
573 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
574 * @cfg {string} [text] Text to insert
575 * @cfg {Array} [content] An array of content elements to append (after #text).
576 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
577 * Instances of OO.ui.Element will have their $element appended.
578 * @cfg {jQuery} [$content] Content elements to append (after #text).
579 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
580 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
581 * Data can also be specified with the #setData method.
582 */
583 OO.ui.Element = function OoUiElement( config ) {
584 this.initialConfig = config;
585 // Configuration initialization
586 config = config || {};
587
588 // Properties
589 this.$ = $;
590 this.elementId = null;
591 this.visible = true;
592 this.data = config.data;
593 this.$element = config.$element ||
594 $( document.createElement( this.getTagName() ) );
595 this.elementGroup = null;
596
597 // Initialization
598 if ( Array.isArray( config.classes ) ) {
599 this.$element.addClass( config.classes.join( ' ' ) );
600 }
601 if ( config.id ) {
602 this.setElementId( config.id );
603 }
604 if ( config.text ) {
605 this.$element.text( config.text );
606 }
607 if ( config.content ) {
608 // The `content` property treats plain strings as text; use an
609 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
610 // appropriate $element appended.
611 this.$element.append( config.content.map( function ( v ) {
612 if ( typeof v === 'string' ) {
613 // Escape string so it is properly represented in HTML.
614 return document.createTextNode( v );
615 } else if ( v instanceof OO.ui.HtmlSnippet ) {
616 // Bypass escaping.
617 return v.toString();
618 } else if ( v instanceof OO.ui.Element ) {
619 return v.$element;
620 }
621 return v;
622 } ) );
623 }
624 if ( config.$content ) {
625 // The `$content` property treats plain strings as HTML.
626 this.$element.append( config.$content );
627 }
628 };
629
630 /* Setup */
631
632 OO.initClass( OO.ui.Element );
633
634 /* Static Properties */
635
636 /**
637 * The name of the HTML tag used by the element.
638 *
639 * The static value may be ignored if the #getTagName method is overridden.
640 *
641 * @static
642 * @inheritable
643 * @property {string}
644 */
645 OO.ui.Element.static.tagName = 'div';
646
647 /* Static Methods */
648
649 /**
650 * Reconstitute a JavaScript object corresponding to a widget created
651 * by the PHP implementation.
652 *
653 * @param {string|HTMLElement|jQuery} idOrNode
654 * A DOM id (if a string) or node for the widget to infuse.
655 * @return {OO.ui.Element}
656 * The `OO.ui.Element` corresponding to this (infusable) document node.
657 * For `Tag` objects emitted on the HTML side (used occasionally for content)
658 * the value returned is a newly-created Element wrapping around the existing
659 * DOM node.
660 */
661 OO.ui.Element.static.infuse = function ( idOrNode ) {
662 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
663 // Verify that the type matches up.
664 // FIXME: uncomment after T89721 is fixed, see T90929.
665 /*
666 if ( !( obj instanceof this['class'] ) ) {
667 throw new Error( 'Infusion type mismatch!' );
668 }
669 */
670 return obj;
671 };
672
673 /**
674 * Implementation helper for `infuse`; skips the type check and has an
675 * extra property so that only the top-level invocation touches the DOM.
676 *
677 * @private
678 * @param {string|HTMLElement|jQuery} idOrNode
679 * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
680 * when the top-level widget of this infusion is inserted into DOM,
681 * replacing the original node; or false for top-level invocation.
682 * @return {OO.ui.Element}
683 */
684 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
685 // look for a cached result of a previous infusion.
686 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
687 if ( typeof idOrNode === 'string' ) {
688 id = idOrNode;
689 $elem = $( document.getElementById( id ) );
690 } else {
691 $elem = $( idOrNode );
692 id = $elem.attr( 'id' );
693 }
694 if ( !$elem.length ) {
695 if ( typeof idOrNode === 'string' ) {
696 error = 'Widget not found: ' + idOrNode;
697 } else if ( idOrNode && idOrNode.selector ) {
698 error = 'Widget not found: ' + idOrNode.selector;
699 } else {
700 error = 'Widget not found';
701 }
702 throw new Error( error );
703 }
704 if ( $elem[ 0 ].oouiInfused ) {
705 $elem = $elem[ 0 ].oouiInfused;
706 }
707 data = $elem.data( 'ooui-infused' );
708 if ( data ) {
709 // cached!
710 if ( data === true ) {
711 throw new Error( 'Circular dependency! ' + id );
712 }
713 if ( domPromise ) {
714 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
715 state = data.constructor.static.gatherPreInfuseState( $elem, data );
716 // restore dynamic state after the new element is re-inserted into DOM under infused parent
717 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
718 infusedChildren = $elem.data( 'ooui-infused-children' );
719 if ( infusedChildren && infusedChildren.length ) {
720 infusedChildren.forEach( function ( data ) {
721 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
722 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
723 } );
724 }
725 }
726 return data;
727 }
728 data = $elem.attr( 'data-ooui' );
729 if ( !data ) {
730 throw new Error( 'No infusion data found: ' + id );
731 }
732 try {
733 data = JSON.parse( data );
734 } catch ( _ ) {
735 data = null;
736 }
737 if ( !( data && data._ ) ) {
738 throw new Error( 'No valid infusion data found: ' + id );
739 }
740 if ( data._ === 'Tag' ) {
741 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
742 return new OO.ui.Element( { $element: $elem } );
743 }
744 parts = data._.split( '.' );
745 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
746 if ( cls === undefined ) {
747 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
748 }
749
750 // Verify that we're creating an OO.ui.Element instance
751 parent = cls.parent;
752
753 while ( parent !== undefined ) {
754 if ( parent === OO.ui.Element ) {
755 // Safe
756 break;
757 }
758
759 parent = parent.parent;
760 }
761
762 if ( parent !== OO.ui.Element ) {
763 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
764 }
765
766 if ( domPromise === false ) {
767 top = $.Deferred();
768 domPromise = top.promise();
769 }
770 $elem.data( 'ooui-infused', true ); // prevent loops
771 data.id = id; // implicit
772 infusedChildren = [];
773 data = OO.copy( data, null, function deserialize( value ) {
774 var infused;
775 if ( OO.isPlainObject( value ) ) {
776 if ( value.tag ) {
777 infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
778 infusedChildren.push( infused );
779 // Flatten the structure
780 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
781 infused.$element.removeData( 'ooui-infused-children' );
782 return infused;
783 }
784 if ( value.html !== undefined ) {
785 return new OO.ui.HtmlSnippet( value.html );
786 }
787 }
788 } );
789 // allow widgets to reuse parts of the DOM
790 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
791 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
792 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
793 // rebuild widget
794 // eslint-disable-next-line new-cap
795 obj = new cls( data );
796 // now replace old DOM with this new DOM.
797 if ( top ) {
798 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
799 // so only mutate the DOM if we need to.
800 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
801 $elem.replaceWith( obj.$element );
802 // This element is now gone from the DOM, but if anyone is holding a reference to it,
803 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
804 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
805 $elem[ 0 ].oouiInfused = obj.$element;
806 }
807 top.resolve();
808 }
809 obj.$element.data( 'ooui-infused', obj );
810 obj.$element.data( 'ooui-infused-children', infusedChildren );
811 // set the 'data-ooui' attribute so we can identify infused widgets
812 obj.$element.attr( 'data-ooui', '' );
813 // restore dynamic state after the new element is inserted into DOM
814 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
815 return obj;
816 };
817
818 /**
819 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
820 *
821 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
822 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
823 * constructor, which will be given the enhanced config.
824 *
825 * @protected
826 * @param {HTMLElement} node
827 * @param {Object} config
828 * @return {Object}
829 */
830 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
831 return config;
832 };
833
834 /**
835 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
836 * (and its children) that represent an Element of the same class and the given configuration,
837 * generated by the PHP implementation.
838 *
839 * This method is called just before `node` is detached from the DOM. The return value of this
840 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
841 * is inserted into DOM to replace `node`.
842 *
843 * @protected
844 * @param {HTMLElement} node
845 * @param {Object} config
846 * @return {Object}
847 */
848 OO.ui.Element.static.gatherPreInfuseState = function () {
849 return {};
850 };
851
852 /**
853 * Get a jQuery function within a specific document.
854 *
855 * @static
856 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
857 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
858 * not in an iframe
859 * @return {Function} Bound jQuery function
860 */
861 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
862 function wrapper( selector ) {
863 return $( selector, wrapper.context );
864 }
865
866 wrapper.context = this.getDocument( context );
867
868 if ( $iframe ) {
869 wrapper.$iframe = $iframe;
870 }
871
872 return wrapper;
873 };
874
875 /**
876 * Get the document of an element.
877 *
878 * @static
879 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
880 * @return {HTMLDocument|null} Document object
881 */
882 OO.ui.Element.static.getDocument = function ( obj ) {
883 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
884 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
885 // Empty jQuery selections might have a context
886 obj.context ||
887 // HTMLElement
888 obj.ownerDocument ||
889 // Window
890 obj.document ||
891 // HTMLDocument
892 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
893 null;
894 };
895
896 /**
897 * Get the window of an element or document.
898 *
899 * @static
900 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
901 * @return {Window} Window object
902 */
903 OO.ui.Element.static.getWindow = function ( obj ) {
904 var doc = this.getDocument( obj );
905 return doc.defaultView;
906 };
907
908 /**
909 * Get the direction of an element or document.
910 *
911 * @static
912 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
913 * @return {string} Text direction, either 'ltr' or 'rtl'
914 */
915 OO.ui.Element.static.getDir = function ( obj ) {
916 var isDoc, isWin;
917
918 if ( obj instanceof jQuery ) {
919 obj = obj[ 0 ];
920 }
921 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
922 isWin = obj.document !== undefined;
923 if ( isDoc || isWin ) {
924 if ( isWin ) {
925 obj = obj.document;
926 }
927 obj = obj.body;
928 }
929 return $( obj ).css( 'direction' );
930 };
931
932 /**
933 * Get the offset between two frames.
934 *
935 * TODO: Make this function not use recursion.
936 *
937 * @static
938 * @param {Window} from Window of the child frame
939 * @param {Window} [to=window] Window of the parent frame
940 * @param {Object} [offset] Offset to start with, used internally
941 * @return {Object} Offset object, containing left and top properties
942 */
943 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
944 var i, len, frames, frame, rect;
945
946 if ( !to ) {
947 to = window;
948 }
949 if ( !offset ) {
950 offset = { top: 0, left: 0 };
951 }
952 if ( from.parent === from ) {
953 return offset;
954 }
955
956 // Get iframe element
957 frames = from.parent.document.getElementsByTagName( 'iframe' );
958 for ( i = 0, len = frames.length; i < len; i++ ) {
959 if ( frames[ i ].contentWindow === from ) {
960 frame = frames[ i ];
961 break;
962 }
963 }
964
965 // Recursively accumulate offset values
966 if ( frame ) {
967 rect = frame.getBoundingClientRect();
968 offset.left += rect.left;
969 offset.top += rect.top;
970 if ( from !== to ) {
971 this.getFrameOffset( from.parent, offset );
972 }
973 }
974 return offset;
975 };
976
977 /**
978 * Get the offset between two elements.
979 *
980 * The two elements may be in a different frame, but in that case the frame $element is in must
981 * be contained in the frame $anchor is in.
982 *
983 * @static
984 * @param {jQuery} $element Element whose position to get
985 * @param {jQuery} $anchor Element to get $element's position relative to
986 * @return {Object} Translated position coordinates, containing top and left properties
987 */
988 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
989 var iframe, iframePos,
990 pos = $element.offset(),
991 anchorPos = $anchor.offset(),
992 elementDocument = this.getDocument( $element ),
993 anchorDocument = this.getDocument( $anchor );
994
995 // If $element isn't in the same document as $anchor, traverse up
996 while ( elementDocument !== anchorDocument ) {
997 iframe = elementDocument.defaultView.frameElement;
998 if ( !iframe ) {
999 throw new Error( '$element frame is not contained in $anchor frame' );
1000 }
1001 iframePos = $( iframe ).offset();
1002 pos.left += iframePos.left;
1003 pos.top += iframePos.top;
1004 elementDocument = iframe.ownerDocument;
1005 }
1006 pos.left -= anchorPos.left;
1007 pos.top -= anchorPos.top;
1008 return pos;
1009 };
1010
1011 /**
1012 * Get element border sizes.
1013 *
1014 * @static
1015 * @param {HTMLElement} el Element to measure
1016 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1017 */
1018 OO.ui.Element.static.getBorders = function ( el ) {
1019 var doc = el.ownerDocument,
1020 win = doc.defaultView,
1021 style = win.getComputedStyle( el, null ),
1022 $el = $( el ),
1023 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1024 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1025 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1026 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1027
1028 return {
1029 top: top,
1030 left: left,
1031 bottom: bottom,
1032 right: right
1033 };
1034 };
1035
1036 /**
1037 * Get dimensions of an element or window.
1038 *
1039 * @static
1040 * @param {HTMLElement|Window} el Element to measure
1041 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1042 */
1043 OO.ui.Element.static.getDimensions = function ( el ) {
1044 var $el, $win,
1045 doc = el.ownerDocument || el.document,
1046 win = doc.defaultView;
1047
1048 if ( win === el || el === doc.documentElement ) {
1049 $win = $( win );
1050 return {
1051 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1052 scroll: {
1053 top: $win.scrollTop(),
1054 left: $win.scrollLeft()
1055 },
1056 scrollbar: { right: 0, bottom: 0 },
1057 rect: {
1058 top: 0,
1059 left: 0,
1060 bottom: $win.innerHeight(),
1061 right: $win.innerWidth()
1062 }
1063 };
1064 } else {
1065 $el = $( el );
1066 return {
1067 borders: this.getBorders( el ),
1068 scroll: {
1069 top: $el.scrollTop(),
1070 left: $el.scrollLeft()
1071 },
1072 scrollbar: {
1073 right: $el.innerWidth() - el.clientWidth,
1074 bottom: $el.innerHeight() - el.clientHeight
1075 },
1076 rect: el.getBoundingClientRect()
1077 };
1078 }
1079 };
1080
1081 /**
1082 * Get the number of pixels that an element's content is scrolled to the left.
1083 *
1084 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1085 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1086 *
1087 * This function smooths out browser inconsistencies (nicely described in the README at
1088 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1089 * with Firefox's 'scrollLeft', which seems the sanest.
1090 *
1091 * @static
1092 * @method
1093 * @param {HTMLElement|Window} el Element to measure
1094 * @return {number} Scroll position from the left.
1095 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1096 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1097 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1098 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1099 */
1100 OO.ui.Element.static.getScrollLeft = ( function () {
1101 var rtlScrollType = null;
1102
1103 function test() {
1104 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1105 definer = $definer[ 0 ];
1106
1107 $definer.appendTo( 'body' );
1108 if ( definer.scrollLeft > 0 ) {
1109 // Safari, Chrome
1110 rtlScrollType = 'default';
1111 } else {
1112 definer.scrollLeft = 1;
1113 if ( definer.scrollLeft === 0 ) {
1114 // Firefox, old Opera
1115 rtlScrollType = 'negative';
1116 } else {
1117 // Internet Explorer, Edge
1118 rtlScrollType = 'reverse';
1119 }
1120 }
1121 $definer.remove();
1122 }
1123
1124 return function getScrollLeft( el ) {
1125 var isRoot = el.window === el ||
1126 el === el.ownerDocument.body ||
1127 el === el.ownerDocument.documentElement,
1128 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1129 // All browsers use the correct scroll type ('negative') on the root, so don't
1130 // do any fixups when looking at the root element
1131 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1132
1133 if ( direction === 'rtl' ) {
1134 if ( rtlScrollType === null ) {
1135 test();
1136 }
1137 if ( rtlScrollType === 'reverse' ) {
1138 scrollLeft = -scrollLeft;
1139 } else if ( rtlScrollType === 'default' ) {
1140 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1141 }
1142 }
1143
1144 return scrollLeft;
1145 };
1146 }() );
1147
1148 /**
1149 * Get the root scrollable element of given element's document.
1150 *
1151 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1152 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1153 * lets us use 'body' or 'documentElement' based on what is working.
1154 *
1155 * https://code.google.com/p/chromium/issues/detail?id=303131
1156 *
1157 * @static
1158 * @param {HTMLElement} el Element to find root scrollable parent for
1159 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1160 * depending on browser
1161 */
1162 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1163 var scrollTop, body;
1164
1165 if ( OO.ui.scrollableElement === undefined ) {
1166 body = el.ownerDocument.body;
1167 scrollTop = body.scrollTop;
1168 body.scrollTop = 1;
1169
1170 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1171 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1172 if ( Math.round( body.scrollTop ) === 1 ) {
1173 body.scrollTop = scrollTop;
1174 OO.ui.scrollableElement = 'body';
1175 } else {
1176 OO.ui.scrollableElement = 'documentElement';
1177 }
1178 }
1179
1180 return el.ownerDocument[ OO.ui.scrollableElement ];
1181 };
1182
1183 /**
1184 * Get closest scrollable container.
1185 *
1186 * Traverses up until either a scrollable element or the root is reached, in which case the root
1187 * scrollable element will be returned (see #getRootScrollableElement).
1188 *
1189 * @static
1190 * @param {HTMLElement} el Element to find scrollable container for
1191 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1192 * @return {HTMLElement} Closest scrollable container
1193 */
1194 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1195 var i, val,
1196 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1197 // 'overflow-y' have different values, so we need to check the separate properties.
1198 props = [ 'overflow-x', 'overflow-y' ],
1199 $parent = $( el ).parent();
1200
1201 if ( dimension === 'x' || dimension === 'y' ) {
1202 props = [ 'overflow-' + dimension ];
1203 }
1204
1205 // Special case for the document root (which doesn't really have any scrollable container, since
1206 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1207 if ( $( el ).is( 'html, body' ) ) {
1208 return this.getRootScrollableElement( el );
1209 }
1210
1211 while ( $parent.length ) {
1212 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1213 return $parent[ 0 ];
1214 }
1215 i = props.length;
1216 while ( i-- ) {
1217 val = $parent.css( props[ i ] );
1218 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1219 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1220 // unintentionally perform a scroll in such case even if the application doesn't scroll
1221 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1222 // This could cause funny issues...
1223 if ( val === 'auto' || val === 'scroll' ) {
1224 return $parent[ 0 ];
1225 }
1226 }
1227 $parent = $parent.parent();
1228 }
1229 // The element is unattached... return something mostly sane
1230 return this.getRootScrollableElement( el );
1231 };
1232
1233 /**
1234 * Scroll element into view.
1235 *
1236 * @static
1237 * @param {HTMLElement} el Element to scroll into view
1238 * @param {Object} [config] Configuration options
1239 * @param {string} [config.duration='fast'] jQuery animation duration value
1240 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1241 * to scroll in both directions
1242 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1243 */
1244 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1245 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1246 deferred = $.Deferred();
1247
1248 // Configuration initialization
1249 config = config || {};
1250
1251 animations = {};
1252 container = this.getClosestScrollableContainer( el, config.direction );
1253 $container = $( container );
1254 elementDimensions = this.getDimensions( el );
1255 containerDimensions = this.getDimensions( container );
1256 $window = $( this.getWindow( el ) );
1257
1258 // Compute the element's position relative to the container
1259 if ( $container.is( 'html, body' ) ) {
1260 // If the scrollable container is the root, this is easy
1261 position = {
1262 top: elementDimensions.rect.top,
1263 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1264 left: elementDimensions.rect.left,
1265 right: $window.innerWidth() - elementDimensions.rect.right
1266 };
1267 } else {
1268 // Otherwise, we have to subtract el's coordinates from container's coordinates
1269 position = {
1270 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1271 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1272 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1273 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1274 };
1275 }
1276
1277 if ( !config.direction || config.direction === 'y' ) {
1278 if ( position.top < 0 ) {
1279 animations.scrollTop = containerDimensions.scroll.top + position.top;
1280 } else if ( position.top > 0 && position.bottom < 0 ) {
1281 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1282 }
1283 }
1284 if ( !config.direction || config.direction === 'x' ) {
1285 if ( position.left < 0 ) {
1286 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1287 } else if ( position.left > 0 && position.right < 0 ) {
1288 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1289 }
1290 }
1291 if ( !$.isEmptyObject( animations ) ) {
1292 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1293 $container.queue( function ( next ) {
1294 deferred.resolve();
1295 next();
1296 } );
1297 } else {
1298 deferred.resolve();
1299 }
1300 return deferred.promise();
1301 };
1302
1303 /**
1304 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1305 * and reserve space for them, because it probably doesn't.
1306 *
1307 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1308 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1309 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1310 * and then reattach (or show) them back.
1311 *
1312 * @static
1313 * @param {HTMLElement} el Element to reconsider the scrollbars on
1314 */
1315 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1316 var i, len, scrollLeft, scrollTop, nodes = [];
1317 // Save scroll position
1318 scrollLeft = el.scrollLeft;
1319 scrollTop = el.scrollTop;
1320 // Detach all children
1321 while ( el.firstChild ) {
1322 nodes.push( el.firstChild );
1323 el.removeChild( el.firstChild );
1324 }
1325 // Force reflow
1326 void el.offsetHeight;
1327 // Reattach all children
1328 for ( i = 0, len = nodes.length; i < len; i++ ) {
1329 el.appendChild( nodes[ i ] );
1330 }
1331 // Restore scroll position (no-op if scrollbars disappeared)
1332 el.scrollLeft = scrollLeft;
1333 el.scrollTop = scrollTop;
1334 };
1335
1336 /* Methods */
1337
1338 /**
1339 * Toggle visibility of an element.
1340 *
1341 * @param {boolean} [show] Make element visible, omit to toggle visibility
1342 * @fires visible
1343 * @chainable
1344 */
1345 OO.ui.Element.prototype.toggle = function ( show ) {
1346 show = show === undefined ? !this.visible : !!show;
1347
1348 if ( show !== this.isVisible() ) {
1349 this.visible = show;
1350 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1351 this.emit( 'toggle', show );
1352 }
1353
1354 return this;
1355 };
1356
1357 /**
1358 * Check if element is visible.
1359 *
1360 * @return {boolean} element is visible
1361 */
1362 OO.ui.Element.prototype.isVisible = function () {
1363 return this.visible;
1364 };
1365
1366 /**
1367 * Get element data.
1368 *
1369 * @return {Mixed} Element data
1370 */
1371 OO.ui.Element.prototype.getData = function () {
1372 return this.data;
1373 };
1374
1375 /**
1376 * Set element data.
1377 *
1378 * @param {Mixed} data Element data
1379 * @chainable
1380 */
1381 OO.ui.Element.prototype.setData = function ( data ) {
1382 this.data = data;
1383 return this;
1384 };
1385
1386 /**
1387 * Set the element has an 'id' attribute.
1388 *
1389 * @param {string} id
1390 * @chainable
1391 */
1392 OO.ui.Element.prototype.setElementId = function ( id ) {
1393 this.elementId = id;
1394 this.$element.attr( 'id', id );
1395 return this;
1396 };
1397
1398 /**
1399 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1400 * and return its value.
1401 *
1402 * @return {string}
1403 */
1404 OO.ui.Element.prototype.getElementId = function () {
1405 if ( this.elementId === null ) {
1406 this.setElementId( OO.ui.generateElementId() );
1407 }
1408 return this.elementId;
1409 };
1410
1411 /**
1412 * Check if element supports one or more methods.
1413 *
1414 * @param {string|string[]} methods Method or list of methods to check
1415 * @return {boolean} All methods are supported
1416 */
1417 OO.ui.Element.prototype.supports = function ( methods ) {
1418 var i, len,
1419 support = 0;
1420
1421 methods = Array.isArray( methods ) ? methods : [ methods ];
1422 for ( i = 0, len = methods.length; i < len; i++ ) {
1423 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1424 support++;
1425 }
1426 }
1427
1428 return methods.length === support;
1429 };
1430
1431 /**
1432 * Update the theme-provided classes.
1433 *
1434 * @localdoc This is called in element mixins and widget classes any time state changes.
1435 * Updating is debounced, minimizing overhead of changing multiple attributes and
1436 * guaranteeing that theme updates do not occur within an element's constructor
1437 */
1438 OO.ui.Element.prototype.updateThemeClasses = function () {
1439 OO.ui.theme.queueUpdateElementClasses( this );
1440 };
1441
1442 /**
1443 * Get the HTML tag name.
1444 *
1445 * Override this method to base the result on instance information.
1446 *
1447 * @return {string} HTML tag name
1448 */
1449 OO.ui.Element.prototype.getTagName = function () {
1450 return this.constructor.static.tagName;
1451 };
1452
1453 /**
1454 * Check if the element is attached to the DOM
1455 *
1456 * @return {boolean} The element is attached to the DOM
1457 */
1458 OO.ui.Element.prototype.isElementAttached = function () {
1459 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1460 };
1461
1462 /**
1463 * Get the DOM document.
1464 *
1465 * @return {HTMLDocument} Document object
1466 */
1467 OO.ui.Element.prototype.getElementDocument = function () {
1468 // Don't cache this in other ways either because subclasses could can change this.$element
1469 return OO.ui.Element.static.getDocument( this.$element );
1470 };
1471
1472 /**
1473 * Get the DOM window.
1474 *
1475 * @return {Window} Window object
1476 */
1477 OO.ui.Element.prototype.getElementWindow = function () {
1478 return OO.ui.Element.static.getWindow( this.$element );
1479 };
1480
1481 /**
1482 * Get closest scrollable container.
1483 *
1484 * @return {HTMLElement} Closest scrollable container
1485 */
1486 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1487 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1488 };
1489
1490 /**
1491 * Get group element is in.
1492 *
1493 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1494 */
1495 OO.ui.Element.prototype.getElementGroup = function () {
1496 return this.elementGroup;
1497 };
1498
1499 /**
1500 * Set group element is in.
1501 *
1502 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1503 * @chainable
1504 */
1505 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1506 this.elementGroup = group;
1507 return this;
1508 };
1509
1510 /**
1511 * Scroll element into view.
1512 *
1513 * @param {Object} [config] Configuration options
1514 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1515 */
1516 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1517 if (
1518 !this.isElementAttached() ||
1519 !this.isVisible() ||
1520 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1521 ) {
1522 return $.Deferred().resolve();
1523 }
1524 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1525 };
1526
1527 /**
1528 * Restore the pre-infusion dynamic state for this widget.
1529 *
1530 * This method is called after #$element has been inserted into DOM. The parameter is the return
1531 * value of #gatherPreInfuseState.
1532 *
1533 * @protected
1534 * @param {Object} state
1535 */
1536 OO.ui.Element.prototype.restorePreInfuseState = function () {
1537 };
1538
1539 /**
1540 * Wraps an HTML snippet for use with configuration values which default
1541 * to strings. This bypasses the default html-escaping done to string
1542 * values.
1543 *
1544 * @class
1545 *
1546 * @constructor
1547 * @param {string} [content] HTML content
1548 */
1549 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1550 // Properties
1551 this.content = content;
1552 };
1553
1554 /* Setup */
1555
1556 OO.initClass( OO.ui.HtmlSnippet );
1557
1558 /* Methods */
1559
1560 /**
1561 * Render into HTML.
1562 *
1563 * @return {string} Unchanged HTML snippet.
1564 */
1565 OO.ui.HtmlSnippet.prototype.toString = function () {
1566 return this.content;
1567 };
1568
1569 /**
1570 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1571 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1572 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1573 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1574 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1575 *
1576 * @abstract
1577 * @class
1578 * @extends OO.ui.Element
1579 * @mixins OO.EventEmitter
1580 *
1581 * @constructor
1582 * @param {Object} [config] Configuration options
1583 */
1584 OO.ui.Layout = function OoUiLayout( config ) {
1585 // Configuration initialization
1586 config = config || {};
1587
1588 // Parent constructor
1589 OO.ui.Layout.parent.call( this, config );
1590
1591 // Mixin constructors
1592 OO.EventEmitter.call( this );
1593
1594 // Initialization
1595 this.$element.addClass( 'oo-ui-layout' );
1596 };
1597
1598 /* Setup */
1599
1600 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1601 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1602
1603 /**
1604 * Widgets are compositions of one or more OOjs UI elements that users can both view
1605 * and interact with. All widgets can be configured and modified via a standard API,
1606 * and their state can change dynamically according to a model.
1607 *
1608 * @abstract
1609 * @class
1610 * @extends OO.ui.Element
1611 * @mixins OO.EventEmitter
1612 *
1613 * @constructor
1614 * @param {Object} [config] Configuration options
1615 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1616 * appearance reflects this state.
1617 */
1618 OO.ui.Widget = function OoUiWidget( config ) {
1619 // Initialize config
1620 config = $.extend( { disabled: false }, config );
1621
1622 // Parent constructor
1623 OO.ui.Widget.parent.call( this, config );
1624
1625 // Mixin constructors
1626 OO.EventEmitter.call( this );
1627
1628 // Properties
1629 this.disabled = null;
1630 this.wasDisabled = null;
1631
1632 // Initialization
1633 this.$element.addClass( 'oo-ui-widget' );
1634 this.setDisabled( !!config.disabled );
1635 };
1636
1637 /* Setup */
1638
1639 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1640 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1641
1642 /* Events */
1643
1644 /**
1645 * @event disable
1646 *
1647 * A 'disable' event is emitted when the disabled state of the widget changes
1648 * (i.e. on disable **and** enable).
1649 *
1650 * @param {boolean} disabled Widget is disabled
1651 */
1652
1653 /**
1654 * @event toggle
1655 *
1656 * A 'toggle' event is emitted when the visibility of the widget changes.
1657 *
1658 * @param {boolean} visible Widget is visible
1659 */
1660
1661 /* Methods */
1662
1663 /**
1664 * Check if the widget is disabled.
1665 *
1666 * @return {boolean} Widget is disabled
1667 */
1668 OO.ui.Widget.prototype.isDisabled = function () {
1669 return this.disabled;
1670 };
1671
1672 /**
1673 * Set the 'disabled' state of the widget.
1674 *
1675 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1676 *
1677 * @param {boolean} disabled Disable widget
1678 * @chainable
1679 */
1680 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1681 var isDisabled;
1682
1683 this.disabled = !!disabled;
1684 isDisabled = this.isDisabled();
1685 if ( isDisabled !== this.wasDisabled ) {
1686 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1687 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1688 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1689 this.emit( 'disable', isDisabled );
1690 this.updateThemeClasses();
1691 }
1692 this.wasDisabled = isDisabled;
1693
1694 return this;
1695 };
1696
1697 /**
1698 * Update the disabled state, in case of changes in parent widget.
1699 *
1700 * @chainable
1701 */
1702 OO.ui.Widget.prototype.updateDisabled = function () {
1703 this.setDisabled( this.disabled );
1704 return this;
1705 };
1706
1707 /**
1708 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1709 * value.
1710 *
1711 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1712 * instead.
1713 *
1714 * @return {string|null} The ID of the labelable element
1715 */
1716 OO.ui.Widget.prototype.getInputId = function () {
1717 return null;
1718 };
1719
1720 /**
1721 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1722 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1723 * override this method to provide intuitive, accessible behavior.
1724 *
1725 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1726 * Individual widgets may override it too.
1727 *
1728 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1729 * directly.
1730 */
1731 OO.ui.Widget.prototype.simulateLabelClick = function () {
1732 };
1733
1734 /**
1735 * Theme logic.
1736 *
1737 * @abstract
1738 * @class
1739 *
1740 * @constructor
1741 */
1742 OO.ui.Theme = function OoUiTheme() {
1743 this.elementClassesQueue = [];
1744 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1745 };
1746
1747 /* Setup */
1748
1749 OO.initClass( OO.ui.Theme );
1750
1751 /* Methods */
1752
1753 /**
1754 * Get a list of classes to be applied to a widget.
1755 *
1756 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1757 * otherwise state transitions will not work properly.
1758 *
1759 * @param {OO.ui.Element} element Element for which to get classes
1760 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1761 */
1762 OO.ui.Theme.prototype.getElementClasses = function () {
1763 return { on: [], off: [] };
1764 };
1765
1766 /**
1767 * Update CSS classes provided by the theme.
1768 *
1769 * For elements with theme logic hooks, this should be called any time there's a state change.
1770 *
1771 * @param {OO.ui.Element} element Element for which to update classes
1772 */
1773 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1774 var $elements = $( [] ),
1775 classes = this.getElementClasses( element );
1776
1777 if ( element.$icon ) {
1778 $elements = $elements.add( element.$icon );
1779 }
1780 if ( element.$indicator ) {
1781 $elements = $elements.add( element.$indicator );
1782 }
1783
1784 $elements
1785 .removeClass( classes.off.join( ' ' ) )
1786 .addClass( classes.on.join( ' ' ) );
1787 };
1788
1789 /**
1790 * @private
1791 */
1792 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1793 var i;
1794 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1795 this.updateElementClasses( this.elementClassesQueue[ i ] );
1796 }
1797 // Clear the queue
1798 this.elementClassesQueue = [];
1799 };
1800
1801 /**
1802 * Queue #updateElementClasses to be called for this element.
1803 *
1804 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1805 * to make them synchronous.
1806 *
1807 * @param {OO.ui.Element} element Element for which to update classes
1808 */
1809 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1810 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1811 // the most common case (this method is often called repeatedly for the same element).
1812 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1813 return;
1814 }
1815 this.elementClassesQueue.push( element );
1816 this.debouncedUpdateQueuedElementClasses();
1817 };
1818
1819 /**
1820 * Get the transition duration in milliseconds for dialogs opening/closing
1821 *
1822 * The dialog should be fully rendered this many milliseconds after the
1823 * ready process has executed.
1824 *
1825 * @return {number} Transition duration in milliseconds
1826 */
1827 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1828 return 0;
1829 };
1830
1831 /**
1832 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1833 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1834 * order in which users will navigate through the focusable elements via the "tab" key.
1835 *
1836 * @example
1837 * // TabIndexedElement is mixed into the ButtonWidget class
1838 * // to provide a tabIndex property.
1839 * var button1 = new OO.ui.ButtonWidget( {
1840 * label: 'fourth',
1841 * tabIndex: 4
1842 * } );
1843 * var button2 = new OO.ui.ButtonWidget( {
1844 * label: 'second',
1845 * tabIndex: 2
1846 * } );
1847 * var button3 = new OO.ui.ButtonWidget( {
1848 * label: 'third',
1849 * tabIndex: 3
1850 * } );
1851 * var button4 = new OO.ui.ButtonWidget( {
1852 * label: 'first',
1853 * tabIndex: 1
1854 * } );
1855 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1856 *
1857 * @abstract
1858 * @class
1859 *
1860 * @constructor
1861 * @param {Object} [config] Configuration options
1862 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1863 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1864 * functionality will be applied to it instead.
1865 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1866 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1867 * to remove the element from the tab-navigation flow.
1868 */
1869 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1870 // Configuration initialization
1871 config = $.extend( { tabIndex: 0 }, config );
1872
1873 // Properties
1874 this.$tabIndexed = null;
1875 this.tabIndex = null;
1876
1877 // Events
1878 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1879
1880 // Initialization
1881 this.setTabIndex( config.tabIndex );
1882 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1883 };
1884
1885 /* Setup */
1886
1887 OO.initClass( OO.ui.mixin.TabIndexedElement );
1888
1889 /* Methods */
1890
1891 /**
1892 * Set the element that should use the tabindex functionality.
1893 *
1894 * This method is used to retarget a tabindex mixin so that its functionality applies
1895 * to the specified element. If an element is currently using the functionality, the mixin’s
1896 * effect on that element is removed before the new element is set up.
1897 *
1898 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1899 * @chainable
1900 */
1901 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1902 var tabIndex = this.tabIndex;
1903 // Remove attributes from old $tabIndexed
1904 this.setTabIndex( null );
1905 // Force update of new $tabIndexed
1906 this.$tabIndexed = $tabIndexed;
1907 this.tabIndex = tabIndex;
1908 return this.updateTabIndex();
1909 };
1910
1911 /**
1912 * Set the value of the tabindex.
1913 *
1914 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1915 * @chainable
1916 */
1917 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1918 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1919
1920 if ( this.tabIndex !== tabIndex ) {
1921 this.tabIndex = tabIndex;
1922 this.updateTabIndex();
1923 }
1924
1925 return this;
1926 };
1927
1928 /**
1929 * Update the `tabindex` attribute, in case of changes to tab index or
1930 * disabled state.
1931 *
1932 * @private
1933 * @chainable
1934 */
1935 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1936 if ( this.$tabIndexed ) {
1937 if ( this.tabIndex !== null ) {
1938 // Do not index over disabled elements
1939 this.$tabIndexed.attr( {
1940 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1941 // Support: ChromeVox and NVDA
1942 // These do not seem to inherit aria-disabled from parent elements
1943 'aria-disabled': this.isDisabled().toString()
1944 } );
1945 } else {
1946 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1947 }
1948 }
1949 return this;
1950 };
1951
1952 /**
1953 * Handle disable events.
1954 *
1955 * @private
1956 * @param {boolean} disabled Element is disabled
1957 */
1958 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1959 this.updateTabIndex();
1960 };
1961
1962 /**
1963 * Get the value of the tabindex.
1964 *
1965 * @return {number|null} Tabindex value
1966 */
1967 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
1968 return this.tabIndex;
1969 };
1970
1971 /**
1972 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
1973 *
1974 * If the element already has an ID then that is returned, otherwise unique ID is
1975 * generated, set on the element, and returned.
1976 *
1977 * @return {string|null} The ID of the focusable element
1978 */
1979 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
1980 var id;
1981
1982 if ( !this.$tabIndexed ) {
1983 return null;
1984 }
1985 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
1986 return null;
1987 }
1988
1989 id = this.$tabIndexed.attr( 'id' );
1990 if ( id === undefined ) {
1991 id = OO.ui.generateElementId();
1992 this.$tabIndexed.attr( 'id', id );
1993 }
1994
1995 return id;
1996 };
1997
1998 /**
1999 * Whether the node is 'labelable' according to the HTML spec
2000 * (i.e., whether it can be interacted with through a `<label for="…">`).
2001 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2002 *
2003 * @private
2004 * @param {jQuery} $node
2005 * @return {boolean}
2006 */
2007 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2008 var
2009 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2010 tagName = $node.prop( 'tagName' ).toLowerCase();
2011
2012 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2013 return true;
2014 }
2015 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2016 return true;
2017 }
2018 return false;
2019 };
2020
2021 /**
2022 * Focus this element.
2023 *
2024 * @chainable
2025 */
2026 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2027 if ( !this.isDisabled() ) {
2028 this.$tabIndexed.focus();
2029 }
2030 return this;
2031 };
2032
2033 /**
2034 * Blur this element.
2035 *
2036 * @chainable
2037 */
2038 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2039 this.$tabIndexed.blur();
2040 return this;
2041 };
2042
2043 /**
2044 * @inheritdoc OO.ui.Widget
2045 */
2046 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2047 this.focus();
2048 };
2049
2050 /**
2051 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2052 * interface element that can be configured with access keys for accessibility.
2053 * See the [OOjs UI documentation on MediaWiki] [1] for examples.
2054 *
2055 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons
2056 *
2057 * @abstract
2058 * @class
2059 *
2060 * @constructor
2061 * @param {Object} [config] Configuration options
2062 * @cfg {jQuery} [$button] The button element created by the class.
2063 * If this configuration is omitted, the button element will use a generated `<a>`.
2064 * @cfg {boolean} [framed=true] Render the button with a frame
2065 */
2066 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2067 // Configuration initialization
2068 config = config || {};
2069
2070 // Properties
2071 this.$button = null;
2072 this.framed = null;
2073 this.active = config.active !== undefined && config.active;
2074 this.onMouseUpHandler = this.onMouseUp.bind( this );
2075 this.onMouseDownHandler = this.onMouseDown.bind( this );
2076 this.onKeyDownHandler = this.onKeyDown.bind( this );
2077 this.onKeyUpHandler = this.onKeyUp.bind( this );
2078 this.onClickHandler = this.onClick.bind( this );
2079 this.onKeyPressHandler = this.onKeyPress.bind( this );
2080
2081 // Initialization
2082 this.$element.addClass( 'oo-ui-buttonElement' );
2083 this.toggleFramed( config.framed === undefined || config.framed );
2084 this.setButtonElement( config.$button || $( '<a>' ) );
2085 };
2086
2087 /* Setup */
2088
2089 OO.initClass( OO.ui.mixin.ButtonElement );
2090
2091 /* Static Properties */
2092
2093 /**
2094 * Cancel mouse down events.
2095 *
2096 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2097 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2098 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2099 * parent widget.
2100 *
2101 * @static
2102 * @inheritable
2103 * @property {boolean}
2104 */
2105 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2106
2107 /* Events */
2108
2109 /**
2110 * A 'click' event is emitted when the button element is clicked.
2111 *
2112 * @event click
2113 */
2114
2115 /* Methods */
2116
2117 /**
2118 * Set the button element.
2119 *
2120 * This method is used to retarget a button mixin so that its functionality applies to
2121 * the specified button element instead of the one created by the class. If a button element
2122 * is already set, the method will remove the mixin’s effect on that element.
2123 *
2124 * @param {jQuery} $button Element to use as button
2125 */
2126 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2127 if ( this.$button ) {
2128 this.$button
2129 .removeClass( 'oo-ui-buttonElement-button' )
2130 .removeAttr( 'role accesskey' )
2131 .off( {
2132 mousedown: this.onMouseDownHandler,
2133 keydown: this.onKeyDownHandler,
2134 click: this.onClickHandler,
2135 keypress: this.onKeyPressHandler
2136 } );
2137 }
2138
2139 this.$button = $button
2140 .addClass( 'oo-ui-buttonElement-button' )
2141 .on( {
2142 mousedown: this.onMouseDownHandler,
2143 keydown: this.onKeyDownHandler,
2144 click: this.onClickHandler,
2145 keypress: this.onKeyPressHandler
2146 } );
2147
2148 // Add `role="button"` on `<a>` elements, where it's needed
2149 // `toUppercase()` is added for XHTML documents
2150 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2151 this.$button.attr( 'role', 'button' );
2152 }
2153 };
2154
2155 /**
2156 * Handles mouse down events.
2157 *
2158 * @protected
2159 * @param {jQuery.Event} e Mouse down event
2160 */
2161 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2162 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2163 return;
2164 }
2165 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2166 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2167 // reliably remove the pressed class
2168 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2169 // Prevent change of focus unless specifically configured otherwise
2170 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2171 return false;
2172 }
2173 };
2174
2175 /**
2176 * Handles mouse up events.
2177 *
2178 * @protected
2179 * @param {MouseEvent} e Mouse up event
2180 */
2181 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2182 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2183 return;
2184 }
2185 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2186 // Stop listening for mouseup, since we only needed this once
2187 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2188 };
2189
2190 /**
2191 * Handles mouse click events.
2192 *
2193 * @protected
2194 * @param {jQuery.Event} e Mouse click event
2195 * @fires click
2196 */
2197 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2198 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2199 if ( this.emit( 'click' ) ) {
2200 return false;
2201 }
2202 }
2203 };
2204
2205 /**
2206 * Handles key down events.
2207 *
2208 * @protected
2209 * @param {jQuery.Event} e Key down event
2210 */
2211 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2212 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2213 return;
2214 }
2215 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2216 // Run the keyup handler no matter where the key is when the button is let go, so we can
2217 // reliably remove the pressed class
2218 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2219 };
2220
2221 /**
2222 * Handles key up events.
2223 *
2224 * @protected
2225 * @param {KeyboardEvent} e Key up event
2226 */
2227 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2228 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2229 return;
2230 }
2231 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2232 // Stop listening for keyup, since we only needed this once
2233 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2234 };
2235
2236 /**
2237 * Handles key press events.
2238 *
2239 * @protected
2240 * @param {jQuery.Event} e Key press event
2241 * @fires click
2242 */
2243 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2244 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2245 if ( this.emit( 'click' ) ) {
2246 return false;
2247 }
2248 }
2249 };
2250
2251 /**
2252 * Check if button has a frame.
2253 *
2254 * @return {boolean} Button is framed
2255 */
2256 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2257 return this.framed;
2258 };
2259
2260 /**
2261 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2262 *
2263 * @param {boolean} [framed] Make button framed, omit to toggle
2264 * @chainable
2265 */
2266 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2267 framed = framed === undefined ? !this.framed : !!framed;
2268 if ( framed !== this.framed ) {
2269 this.framed = framed;
2270 this.$element
2271 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2272 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2273 this.updateThemeClasses();
2274 }
2275
2276 return this;
2277 };
2278
2279 /**
2280 * Set the button's active state.
2281 *
2282 * The active state can be set on:
2283 *
2284 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2285 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2286 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2287 *
2288 * @protected
2289 * @param {boolean} value Make button active
2290 * @chainable
2291 */
2292 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2293 this.active = !!value;
2294 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2295 this.updateThemeClasses();
2296 return this;
2297 };
2298
2299 /**
2300 * Check if the button is active
2301 *
2302 * @protected
2303 * @return {boolean} The button is active
2304 */
2305 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2306 return this.active;
2307 };
2308
2309 /**
2310 * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2311 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2312 * items from the group is done through the interface the class provides.
2313 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
2314 *
2315 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups
2316 *
2317 * @abstract
2318 * @mixins OO.EmitterList
2319 * @class
2320 *
2321 * @constructor
2322 * @param {Object} [config] Configuration options
2323 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2324 * is omitted, the group element will use a generated `<div>`.
2325 */
2326 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2327 // Configuration initialization
2328 config = config || {};
2329
2330 // Mixin constructors
2331 OO.EmitterList.call( this, config );
2332
2333 // Properties
2334 this.$group = null;
2335
2336 // Initialization
2337 this.setGroupElement( config.$group || $( '<div>' ) );
2338 };
2339
2340 /* Setup */
2341
2342 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2343
2344 /* Events */
2345
2346 /**
2347 * @event change
2348 *
2349 * A change event is emitted when the set of selected items changes.
2350 *
2351 * @param {OO.ui.Element[]} items Items currently in the group
2352 */
2353
2354 /* Methods */
2355
2356 /**
2357 * Set the group element.
2358 *
2359 * If an element is already set, items will be moved to the new element.
2360 *
2361 * @param {jQuery} $group Element to use as group
2362 */
2363 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2364 var i, len;
2365
2366 this.$group = $group;
2367 for ( i = 0, len = this.items.length; i < len; i++ ) {
2368 this.$group.append( this.items[ i ].$element );
2369 }
2370 };
2371
2372 /**
2373 * Get an item by its data.
2374 *
2375 * Only the first item with matching data will be returned. To return all matching items,
2376 * use the #getItemsFromData method.
2377 *
2378 * @param {Object} data Item data to search for
2379 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2380 */
2381 OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
2382 var i, len, item,
2383 hash = OO.getHash( data );
2384
2385 for ( i = 0, len = this.items.length; i < len; i++ ) {
2386 item = this.items[ i ];
2387 if ( hash === OO.getHash( item.getData() ) ) {
2388 return item;
2389 }
2390 }
2391
2392 return null;
2393 };
2394
2395 /**
2396 * Get items by their data.
2397 *
2398 * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
2399 *
2400 * @param {Object} data Item data to search for
2401 * @return {OO.ui.Element[]} Items with equivalent data
2402 */
2403 OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
2404 var i, len, item,
2405 hash = OO.getHash( data ),
2406 items = [];
2407
2408 for ( i = 0, len = this.items.length; i < len; i++ ) {
2409 item = this.items[ i ];
2410 if ( hash === OO.getHash( item.getData() ) ) {
2411 items.push( item );
2412 }
2413 }
2414
2415 return items;
2416 };
2417
2418 /**
2419 * Add items to the group.
2420 *
2421 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2422 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2423 *
2424 * @param {OO.ui.Element[]} items An array of items to add to the group
2425 * @param {number} [index] Index of the insertion point
2426 * @chainable
2427 */
2428 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2429 // Mixin method
2430 OO.EmitterList.prototype.addItems.call( this, items, index );
2431
2432 this.emit( 'change', this.getItems() );
2433 return this;
2434 };
2435
2436 /**
2437 * @inheritdoc
2438 */
2439 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2440 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2441 this.insertItemElements( items, newIndex );
2442
2443 // Mixin method
2444 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2445
2446 return newIndex;
2447 };
2448
2449 /**
2450 * @inheritdoc
2451 */
2452 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2453 item.setElementGroup( this );
2454 this.insertItemElements( item, index );
2455
2456 // Mixin method
2457 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2458
2459 return index;
2460 };
2461
2462 /**
2463 * Insert elements into the group
2464 *
2465 * @private
2466 * @param {OO.ui.Element} itemWidget Item to insert
2467 * @param {number} index Insertion index
2468 */
2469 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2470 if ( index === undefined || index < 0 || index >= this.items.length ) {
2471 this.$group.append( itemWidget.$element );
2472 } else if ( index === 0 ) {
2473 this.$group.prepend( itemWidget.$element );
2474 } else {
2475 this.items[ index ].$element.before( itemWidget.$element );
2476 }
2477 };
2478
2479 /**
2480 * Remove the specified items from a group.
2481 *
2482 * Removed items are detached (not removed) from the DOM so that they may be reused.
2483 * To remove all items from a group, you may wish to use the #clearItems method instead.
2484 *
2485 * @param {OO.ui.Element[]} items An array of items to remove
2486 * @chainable
2487 */
2488 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2489 var i, len, item, index;
2490
2491 // Remove specific items elements
2492 for ( i = 0, len = items.length; i < len; i++ ) {
2493 item = items[ i ];
2494 index = this.items.indexOf( item );
2495 if ( index !== -1 ) {
2496 item.setElementGroup( null );
2497 item.$element.detach();
2498 }
2499 }
2500
2501 // Mixin method
2502 OO.EmitterList.prototype.removeItems.call( this, items );
2503
2504 this.emit( 'change', this.getItems() );
2505 return this;
2506 };
2507
2508 /**
2509 * Clear all items from the group.
2510 *
2511 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2512 * To remove only a subset of items from a group, use the #removeItems method.
2513 *
2514 * @chainable
2515 */
2516 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2517 var i, len;
2518
2519 // Remove all item elements
2520 for ( i = 0, len = this.items.length; i < len; i++ ) {
2521 this.items[ i ].setElementGroup( null );
2522 this.items[ i ].$element.detach();
2523 }
2524
2525 // Mixin method
2526 OO.EmitterList.prototype.clearItems.call( this );
2527
2528 this.emit( 'change', this.getItems() );
2529 return this;
2530 };
2531
2532 /**
2533 * IconElement is often mixed into other classes to generate an icon.
2534 * Icons are graphics, about the size of normal text. They are used to aid the user
2535 * in locating a control or to convey information in a space-efficient way. See the
2536 * [OOjs UI documentation on MediaWiki] [1] for a list of icons
2537 * included in the library.
2538 *
2539 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2540 *
2541 * @abstract
2542 * @class
2543 *
2544 * @constructor
2545 * @param {Object} [config] Configuration options
2546 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2547 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2548 * the icon element be set to an existing icon instead of the one generated by this class, set a
2549 * value using a jQuery selection. For example:
2550 *
2551 * // Use a <div> tag instead of a <span>
2552 * $icon: $("<div>")
2553 * // Use an existing icon element instead of the one generated by the class
2554 * $icon: this.$element
2555 * // Use an icon element from a child widget
2556 * $icon: this.childwidget.$element
2557 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2558 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2559 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2560 * by the user's language.
2561 *
2562 * Example of an i18n map:
2563 *
2564 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2565 * See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
2566 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2567 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2568 * text. The icon title is displayed when users move the mouse over the icon.
2569 */
2570 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2571 // Configuration initialization
2572 config = config || {};
2573
2574 // Properties
2575 this.$icon = null;
2576 this.icon = null;
2577 this.iconTitle = null;
2578
2579 // Initialization
2580 this.setIcon( config.icon || this.constructor.static.icon );
2581 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2582 this.setIconElement( config.$icon || $( '<span>' ) );
2583 };
2584
2585 /* Setup */
2586
2587 OO.initClass( OO.ui.mixin.IconElement );
2588
2589 /* Static Properties */
2590
2591 /**
2592 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2593 * for i18n purposes and contains a `default` icon name and additional names keyed by
2594 * language code. The `default` name is used when no icon is keyed by the user's language.
2595 *
2596 * Example of an i18n map:
2597 *
2598 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2599 *
2600 * Note: the static property will be overridden if the #icon configuration is used.
2601 *
2602 * @static
2603 * @inheritable
2604 * @property {Object|string}
2605 */
2606 OO.ui.mixin.IconElement.static.icon = null;
2607
2608 /**
2609 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2610 * function that returns title text, or `null` for no title.
2611 *
2612 * The static property will be overridden if the #iconTitle configuration is used.
2613 *
2614 * @static
2615 * @inheritable
2616 * @property {string|Function|null}
2617 */
2618 OO.ui.mixin.IconElement.static.iconTitle = null;
2619
2620 /* Methods */
2621
2622 /**
2623 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2624 * applies to the specified icon element instead of the one created by the class. If an icon
2625 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2626 * and mixin methods will no longer affect the element.
2627 *
2628 * @param {jQuery} $icon Element to use as icon
2629 */
2630 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2631 if ( this.$icon ) {
2632 this.$icon
2633 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2634 .removeAttr( 'title' );
2635 }
2636
2637 this.$icon = $icon
2638 .addClass( 'oo-ui-iconElement-icon' )
2639 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2640 if ( this.iconTitle !== null ) {
2641 this.$icon.attr( 'title', this.iconTitle );
2642 }
2643
2644 this.updateThemeClasses();
2645 };
2646
2647 /**
2648 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2649 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2650 * for an example.
2651 *
2652 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2653 * by language code, or `null` to remove the icon.
2654 * @chainable
2655 */
2656 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2657 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2658 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2659
2660 if ( this.icon !== icon ) {
2661 if ( this.$icon ) {
2662 if ( this.icon !== null ) {
2663 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2664 }
2665 if ( icon !== null ) {
2666 this.$icon.addClass( 'oo-ui-icon-' + icon );
2667 }
2668 }
2669 this.icon = icon;
2670 }
2671
2672 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2673 this.updateThemeClasses();
2674
2675 return this;
2676 };
2677
2678 /**
2679 * Set the icon title. Use `null` to remove the title.
2680 *
2681 * @param {string|Function|null} iconTitle A text string used as the icon title,
2682 * a function that returns title text, or `null` for no title.
2683 * @chainable
2684 */
2685 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2686 iconTitle =
2687 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2688 OO.ui.resolveMsg( iconTitle ) : null;
2689
2690 if ( this.iconTitle !== iconTitle ) {
2691 this.iconTitle = iconTitle;
2692 if ( this.$icon ) {
2693 if ( this.iconTitle !== null ) {
2694 this.$icon.attr( 'title', iconTitle );
2695 } else {
2696 this.$icon.removeAttr( 'title' );
2697 }
2698 }
2699 }
2700
2701 return this;
2702 };
2703
2704 /**
2705 * Get the symbolic name of the icon.
2706 *
2707 * @return {string} Icon name
2708 */
2709 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2710 return this.icon;
2711 };
2712
2713 /**
2714 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2715 *
2716 * @return {string} Icon title text
2717 */
2718 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2719 return this.iconTitle;
2720 };
2721
2722 /**
2723 * IndicatorElement is often mixed into other classes to generate an indicator.
2724 * Indicators are small graphics that are generally used in two ways:
2725 *
2726 * - To draw attention to the status of an item. For example, an indicator might be
2727 * used to show that an item in a list has errors that need to be resolved.
2728 * - To clarify the function of a control that acts in an exceptional way (a button
2729 * that opens a menu instead of performing an action directly, for example).
2730 *
2731 * For a list of indicators included in the library, please see the
2732 * [OOjs UI documentation on MediaWiki] [1].
2733 *
2734 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2735 *
2736 * @abstract
2737 * @class
2738 *
2739 * @constructor
2740 * @param {Object} [config] Configuration options
2741 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2742 * configuration is omitted, the indicator element will use a generated `<span>`.
2743 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2744 * See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
2745 * in the library.
2746 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2747 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2748 * or a function that returns title text. The indicator title is displayed when users move
2749 * the mouse over the indicator.
2750 */
2751 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2752 // Configuration initialization
2753 config = config || {};
2754
2755 // Properties
2756 this.$indicator = null;
2757 this.indicator = null;
2758 this.indicatorTitle = null;
2759
2760 // Initialization
2761 this.setIndicator( config.indicator || this.constructor.static.indicator );
2762 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2763 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2764 };
2765
2766 /* Setup */
2767
2768 OO.initClass( OO.ui.mixin.IndicatorElement );
2769
2770 /* Static Properties */
2771
2772 /**
2773 * Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2774 * The static property will be overridden if the #indicator configuration is used.
2775 *
2776 * @static
2777 * @inheritable
2778 * @property {string|null}
2779 */
2780 OO.ui.mixin.IndicatorElement.static.indicator = null;
2781
2782 /**
2783 * A text string used as the indicator title, a function that returns title text, or `null`
2784 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2785 *
2786 * @static
2787 * @inheritable
2788 * @property {string|Function|null}
2789 */
2790 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2791
2792 /* Methods */
2793
2794 /**
2795 * Set the indicator element.
2796 *
2797 * If an element is already set, it will be cleaned up before setting up the new element.
2798 *
2799 * @param {jQuery} $indicator Element to use as indicator
2800 */
2801 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2802 if ( this.$indicator ) {
2803 this.$indicator
2804 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2805 .removeAttr( 'title' );
2806 }
2807
2808 this.$indicator = $indicator
2809 .addClass( 'oo-ui-indicatorElement-indicator' )
2810 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2811 if ( this.indicatorTitle !== null ) {
2812 this.$indicator.attr( 'title', this.indicatorTitle );
2813 }
2814
2815 this.updateThemeClasses();
2816 };
2817
2818 /**
2819 * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
2820 *
2821 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2822 * @chainable
2823 */
2824 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2825 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2826
2827 if ( this.indicator !== indicator ) {
2828 if ( this.$indicator ) {
2829 if ( this.indicator !== null ) {
2830 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2831 }
2832 if ( indicator !== null ) {
2833 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2834 }
2835 }
2836 this.indicator = indicator;
2837 }
2838
2839 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2840 this.updateThemeClasses();
2841
2842 return this;
2843 };
2844
2845 /**
2846 * Set the indicator title.
2847 *
2848 * The title is displayed when a user moves the mouse over the indicator.
2849 *
2850 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2851 * `null` for no indicator title
2852 * @chainable
2853 */
2854 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2855 indicatorTitle =
2856 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2857 OO.ui.resolveMsg( indicatorTitle ) : null;
2858
2859 if ( this.indicatorTitle !== indicatorTitle ) {
2860 this.indicatorTitle = indicatorTitle;
2861 if ( this.$indicator ) {
2862 if ( this.indicatorTitle !== null ) {
2863 this.$indicator.attr( 'title', indicatorTitle );
2864 } else {
2865 this.$indicator.removeAttr( 'title' );
2866 }
2867 }
2868 }
2869
2870 return this;
2871 };
2872
2873 /**
2874 * Get the symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2875 *
2876 * @return {string} Symbolic name of indicator
2877 */
2878 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2879 return this.indicator;
2880 };
2881
2882 /**
2883 * Get the indicator title.
2884 *
2885 * The title is displayed when a user moves the mouse over the indicator.
2886 *
2887 * @return {string} Indicator title text
2888 */
2889 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2890 return this.indicatorTitle;
2891 };
2892
2893 /**
2894 * LabelElement is often mixed into other classes to generate a label, which
2895 * helps identify the function of an interface element.
2896 * See the [OOjs UI documentation on MediaWiki] [1] for more information.
2897 *
2898 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2899 *
2900 * @abstract
2901 * @class
2902 *
2903 * @constructor
2904 * @param {Object} [config] Configuration options
2905 * @cfg {jQuery} [$label] The label element created by the class. If this
2906 * configuration is omitted, the label element will use a generated `<span>`.
2907 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2908 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2909 * in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
2910 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2911 */
2912 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2913 // Configuration initialization
2914 config = config || {};
2915
2916 // Properties
2917 this.$label = null;
2918 this.label = null;
2919
2920 // Initialization
2921 this.setLabel( config.label || this.constructor.static.label );
2922 this.setLabelElement( config.$label || $( '<span>' ) );
2923 };
2924
2925 /* Setup */
2926
2927 OO.initClass( OO.ui.mixin.LabelElement );
2928
2929 /* Events */
2930
2931 /**
2932 * @event labelChange
2933 * @param {string} value
2934 */
2935
2936 /* Static Properties */
2937
2938 /**
2939 * The label text. The label can be specified as a plaintext string, a function that will
2940 * produce a string in the future, or `null` for no label. The static value will
2941 * be overridden if a label is specified with the #label config option.
2942 *
2943 * @static
2944 * @inheritable
2945 * @property {string|Function|null}
2946 */
2947 OO.ui.mixin.LabelElement.static.label = null;
2948
2949 /* Static methods */
2950
2951 /**
2952 * Highlight the first occurrence of the query in the given text
2953 *
2954 * @param {string} text Text
2955 * @param {string} query Query to find
2956 * @return {jQuery} Text with the first match of the query
2957 * sub-string wrapped in highlighted span
2958 */
2959 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query ) {
2960 var $result = $( '<span>' ),
2961 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2962
2963 if ( !query.length || offset === -1 ) {
2964 return $result.text( text );
2965 }
2966 $result.append(
2967 document.createTextNode( text.slice( 0, offset ) ),
2968 $( '<span>' )
2969 .addClass( 'oo-ui-labelElement-label-highlight' )
2970 .text( text.slice( offset, offset + query.length ) ),
2971 document.createTextNode( text.slice( offset + query.length ) )
2972 );
2973 return $result.contents();
2974 };
2975
2976 /* Methods */
2977
2978 /**
2979 * Set the label element.
2980 *
2981 * If an element is already set, it will be cleaned up before setting up the new element.
2982 *
2983 * @param {jQuery} $label Element to use as label
2984 */
2985 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2986 if ( this.$label ) {
2987 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2988 }
2989
2990 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2991 this.setLabelContent( this.label );
2992 };
2993
2994 /**
2995 * Set the label.
2996 *
2997 * An empty string will result in the label being hidden. A string containing only whitespace will
2998 * be converted to a single `&nbsp;`.
2999 *
3000 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3001 * text; or null for no label
3002 * @chainable
3003 */
3004 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3005 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3006 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3007
3008 if ( this.label !== label ) {
3009 if ( this.$label ) {
3010 this.setLabelContent( label );
3011 }
3012 this.label = label;
3013 this.emit( 'labelChange' );
3014 }
3015
3016 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3017
3018 return this;
3019 };
3020
3021 /**
3022 * Set the label as plain text with a highlighted query
3023 *
3024 * @param {string} text Text label to set
3025 * @param {string} query Substring of text to highlight
3026 * @chainable
3027 */
3028 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query ) {
3029 return this.setLabel( this.constructor.static.highlightQuery( text, query ) );
3030 };
3031
3032 /**
3033 * Get the label.
3034 *
3035 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3036 * text; or null for no label
3037 */
3038 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3039 return this.label;
3040 };
3041
3042 /**
3043 * Set the content of the label.
3044 *
3045 * Do not call this method until after the label element has been set by #setLabelElement.
3046 *
3047 * @private
3048 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3049 * text; or null for no label
3050 */
3051 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3052 if ( typeof label === 'string' ) {
3053 if ( label.match( /^\s*$/ ) ) {
3054 // Convert whitespace only string to a single non-breaking space
3055 this.$label.html( '&nbsp;' );
3056 } else {
3057 this.$label.text( label );
3058 }
3059 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3060 this.$label.html( label.toString() );
3061 } else if ( label instanceof jQuery ) {
3062 this.$label.empty().append( label );
3063 } else {
3064 this.$label.empty();
3065 }
3066 };
3067
3068 /**
3069 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3070 * additional functionality to an element created by another class. The class provides
3071 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3072 * which are used to customize the look and feel of a widget to better describe its
3073 * importance and functionality.
3074 *
3075 * The library currently contains the following styling flags for general use:
3076 *
3077 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3078 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3079 * - **constructive**: Constructive styling is applied to convey that the widget will create something.
3080 *
3081 * The flags affect the appearance of the buttons:
3082 *
3083 * @example
3084 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3085 * var button1 = new OO.ui.ButtonWidget( {
3086 * label: 'Constructive',
3087 * flags: 'constructive'
3088 * } );
3089 * var button2 = new OO.ui.ButtonWidget( {
3090 * label: 'Destructive',
3091 * flags: 'destructive'
3092 * } );
3093 * var button3 = new OO.ui.ButtonWidget( {
3094 * label: 'Progressive',
3095 * flags: 'progressive'
3096 * } );
3097 * $( 'body' ).append( button1.$element, button2.$element, button3.$element );
3098 *
3099 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3100 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
3101 *
3102 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3103 *
3104 * @abstract
3105 * @class
3106 *
3107 * @constructor
3108 * @param {Object} [config] Configuration options
3109 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'constructive' or 'primary') to apply.
3110 * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
3111 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3112 * @cfg {jQuery} [$flagged] The flagged element. By default,
3113 * the flagged functionality is applied to the element created by the class ($element).
3114 * If a different element is specified, the flagged functionality will be applied to it instead.
3115 */
3116 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3117 // Configuration initialization
3118 config = config || {};
3119
3120 // Properties
3121 this.flags = {};
3122 this.$flagged = null;
3123
3124 // Initialization
3125 this.setFlags( config.flags );
3126 this.setFlaggedElement( config.$flagged || this.$element );
3127 };
3128
3129 /* Events */
3130
3131 /**
3132 * @event flag
3133 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3134 * parameter contains the name of each modified flag and indicates whether it was
3135 * added or removed.
3136 *
3137 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3138 * that the flag was added, `false` that the flag was removed.
3139 */
3140
3141 /* Methods */
3142
3143 /**
3144 * Set the flagged element.
3145 *
3146 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3147 * If an element is already set, the method will remove the mixin’s effect on that element.
3148 *
3149 * @param {jQuery} $flagged Element that should be flagged
3150 */
3151 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3152 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3153 return 'oo-ui-flaggedElement-' + flag;
3154 } ).join( ' ' );
3155
3156 if ( this.$flagged ) {
3157 this.$flagged.removeClass( classNames );
3158 }
3159
3160 this.$flagged = $flagged.addClass( classNames );
3161 };
3162
3163 /**
3164 * Check if the specified flag is set.
3165 *
3166 * @param {string} flag Name of flag
3167 * @return {boolean} The flag is set
3168 */
3169 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3170 // This may be called before the constructor, thus before this.flags is set
3171 return this.flags && ( flag in this.flags );
3172 };
3173
3174 /**
3175 * Get the names of all flags set.
3176 *
3177 * @return {string[]} Flag names
3178 */
3179 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3180 // This may be called before the constructor, thus before this.flags is set
3181 return Object.keys( this.flags || {} );
3182 };
3183
3184 /**
3185 * Clear all flags.
3186 *
3187 * @chainable
3188 * @fires flag
3189 */
3190 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3191 var flag, className,
3192 changes = {},
3193 remove = [],
3194 classPrefix = 'oo-ui-flaggedElement-';
3195
3196 for ( flag in this.flags ) {
3197 className = classPrefix + flag;
3198 changes[ flag ] = false;
3199 delete this.flags[ flag ];
3200 remove.push( className );
3201 }
3202
3203 if ( this.$flagged ) {
3204 this.$flagged.removeClass( remove.join( ' ' ) );
3205 }
3206
3207 this.updateThemeClasses();
3208 this.emit( 'flag', changes );
3209
3210 return this;
3211 };
3212
3213 /**
3214 * Add one or more flags.
3215 *
3216 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3217 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3218 * be added (`true`) or removed (`false`).
3219 * @chainable
3220 * @fires flag
3221 */
3222 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3223 var i, len, flag, className,
3224 changes = {},
3225 add = [],
3226 remove = [],
3227 classPrefix = 'oo-ui-flaggedElement-';
3228
3229 if ( typeof flags === 'string' ) {
3230 className = classPrefix + flags;
3231 // Set
3232 if ( !this.flags[ flags ] ) {
3233 this.flags[ flags ] = true;
3234 add.push( className );
3235 }
3236 } else if ( Array.isArray( flags ) ) {
3237 for ( i = 0, len = flags.length; i < len; i++ ) {
3238 flag = flags[ i ];
3239 className = classPrefix + flag;
3240 // Set
3241 if ( !this.flags[ flag ] ) {
3242 changes[ flag ] = true;
3243 this.flags[ flag ] = true;
3244 add.push( className );
3245 }
3246 }
3247 } else if ( OO.isPlainObject( flags ) ) {
3248 for ( flag in flags ) {
3249 className = classPrefix + flag;
3250 if ( flags[ flag ] ) {
3251 // Set
3252 if ( !this.flags[ flag ] ) {
3253 changes[ flag ] = true;
3254 this.flags[ flag ] = true;
3255 add.push( className );
3256 }
3257 } else {
3258 // Remove
3259 if ( this.flags[ flag ] ) {
3260 changes[ flag ] = false;
3261 delete this.flags[ flag ];
3262 remove.push( className );
3263 }
3264 }
3265 }
3266 }
3267
3268 if ( this.$flagged ) {
3269 this.$flagged
3270 .addClass( add.join( ' ' ) )
3271 .removeClass( remove.join( ' ' ) );
3272 }
3273
3274 this.updateThemeClasses();
3275 this.emit( 'flag', changes );
3276
3277 return this;
3278 };
3279
3280 /**
3281 * TitledElement is mixed into other classes to provide a `title` attribute.
3282 * Titles are rendered by the browser and are made visible when the user moves
3283 * the mouse over the element. Titles are not visible on touch devices.
3284 *
3285 * @example
3286 * // TitledElement provides a 'title' attribute to the
3287 * // ButtonWidget class
3288 * var button = new OO.ui.ButtonWidget( {
3289 * label: 'Button with Title',
3290 * title: 'I am a button'
3291 * } );
3292 * $( 'body' ).append( button.$element );
3293 *
3294 * @abstract
3295 * @class
3296 *
3297 * @constructor
3298 * @param {Object} [config] Configuration options
3299 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3300 * If this config is omitted, the title functionality is applied to $element, the
3301 * element created by the class.
3302 * @cfg {string|Function} [title] The title text or a function that returns text. If
3303 * this config is omitted, the value of the {@link #static-title static title} property is used.
3304 */
3305 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3306 // Configuration initialization
3307 config = config || {};
3308
3309 // Properties
3310 this.$titled = null;
3311 this.title = null;
3312
3313 // Initialization
3314 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3315 this.setTitledElement( config.$titled || this.$element );
3316 };
3317
3318 /* Setup */
3319
3320 OO.initClass( OO.ui.mixin.TitledElement );
3321
3322 /* Static Properties */
3323
3324 /**
3325 * The title text, a function that returns text, or `null` for no title. The value of the static property
3326 * is overridden if the #title config option is used.
3327 *
3328 * @static
3329 * @inheritable
3330 * @property {string|Function|null}
3331 */
3332 OO.ui.mixin.TitledElement.static.title = null;
3333
3334 /* Methods */
3335
3336 /**
3337 * Set the titled element.
3338 *
3339 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3340 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3341 *
3342 * @param {jQuery} $titled Element that should use the 'titled' functionality
3343 */
3344 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3345 if ( this.$titled ) {
3346 this.$titled.removeAttr( 'title' );
3347 }
3348
3349 this.$titled = $titled;
3350 if ( this.title ) {
3351 this.updateTitle();
3352 }
3353 };
3354
3355 /**
3356 * Set title.
3357 *
3358 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3359 * @chainable
3360 */
3361 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3362 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3363 title = ( typeof title === 'string' && title.length ) ? title : null;
3364
3365 if ( this.title !== title ) {
3366 this.title = title;
3367 this.updateTitle();
3368 }
3369
3370 return this;
3371 };
3372
3373 /**
3374 * Update the title attribute, in case of changes to title or accessKey.
3375 *
3376 * @protected
3377 * @chainable
3378 */
3379 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3380 var title = this.getTitle();
3381 if ( this.$titled ) {
3382 if ( title !== null ) {
3383 // Only if this is an AccessKeyedElement
3384 if ( this.formatTitleWithAccessKey ) {
3385 title = this.formatTitleWithAccessKey( title );
3386 }
3387 this.$titled.attr( 'title', title );
3388 } else {
3389 this.$titled.removeAttr( 'title' );
3390 }
3391 }
3392 return this;
3393 };
3394
3395 /**
3396 * Get title.
3397 *
3398 * @return {string} Title string
3399 */
3400 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3401 return this.title;
3402 };
3403
3404 /**
3405 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3406 * Accesskeys allow an user to go to a specific element by using
3407 * a shortcut combination of a browser specific keys + the key
3408 * set to the field.
3409 *
3410 * @example
3411 * // AccessKeyedElement provides an 'accesskey' attribute to the
3412 * // ButtonWidget class
3413 * var button = new OO.ui.ButtonWidget( {
3414 * label: 'Button with Accesskey',
3415 * accessKey: 'k'
3416 * } );
3417 * $( 'body' ).append( button.$element );
3418 *
3419 * @abstract
3420 * @class
3421 *
3422 * @constructor
3423 * @param {Object} [config] Configuration options
3424 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3425 * If this config is omitted, the accesskey functionality is applied to $element, the
3426 * element created by the class.
3427 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3428 * this config is omitted, no accesskey will be added.
3429 */
3430 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3431 // Configuration initialization
3432 config = config || {};
3433
3434 // Properties
3435 this.$accessKeyed = null;
3436 this.accessKey = null;
3437
3438 // Initialization
3439 this.setAccessKey( config.accessKey || null );
3440 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3441
3442 // If this is also a TitledElement and it initialized before we did, we may have
3443 // to update the title with the access key
3444 if ( this.updateTitle ) {
3445 this.updateTitle();
3446 }
3447 };
3448
3449 /* Setup */
3450
3451 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3452
3453 /* Static Properties */
3454
3455 /**
3456 * The access key, a function that returns a key, or `null` for no accesskey.
3457 *
3458 * @static
3459 * @inheritable
3460 * @property {string|Function|null}
3461 */
3462 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3463
3464 /* Methods */
3465
3466 /**
3467 * Set the accesskeyed element.
3468 *
3469 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3470 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3471 *
3472 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3473 */
3474 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3475 if ( this.$accessKeyed ) {
3476 this.$accessKeyed.removeAttr( 'accesskey' );
3477 }
3478
3479 this.$accessKeyed = $accessKeyed;
3480 if ( this.accessKey ) {
3481 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3482 }
3483 };
3484
3485 /**
3486 * Set accesskey.
3487 *
3488 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3489 * @chainable
3490 */
3491 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3492 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3493
3494 if ( this.accessKey !== accessKey ) {
3495 if ( this.$accessKeyed ) {
3496 if ( accessKey !== null ) {
3497 this.$accessKeyed.attr( 'accesskey', accessKey );
3498 } else {
3499 this.$accessKeyed.removeAttr( 'accesskey' );
3500 }
3501 }
3502 this.accessKey = accessKey;
3503
3504 // Only if this is a TitledElement
3505 if ( this.updateTitle ) {
3506 this.updateTitle();
3507 }
3508 }
3509
3510 return this;
3511 };
3512
3513 /**
3514 * Get accesskey.
3515 *
3516 * @return {string} accessKey string
3517 */
3518 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3519 return this.accessKey;
3520 };
3521
3522 /**
3523 * Add information about the access key to the element's tooltip label.
3524 * (This is only public for hacky usage in FieldLayout.)
3525 *
3526 * @param {string} title Tooltip label for `title` attribute
3527 * @return {string}
3528 */
3529 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3530 var accessKey;
3531
3532 if ( !this.$accessKeyed ) {
3533 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3534 return title;
3535 }
3536 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3537 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3538 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3539 } else {
3540 accessKey = this.getAccessKey();
3541 }
3542 if ( accessKey ) {
3543 title += ' [' + accessKey + ']';
3544 }
3545 return title;
3546 };
3547
3548 /**
3549 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3550 * feels, and functionality can be customized via the class’s configuration options
3551 * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information
3552 * and examples.
3553 *
3554 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches
3555 *
3556 * @example
3557 * // A button widget
3558 * var button = new OO.ui.ButtonWidget( {
3559 * label: 'Button with Icon',
3560 * icon: 'trash',
3561 * iconTitle: 'Remove'
3562 * } );
3563 * $( 'body' ).append( button.$element );
3564 *
3565 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3566 *
3567 * @class
3568 * @extends OO.ui.Widget
3569 * @mixins OO.ui.mixin.ButtonElement
3570 * @mixins OO.ui.mixin.IconElement
3571 * @mixins OO.ui.mixin.IndicatorElement
3572 * @mixins OO.ui.mixin.LabelElement
3573 * @mixins OO.ui.mixin.TitledElement
3574 * @mixins OO.ui.mixin.FlaggedElement
3575 * @mixins OO.ui.mixin.TabIndexedElement
3576 * @mixins OO.ui.mixin.AccessKeyedElement
3577 *
3578 * @constructor
3579 * @param {Object} [config] Configuration options
3580 * @cfg {boolean} [active=false] Whether button should be shown as active
3581 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3582 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3583 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3584 */
3585 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3586 // Configuration initialization
3587 config = config || {};
3588
3589 // Parent constructor
3590 OO.ui.ButtonWidget.parent.call( this, config );
3591
3592 // Mixin constructors
3593 OO.ui.mixin.ButtonElement.call( this, config );
3594 OO.ui.mixin.IconElement.call( this, config );
3595 OO.ui.mixin.IndicatorElement.call( this, config );
3596 OO.ui.mixin.LabelElement.call( this, config );
3597 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3598 OO.ui.mixin.FlaggedElement.call( this, config );
3599 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3600 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3601
3602 // Properties
3603 this.href = null;
3604 this.target = null;
3605 this.noFollow = false;
3606
3607 // Events
3608 this.connect( this, { disable: 'onDisable' } );
3609
3610 // Initialization
3611 this.$button.append( this.$icon, this.$label, this.$indicator );
3612 this.$element
3613 .addClass( 'oo-ui-buttonWidget' )
3614 .append( this.$button );
3615 this.setActive( config.active );
3616 this.setHref( config.href );
3617 this.setTarget( config.target );
3618 this.setNoFollow( config.noFollow );
3619 };
3620
3621 /* Setup */
3622
3623 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3624 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3625 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3626 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3627 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3628 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3629 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3630 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3631 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3632
3633 /* Static Properties */
3634
3635 /**
3636 * @static
3637 * @inheritdoc
3638 */
3639 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3640
3641 /**
3642 * @static
3643 * @inheritdoc
3644 */
3645 OO.ui.ButtonWidget.static.tagName = 'span';
3646
3647 /* Methods */
3648
3649 /**
3650 * Get hyperlink location.
3651 *
3652 * @return {string} Hyperlink location
3653 */
3654 OO.ui.ButtonWidget.prototype.getHref = function () {
3655 return this.href;
3656 };
3657
3658 /**
3659 * Get hyperlink target.
3660 *
3661 * @return {string} Hyperlink target
3662 */
3663 OO.ui.ButtonWidget.prototype.getTarget = function () {
3664 return this.target;
3665 };
3666
3667 /**
3668 * Get search engine traversal hint.
3669 *
3670 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3671 */
3672 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3673 return this.noFollow;
3674 };
3675
3676 /**
3677 * Set hyperlink location.
3678 *
3679 * @param {string|null} href Hyperlink location, null to remove
3680 */
3681 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3682 href = typeof href === 'string' ? href : null;
3683 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3684 href = './' + href;
3685 }
3686
3687 if ( href !== this.href ) {
3688 this.href = href;
3689 this.updateHref();
3690 }
3691
3692 return this;
3693 };
3694
3695 /**
3696 * Update the `href` attribute, in case of changes to href or
3697 * disabled state.
3698 *
3699 * @private
3700 * @chainable
3701 */
3702 OO.ui.ButtonWidget.prototype.updateHref = function () {
3703 if ( this.href !== null && !this.isDisabled() ) {
3704 this.$button.attr( 'href', this.href );
3705 } else {
3706 this.$button.removeAttr( 'href' );
3707 }
3708
3709 return this;
3710 };
3711
3712 /**
3713 * Handle disable events.
3714 *
3715 * @private
3716 * @param {boolean} disabled Element is disabled
3717 */
3718 OO.ui.ButtonWidget.prototype.onDisable = function () {
3719 this.updateHref();
3720 };
3721
3722 /**
3723 * Set hyperlink target.
3724 *
3725 * @param {string|null} target Hyperlink target, null to remove
3726 */
3727 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3728 target = typeof target === 'string' ? target : null;
3729
3730 if ( target !== this.target ) {
3731 this.target = target;
3732 if ( target !== null ) {
3733 this.$button.attr( 'target', target );
3734 } else {
3735 this.$button.removeAttr( 'target' );
3736 }
3737 }
3738
3739 return this;
3740 };
3741
3742 /**
3743 * Set search engine traversal hint.
3744 *
3745 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3746 */
3747 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3748 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3749
3750 if ( noFollow !== this.noFollow ) {
3751 this.noFollow = noFollow;
3752 if ( noFollow ) {
3753 this.$button.attr( 'rel', 'nofollow' );
3754 } else {
3755 this.$button.removeAttr( 'rel' );
3756 }
3757 }
3758
3759 return this;
3760 };
3761
3762 // Override method visibility hints from ButtonElement
3763 /**
3764 * @method setActive
3765 * @inheritdoc
3766 */
3767 /**
3768 * @method isActive
3769 * @inheritdoc
3770 */
3771
3772 /**
3773 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3774 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3775 * removed, and cleared from the group.
3776 *
3777 * @example
3778 * // Example: A ButtonGroupWidget with two buttons
3779 * var button1 = new OO.ui.PopupButtonWidget( {
3780 * label: 'Select a category',
3781 * icon: 'menu',
3782 * popup: {
3783 * $content: $( '<p>List of categories...</p>' ),
3784 * padded: true,
3785 * align: 'left'
3786 * }
3787 * } );
3788 * var button2 = new OO.ui.ButtonWidget( {
3789 * label: 'Add item'
3790 * });
3791 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3792 * items: [button1, button2]
3793 * } );
3794 * $( 'body' ).append( buttonGroup.$element );
3795 *
3796 * @class
3797 * @extends OO.ui.Widget
3798 * @mixins OO.ui.mixin.GroupElement
3799 *
3800 * @constructor
3801 * @param {Object} [config] Configuration options
3802 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3803 */
3804 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3805 // Configuration initialization
3806 config = config || {};
3807
3808 // Parent constructor
3809 OO.ui.ButtonGroupWidget.parent.call( this, config );
3810
3811 // Mixin constructors
3812 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3813
3814 // Initialization
3815 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3816 if ( Array.isArray( config.items ) ) {
3817 this.addItems( config.items );
3818 }
3819 };
3820
3821 /* Setup */
3822
3823 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3824 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3825
3826 /* Static Properties */
3827
3828 /**
3829 * @static
3830 * @inheritdoc
3831 */
3832 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3833
3834 /* Methods */
3835
3836 /**
3837 * Focus the widget
3838 *
3839 * @chainable
3840 */
3841 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3842 if ( !this.isDisabled() ) {
3843 if ( this.items[ 0 ] ) {
3844 this.items[ 0 ].focus();
3845 }
3846 }
3847 return this;
3848 };
3849
3850 /**
3851 * @inheritdoc
3852 */
3853 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3854 this.focus();
3855 };
3856
3857 /**
3858 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3859 * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
3860 * for a list of icons included in the library.
3861 *
3862 * @example
3863 * // An icon widget with a label
3864 * var myIcon = new OO.ui.IconWidget( {
3865 * icon: 'help',
3866 * iconTitle: 'Help'
3867 * } );
3868 * // Create a label.
3869 * var iconLabel = new OO.ui.LabelWidget( {
3870 * label: 'Help'
3871 * } );
3872 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3873 *
3874 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
3875 *
3876 * @class
3877 * @extends OO.ui.Widget
3878 * @mixins OO.ui.mixin.IconElement
3879 * @mixins OO.ui.mixin.TitledElement
3880 * @mixins OO.ui.mixin.FlaggedElement
3881 *
3882 * @constructor
3883 * @param {Object} [config] Configuration options
3884 */
3885 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3886 // Configuration initialization
3887 config = config || {};
3888
3889 // Parent constructor
3890 OO.ui.IconWidget.parent.call( this, config );
3891
3892 // Mixin constructors
3893 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3894 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3895 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3896
3897 // Initialization
3898 this.$element.addClass( 'oo-ui-iconWidget' );
3899 };
3900
3901 /* Setup */
3902
3903 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3904 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3905 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3906 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3907
3908 /* Static Properties */
3909
3910 /**
3911 * @static
3912 * @inheritdoc
3913 */
3914 OO.ui.IconWidget.static.tagName = 'span';
3915
3916 /**
3917 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3918 * attention to the status of an item or to clarify the function of a control. For a list of
3919 * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
3920 *
3921 * @example
3922 * // Example of an indicator widget
3923 * var indicator1 = new OO.ui.IndicatorWidget( {
3924 * indicator: 'alert'
3925 * } );
3926 *
3927 * // Create a fieldset layout to add a label
3928 * var fieldset = new OO.ui.FieldsetLayout();
3929 * fieldset.addItems( [
3930 * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
3931 * ] );
3932 * $( 'body' ).append( fieldset.$element );
3933 *
3934 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3935 *
3936 * @class
3937 * @extends OO.ui.Widget
3938 * @mixins OO.ui.mixin.IndicatorElement
3939 * @mixins OO.ui.mixin.TitledElement
3940 *
3941 * @constructor
3942 * @param {Object} [config] Configuration options
3943 */
3944 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
3945 // Configuration initialization
3946 config = config || {};
3947
3948 // Parent constructor
3949 OO.ui.IndicatorWidget.parent.call( this, config );
3950
3951 // Mixin constructors
3952 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
3953 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3954
3955 // Initialization
3956 this.$element.addClass( 'oo-ui-indicatorWidget' );
3957 };
3958
3959 /* Setup */
3960
3961 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
3962 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
3963 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
3964
3965 /* Static Properties */
3966
3967 /**
3968 * @static
3969 * @inheritdoc
3970 */
3971 OO.ui.IndicatorWidget.static.tagName = 'span';
3972
3973 /**
3974 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
3975 * be configured with a `label` option that is set to a string, a label node, or a function:
3976 *
3977 * - String: a plaintext string
3978 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
3979 * label that includes a link or special styling, such as a gray color or additional graphical elements.
3980 * - Function: a function that will produce a string in the future. Functions are used
3981 * in cases where the value of the label is not currently defined.
3982 *
3983 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
3984 * will come into focus when the label is clicked.
3985 *
3986 * @example
3987 * // Examples of LabelWidgets
3988 * var label1 = new OO.ui.LabelWidget( {
3989 * label: 'plaintext label'
3990 * } );
3991 * var label2 = new OO.ui.LabelWidget( {
3992 * label: $( '<a href="default.html">jQuery label</a>' )
3993 * } );
3994 * // Create a fieldset layout with fields for each example
3995 * var fieldset = new OO.ui.FieldsetLayout();
3996 * fieldset.addItems( [
3997 * new OO.ui.FieldLayout( label1 ),
3998 * new OO.ui.FieldLayout( label2 )
3999 * ] );
4000 * $( 'body' ).append( fieldset.$element );
4001 *
4002 * @class
4003 * @extends OO.ui.Widget
4004 * @mixins OO.ui.mixin.LabelElement
4005 * @mixins OO.ui.mixin.TitledElement
4006 *
4007 * @constructor
4008 * @param {Object} [config] Configuration options
4009 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4010 * Clicking the label will focus the specified input field.
4011 */
4012 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4013 // Configuration initialization
4014 config = config || {};
4015
4016 // Parent constructor
4017 OO.ui.LabelWidget.parent.call( this, config );
4018
4019 // Mixin constructors
4020 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4021 OO.ui.mixin.TitledElement.call( this, config );
4022
4023 // Properties
4024 this.input = config.input;
4025
4026 // Initialization
4027 if ( this.input ) {
4028 if ( this.input.getInputId() ) {
4029 this.$element.attr( 'for', this.input.getInputId() );
4030 } else {
4031 this.$label.on( 'click', function () {
4032 this.input.simulateLabelClick();
4033 return false;
4034 }.bind( this ) );
4035 }
4036 }
4037 this.$element.addClass( 'oo-ui-labelWidget' );
4038 };
4039
4040 /* Setup */
4041
4042 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4043 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4044 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4045
4046 /* Static Properties */
4047
4048 /**
4049 * @static
4050 * @inheritdoc
4051 */
4052 OO.ui.LabelWidget.static.tagName = 'label';
4053
4054 /**
4055 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4056 * and that they should wait before proceeding. The pending state is visually represented with a pending
4057 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4058 * field of a {@link OO.ui.TextInputWidget text input widget}.
4059 *
4060 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4061 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4062 * in process dialogs.
4063 *
4064 * @example
4065 * function MessageDialog( config ) {
4066 * MessageDialog.parent.call( this, config );
4067 * }
4068 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4069 *
4070 * MessageDialog.static.name = 'myMessageDialog';
4071 * MessageDialog.static.actions = [
4072 * { action: 'save', label: 'Done', flags: 'primary' },
4073 * { label: 'Cancel', flags: 'safe' }
4074 * ];
4075 *
4076 * MessageDialog.prototype.initialize = function () {
4077 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4078 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4079 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
4080 * this.$body.append( this.content.$element );
4081 * };
4082 * MessageDialog.prototype.getBodyHeight = function () {
4083 * return 100;
4084 * }
4085 * MessageDialog.prototype.getActionProcess = function ( action ) {
4086 * var dialog = this;
4087 * if ( action === 'save' ) {
4088 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4089 * return new OO.ui.Process()
4090 * .next( 1000 )
4091 * .next( function () {
4092 * dialog.getActions().get({actions: 'save'})[0].popPending();
4093 * } );
4094 * }
4095 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4096 * };
4097 *
4098 * var windowManager = new OO.ui.WindowManager();
4099 * $( 'body' ).append( windowManager.$element );
4100 *
4101 * var dialog = new MessageDialog();
4102 * windowManager.addWindows( [ dialog ] );
4103 * windowManager.openWindow( dialog );
4104 *
4105 * @abstract
4106 * @class
4107 *
4108 * @constructor
4109 * @param {Object} [config] Configuration options
4110 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4111 */
4112 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4113 // Configuration initialization
4114 config = config || {};
4115
4116 // Properties
4117 this.pending = 0;
4118 this.$pending = null;
4119
4120 // Initialisation
4121 this.setPendingElement( config.$pending || this.$element );
4122 };
4123
4124 /* Setup */
4125
4126 OO.initClass( OO.ui.mixin.PendingElement );
4127
4128 /* Methods */
4129
4130 /**
4131 * Set the pending element (and clean up any existing one).
4132 *
4133 * @param {jQuery} $pending The element to set to pending.
4134 */
4135 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4136 if ( this.$pending ) {
4137 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4138 }
4139
4140 this.$pending = $pending;
4141 if ( this.pending > 0 ) {
4142 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4143 }
4144 };
4145
4146 /**
4147 * Check if an element is pending.
4148 *
4149 * @return {boolean} Element is pending
4150 */
4151 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4152 return !!this.pending;
4153 };
4154
4155 /**
4156 * Increase the pending counter. The pending state will remain active until the counter is zero
4157 * (i.e., the number of calls to #pushPending and #popPending is the same).
4158 *
4159 * @chainable
4160 */
4161 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4162 if ( this.pending === 0 ) {
4163 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4164 this.updateThemeClasses();
4165 }
4166 this.pending++;
4167
4168 return this;
4169 };
4170
4171 /**
4172 * Decrease the pending counter. The pending state will remain active until the counter is zero
4173 * (i.e., the number of calls to #pushPending and #popPending is the same).
4174 *
4175 * @chainable
4176 */
4177 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4178 if ( this.pending === 1 ) {
4179 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4180 this.updateThemeClasses();
4181 }
4182 this.pending = Math.max( 0, this.pending - 1 );
4183
4184 return this;
4185 };
4186
4187 /**
4188 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4189 * in the document (for example, in an OO.ui.Window's $overlay).
4190 *
4191 * The elements's position is automatically calculated and maintained when window is resized or the
4192 * page is scrolled. If you reposition the container manually, you have to call #position to make
4193 * sure the element is still placed correctly.
4194 *
4195 * As positioning is only possible when both the element and the container are attached to the DOM
4196 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4197 * the #toggle method to display a floating popup, for example.
4198 *
4199 * @abstract
4200 * @class
4201 *
4202 * @constructor
4203 * @param {Object} [config] Configuration options
4204 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4205 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4206 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4207 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4208 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4209 * 'top': Align the top edge with $floatableContainer's top edge
4210 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4211 * 'center': Vertically align the center with $floatableContainer's center
4212 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4213 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4214 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4215 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4216 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4217 * 'center': Horizontally align the center with $floatableContainer's center
4218 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4219 * is out of view
4220 */
4221 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4222 // Configuration initialization
4223 config = config || {};
4224
4225 // Properties
4226 this.$floatable = null;
4227 this.$floatableContainer = null;
4228 this.$floatableWindow = null;
4229 this.$floatableClosestScrollable = null;
4230 this.onFloatableScrollHandler = this.position.bind( this );
4231 this.onFloatableWindowResizeHandler = this.position.bind( this );
4232
4233 // Initialization
4234 this.setFloatableContainer( config.$floatableContainer );
4235 this.setFloatableElement( config.$floatable || this.$element );
4236 this.setVerticalPosition( config.verticalPosition || 'below' );
4237 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4238 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4239 };
4240
4241 /* Methods */
4242
4243 /**
4244 * Set floatable element.
4245 *
4246 * If an element is already set, it will be cleaned up before setting up the new element.
4247 *
4248 * @param {jQuery} $floatable Element to make floatable
4249 */
4250 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4251 if ( this.$floatable ) {
4252 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4253 this.$floatable.css( { left: '', top: '' } );
4254 }
4255
4256 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4257 this.position();
4258 };
4259
4260 /**
4261 * Set floatable container.
4262 *
4263 * The element will be positioned relative to the specified container.
4264 *
4265 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4266 */
4267 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4268 this.$floatableContainer = $floatableContainer;
4269 if ( this.$floatable ) {
4270 this.position();
4271 }
4272 };
4273
4274 /**
4275 * Change how the element is positioned vertically.
4276 *
4277 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4278 */
4279 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4280 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4281 throw new Error( 'Invalid value for vertical position: ' + position );
4282 }
4283 if ( this.verticalPosition !== position ) {
4284 this.verticalPosition = position;
4285 if ( this.$floatable ) {
4286 this.position();
4287 }
4288 }
4289 };
4290
4291 /**
4292 * Change how the element is positioned horizontally.
4293 *
4294 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4295 */
4296 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4297 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4298 throw new Error( 'Invalid value for horizontal position: ' + position );
4299 }
4300 if ( this.horizontalPosition !== position ) {
4301 this.horizontalPosition = position;
4302 if ( this.$floatable ) {
4303 this.position();
4304 }
4305 }
4306 };
4307
4308 /**
4309 * Toggle positioning.
4310 *
4311 * Do not turn positioning on until after the element is attached to the DOM and visible.
4312 *
4313 * @param {boolean} [positioning] Enable positioning, omit to toggle
4314 * @chainable
4315 */
4316 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4317 var closestScrollableOfContainer;
4318
4319 if ( !this.$floatable || !this.$floatableContainer ) {
4320 return this;
4321 }
4322
4323 positioning = positioning === undefined ? !this.positioning : !!positioning;
4324
4325 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4326 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4327 this.warnedUnattached = true;
4328 }
4329
4330 if ( this.positioning !== positioning ) {
4331 this.positioning = positioning;
4332
4333 this.needsCustomPosition =
4334 this.verticalPostion !== 'below' ||
4335 this.horizontalPosition !== 'start' ||
4336 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4337
4338 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4339 // If the scrollable is the root, we have to listen to scroll events
4340 // on the window because of browser inconsistencies.
4341 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4342 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4343 }
4344
4345 if ( positioning ) {
4346 this.$floatableWindow = $( this.getElementWindow() );
4347 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4348
4349 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4350 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4351
4352 // Initial position after visible
4353 this.position();
4354 } else {
4355 if ( this.$floatableWindow ) {
4356 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4357 this.$floatableWindow = null;
4358 }
4359
4360 if ( this.$floatableClosestScrollable ) {
4361 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4362 this.$floatableClosestScrollable = null;
4363 }
4364
4365 this.$floatable.css( { left: '', right: '', top: '' } );
4366 }
4367 }
4368
4369 return this;
4370 };
4371
4372 /**
4373 * Check whether the bottom edge of the given element is within the viewport of the given container.
4374 *
4375 * @private
4376 * @param {jQuery} $element
4377 * @param {jQuery} $container
4378 * @return {boolean}
4379 */
4380 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4381 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4382 startEdgeInBounds, endEdgeInBounds,
4383 direction = $element.css( 'direction' );
4384
4385 elemRect = $element[ 0 ].getBoundingClientRect();
4386 if ( $container[ 0 ] === window ) {
4387 contRect = {
4388 top: 0,
4389 left: 0,
4390 right: document.documentElement.clientWidth,
4391 bottom: document.documentElement.clientHeight
4392 };
4393 } else {
4394 contRect = $container[ 0 ].getBoundingClientRect();
4395 }
4396
4397 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4398 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4399 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4400 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4401 if ( direction === 'rtl' ) {
4402 startEdgeInBounds = rightEdgeInBounds;
4403 endEdgeInBounds = leftEdgeInBounds;
4404 } else {
4405 startEdgeInBounds = leftEdgeInBounds;
4406 endEdgeInBounds = rightEdgeInBounds;
4407 }
4408
4409 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4410 return false;
4411 }
4412 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4413 return false;
4414 }
4415 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4416 return false;
4417 }
4418 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4419 return false;
4420 }
4421
4422 // The other positioning values are all about being inside the container,
4423 // so in those cases all we care about is that any part of the container is visible.
4424 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4425 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4426 };
4427
4428 /**
4429 * Position the floatable below its container.
4430 *
4431 * This should only be done when both of them are attached to the DOM and visible.
4432 *
4433 * @chainable
4434 */
4435 OO.ui.mixin.FloatableElement.prototype.position = function () {
4436 if ( !this.positioning ) {
4437 return this;
4438 }
4439
4440 if ( !(
4441 // To continue, some things need to be true:
4442 // The element must actually be in the DOM
4443 this.isElementAttached() && (
4444 // The closest scrollable is the current window
4445 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4446 // OR is an element in the element's DOM
4447 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4448 )
4449 ) ) {
4450 // Abort early if important parts of the widget are no longer attached to the DOM
4451 return this;
4452 }
4453
4454 if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
4455 this.$floatable.addClass( 'oo-ui-element-hidden' );
4456 return this;
4457 } else {
4458 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4459 }
4460
4461 if ( !this.needsCustomPosition ) {
4462 return this;
4463 }
4464
4465 this.$floatable.css( this.computePosition() );
4466
4467 // We updated the position, so re-evaluate the clipping state.
4468 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4469 // will not notice the need to update itself.)
4470 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4471 // it not listen to the right events in the right places?
4472 if ( this.clip ) {
4473 this.clip();
4474 }
4475
4476 return this;
4477 };
4478
4479 /**
4480 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4481 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4482 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4483 *
4484 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4485 */
4486 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4487 var isBody, scrollableX, scrollableY, containerPos,
4488 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4489 newPos = { top: '', left: '', bottom: '', right: '' },
4490 direction = this.$floatableContainer.css( 'direction' ),
4491 $offsetParent = this.$floatable.offsetParent();
4492
4493 if ( $offsetParent.is( 'html' ) ) {
4494 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4495 // <html> element, but they do work on the <body>
4496 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4497 }
4498 isBody = $offsetParent.is( 'body' );
4499 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4500 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4501
4502 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4503 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4504 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4505 // or if it isn't scrollable
4506 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4507 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4508
4509 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4510 // if the <body> has a margin
4511 containerPos = isBody ?
4512 this.$floatableContainer.offset() :
4513 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4514 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4515 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4516 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4517 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4518
4519 if ( this.verticalPosition === 'below' ) {
4520 newPos.top = containerPos.bottom;
4521 } else if ( this.verticalPosition === 'above' ) {
4522 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4523 } else if ( this.verticalPosition === 'top' ) {
4524 newPos.top = containerPos.top;
4525 } else if ( this.verticalPosition === 'bottom' ) {
4526 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4527 } else if ( this.verticalPosition === 'center' ) {
4528 newPos.top = containerPos.top +
4529 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4530 }
4531
4532 if ( this.horizontalPosition === 'before' ) {
4533 newPos.end = containerPos.start;
4534 } else if ( this.horizontalPosition === 'after' ) {
4535 newPos.start = containerPos.end;
4536 } else if ( this.horizontalPosition === 'start' ) {
4537 newPos.start = containerPos.start;
4538 } else if ( this.horizontalPosition === 'end' ) {
4539 newPos.end = containerPos.end;
4540 } else if ( this.horizontalPosition === 'center' ) {
4541 newPos.left = containerPos.left +
4542 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4543 }
4544
4545 if ( newPos.start !== undefined ) {
4546 if ( direction === 'rtl' ) {
4547 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4548 } else {
4549 newPos.left = newPos.start;
4550 }
4551 delete newPos.start;
4552 }
4553 if ( newPos.end !== undefined ) {
4554 if ( direction === 'rtl' ) {
4555 newPos.left = newPos.end;
4556 } else {
4557 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4558 }
4559 delete newPos.end;
4560 }
4561
4562 // Account for scroll position
4563 if ( newPos.top !== '' ) {
4564 newPos.top += scrollTop;
4565 }
4566 if ( newPos.bottom !== '' ) {
4567 newPos.bottom -= scrollTop;
4568 }
4569 if ( newPos.left !== '' ) {
4570 newPos.left += scrollLeft;
4571 }
4572 if ( newPos.right !== '' ) {
4573 newPos.right -= scrollLeft;
4574 }
4575
4576 // Account for scrollbar gutter
4577 if ( newPos.bottom !== '' ) {
4578 newPos.bottom -= horizScrollbarHeight;
4579 }
4580 if ( direction === 'rtl' ) {
4581 if ( newPos.left !== '' ) {
4582 newPos.left -= vertScrollbarWidth;
4583 }
4584 } else {
4585 if ( newPos.right !== '' ) {
4586 newPos.right -= vertScrollbarWidth;
4587 }
4588 }
4589
4590 return newPos;
4591 };
4592
4593 /**
4594 * Element that can be automatically clipped to visible boundaries.
4595 *
4596 * Whenever the element's natural height changes, you have to call
4597 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4598 * clipping correctly.
4599 *
4600 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4601 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4602 * then #$clippable will be given a fixed reduced height and/or width and will be made
4603 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4604 * but you can build a static footer by setting #$clippableContainer to an element that contains
4605 * #$clippable and the footer.
4606 *
4607 * @abstract
4608 * @class
4609 *
4610 * @constructor
4611 * @param {Object} [config] Configuration options
4612 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4613 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4614 * omit to use #$clippable
4615 */
4616 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4617 // Configuration initialization
4618 config = config || {};
4619
4620 // Properties
4621 this.$clippable = null;
4622 this.$clippableContainer = null;
4623 this.clipping = false;
4624 this.clippedHorizontally = false;
4625 this.clippedVertically = false;
4626 this.$clippableScrollableContainer = null;
4627 this.$clippableScroller = null;
4628 this.$clippableWindow = null;
4629 this.idealWidth = null;
4630 this.idealHeight = null;
4631 this.onClippableScrollHandler = this.clip.bind( this );
4632 this.onClippableWindowResizeHandler = this.clip.bind( this );
4633
4634 // Initialization
4635 if ( config.$clippableContainer ) {
4636 this.setClippableContainer( config.$clippableContainer );
4637 }
4638 this.setClippableElement( config.$clippable || this.$element );
4639 };
4640
4641 /* Methods */
4642
4643 /**
4644 * Set clippable element.
4645 *
4646 * If an element is already set, it will be cleaned up before setting up the new element.
4647 *
4648 * @param {jQuery} $clippable Element to make clippable
4649 */
4650 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4651 if ( this.$clippable ) {
4652 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4653 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4654 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4655 }
4656
4657 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4658 this.clip();
4659 };
4660
4661 /**
4662 * Set clippable container.
4663 *
4664 * This is the container that will be measured when deciding whether to clip. When clipping,
4665 * #$clippable will be resized in order to keep the clippable container fully visible.
4666 *
4667 * If the clippable container is unset, #$clippable will be used.
4668 *
4669 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4670 */
4671 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4672 this.$clippableContainer = $clippableContainer;
4673 if ( this.$clippable ) {
4674 this.clip();
4675 }
4676 };
4677
4678 /**
4679 * Toggle clipping.
4680 *
4681 * Do not turn clipping on until after the element is attached to the DOM and visible.
4682 *
4683 * @param {boolean} [clipping] Enable clipping, omit to toggle
4684 * @chainable
4685 */
4686 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4687 clipping = clipping === undefined ? !this.clipping : !!clipping;
4688
4689 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4690 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4691 this.warnedUnattached = true;
4692 }
4693
4694 if ( this.clipping !== clipping ) {
4695 this.clipping = clipping;
4696 if ( clipping ) {
4697 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4698 // If the clippable container is the root, we have to listen to scroll events and check
4699 // jQuery.scrollTop on the window because of browser inconsistencies
4700 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4701 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4702 this.$clippableScrollableContainer;
4703 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4704 this.$clippableWindow = $( this.getElementWindow() )
4705 .on( 'resize', this.onClippableWindowResizeHandler );
4706 // Initial clip after visible
4707 this.clip();
4708 } else {
4709 this.$clippable.css( {
4710 width: '',
4711 height: '',
4712 maxWidth: '',
4713 maxHeight: '',
4714 overflowX: '',
4715 overflowY: ''
4716 } );
4717 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4718
4719 this.$clippableScrollableContainer = null;
4720 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4721 this.$clippableScroller = null;
4722 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4723 this.$clippableWindow = null;
4724 }
4725 }
4726
4727 return this;
4728 };
4729
4730 /**
4731 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4732 *
4733 * @return {boolean} Element will be clipped to the visible area
4734 */
4735 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4736 return this.clipping;
4737 };
4738
4739 /**
4740 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4741 *
4742 * @return {boolean} Part of the element is being clipped
4743 */
4744 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4745 return this.clippedHorizontally || this.clippedVertically;
4746 };
4747
4748 /**
4749 * Check if the right of the element is being clipped by the nearest scrollable container.
4750 *
4751 * @return {boolean} Part of the element is being clipped
4752 */
4753 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4754 return this.clippedHorizontally;
4755 };
4756
4757 /**
4758 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4759 *
4760 * @return {boolean} Part of the element is being clipped
4761 */
4762 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4763 return this.clippedVertically;
4764 };
4765
4766 /**
4767 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4768 *
4769 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4770 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4771 */
4772 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4773 this.idealWidth = width;
4774 this.idealHeight = height;
4775
4776 if ( !this.clipping ) {
4777 // Update dimensions
4778 this.$clippable.css( { width: width, height: height } );
4779 }
4780 // While clipping, idealWidth and idealHeight are not considered
4781 };
4782
4783 /**
4784 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4785 * when the element's natural height changes.
4786 *
4787 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4788 * overlapped by, the visible area of the nearest scrollable container.
4789 *
4790 * Because calling clip() when the natural height changes isn't always possible, we also set
4791 * max-height when the element isn't being clipped. This means that if the element tries to grow
4792 * beyond the edge, something reasonable will happen before clip() is called.
4793 *
4794 * @chainable
4795 */
4796 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4797 var $container, extraHeight, extraWidth, ccOffset,
4798 $scrollableContainer, scOffset, scHeight, scWidth,
4799 ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
4800 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4801 naturalWidth, naturalHeight, clipWidth, clipHeight,
4802 buffer = 7; // Chosen by fair dice roll
4803
4804 if ( !this.clipping ) {
4805 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4806 return this;
4807 }
4808
4809 $container = this.$clippableContainer || this.$clippable;
4810 extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
4811 extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
4812 ccOffset = $container.offset();
4813 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4814 $scrollableContainer = this.$clippableWindow;
4815 scOffset = { top: 0, left: 0 };
4816 } else {
4817 $scrollableContainer = this.$clippableScrollableContainer;
4818 scOffset = $scrollableContainer.offset();
4819 }
4820 scHeight = $scrollableContainer.innerHeight() - buffer;
4821 scWidth = $scrollableContainer.innerWidth() - buffer;
4822 ccWidth = $container.outerWidth() + buffer;
4823 scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
4824 scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
4825 scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
4826 desiredWidth = ccOffset.left < 0 ?
4827 ccWidth + ccOffset.left :
4828 ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
4829 desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
4830 // It should never be desirable to exceed the dimensions of the browser viewport... right?
4831 desiredWidth = Math.min( desiredWidth, document.documentElement.clientWidth );
4832 desiredHeight = Math.min( desiredHeight, document.documentElement.clientHeight );
4833 allotedWidth = Math.ceil( desiredWidth - extraWidth );
4834 allotedHeight = Math.ceil( desiredHeight - extraHeight );
4835 naturalWidth = this.$clippable.prop( 'scrollWidth' );
4836 naturalHeight = this.$clippable.prop( 'scrollHeight' );
4837 clipWidth = allotedWidth < naturalWidth;
4838 clipHeight = allotedHeight < naturalHeight;
4839
4840 if ( clipWidth ) {
4841 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4842 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4843 this.$clippable.css( 'overflowX', 'scroll' );
4844 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4845 this.$clippable.css( {
4846 width: Math.max( 0, allotedWidth ),
4847 maxWidth: ''
4848 } );
4849 } else {
4850 this.$clippable.css( {
4851 overflowX: '',
4852 width: this.idealWidth || '',
4853 maxWidth: Math.max( 0, allotedWidth )
4854 } );
4855 }
4856 if ( clipHeight ) {
4857 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4858 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4859 this.$clippable.css( 'overflowY', 'scroll' );
4860 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4861 this.$clippable.css( {
4862 height: Math.max( 0, allotedHeight ),
4863 maxHeight: ''
4864 } );
4865 } else {
4866 this.$clippable.css( {
4867 overflowY: '',
4868 height: this.idealHeight || '',
4869 maxHeight: Math.max( 0, allotedHeight )
4870 } );
4871 }
4872
4873 // If we stopped clipping in at least one of the dimensions
4874 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
4875 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4876 }
4877
4878 this.clippedHorizontally = clipWidth;
4879 this.clippedVertically = clipHeight;
4880
4881 return this;
4882 };
4883
4884 /**
4885 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
4886 * By default, each popup has an anchor that points toward its origin.
4887 * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
4888 *
4889 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
4890 *
4891 * @example
4892 * // A popup widget.
4893 * var popup = new OO.ui.PopupWidget( {
4894 * $content: $( '<p>Hi there!</p>' ),
4895 * padded: true,
4896 * width: 300
4897 * } );
4898 *
4899 * $( 'body' ).append( popup.$element );
4900 * // To display the popup, toggle the visibility to 'true'.
4901 * popup.toggle( true );
4902 *
4903 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
4904 *
4905 * @class
4906 * @extends OO.ui.Widget
4907 * @mixins OO.ui.mixin.LabelElement
4908 * @mixins OO.ui.mixin.ClippableElement
4909 * @mixins OO.ui.mixin.FloatableElement
4910 *
4911 * @constructor
4912 * @param {Object} [config] Configuration options
4913 * @cfg {number} [width=320] Width of popup in pixels
4914 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
4915 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
4916 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
4917 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
4918 * of $floatableContainer
4919 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
4920 * of $floatableContainer
4921 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
4922 * endwards (right/left) to the vertical center of $floatableContainer
4923 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
4924 * startwards (left/right) to the vertical center of $floatableContainer
4925 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
4926 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
4927 * as possible while still keeping the anchor within the popup;
4928 * if position is before/after, move the popup as far downwards as possible.
4929 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
4930 * as possible while still keeping the anchor within the popup;
4931 * if position in before/after, move the popup as far upwards as possible.
4932 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
4933 * of the popup with the center of $floatableContainer.
4934 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
4935 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
4936 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
4937 * See the [OOjs UI docs on MediaWiki][3] for an example.
4938 * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
4939 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
4940 * @cfg {jQuery} [$content] Content to append to the popup's body
4941 * @cfg {jQuery} [$footer] Content to append to the popup's footer
4942 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
4943 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
4944 * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
4945 * for an example.
4946 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
4947 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
4948 * button.
4949 * @cfg {boolean} [padded=false] Add padding to the popup's body
4950 */
4951 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
4952 // Configuration initialization
4953 config = config || {};
4954
4955 // Parent constructor
4956 OO.ui.PopupWidget.parent.call( this, config );
4957
4958 // Properties (must be set before ClippableElement constructor call)
4959 this.$body = $( '<div>' );
4960 this.$popup = $( '<div>' );
4961
4962 // Mixin constructors
4963 OO.ui.mixin.LabelElement.call( this, config );
4964 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
4965 $clippable: this.$body,
4966 $clippableContainer: this.$popup
4967 } ) );
4968 OO.ui.mixin.FloatableElement.call( this, config );
4969
4970 // Properties
4971 this.$anchor = $( '<div>' );
4972 // If undefined, will be computed lazily in computePosition()
4973 this.$container = config.$container;
4974 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
4975 this.autoClose = !!config.autoClose;
4976 this.$autoCloseIgnore = config.$autoCloseIgnore;
4977 this.transitionTimeout = null;
4978 this.anchored = false;
4979 this.width = config.width !== undefined ? config.width : 320;
4980 this.height = config.height !== undefined ? config.height : null;
4981 this.onMouseDownHandler = this.onMouseDown.bind( this );
4982 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
4983
4984 // Initialization
4985 this.toggleAnchor( config.anchor === undefined || config.anchor );
4986 this.setAlignment( config.align || 'center' );
4987 this.setPosition( config.position || 'below' );
4988 this.$body.addClass( 'oo-ui-popupWidget-body' );
4989 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
4990 this.$popup
4991 .addClass( 'oo-ui-popupWidget-popup' )
4992 .append( this.$body );
4993 this.$element
4994 .addClass( 'oo-ui-popupWidget' )
4995 .append( this.$popup, this.$anchor );
4996 // Move content, which was added to #$element by OO.ui.Widget, to the body
4997 // FIXME This is gross, we should use '$body' or something for the config
4998 if ( config.$content instanceof jQuery ) {
4999 this.$body.append( config.$content );
5000 }
5001
5002 if ( config.padded ) {
5003 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5004 }
5005
5006 if ( config.head ) {
5007 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5008 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5009 this.$head = $( '<div>' )
5010 .addClass( 'oo-ui-popupWidget-head' )
5011 .append( this.$label, this.closeButton.$element );
5012 this.$popup.prepend( this.$head );
5013 }
5014
5015 if ( config.$footer ) {
5016 this.$footer = $( '<div>' )
5017 .addClass( 'oo-ui-popupWidget-footer' )
5018 .append( config.$footer );
5019 this.$popup.append( this.$footer );
5020 }
5021
5022 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5023 // that reference properties not initialized at that time of parent class construction
5024 // TODO: Find a better way to handle post-constructor setup
5025 this.visible = false;
5026 this.$element.addClass( 'oo-ui-element-hidden' );
5027 };
5028
5029 /* Setup */
5030
5031 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5032 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5033 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5034 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5035
5036 /* Events */
5037
5038 /**
5039 * @event ready
5040 *
5041 * The popup is ready: it is visible and has been positioned and clipped.
5042 */
5043
5044 /* Methods */
5045
5046 /**
5047 * Handles mouse down events.
5048 *
5049 * @private
5050 * @param {MouseEvent} e Mouse down event
5051 */
5052 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5053 if (
5054 this.isVisible() &&
5055 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5056 ) {
5057 this.toggle( false );
5058 }
5059 };
5060
5061 /**
5062 * Bind mouse down listener.
5063 *
5064 * @private
5065 */
5066 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5067 // Capture clicks outside popup
5068 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5069 };
5070
5071 /**
5072 * Handles close button click events.
5073 *
5074 * @private
5075 */
5076 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5077 if ( this.isVisible() ) {
5078 this.toggle( false );
5079 }
5080 };
5081
5082 /**
5083 * Unbind mouse down listener.
5084 *
5085 * @private
5086 */
5087 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5088 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5089 };
5090
5091 /**
5092 * Handles key down events.
5093 *
5094 * @private
5095 * @param {KeyboardEvent} e Key down event
5096 */
5097 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5098 if (
5099 e.which === OO.ui.Keys.ESCAPE &&
5100 this.isVisible()
5101 ) {
5102 this.toggle( false );
5103 e.preventDefault();
5104 e.stopPropagation();
5105 }
5106 };
5107
5108 /**
5109 * Bind key down listener.
5110 *
5111 * @private
5112 */
5113 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5114 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5115 };
5116
5117 /**
5118 * Unbind key down listener.
5119 *
5120 * @private
5121 */
5122 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5123 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5124 };
5125
5126 /**
5127 * Show, hide, or toggle the visibility of the anchor.
5128 *
5129 * @param {boolean} [show] Show anchor, omit to toggle
5130 */
5131 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5132 show = show === undefined ? !this.anchored : !!show;
5133
5134 if ( this.anchored !== show ) {
5135 if ( show ) {
5136 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5137 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5138 } else {
5139 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5140 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5141 }
5142 this.anchored = show;
5143 }
5144 };
5145 /**
5146 * Change which edge the anchor appears on.
5147 *
5148 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5149 */
5150 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5151 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5152 throw new Error( 'Invalid value for edge: ' + edge );
5153 }
5154 if ( this.anchorEdge !== null ) {
5155 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5156 }
5157 this.anchorEdge = edge;
5158 if ( this.anchored ) {
5159 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5160 }
5161 };
5162
5163 /**
5164 * Check if the anchor is visible.
5165 *
5166 * @return {boolean} Anchor is visible
5167 */
5168 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5169 return this.anchored;
5170 };
5171
5172 /**
5173 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5174 * `.toggle( true )` after its #$element is attached to the DOM.
5175 *
5176 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5177 * it in the right place and with the right dimensions only work correctly while it is attached.
5178 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5179 * strictly enforced, so currently it only generates a warning in the browser console.
5180 *
5181 * @fires ready
5182 * @inheritdoc
5183 */
5184 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5185 var change;
5186 show = show === undefined ? !this.isVisible() : !!show;
5187
5188 change = show !== this.isVisible();
5189
5190 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5191 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5192 this.warnedUnattached = true;
5193 }
5194 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5195 // Fall back to the parent node if the floatableContainer is not set
5196 this.setFloatableContainer( this.$element.parent() );
5197 }
5198
5199 // Parent method
5200 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5201
5202 if ( change ) {
5203 this.togglePositioning( show && !!this.$floatableContainer );
5204
5205 if ( show ) {
5206 if ( this.autoClose ) {
5207 this.bindMouseDownListener();
5208 this.bindKeyDownListener();
5209 }
5210 this.updateDimensions();
5211 this.toggleClipping( true );
5212 this.emit( 'ready' );
5213 } else {
5214 this.toggleClipping( false );
5215 if ( this.autoClose ) {
5216 this.unbindMouseDownListener();
5217 this.unbindKeyDownListener();
5218 }
5219 }
5220 }
5221
5222 return this;
5223 };
5224
5225 /**
5226 * Set the size of the popup.
5227 *
5228 * Changing the size may also change the popup's position depending on the alignment.
5229 *
5230 * @param {number} width Width in pixels
5231 * @param {number} height Height in pixels
5232 * @param {boolean} [transition=false] Use a smooth transition
5233 * @chainable
5234 */
5235 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5236 this.width = width;
5237 this.height = height !== undefined ? height : null;
5238 if ( this.isVisible() ) {
5239 this.updateDimensions( transition );
5240 }
5241 };
5242
5243 /**
5244 * Update the size and position.
5245 *
5246 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5247 * be called automatically.
5248 *
5249 * @param {boolean} [transition=false] Use a smooth transition
5250 * @chainable
5251 */
5252 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5253 var widget = this;
5254
5255 // Prevent transition from being interrupted
5256 clearTimeout( this.transitionTimeout );
5257 if ( transition ) {
5258 // Enable transition
5259 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5260 }
5261
5262 this.position();
5263
5264 if ( transition ) {
5265 // Prevent transitioning after transition is complete
5266 this.transitionTimeout = setTimeout( function () {
5267 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5268 }, 200 );
5269 } else {
5270 // Prevent transitioning immediately
5271 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5272 }
5273 };
5274
5275 /**
5276 * @inheritdoc
5277 */
5278 OO.ui.PopupWidget.prototype.computePosition = function () {
5279 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5280 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5281 offsetParentPos, containerPos,
5282 popupPos = {},
5283 anchorCss = { left: '', right: '', top: '', bottom: '' },
5284 alignMap = {
5285 ltr: {
5286 'force-left': 'backwards',
5287 'force-right': 'forwards'
5288 },
5289 rtl: {
5290 'force-left': 'forwards',
5291 'force-right': 'backwards'
5292 }
5293 },
5294 anchorEdgeMap = {
5295 above: 'bottom',
5296 below: 'top',
5297 before: 'end',
5298 after: 'start'
5299 },
5300 hPosMap = {
5301 forwards: 'start',
5302 center: 'center',
5303 backwards: this.anchored ? 'before' : 'end'
5304 },
5305 vPosMap = {
5306 forwards: 'top',
5307 center: 'center',
5308 backwards: 'bottom'
5309 };
5310
5311 if ( !this.$container ) {
5312 // Lazy-initialize $container if not specified in constructor
5313 this.$container = $( this.getClosestScrollableElementContainer() );
5314 }
5315 direction = this.$container.css( 'direction' );
5316
5317 // Set height and width before we do anything else, since it might cause our measurements
5318 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5319 this.$popup.css( {
5320 width: this.width,
5321 height: this.height !== null ? this.height : 'auto'
5322 } );
5323
5324 align = alignMap[ direction ][ this.align ] || this.align;
5325 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5326 vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
5327 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5328 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5329 near = vertical ? 'top' : 'left';
5330 far = vertical ? 'bottom' : 'right';
5331 sizeProp = vertical ? 'Height' : 'Width';
5332 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5333
5334 this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
5335 this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
5336 this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
5337
5338 // Parent method
5339 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5340 // Find out which property FloatableElement used for positioning, and adjust that value
5341 positionProp = vertical ?
5342 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5343 ( parentPosition.left !== '' ? 'left' : 'right' );
5344
5345 // Figure out where the near and far edges of the popup and $floatableContainer are
5346 floatablePos = this.$floatableContainer.offset();
5347 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5348 // Measure where the offsetParent is and compute our position based on that and parentPosition
5349 offsetParentPos = this.$element.offsetParent().offset();
5350
5351 if ( positionProp === near ) {
5352 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5353 popupPos[ far ] = popupPos[ near ] + popupSize;
5354 } else {
5355 popupPos[ far ] = offsetParentPos[ near ] +
5356 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5357 popupPos[ near ] = popupPos[ far ] - popupSize;
5358 }
5359
5360 if ( this.anchored ) {
5361 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5362 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5363 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5364
5365 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5366 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5367 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5368 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5369 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5370 // Not enough space for the anchor on the start side; pull the popup startwards
5371 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5372 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5373 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5374 // Not enough space for the anchor on the end side; pull the popup endwards
5375 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5376 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5377 } else {
5378 positionAdjustment = 0;
5379 }
5380 } else {
5381 positionAdjustment = 0;
5382 }
5383
5384 // Check if the popup will go beyond the edge of this.$container
5385 containerPos = this.$container.offset();
5386 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5387 // Take into account how much the popup will move because of the adjustments we're going to make
5388 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5389 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5390 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5391 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5392 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5393 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5394 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5395 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5396 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5397 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5398 }
5399
5400 if ( this.anchored ) {
5401 // Adjust anchorOffset for positionAdjustment
5402 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5403
5404 // Position the anchor
5405 anchorCss[ start ] = anchorOffset;
5406 this.$anchor.css( anchorCss );
5407 }
5408
5409 // Move the popup if needed
5410 parentPosition[ positionProp ] += positionAdjustment;
5411
5412 return parentPosition;
5413 };
5414
5415 /**
5416 * Set popup alignment
5417 *
5418 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5419 * `backwards` or `forwards`.
5420 */
5421 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5422 // Validate alignment
5423 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5424 this.align = align;
5425 } else {
5426 this.align = 'center';
5427 }
5428 this.position();
5429 };
5430
5431 /**
5432 * Get popup alignment
5433 *
5434 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5435 * `backwards` or `forwards`.
5436 */
5437 OO.ui.PopupWidget.prototype.getAlignment = function () {
5438 return this.align;
5439 };
5440
5441 /**
5442 * Change the positioning of the popup.
5443 *
5444 * @param {string} position 'above', 'below', 'before' or 'after'
5445 */
5446 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5447 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5448 position = 'below';
5449 }
5450 this.popupPosition = position;
5451 this.position();
5452 };
5453
5454 /**
5455 * Get popup positioning.
5456 *
5457 * @return {string} 'above', 'below', 'before' or 'after'
5458 */
5459 OO.ui.PopupWidget.prototype.getPosition = function () {
5460 return this.popupPosition;
5461 };
5462
5463 /**
5464 * Get an ID of the body element, this can be used as the
5465 * `aria-describedby` attribute for an input field.
5466 *
5467 * @return {string} The ID of the body element
5468 */
5469 OO.ui.PopupWidget.prototype.getBodyId = function () {
5470 var id = this.$body.attr( 'id' );
5471 if ( id === undefined ) {
5472 id = OO.ui.generateElementId();
5473 this.$body.attr( 'id', id );
5474 }
5475 return id;
5476 };
5477
5478 /**
5479 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5480 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5481 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5482 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5483 *
5484 * @abstract
5485 * @class
5486 *
5487 * @constructor
5488 * @param {Object} [config] Configuration options
5489 * @cfg {Object} [popup] Configuration to pass to popup
5490 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5491 */
5492 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5493 // Configuration initialization
5494 config = config || {};
5495
5496 // Properties
5497 this.popup = new OO.ui.PopupWidget( $.extend(
5498 {
5499 autoClose: true,
5500 $floatableContainer: this.$element
5501 },
5502 config.popup,
5503 {
5504 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5505 }
5506 ) );
5507 };
5508
5509 /* Methods */
5510
5511 /**
5512 * Get popup.
5513 *
5514 * @return {OO.ui.PopupWidget} Popup widget
5515 */
5516 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5517 return this.popup;
5518 };
5519
5520 /**
5521 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5522 * which is used to display additional information or options.
5523 *
5524 * @example
5525 * // Example of a popup button.
5526 * var popupButton = new OO.ui.PopupButtonWidget( {
5527 * label: 'Popup button with options',
5528 * icon: 'menu',
5529 * popup: {
5530 * $content: $( '<p>Additional options here.</p>' ),
5531 * padded: true,
5532 * align: 'force-left'
5533 * }
5534 * } );
5535 * // Append the button to the DOM.
5536 * $( 'body' ).append( popupButton.$element );
5537 *
5538 * @class
5539 * @extends OO.ui.ButtonWidget
5540 * @mixins OO.ui.mixin.PopupElement
5541 *
5542 * @constructor
5543 * @param {Object} [config] Configuration options
5544 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5545 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5546 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5547 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
5548 */
5549 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5550 // Configuration initialization
5551 config = config || {};
5552
5553 // Parent constructor
5554 OO.ui.PopupButtonWidget.parent.call( this, config );
5555
5556 // Mixin constructors
5557 OO.ui.mixin.PopupElement.call( this, config );
5558
5559 // Properties
5560 this.$overlay = config.$overlay || this.$element;
5561
5562 // Events
5563 this.connect( this, { click: 'onAction' } );
5564
5565 // Initialization
5566 this.$element
5567 .addClass( 'oo-ui-popupButtonWidget' )
5568 .attr( 'aria-haspopup', 'true' );
5569 this.popup.$element
5570 .addClass( 'oo-ui-popupButtonWidget-popup' )
5571 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5572 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5573 this.$overlay.append( this.popup.$element );
5574 };
5575
5576 /* Setup */
5577
5578 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5579 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5580
5581 /* Methods */
5582
5583 /**
5584 * Handle the button action being triggered.
5585 *
5586 * @private
5587 */
5588 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5589 this.popup.toggle();
5590 };
5591
5592 /**
5593 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5594 *
5595 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5596 *
5597 * @private
5598 * @abstract
5599 * @class
5600 * @mixins OO.ui.mixin.GroupElement
5601 *
5602 * @constructor
5603 * @param {Object} [config] Configuration options
5604 */
5605 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5606 // Mixin constructors
5607 OO.ui.mixin.GroupElement.call( this, config );
5608 };
5609
5610 /* Setup */
5611
5612 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5613
5614 /* Methods */
5615
5616 /**
5617 * Set the disabled state of the widget.
5618 *
5619 * This will also update the disabled state of child widgets.
5620 *
5621 * @param {boolean} disabled Disable widget
5622 * @chainable
5623 */
5624 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5625 var i, len;
5626
5627 // Parent method
5628 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5629 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5630
5631 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5632 if ( this.items ) {
5633 for ( i = 0, len = this.items.length; i < len; i++ ) {
5634 this.items[ i ].updateDisabled();
5635 }
5636 }
5637
5638 return this;
5639 };
5640
5641 /**
5642 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5643 *
5644 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5645 * allows bidirectional communication.
5646 *
5647 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5648 *
5649 * @private
5650 * @abstract
5651 * @class
5652 *
5653 * @constructor
5654 */
5655 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5656 //
5657 };
5658
5659 /* Methods */
5660
5661 /**
5662 * Check if widget is disabled.
5663 *
5664 * Checks parent if present, making disabled state inheritable.
5665 *
5666 * @return {boolean} Widget is disabled
5667 */
5668 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5669 return this.disabled ||
5670 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5671 };
5672
5673 /**
5674 * Set group element is in.
5675 *
5676 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5677 * @chainable
5678 */
5679 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5680 // Parent method
5681 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5682 OO.ui.Element.prototype.setElementGroup.call( this, group );
5683
5684 // Initialize item disabled states
5685 this.updateDisabled();
5686
5687 return this;
5688 };
5689
5690 /**
5691 * OptionWidgets are special elements that can be selected and configured with data. The
5692 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5693 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5694 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
5695 *
5696 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5697 *
5698 * @class
5699 * @extends OO.ui.Widget
5700 * @mixins OO.ui.mixin.ItemWidget
5701 * @mixins OO.ui.mixin.LabelElement
5702 * @mixins OO.ui.mixin.FlaggedElement
5703 * @mixins OO.ui.mixin.AccessKeyedElement
5704 *
5705 * @constructor
5706 * @param {Object} [config] Configuration options
5707 */
5708 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5709 // Configuration initialization
5710 config = config || {};
5711
5712 // Parent constructor
5713 OO.ui.OptionWidget.parent.call( this, config );
5714
5715 // Mixin constructors
5716 OO.ui.mixin.ItemWidget.call( this );
5717 OO.ui.mixin.LabelElement.call( this, config );
5718 OO.ui.mixin.FlaggedElement.call( this, config );
5719 OO.ui.mixin.AccessKeyedElement.call( this, config );
5720
5721 // Properties
5722 this.selected = false;
5723 this.highlighted = false;
5724 this.pressed = false;
5725
5726 // Initialization
5727 this.$element
5728 .data( 'oo-ui-optionWidget', this )
5729 // Allow programmatic focussing (and by accesskey), but not tabbing
5730 .attr( 'tabindex', '-1' )
5731 .attr( 'role', 'option' )
5732 .attr( 'aria-selected', 'false' )
5733 .addClass( 'oo-ui-optionWidget' )
5734 .append( this.$label );
5735 };
5736
5737 /* Setup */
5738
5739 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
5740 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
5741 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
5742 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
5743 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
5744
5745 /* Static Properties */
5746
5747 /**
5748 * Whether this option can be selected. See #setSelected.
5749 *
5750 * @static
5751 * @inheritable
5752 * @property {boolean}
5753 */
5754 OO.ui.OptionWidget.static.selectable = true;
5755
5756 /**
5757 * Whether this option can be highlighted. See #setHighlighted.
5758 *
5759 * @static
5760 * @inheritable
5761 * @property {boolean}
5762 */
5763 OO.ui.OptionWidget.static.highlightable = true;
5764
5765 /**
5766 * Whether this option can be pressed. See #setPressed.
5767 *
5768 * @static
5769 * @inheritable
5770 * @property {boolean}
5771 */
5772 OO.ui.OptionWidget.static.pressable = true;
5773
5774 /**
5775 * Whether this option will be scrolled into view when it is selected.
5776 *
5777 * @static
5778 * @inheritable
5779 * @property {boolean}
5780 */
5781 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
5782
5783 /* Methods */
5784
5785 /**
5786 * Check if the option can be selected.
5787 *
5788 * @return {boolean} Item is selectable
5789 */
5790 OO.ui.OptionWidget.prototype.isSelectable = function () {
5791 return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
5792 };
5793
5794 /**
5795 * Check if the option can be highlighted. A highlight indicates that the option
5796 * may be selected when a user presses enter or clicks. Disabled items cannot
5797 * be highlighted.
5798 *
5799 * @return {boolean} Item is highlightable
5800 */
5801 OO.ui.OptionWidget.prototype.isHighlightable = function () {
5802 return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
5803 };
5804
5805 /**
5806 * Check if the option can be pressed. The pressed state occurs when a user mouses
5807 * down on an item, but has not yet let go of the mouse.
5808 *
5809 * @return {boolean} Item is pressable
5810 */
5811 OO.ui.OptionWidget.prototype.isPressable = function () {
5812 return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
5813 };
5814
5815 /**
5816 * Check if the option is selected.
5817 *
5818 * @return {boolean} Item is selected
5819 */
5820 OO.ui.OptionWidget.prototype.isSelected = function () {
5821 return this.selected;
5822 };
5823
5824 /**
5825 * Check if the option is highlighted. A highlight indicates that the
5826 * item may be selected when a user presses enter or clicks.
5827 *
5828 * @return {boolean} Item is highlighted
5829 */
5830 OO.ui.OptionWidget.prototype.isHighlighted = function () {
5831 return this.highlighted;
5832 };
5833
5834 /**
5835 * Check if the option is pressed. The pressed state occurs when a user mouses
5836 * down on an item, but has not yet let go of the mouse. The item may appear
5837 * selected, but it will not be selected until the user releases the mouse.
5838 *
5839 * @return {boolean} Item is pressed
5840 */
5841 OO.ui.OptionWidget.prototype.isPressed = function () {
5842 return this.pressed;
5843 };
5844
5845 /**
5846 * Set the option’s selected state. In general, all modifications to the selection
5847 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
5848 * method instead of this method.
5849 *
5850 * @param {boolean} [state=false] Select option
5851 * @chainable
5852 */
5853 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
5854 if ( this.constructor.static.selectable ) {
5855 this.selected = !!state;
5856 this.$element
5857 .toggleClass( 'oo-ui-optionWidget-selected', state )
5858 .attr( 'aria-selected', state.toString() );
5859 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
5860 this.scrollElementIntoView();
5861 }
5862 this.updateThemeClasses();
5863 }
5864 return this;
5865 };
5866
5867 /**
5868 * Set the option’s highlighted state. In general, all programmatic
5869 * modifications to the highlight should be handled by the
5870 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
5871 * method instead of this method.
5872 *
5873 * @param {boolean} [state=false] Highlight option
5874 * @chainable
5875 */
5876 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
5877 if ( this.constructor.static.highlightable ) {
5878 this.highlighted = !!state;
5879 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
5880 this.updateThemeClasses();
5881 }
5882 return this;
5883 };
5884
5885 /**
5886 * Set the option’s pressed state. In general, all
5887 * programmatic modifications to the pressed state should be handled by the
5888 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
5889 * method instead of this method.
5890 *
5891 * @param {boolean} [state=false] Press option
5892 * @chainable
5893 */
5894 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
5895 if ( this.constructor.static.pressable ) {
5896 this.pressed = !!state;
5897 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
5898 this.updateThemeClasses();
5899 }
5900 return this;
5901 };
5902
5903 /**
5904 * Get text to match search strings against.
5905 *
5906 * The default implementation returns the label text, but subclasses
5907 * can override this to provide more complex behavior.
5908 *
5909 * @return {string|boolean} String to match search string against
5910 */
5911 OO.ui.OptionWidget.prototype.getMatchText = function () {
5912 var label = this.getLabel();
5913 return typeof label === 'string' ? label : this.$label.text();
5914 };
5915
5916 /**
5917 * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
5918 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
5919 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
5920 * menu selects}.
5921 *
5922 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
5923 * information, please see the [OOjs UI documentation on MediaWiki][1].
5924 *
5925 * @example
5926 * // Example of a select widget with three options
5927 * var select = new OO.ui.SelectWidget( {
5928 * items: [
5929 * new OO.ui.OptionWidget( {
5930 * data: 'a',
5931 * label: 'Option One',
5932 * } ),
5933 * new OO.ui.OptionWidget( {
5934 * data: 'b',
5935 * label: 'Option Two',
5936 * } ),
5937 * new OO.ui.OptionWidget( {
5938 * data: 'c',
5939 * label: 'Option Three',
5940 * } )
5941 * ]
5942 * } );
5943 * $( 'body' ).append( select.$element );
5944 *
5945 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5946 *
5947 * @abstract
5948 * @class
5949 * @extends OO.ui.Widget
5950 * @mixins OO.ui.mixin.GroupWidget
5951 *
5952 * @constructor
5953 * @param {Object} [config] Configuration options
5954 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
5955 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
5956 * the [OOjs UI documentation on MediaWiki] [2] for examples.
5957 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5958 */
5959 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
5960 // Configuration initialization
5961 config = config || {};
5962
5963 // Parent constructor
5964 OO.ui.SelectWidget.parent.call( this, config );
5965
5966 // Mixin constructors
5967 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
5968
5969 // Properties
5970 this.pressed = false;
5971 this.selecting = null;
5972 this.onMouseUpHandler = this.onMouseUp.bind( this );
5973 this.onMouseMoveHandler = this.onMouseMove.bind( this );
5974 this.onKeyDownHandler = this.onKeyDown.bind( this );
5975 this.onKeyPressHandler = this.onKeyPress.bind( this );
5976 this.keyPressBuffer = '';
5977 this.keyPressBufferTimer = null;
5978 this.blockMouseOverEvents = 0;
5979
5980 // Events
5981 this.connect( this, {
5982 toggle: 'onToggle'
5983 } );
5984 this.$element.on( {
5985 focusin: this.onFocus.bind( this ),
5986 mousedown: this.onMouseDown.bind( this ),
5987 mouseover: this.onMouseOver.bind( this ),
5988 mouseleave: this.onMouseLeave.bind( this )
5989 } );
5990
5991 // Initialization
5992 this.$element
5993 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
5994 .attr( 'role', 'listbox' );
5995 this.setFocusOwner( this.$element );
5996 if ( Array.isArray( config.items ) ) {
5997 this.addItems( config.items );
5998 }
5999 };
6000
6001 /* Setup */
6002
6003 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6004 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6005
6006 /* Events */
6007
6008 /**
6009 * @event highlight
6010 *
6011 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6012 *
6013 * @param {OO.ui.OptionWidget|null} item Highlighted item
6014 */
6015
6016 /**
6017 * @event press
6018 *
6019 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6020 * pressed state of an option.
6021 *
6022 * @param {OO.ui.OptionWidget|null} item Pressed item
6023 */
6024
6025 /**
6026 * @event select
6027 *
6028 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6029 *
6030 * @param {OO.ui.OptionWidget|null} item Selected item
6031 */
6032
6033 /**
6034 * @event choose
6035 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6036 * @param {OO.ui.OptionWidget} item Chosen item
6037 */
6038
6039 /**
6040 * @event add
6041 *
6042 * An `add` event is emitted when options are added to the select with the #addItems method.
6043 *
6044 * @param {OO.ui.OptionWidget[]} items Added items
6045 * @param {number} index Index of insertion point
6046 */
6047
6048 /**
6049 * @event remove
6050 *
6051 * A `remove` event is emitted when options are removed from the select with the #clearItems
6052 * or #removeItems methods.
6053 *
6054 * @param {OO.ui.OptionWidget[]} items Removed items
6055 */
6056
6057 /* Methods */
6058
6059 /**
6060 * Handle focus events
6061 *
6062 * @private
6063 * @param {jQuery.Event} event
6064 */
6065 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6066 var item;
6067 if ( event.target === this.$element[ 0 ] ) {
6068 // This widget was focussed, e.g. by the user tabbing to it.
6069 // The styles for focus state depend on one of the items being selected.
6070 if ( !this.getSelectedItem() ) {
6071 item = this.findFirstSelectableItem();
6072 }
6073 } else {
6074 // One of the options got focussed (and the event bubbled up here).
6075 // They can't be tabbed to, but they can be activated using accesskeys.
6076 item = this.findTargetItem( event );
6077 }
6078
6079 if ( item ) {
6080 if ( item.constructor.static.highlightable ) {
6081 this.highlightItem( item );
6082 } else {
6083 this.selectItem( item );
6084 }
6085 }
6086
6087 if ( event.target !== this.$element[ 0 ] ) {
6088 this.$focusOwner.focus();
6089 }
6090 };
6091
6092 /**
6093 * Handle mouse down events.
6094 *
6095 * @private
6096 * @param {jQuery.Event} e Mouse down event
6097 */
6098 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6099 var item;
6100
6101 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6102 this.togglePressed( true );
6103 item = this.findTargetItem( e );
6104 if ( item && item.isSelectable() ) {
6105 this.pressItem( item );
6106 this.selecting = item;
6107 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6108 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6109 }
6110 }
6111 return false;
6112 };
6113
6114 /**
6115 * Handle mouse up events.
6116 *
6117 * @private
6118 * @param {MouseEvent} e Mouse up event
6119 */
6120 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6121 var item;
6122
6123 this.togglePressed( false );
6124 if ( !this.selecting ) {
6125 item = this.findTargetItem( e );
6126 if ( item && item.isSelectable() ) {
6127 this.selecting = item;
6128 }
6129 }
6130 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6131 this.pressItem( null );
6132 this.chooseItem( this.selecting );
6133 this.selecting = null;
6134 }
6135
6136 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6137 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6138
6139 return false;
6140 };
6141
6142 /**
6143 * Handle mouse move events.
6144 *
6145 * @private
6146 * @param {MouseEvent} e Mouse move event
6147 */
6148 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6149 var item;
6150
6151 if ( !this.isDisabled() && this.pressed ) {
6152 item = this.findTargetItem( e );
6153 if ( item && item !== this.selecting && item.isSelectable() ) {
6154 this.pressItem( item );
6155 this.selecting = item;
6156 }
6157 }
6158 };
6159
6160 /**
6161 * Handle mouse over events.
6162 *
6163 * @private
6164 * @param {jQuery.Event} e Mouse over event
6165 */
6166 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6167 var item;
6168 if ( this.blockMouseOverEvents ) {
6169 return;
6170 }
6171 if ( !this.isDisabled() ) {
6172 item = this.findTargetItem( e );
6173 this.highlightItem( item && item.isHighlightable() ? item : null );
6174 }
6175 return false;
6176 };
6177
6178 /**
6179 * Handle mouse leave events.
6180 *
6181 * @private
6182 * @param {jQuery.Event} e Mouse over event
6183 */
6184 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6185 if ( !this.isDisabled() ) {
6186 this.highlightItem( null );
6187 }
6188 return false;
6189 };
6190
6191 /**
6192 * Handle key down events.
6193 *
6194 * @protected
6195 * @param {KeyboardEvent} e Key down event
6196 */
6197 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6198 var nextItem,
6199 handled = false,
6200 currentItem = this.findHighlightedItem() || this.getSelectedItem();
6201
6202 if ( !this.isDisabled() && this.isVisible() ) {
6203 switch ( e.keyCode ) {
6204 case OO.ui.Keys.ENTER:
6205 if ( currentItem && currentItem.constructor.static.highlightable ) {
6206 // Was only highlighted, now let's select it. No-op if already selected.
6207 this.chooseItem( currentItem );
6208 handled = true;
6209 }
6210 break;
6211 case OO.ui.Keys.UP:
6212 case OO.ui.Keys.LEFT:
6213 this.clearKeyPressBuffer();
6214 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6215 handled = true;
6216 break;
6217 case OO.ui.Keys.DOWN:
6218 case OO.ui.Keys.RIGHT:
6219 this.clearKeyPressBuffer();
6220 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6221 handled = true;
6222 break;
6223 case OO.ui.Keys.ESCAPE:
6224 case OO.ui.Keys.TAB:
6225 if ( currentItem && currentItem.constructor.static.highlightable ) {
6226 currentItem.setHighlighted( false );
6227 }
6228 this.unbindKeyDownListener();
6229 this.unbindKeyPressListener();
6230 // Don't prevent tabbing away / defocusing
6231 handled = false;
6232 break;
6233 }
6234
6235 if ( nextItem ) {
6236 if ( nextItem.constructor.static.highlightable ) {
6237 this.highlightItem( nextItem );
6238 } else {
6239 this.chooseItem( nextItem );
6240 }
6241 this.scrollItemIntoView( nextItem );
6242 }
6243
6244 if ( handled ) {
6245 e.preventDefault();
6246 e.stopPropagation();
6247 }
6248 }
6249 };
6250
6251 /**
6252 * Bind key down listener.
6253 *
6254 * @protected
6255 */
6256 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6257 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6258 };
6259
6260 /**
6261 * Unbind key down listener.
6262 *
6263 * @protected
6264 */
6265 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6266 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6267 };
6268
6269 /**
6270 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6271 *
6272 * @param {OO.ui.OptionWidget} item Item to scroll into view
6273 */
6274 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6275 var widget = this;
6276 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6277 // and around 100-150 ms after it is finished.
6278 this.blockMouseOverEvents++;
6279 item.scrollElementIntoView().done( function () {
6280 setTimeout( function () {
6281 widget.blockMouseOverEvents--;
6282 }, 200 );
6283 } );
6284 };
6285
6286 /**
6287 * Clear the key-press buffer
6288 *
6289 * @protected
6290 */
6291 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6292 if ( this.keyPressBufferTimer ) {
6293 clearTimeout( this.keyPressBufferTimer );
6294 this.keyPressBufferTimer = null;
6295 }
6296 this.keyPressBuffer = '';
6297 };
6298
6299 /**
6300 * Handle key press events.
6301 *
6302 * @protected
6303 * @param {KeyboardEvent} e Key press event
6304 */
6305 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6306 var c, filter, item;
6307
6308 if ( !e.charCode ) {
6309 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6310 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6311 return false;
6312 }
6313 return;
6314 }
6315 if ( String.fromCodePoint ) {
6316 c = String.fromCodePoint( e.charCode );
6317 } else {
6318 c = String.fromCharCode( e.charCode );
6319 }
6320
6321 if ( this.keyPressBufferTimer ) {
6322 clearTimeout( this.keyPressBufferTimer );
6323 }
6324 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6325
6326 item = this.findHighlightedItem() || this.getSelectedItem();
6327
6328 if ( this.keyPressBuffer === c ) {
6329 // Common (if weird) special case: typing "xxxx" will cycle through all
6330 // the items beginning with "x".
6331 if ( item ) {
6332 item = this.findRelativeSelectableItem( item, 1 );
6333 }
6334 } else {
6335 this.keyPressBuffer += c;
6336 }
6337
6338 filter = this.getItemMatcher( this.keyPressBuffer, false );
6339 if ( !item || !filter( item ) ) {
6340 item = this.findRelativeSelectableItem( item, 1, filter );
6341 }
6342 if ( item ) {
6343 if ( this.isVisible() && item.constructor.static.highlightable ) {
6344 this.highlightItem( item );
6345 } else {
6346 this.chooseItem( item );
6347 }
6348 this.scrollItemIntoView( item );
6349 }
6350
6351 e.preventDefault();
6352 e.stopPropagation();
6353 };
6354
6355 /**
6356 * Get a matcher for the specific string
6357 *
6358 * @protected
6359 * @param {string} s String to match against items
6360 * @param {boolean} [exact=false] Only accept exact matches
6361 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6362 */
6363 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6364 var re;
6365
6366 if ( s.normalize ) {
6367 s = s.normalize();
6368 }
6369 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6370 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6371 if ( exact ) {
6372 re += '\\s*$';
6373 }
6374 re = new RegExp( re, 'i' );
6375 return function ( item ) {
6376 var matchText = item.getMatchText();
6377 if ( matchText.normalize ) {
6378 matchText = matchText.normalize();
6379 }
6380 return re.test( matchText );
6381 };
6382 };
6383
6384 /**
6385 * Bind key press listener.
6386 *
6387 * @protected
6388 */
6389 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6390 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6391 };
6392
6393 /**
6394 * Unbind key down listener.
6395 *
6396 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6397 * implementation.
6398 *
6399 * @protected
6400 */
6401 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6402 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6403 this.clearKeyPressBuffer();
6404 };
6405
6406 /**
6407 * Visibility change handler
6408 *
6409 * @protected
6410 * @param {boolean} visible
6411 */
6412 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6413 if ( !visible ) {
6414 this.clearKeyPressBuffer();
6415 }
6416 };
6417
6418 /**
6419 * Get the closest item to a jQuery.Event.
6420 *
6421 * @private
6422 * @param {jQuery.Event} e
6423 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6424 */
6425 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6426 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6427 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6428 return null;
6429 }
6430 return $option.data( 'oo-ui-optionWidget' ) || null;
6431 };
6432
6433 /**
6434 * Get selected item.
6435 *
6436 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6437 */
6438 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6439 var i, len;
6440
6441 for ( i = 0, len = this.items.length; i < len; i++ ) {
6442 if ( this.items[ i ].isSelected() ) {
6443 return this.items[ i ];
6444 }
6445 }
6446 return null;
6447 };
6448
6449 /**
6450 * Find highlighted item.
6451 *
6452 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6453 */
6454 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6455 var i, len;
6456
6457 for ( i = 0, len = this.items.length; i < len; i++ ) {
6458 if ( this.items[ i ].isHighlighted() ) {
6459 return this.items[ i ];
6460 }
6461 }
6462 return null;
6463 };
6464
6465 /**
6466 * Get highlighted item.
6467 *
6468 * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead.
6469 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6470 */
6471 OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
6472 OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' );
6473 return this.findHighlightedItem();
6474 };
6475
6476 /**
6477 * Toggle pressed state.
6478 *
6479 * Press is a state that occurs when a user mouses down on an item, but
6480 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6481 * until the user releases the mouse.
6482 *
6483 * @param {boolean} pressed An option is being pressed
6484 */
6485 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6486 if ( pressed === undefined ) {
6487 pressed = !this.pressed;
6488 }
6489 if ( pressed !== this.pressed ) {
6490 this.$element
6491 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6492 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6493 this.pressed = pressed;
6494 }
6495 };
6496
6497 /**
6498 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6499 * and any existing highlight will be removed. The highlight is mutually exclusive.
6500 *
6501 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6502 * @fires highlight
6503 * @chainable
6504 */
6505 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6506 var i, len, highlighted,
6507 changed = false;
6508
6509 for ( i = 0, len = this.items.length; i < len; i++ ) {
6510 highlighted = this.items[ i ] === item;
6511 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6512 this.items[ i ].setHighlighted( highlighted );
6513 changed = true;
6514 }
6515 }
6516 if ( changed ) {
6517 if ( item ) {
6518 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6519 } else {
6520 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6521 }
6522 this.emit( 'highlight', item );
6523 }
6524
6525 return this;
6526 };
6527
6528 /**
6529 * Fetch an item by its label.
6530 *
6531 * @param {string} label Label of the item to select.
6532 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6533 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6534 */
6535 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6536 var i, item, found,
6537 len = this.items.length,
6538 filter = this.getItemMatcher( label, true );
6539
6540 for ( i = 0; i < len; i++ ) {
6541 item = this.items[ i ];
6542 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6543 return item;
6544 }
6545 }
6546
6547 if ( prefix ) {
6548 found = null;
6549 filter = this.getItemMatcher( label, false );
6550 for ( i = 0; i < len; i++ ) {
6551 item = this.items[ i ];
6552 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6553 if ( found ) {
6554 return null;
6555 }
6556 found = item;
6557 }
6558 }
6559 if ( found ) {
6560 return found;
6561 }
6562 }
6563
6564 return null;
6565 };
6566
6567 /**
6568 * Programmatically select an option by its label. If the item does not exist,
6569 * all options will be deselected.
6570 *
6571 * @param {string} [label] Label of the item to select.
6572 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6573 * @fires select
6574 * @chainable
6575 */
6576 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6577 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6578 if ( label === undefined || !itemFromLabel ) {
6579 return this.selectItem();
6580 }
6581 return this.selectItem( itemFromLabel );
6582 };
6583
6584 /**
6585 * Programmatically select an option by its data. If the `data` parameter is omitted,
6586 * or if the item does not exist, all options will be deselected.
6587 *
6588 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6589 * @fires select
6590 * @chainable
6591 */
6592 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6593 var itemFromData = this.getItemFromData( data );
6594 if ( data === undefined || !itemFromData ) {
6595 return this.selectItem();
6596 }
6597 return this.selectItem( itemFromData );
6598 };
6599
6600 /**
6601 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6602 * all options will be deselected.
6603 *
6604 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6605 * @fires select
6606 * @chainable
6607 */
6608 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6609 var i, len, selected,
6610 changed = false;
6611
6612 for ( i = 0, len = this.items.length; i < len; i++ ) {
6613 selected = this.items[ i ] === item;
6614 if ( this.items[ i ].isSelected() !== selected ) {
6615 this.items[ i ].setSelected( selected );
6616 changed = true;
6617 }
6618 }
6619 if ( changed ) {
6620 if ( item && !item.constructor.static.highlightable ) {
6621 if ( item ) {
6622 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6623 } else {
6624 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6625 }
6626 }
6627 this.emit( 'select', item );
6628 }
6629
6630 return this;
6631 };
6632
6633 /**
6634 * Press an item.
6635 *
6636 * Press is a state that occurs when a user mouses down on an item, but has not
6637 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6638 * releases the mouse.
6639 *
6640 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6641 * @fires press
6642 * @chainable
6643 */
6644 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6645 var i, len, pressed,
6646 changed = false;
6647
6648 for ( i = 0, len = this.items.length; i < len; i++ ) {
6649 pressed = this.items[ i ] === item;
6650 if ( this.items[ i ].isPressed() !== pressed ) {
6651 this.items[ i ].setPressed( pressed );
6652 changed = true;
6653 }
6654 }
6655 if ( changed ) {
6656 this.emit( 'press', item );
6657 }
6658
6659 return this;
6660 };
6661
6662 /**
6663 * Choose an item.
6664 *
6665 * Note that ‘choose’ should never be modified programmatically. A user can choose
6666 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6667 * use the #selectItem method.
6668 *
6669 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6670 * when users choose an item with the keyboard or mouse.
6671 *
6672 * @param {OO.ui.OptionWidget} item Item to choose
6673 * @fires choose
6674 * @chainable
6675 */
6676 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6677 if ( item ) {
6678 this.selectItem( item );
6679 this.emit( 'choose', item );
6680 }
6681
6682 return this;
6683 };
6684
6685 /**
6686 * Find an option by its position relative to the specified item (or to the start of the option array,
6687 * if item is `null`). The direction in which to search through the option array is specified with a
6688 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6689 * `null` if there are no options in the array.
6690 *
6691 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6692 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6693 * @param {Function} [filter] Only consider items for which this function returns
6694 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6695 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6696 */
6697 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6698 var currentIndex, nextIndex, i,
6699 increase = direction > 0 ? 1 : -1,
6700 len = this.items.length;
6701
6702 if ( item instanceof OO.ui.OptionWidget ) {
6703 currentIndex = this.items.indexOf( item );
6704 nextIndex = ( currentIndex + increase + len ) % len;
6705 } else {
6706 // If no item is selected and moving forward, start at the beginning.
6707 // If moving backward, start at the end.
6708 nextIndex = direction > 0 ? 0 : len - 1;
6709 }
6710
6711 for ( i = 0; i < len; i++ ) {
6712 item = this.items[ nextIndex ];
6713 if (
6714 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6715 ( !filter || filter( item ) )
6716 ) {
6717 return item;
6718 }
6719 nextIndex = ( nextIndex + increase + len ) % len;
6720 }
6721 return null;
6722 };
6723
6724 /**
6725 * Get an option by its position relative to the specified item (or to the start of the option array,
6726 * if item is `null`). The direction in which to search through the option array is specified with a
6727 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6728 * `null` if there are no options in the array.
6729 *
6730 * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead
6731 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6732 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6733 * @param {Function} [filter] Only consider items for which this function returns
6734 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6735 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6736 */
6737 OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
6738 OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' );
6739 return this.findRelativeSelectableItem( item, direction, filter );
6740 };
6741
6742 /**
6743 * Find the next selectable item or `null` if there are no selectable items.
6744 * Disabled options and menu-section markers and breaks are not selectable.
6745 *
6746 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6747 */
6748 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
6749 return this.findRelativeSelectableItem( null, 1 );
6750 };
6751
6752 /**
6753 * Get the next selectable item or `null` if there are no selectable items.
6754 * Disabled options and menu-section markers and breaks are not selectable.
6755 *
6756 * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead.
6757 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6758 */
6759 OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
6760 OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' );
6761 return this.findFirstSelectableItem();
6762 };
6763
6764 /**
6765 * Add an array of options to the select. Optionally, an index number can be used to
6766 * specify an insertion point.
6767 *
6768 * @param {OO.ui.OptionWidget[]} items Items to add
6769 * @param {number} [index] Index to insert items after
6770 * @fires add
6771 * @chainable
6772 */
6773 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
6774 // Mixin method
6775 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
6776
6777 // Always provide an index, even if it was omitted
6778 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
6779
6780 return this;
6781 };
6782
6783 /**
6784 * Remove the specified array of options from the select. Options will be detached
6785 * from the DOM, not removed, so they can be reused later. To remove all options from
6786 * the select, you may wish to use the #clearItems method instead.
6787 *
6788 * @param {OO.ui.OptionWidget[]} items Items to remove
6789 * @fires remove
6790 * @chainable
6791 */
6792 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
6793 var i, len, item;
6794
6795 // Deselect items being removed
6796 for ( i = 0, len = items.length; i < len; i++ ) {
6797 item = items[ i ];
6798 if ( item.isSelected() ) {
6799 this.selectItem( null );
6800 }
6801 }
6802
6803 // Mixin method
6804 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
6805
6806 this.emit( 'remove', items );
6807
6808 return this;
6809 };
6810
6811 /**
6812 * Clear all options from the select. Options will be detached from the DOM, not removed,
6813 * so that they can be reused later. To remove a subset of options from the select, use
6814 * the #removeItems method.
6815 *
6816 * @fires remove
6817 * @chainable
6818 */
6819 OO.ui.SelectWidget.prototype.clearItems = function () {
6820 var items = this.items.slice();
6821
6822 // Mixin method
6823 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
6824
6825 // Clear selection
6826 this.selectItem( null );
6827
6828 this.emit( 'remove', items );
6829
6830 return this;
6831 };
6832
6833 /**
6834 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
6835 *
6836 * Currently this is just used to set `aria-activedescendant` on it.
6837 *
6838 * @protected
6839 * @param {jQuery} $focusOwner
6840 */
6841 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
6842 this.$focusOwner = $focusOwner;
6843 };
6844
6845 /**
6846 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
6847 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
6848 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
6849 * options. For more information about options and selects, please see the
6850 * [OOjs UI documentation on MediaWiki][1].
6851 *
6852 * @example
6853 * // Decorated options in a select widget
6854 * var select = new OO.ui.SelectWidget( {
6855 * items: [
6856 * new OO.ui.DecoratedOptionWidget( {
6857 * data: 'a',
6858 * label: 'Option with icon',
6859 * icon: 'help'
6860 * } ),
6861 * new OO.ui.DecoratedOptionWidget( {
6862 * data: 'b',
6863 * label: 'Option with indicator',
6864 * indicator: 'next'
6865 * } )
6866 * ]
6867 * } );
6868 * $( 'body' ).append( select.$element );
6869 *
6870 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6871 *
6872 * @class
6873 * @extends OO.ui.OptionWidget
6874 * @mixins OO.ui.mixin.IconElement
6875 * @mixins OO.ui.mixin.IndicatorElement
6876 *
6877 * @constructor
6878 * @param {Object} [config] Configuration options
6879 */
6880 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
6881 // Parent constructor
6882 OO.ui.DecoratedOptionWidget.parent.call( this, config );
6883
6884 // Mixin constructors
6885 OO.ui.mixin.IconElement.call( this, config );
6886 OO.ui.mixin.IndicatorElement.call( this, config );
6887
6888 // Initialization
6889 this.$element
6890 .addClass( 'oo-ui-decoratedOptionWidget' )
6891 .prepend( this.$icon )
6892 .append( this.$indicator );
6893 };
6894
6895 /* Setup */
6896
6897 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
6898 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
6899 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
6900
6901 /**
6902 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
6903 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
6904 * the [OOjs UI documentation on MediaWiki] [1] for more information.
6905 *
6906 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
6907 *
6908 * @class
6909 * @extends OO.ui.DecoratedOptionWidget
6910 *
6911 * @constructor
6912 * @param {Object} [config] Configuration options
6913 */
6914 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
6915 // Parent constructor
6916 OO.ui.MenuOptionWidget.parent.call( this, config );
6917
6918 // Initialization
6919 this.$element.addClass( 'oo-ui-menuOptionWidget' );
6920 };
6921
6922 /* Setup */
6923
6924 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
6925
6926 /* Static Properties */
6927
6928 /**
6929 * @static
6930 * @inheritdoc
6931 */
6932 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
6933
6934 /**
6935 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
6936 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
6937 *
6938 * @example
6939 * var myDropdown = new OO.ui.DropdownWidget( {
6940 * menu: {
6941 * items: [
6942 * new OO.ui.MenuSectionOptionWidget( {
6943 * label: 'Dogs'
6944 * } ),
6945 * new OO.ui.MenuOptionWidget( {
6946 * data: 'corgi',
6947 * label: 'Welsh Corgi'
6948 * } ),
6949 * new OO.ui.MenuOptionWidget( {
6950 * data: 'poodle',
6951 * label: 'Standard Poodle'
6952 * } ),
6953 * new OO.ui.MenuSectionOptionWidget( {
6954 * label: 'Cats'
6955 * } ),
6956 * new OO.ui.MenuOptionWidget( {
6957 * data: 'lion',
6958 * label: 'Lion'
6959 * } )
6960 * ]
6961 * }
6962 * } );
6963 * $( 'body' ).append( myDropdown.$element );
6964 *
6965 * @class
6966 * @extends OO.ui.DecoratedOptionWidget
6967 *
6968 * @constructor
6969 * @param {Object} [config] Configuration options
6970 */
6971 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
6972 // Parent constructor
6973 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
6974
6975 // Initialization
6976 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
6977 .removeAttr( 'role aria-selected' );
6978 };
6979
6980 /* Setup */
6981
6982 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
6983
6984 /* Static Properties */
6985
6986 /**
6987 * @static
6988 * @inheritdoc
6989 */
6990 OO.ui.MenuSectionOptionWidget.static.selectable = false;
6991
6992 /**
6993 * @static
6994 * @inheritdoc
6995 */
6996 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
6997
6998 /**
6999 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7000 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7001 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7002 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7003 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7004 * and customized to be opened, closed, and displayed as needed.
7005 *
7006 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7007 * mouse outside the menu.
7008 *
7009 * Menus also have support for keyboard interaction:
7010 *
7011 * - Enter/Return key: choose and select a menu option
7012 * - Up-arrow key: highlight the previous menu option
7013 * - Down-arrow key: highlight the next menu option
7014 * - Esc key: hide the menu
7015 *
7016 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7017 *
7018 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7019 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7020 *
7021 * @class
7022 * @extends OO.ui.SelectWidget
7023 * @mixins OO.ui.mixin.ClippableElement
7024 * @mixins OO.ui.mixin.FloatableElement
7025 *
7026 * @constructor
7027 * @param {Object} [config] Configuration options
7028 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7029 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7030 * and {@link OO.ui.mixin.LookupElement LookupElement}
7031 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7032 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7033 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7034 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7035 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7036 * that button, unless the button (or its parent widget) is passed in here.
7037 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7038 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7039 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7040 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7041 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7042 * @cfg {number} [width] Width of the menu
7043 */
7044 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7045 // Configuration initialization
7046 config = config || {};
7047
7048 // Parent constructor
7049 OO.ui.MenuSelectWidget.parent.call( this, config );
7050
7051 // Mixin constructors
7052 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7053 OO.ui.mixin.FloatableElement.call( this, config );
7054
7055 // Properties
7056 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7057 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7058 this.filterFromInput = !!config.filterFromInput;
7059 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7060 this.$widget = config.widget ? config.widget.$element : null;
7061 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7062 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7063 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7064 this.highlightOnFilter = !!config.highlightOnFilter;
7065 this.width = config.width;
7066
7067 // Initialization
7068 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7069 if ( config.widget ) {
7070 this.setFocusOwner( config.widget.$tabIndexed );
7071 }
7072
7073 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7074 // that reference properties not initialized at that time of parent class construction
7075 // TODO: Find a better way to handle post-constructor setup
7076 this.visible = false;
7077 this.$element.addClass( 'oo-ui-element-hidden' );
7078 };
7079
7080 /* Setup */
7081
7082 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7083 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7084 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7085
7086 /* Events */
7087
7088 /**
7089 * @event ready
7090 *
7091 * The menu is ready: it is visible and has been positioned and clipped.
7092 */
7093
7094 /* Methods */
7095
7096 /**
7097 * Handles document mouse down events.
7098 *
7099 * @protected
7100 * @param {MouseEvent} e Mouse down event
7101 */
7102 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7103 if (
7104 this.isVisible() &&
7105 !OO.ui.contains(
7106 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7107 e.target,
7108 true
7109 )
7110 ) {
7111 this.toggle( false );
7112 }
7113 };
7114
7115 /**
7116 * @inheritdoc
7117 */
7118 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7119 var currentItem = this.findHighlightedItem() || this.getSelectedItem();
7120
7121 if ( !this.isDisabled() && this.isVisible() ) {
7122 switch ( e.keyCode ) {
7123 case OO.ui.Keys.LEFT:
7124 case OO.ui.Keys.RIGHT:
7125 // Do nothing if a text field is associated, arrow keys will be handled natively
7126 if ( !this.$input ) {
7127 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7128 }
7129 break;
7130 case OO.ui.Keys.ESCAPE:
7131 case OO.ui.Keys.TAB:
7132 if ( currentItem ) {
7133 currentItem.setHighlighted( false );
7134 }
7135 this.toggle( false );
7136 // Don't prevent tabbing away, prevent defocusing
7137 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7138 e.preventDefault();
7139 e.stopPropagation();
7140 }
7141 break;
7142 default:
7143 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7144 return;
7145 }
7146 }
7147 };
7148
7149 /**
7150 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7151 * or after items were added/removed (always).
7152 *
7153 * @protected
7154 */
7155 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7156 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7157 firstItemFound = false,
7158 anyVisible = false,
7159 len = this.items.length,
7160 showAll = !this.isVisible(),
7161 exactMatch = false;
7162
7163 if ( this.$input && this.filterFromInput ) {
7164 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7165 exactFilter = this.getItemMatcher( this.$input.val(), true );
7166
7167 // Hide non-matching options, and also hide section headers if all options
7168 // in their section are hidden.
7169 for ( i = 0; i < len; i++ ) {
7170 item = this.items[ i ];
7171 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7172 if ( section ) {
7173 // If the previous section was empty, hide its header
7174 section.toggle( showAll || !sectionEmpty );
7175 }
7176 section = item;
7177 sectionEmpty = true;
7178 } else if ( item instanceof OO.ui.OptionWidget ) {
7179 visible = showAll || filter( item );
7180 exactMatch = exactMatch || exactFilter( item );
7181 anyVisible = anyVisible || visible;
7182 sectionEmpty = sectionEmpty && !visible;
7183 item.toggle( visible );
7184 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7185 // Highlight the first item in the list
7186 this.highlightItem( item );
7187 firstItemFound = true;
7188 }
7189 }
7190 }
7191 // Process the final section
7192 if ( section ) {
7193 section.toggle( showAll || !sectionEmpty );
7194 }
7195
7196 if ( anyVisible && this.items.length && !exactMatch ) {
7197 this.scrollItemIntoView( this.items[ 0 ] );
7198 }
7199
7200 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7201 }
7202
7203 // Reevaluate clipping
7204 this.clip();
7205 };
7206
7207 /**
7208 * @inheritdoc
7209 */
7210 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7211 if ( this.$input ) {
7212 this.$input.on( 'keydown', this.onKeyDownHandler );
7213 } else {
7214 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7215 }
7216 };
7217
7218 /**
7219 * @inheritdoc
7220 */
7221 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7222 if ( this.$input ) {
7223 this.$input.off( 'keydown', this.onKeyDownHandler );
7224 } else {
7225 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7226 }
7227 };
7228
7229 /**
7230 * @inheritdoc
7231 */
7232 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7233 if ( this.$input ) {
7234 if ( this.filterFromInput ) {
7235 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7236 this.updateItemVisibility();
7237 }
7238 } else {
7239 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7240 }
7241 };
7242
7243 /**
7244 * @inheritdoc
7245 */
7246 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7247 if ( this.$input ) {
7248 if ( this.filterFromInput ) {
7249 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7250 this.updateItemVisibility();
7251 }
7252 } else {
7253 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7254 }
7255 };
7256
7257 /**
7258 * Choose an item.
7259 *
7260 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7261 *
7262 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7263 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7264 *
7265 * @param {OO.ui.OptionWidget} item Item to choose
7266 * @chainable
7267 */
7268 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7269 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7270 if ( this.hideOnChoose ) {
7271 this.toggle( false );
7272 }
7273 return this;
7274 };
7275
7276 /**
7277 * @inheritdoc
7278 */
7279 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7280 // Parent method
7281 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7282
7283 this.updateItemVisibility();
7284
7285 return this;
7286 };
7287
7288 /**
7289 * @inheritdoc
7290 */
7291 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7292 // Parent method
7293 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7294
7295 this.updateItemVisibility();
7296
7297 return this;
7298 };
7299
7300 /**
7301 * @inheritdoc
7302 */
7303 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7304 // Parent method
7305 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7306
7307 this.updateItemVisibility();
7308
7309 return this;
7310 };
7311
7312 /**
7313 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7314 * `.toggle( true )` after its #$element is attached to the DOM.
7315 *
7316 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7317 * it in the right place and with the right dimensions only work correctly while it is attached.
7318 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7319 * strictly enforced, so currently it only generates a warning in the browser console.
7320 *
7321 * @fires ready
7322 * @inheritdoc
7323 */
7324 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7325 var change;
7326
7327 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7328 change = visible !== this.isVisible();
7329
7330 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7331 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7332 this.warnedUnattached = true;
7333 }
7334
7335 if ( change && visible && ( this.width || this.$floatableContainer ) ) {
7336 this.setIdealSize( this.width || this.$floatableContainer.width() );
7337 }
7338
7339 // Parent method
7340 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7341
7342 if ( change ) {
7343 if ( visible ) {
7344 this.bindKeyDownListener();
7345 this.bindKeyPressListener();
7346
7347 this.togglePositioning( !!this.$floatableContainer );
7348 this.toggleClipping( true );
7349
7350 this.$focusOwner.attr( 'aria-expanded', 'true' );
7351
7352 if ( this.getSelectedItem() ) {
7353 this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
7354 this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
7355 }
7356
7357 // Auto-hide
7358 if ( this.autoHide ) {
7359 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7360 }
7361
7362 this.emit( 'ready' );
7363 } else {
7364 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7365 this.unbindKeyDownListener();
7366 this.unbindKeyPressListener();
7367 this.$focusOwner.attr( 'aria-expanded', 'false' );
7368 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7369 this.togglePositioning( false );
7370 this.toggleClipping( false );
7371 }
7372 }
7373
7374 return this;
7375 };
7376
7377 /**
7378 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7379 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7380 * users can interact with it.
7381 *
7382 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7383 * OO.ui.DropdownInputWidget instead.
7384 *
7385 * @example
7386 * // Example: A DropdownWidget with a menu that contains three options
7387 * var dropDown = new OO.ui.DropdownWidget( {
7388 * label: 'Dropdown menu: Select a menu option',
7389 * menu: {
7390 * items: [
7391 * new OO.ui.MenuOptionWidget( {
7392 * data: 'a',
7393 * label: 'First'
7394 * } ),
7395 * new OO.ui.MenuOptionWidget( {
7396 * data: 'b',
7397 * label: 'Second'
7398 * } ),
7399 * new OO.ui.MenuOptionWidget( {
7400 * data: 'c',
7401 * label: 'Third'
7402 * } )
7403 * ]
7404 * }
7405 * } );
7406 *
7407 * $( 'body' ).append( dropDown.$element );
7408 *
7409 * dropDown.getMenu().selectItemByData( 'b' );
7410 *
7411 * dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
7412 *
7413 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
7414 *
7415 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7416 *
7417 * @class
7418 * @extends OO.ui.Widget
7419 * @mixins OO.ui.mixin.IconElement
7420 * @mixins OO.ui.mixin.IndicatorElement
7421 * @mixins OO.ui.mixin.LabelElement
7422 * @mixins OO.ui.mixin.TitledElement
7423 * @mixins OO.ui.mixin.TabIndexedElement
7424 *
7425 * @constructor
7426 * @param {Object} [config] Configuration options
7427 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7428 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7429 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7430 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7431 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
7432 */
7433 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7434 // Configuration initialization
7435 config = $.extend( { indicator: 'down' }, config );
7436
7437 // Parent constructor
7438 OO.ui.DropdownWidget.parent.call( this, config );
7439
7440 // Properties (must be set before TabIndexedElement constructor call)
7441 this.$handle = this.$( '<span>' );
7442 this.$overlay = config.$overlay || this.$element;
7443
7444 // Mixin constructors
7445 OO.ui.mixin.IconElement.call( this, config );
7446 OO.ui.mixin.IndicatorElement.call( this, config );
7447 OO.ui.mixin.LabelElement.call( this, config );
7448 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7449 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7450
7451 // Properties
7452 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7453 widget: this,
7454 $floatableContainer: this.$element
7455 }, config.menu ) );
7456
7457 // Events
7458 this.$handle.on( {
7459 click: this.onClick.bind( this ),
7460 keydown: this.onKeyDown.bind( this ),
7461 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7462 keypress: this.menu.onKeyPressHandler,
7463 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7464 } );
7465 this.menu.connect( this, {
7466 select: 'onMenuSelect',
7467 toggle: 'onMenuToggle'
7468 } );
7469
7470 // Initialization
7471 this.$handle
7472 .addClass( 'oo-ui-dropdownWidget-handle' )
7473 .attr( {
7474 role: 'combobox',
7475 'aria-owns': this.menu.getElementId(),
7476 'aria-autocomplete': 'list'
7477 } )
7478 .append( this.$icon, this.$label, this.$indicator );
7479 this.$element
7480 .addClass( 'oo-ui-dropdownWidget' )
7481 .append( this.$handle );
7482 this.$overlay.append( this.menu.$element );
7483 };
7484
7485 /* Setup */
7486
7487 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7488 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7489 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7490 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7491 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7492 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7493
7494 /* Methods */
7495
7496 /**
7497 * Get the menu.
7498 *
7499 * @return {OO.ui.MenuSelectWidget} Menu of widget
7500 */
7501 OO.ui.DropdownWidget.prototype.getMenu = function () {
7502 return this.menu;
7503 };
7504
7505 /**
7506 * Handles menu select events.
7507 *
7508 * @private
7509 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7510 */
7511 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7512 var selectedLabel;
7513
7514 if ( !item ) {
7515 this.setLabel( null );
7516 return;
7517 }
7518
7519 selectedLabel = item.getLabel();
7520
7521 // If the label is a DOM element, clone it, because setLabel will append() it
7522 if ( selectedLabel instanceof jQuery ) {
7523 selectedLabel = selectedLabel.clone();
7524 }
7525
7526 this.setLabel( selectedLabel );
7527 };
7528
7529 /**
7530 * Handle menu toggle events.
7531 *
7532 * @private
7533 * @param {boolean} isVisible Menu toggle event
7534 */
7535 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7536 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7537 this.$handle.attr(
7538 'aria-expanded',
7539 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7540 );
7541 };
7542
7543 /**
7544 * Handle mouse click events.
7545 *
7546 * @private
7547 * @param {jQuery.Event} e Mouse click event
7548 */
7549 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7550 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7551 this.menu.toggle();
7552 }
7553 return false;
7554 };
7555
7556 /**
7557 * Handle key down events.
7558 *
7559 * @private
7560 * @param {jQuery.Event} e Key down event
7561 */
7562 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7563 if (
7564 !this.isDisabled() &&
7565 (
7566 e.which === OO.ui.Keys.ENTER ||
7567 (
7568 !this.menu.isVisible() &&
7569 (
7570 e.which === OO.ui.Keys.SPACE ||
7571 e.which === OO.ui.Keys.UP ||
7572 e.which === OO.ui.Keys.DOWN
7573 )
7574 )
7575 )
7576 ) {
7577 this.menu.toggle();
7578 return false;
7579 }
7580 };
7581
7582 /**
7583 * RadioOptionWidget is an option widget that looks like a radio button.
7584 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7585 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7586 *
7587 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7588 *
7589 * @class
7590 * @extends OO.ui.OptionWidget
7591 *
7592 * @constructor
7593 * @param {Object} [config] Configuration options
7594 */
7595 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7596 // Configuration initialization
7597 config = config || {};
7598
7599 // Properties (must be done before parent constructor which calls #setDisabled)
7600 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7601
7602 // Parent constructor
7603 OO.ui.RadioOptionWidget.parent.call( this, config );
7604
7605 // Initialization
7606 // Remove implicit role, we're handling it ourselves
7607 this.radio.$input.attr( 'role', 'presentation' );
7608 this.$element
7609 .addClass( 'oo-ui-radioOptionWidget' )
7610 .attr( 'role', 'radio' )
7611 .attr( 'aria-checked', 'false' )
7612 .removeAttr( 'aria-selected' )
7613 .prepend( this.radio.$element );
7614 };
7615
7616 /* Setup */
7617
7618 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7619
7620 /* Static Properties */
7621
7622 /**
7623 * @static
7624 * @inheritdoc
7625 */
7626 OO.ui.RadioOptionWidget.static.highlightable = false;
7627
7628 /**
7629 * @static
7630 * @inheritdoc
7631 */
7632 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7633
7634 /**
7635 * @static
7636 * @inheritdoc
7637 */
7638 OO.ui.RadioOptionWidget.static.pressable = false;
7639
7640 /**
7641 * @static
7642 * @inheritdoc
7643 */
7644 OO.ui.RadioOptionWidget.static.tagName = 'label';
7645
7646 /* Methods */
7647
7648 /**
7649 * @inheritdoc
7650 */
7651 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7652 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7653
7654 this.radio.setSelected( state );
7655 this.$element
7656 .attr( 'aria-checked', state.toString() )
7657 .removeAttr( 'aria-selected' );
7658
7659 return this;
7660 };
7661
7662 /**
7663 * @inheritdoc
7664 */
7665 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7666 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7667
7668 this.radio.setDisabled( this.isDisabled() );
7669
7670 return this;
7671 };
7672
7673 /**
7674 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7675 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7676 * an interface for adding, removing and selecting options.
7677 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7678 *
7679 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7680 * OO.ui.RadioSelectInputWidget instead.
7681 *
7682 * @example
7683 * // A RadioSelectWidget with RadioOptions.
7684 * var option1 = new OO.ui.RadioOptionWidget( {
7685 * data: 'a',
7686 * label: 'Selected radio option'
7687 * } );
7688 *
7689 * var option2 = new OO.ui.RadioOptionWidget( {
7690 * data: 'b',
7691 * label: 'Unselected radio option'
7692 * } );
7693 *
7694 * var radioSelect=new OO.ui.RadioSelectWidget( {
7695 * items: [ option1, option2 ]
7696 * } );
7697 *
7698 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7699 * radioSelect.selectItem( option1 );
7700 *
7701 * $( 'body' ).append( radioSelect.$element );
7702 *
7703 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7704
7705 *
7706 * @class
7707 * @extends OO.ui.SelectWidget
7708 * @mixins OO.ui.mixin.TabIndexedElement
7709 *
7710 * @constructor
7711 * @param {Object} [config] Configuration options
7712 */
7713 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7714 // Parent constructor
7715 OO.ui.RadioSelectWidget.parent.call( this, config );
7716
7717 // Mixin constructors
7718 OO.ui.mixin.TabIndexedElement.call( this, config );
7719
7720 // Events
7721 this.$element.on( {
7722 focus: this.bindKeyDownListener.bind( this ),
7723 blur: this.unbindKeyDownListener.bind( this )
7724 } );
7725
7726 // Initialization
7727 this.$element
7728 .addClass( 'oo-ui-radioSelectWidget' )
7729 .attr( 'role', 'radiogroup' );
7730 };
7731
7732 /* Setup */
7733
7734 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
7735 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
7736
7737 /**
7738 * MultioptionWidgets are special elements that can be selected and configured with data. The
7739 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
7740 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
7741 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
7742 *
7743 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
7744 *
7745 * @class
7746 * @extends OO.ui.Widget
7747 * @mixins OO.ui.mixin.ItemWidget
7748 * @mixins OO.ui.mixin.LabelElement
7749 *
7750 * @constructor
7751 * @param {Object} [config] Configuration options
7752 * @cfg {boolean} [selected=false] Whether the option is initially selected
7753 */
7754 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
7755 // Configuration initialization
7756 config = config || {};
7757
7758 // Parent constructor
7759 OO.ui.MultioptionWidget.parent.call( this, config );
7760
7761 // Mixin constructors
7762 OO.ui.mixin.ItemWidget.call( this );
7763 OO.ui.mixin.LabelElement.call( this, config );
7764
7765 // Properties
7766 this.selected = null;
7767
7768 // Initialization
7769 this.$element
7770 .addClass( 'oo-ui-multioptionWidget' )
7771 .append( this.$label );
7772 this.setSelected( config.selected );
7773 };
7774
7775 /* Setup */
7776
7777 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
7778 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
7779 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
7780
7781 /* Events */
7782
7783 /**
7784 * @event change
7785 *
7786 * A change event is emitted when the selected state of the option changes.
7787 *
7788 * @param {boolean} selected Whether the option is now selected
7789 */
7790
7791 /* Methods */
7792
7793 /**
7794 * Check if the option is selected.
7795 *
7796 * @return {boolean} Item is selected
7797 */
7798 OO.ui.MultioptionWidget.prototype.isSelected = function () {
7799 return this.selected;
7800 };
7801
7802 /**
7803 * Set the option’s selected state. In general, all modifications to the selection
7804 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
7805 * method instead of this method.
7806 *
7807 * @param {boolean} [state=false] Select option
7808 * @chainable
7809 */
7810 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
7811 state = !!state;
7812 if ( this.selected !== state ) {
7813 this.selected = state;
7814 this.emit( 'change', state );
7815 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
7816 }
7817 return this;
7818 };
7819
7820 /**
7821 * MultiselectWidget allows selecting multiple options from a list.
7822 *
7823 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
7824 *
7825 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7826 *
7827 * @class
7828 * @abstract
7829 * @extends OO.ui.Widget
7830 * @mixins OO.ui.mixin.GroupWidget
7831 *
7832 * @constructor
7833 * @param {Object} [config] Configuration options
7834 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
7835 */
7836 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
7837 // Parent constructor
7838 OO.ui.MultiselectWidget.parent.call( this, config );
7839
7840 // Configuration initialization
7841 config = config || {};
7842
7843 // Mixin constructors
7844 OO.ui.mixin.GroupWidget.call( this, config );
7845
7846 // Events
7847 this.aggregate( { change: 'select' } );
7848 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
7849 // by GroupElement only when items are added/removed
7850 this.connect( this, { select: [ 'emit', 'change' ] } );
7851
7852 // Initialization
7853 if ( config.items ) {
7854 this.addItems( config.items );
7855 }
7856 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
7857 this.$element.addClass( 'oo-ui-multiselectWidget' )
7858 .append( this.$group );
7859 };
7860
7861 /* Setup */
7862
7863 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
7864 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
7865
7866 /* Events */
7867
7868 /**
7869 * @event change
7870 *
7871 * A change event is emitted when the set of items changes, or an item is selected or deselected.
7872 */
7873
7874 /**
7875 * @event select
7876 *
7877 * A select event is emitted when an item is selected or deselected.
7878 */
7879
7880 /* Methods */
7881
7882 /**
7883 * Get options that are selected.
7884 *
7885 * @return {OO.ui.MultioptionWidget[]} Selected options
7886 */
7887 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
7888 return this.items.filter( function ( item ) {
7889 return item.isSelected();
7890 } );
7891 };
7892
7893 /**
7894 * Get the data of options that are selected.
7895 *
7896 * @return {Object[]|string[]} Values of selected options
7897 */
7898 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
7899 return this.getSelectedItems().map( function ( item ) {
7900 return item.data;
7901 } );
7902 };
7903
7904 /**
7905 * Select options by reference. Options not mentioned in the `items` array will be deselected.
7906 *
7907 * @param {OO.ui.MultioptionWidget[]} items Items to select
7908 * @chainable
7909 */
7910 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
7911 this.items.forEach( function ( item ) {
7912 var selected = items.indexOf( item ) !== -1;
7913 item.setSelected( selected );
7914 } );
7915 return this;
7916 };
7917
7918 /**
7919 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
7920 *
7921 * @param {Object[]|string[]} datas Values of items to select
7922 * @chainable
7923 */
7924 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
7925 var items,
7926 widget = this;
7927 items = datas.map( function ( data ) {
7928 return widget.getItemFromData( data );
7929 } );
7930 this.selectItems( items );
7931 return this;
7932 };
7933
7934 /**
7935 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
7936 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
7937 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7938 *
7939 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7940 *
7941 * @class
7942 * @extends OO.ui.MultioptionWidget
7943 *
7944 * @constructor
7945 * @param {Object} [config] Configuration options
7946 */
7947 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
7948 // Configuration initialization
7949 config = config || {};
7950
7951 // Properties (must be done before parent constructor which calls #setDisabled)
7952 this.checkbox = new OO.ui.CheckboxInputWidget();
7953
7954 // Parent constructor
7955 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
7956
7957 // Events
7958 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
7959 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
7960
7961 // Initialization
7962 this.$element
7963 .addClass( 'oo-ui-checkboxMultioptionWidget' )
7964 .prepend( this.checkbox.$element );
7965 };
7966
7967 /* Setup */
7968
7969 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
7970
7971 /* Static Properties */
7972
7973 /**
7974 * @static
7975 * @inheritdoc
7976 */
7977 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
7978
7979 /* Methods */
7980
7981 /**
7982 * Handle checkbox selected state change.
7983 *
7984 * @private
7985 */
7986 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
7987 this.setSelected( this.checkbox.isSelected() );
7988 };
7989
7990 /**
7991 * @inheritdoc
7992 */
7993 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
7994 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
7995 this.checkbox.setSelected( state );
7996 return this;
7997 };
7998
7999 /**
8000 * @inheritdoc
8001 */
8002 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8003 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8004 this.checkbox.setDisabled( this.isDisabled() );
8005 return this;
8006 };
8007
8008 /**
8009 * Focus the widget.
8010 */
8011 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8012 this.checkbox.focus();
8013 };
8014
8015 /**
8016 * Handle key down events.
8017 *
8018 * @protected
8019 * @param {jQuery.Event} e
8020 */
8021 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8022 var
8023 element = this.getElementGroup(),
8024 nextItem;
8025
8026 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8027 nextItem = element.getRelativeFocusableItem( this, -1 );
8028 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8029 nextItem = element.getRelativeFocusableItem( this, 1 );
8030 }
8031
8032 if ( nextItem ) {
8033 e.preventDefault();
8034 nextItem.focus();
8035 }
8036 };
8037
8038 /**
8039 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8040 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8041 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8042 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8043 *
8044 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8045 * OO.ui.CheckboxMultiselectInputWidget instead.
8046 *
8047 * @example
8048 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8049 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8050 * data: 'a',
8051 * selected: true,
8052 * label: 'Selected checkbox'
8053 * } );
8054 *
8055 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8056 * data: 'b',
8057 * label: 'Unselected checkbox'
8058 * } );
8059 *
8060 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8061 * items: [ option1, option2 ]
8062 * } );
8063 *
8064 * $( 'body' ).append( multiselect.$element );
8065 *
8066 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8067 *
8068 * @class
8069 * @extends OO.ui.MultiselectWidget
8070 *
8071 * @constructor
8072 * @param {Object} [config] Configuration options
8073 */
8074 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8075 // Parent constructor
8076 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8077
8078 // Properties
8079 this.$lastClicked = null;
8080
8081 // Events
8082 this.$group.on( 'click', this.onClick.bind( this ) );
8083
8084 // Initialization
8085 this.$element
8086 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8087 };
8088
8089 /* Setup */
8090
8091 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8092
8093 /* Methods */
8094
8095 /**
8096 * Get an option by its position relative to the specified item (or to the start of the option array,
8097 * if item is `null`). The direction in which to search through the option array is specified with a
8098 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8099 * `null` if there are no options in the array.
8100 *
8101 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8102 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8103 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8104 */
8105 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8106 var currentIndex, nextIndex, i,
8107 increase = direction > 0 ? 1 : -1,
8108 len = this.items.length;
8109
8110 if ( item ) {
8111 currentIndex = this.items.indexOf( item );
8112 nextIndex = ( currentIndex + increase + len ) % len;
8113 } else {
8114 // If no item is selected and moving forward, start at the beginning.
8115 // If moving backward, start at the end.
8116 nextIndex = direction > 0 ? 0 : len - 1;
8117 }
8118
8119 for ( i = 0; i < len; i++ ) {
8120 item = this.items[ nextIndex ];
8121 if ( item && !item.isDisabled() ) {
8122 return item;
8123 }
8124 nextIndex = ( nextIndex + increase + len ) % len;
8125 }
8126 return null;
8127 };
8128
8129 /**
8130 * Handle click events on checkboxes.
8131 *
8132 * @param {jQuery.Event} e
8133 */
8134 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8135 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8136 $lastClicked = this.$lastClicked,
8137 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8138 .not( '.oo-ui-widget-disabled' );
8139
8140 // Allow selecting multiple options at once by Shift-clicking them
8141 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8142 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8143 lastClickedIndex = $options.index( $lastClicked );
8144 nowClickedIndex = $options.index( $nowClicked );
8145 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8146 // browser. In either case we don't need custom handling.
8147 if ( nowClickedIndex !== lastClickedIndex ) {
8148 items = this.items;
8149 wasSelected = items[ nowClickedIndex ].isSelected();
8150 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8151
8152 // This depends on the DOM order of the items and the order of the .items array being the same.
8153 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8154 if ( !items[ i ].isDisabled() ) {
8155 items[ i ].setSelected( !wasSelected );
8156 }
8157 }
8158 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8159 // handling first, then set our value. The order in which events happen is different for
8160 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8161 // non-click actions that change the checkboxes.
8162 e.preventDefault();
8163 setTimeout( function () {
8164 if ( !items[ nowClickedIndex ].isDisabled() ) {
8165 items[ nowClickedIndex ].setSelected( !wasSelected );
8166 }
8167 } );
8168 }
8169 }
8170
8171 if ( $nowClicked.length ) {
8172 this.$lastClicked = $nowClicked;
8173 }
8174 };
8175
8176 /**
8177 * Focus the widget
8178 *
8179 * @chainable
8180 */
8181 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8182 var item;
8183 if ( !this.isDisabled() ) {
8184 item = this.getRelativeFocusableItem( null, 1 );
8185 if ( item ) {
8186 item.focus();
8187 }
8188 }
8189 return this;
8190 };
8191
8192 /**
8193 * @inheritdoc
8194 */
8195 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8196 this.focus();
8197 };
8198
8199 /**
8200 * Progress bars visually display the status of an operation, such as a download,
8201 * and can be either determinate or indeterminate:
8202 *
8203 * - **determinate** process bars show the percent of an operation that is complete.
8204 *
8205 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8206 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8207 * not use percentages.
8208 *
8209 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8210 *
8211 * @example
8212 * // Examples of determinate and indeterminate progress bars.
8213 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8214 * progress: 33
8215 * } );
8216 * var progressBar2 = new OO.ui.ProgressBarWidget();
8217 *
8218 * // Create a FieldsetLayout to layout progress bars
8219 * var fieldset = new OO.ui.FieldsetLayout;
8220 * fieldset.addItems( [
8221 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8222 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8223 * ] );
8224 * $( 'body' ).append( fieldset.$element );
8225 *
8226 * @class
8227 * @extends OO.ui.Widget
8228 *
8229 * @constructor
8230 * @param {Object} [config] Configuration options
8231 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8232 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8233 * By default, the progress bar is indeterminate.
8234 */
8235 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8236 // Configuration initialization
8237 config = config || {};
8238
8239 // Parent constructor
8240 OO.ui.ProgressBarWidget.parent.call( this, config );
8241
8242 // Properties
8243 this.$bar = $( '<div>' );
8244 this.progress = null;
8245
8246 // Initialization
8247 this.setProgress( config.progress !== undefined ? config.progress : false );
8248 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8249 this.$element
8250 .attr( {
8251 role: 'progressbar',
8252 'aria-valuemin': 0,
8253 'aria-valuemax': 100
8254 } )
8255 .addClass( 'oo-ui-progressBarWidget' )
8256 .append( this.$bar );
8257 };
8258
8259 /* Setup */
8260
8261 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8262
8263 /* Static Properties */
8264
8265 /**
8266 * @static
8267 * @inheritdoc
8268 */
8269 OO.ui.ProgressBarWidget.static.tagName = 'div';
8270
8271 /* Methods */
8272
8273 /**
8274 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8275 *
8276 * @return {number|boolean} Progress percent
8277 */
8278 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8279 return this.progress;
8280 };
8281
8282 /**
8283 * Set the percent of the process completed or `false` for an indeterminate process.
8284 *
8285 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8286 */
8287 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8288 this.progress = progress;
8289
8290 if ( progress !== false ) {
8291 this.$bar.css( 'width', this.progress + '%' );
8292 this.$element.attr( 'aria-valuenow', this.progress );
8293 } else {
8294 this.$bar.css( 'width', '' );
8295 this.$element.removeAttr( 'aria-valuenow' );
8296 }
8297 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8298 };
8299
8300 /**
8301 * InputWidget is the base class for all input widgets, which
8302 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8303 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8304 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8305 *
8306 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8307 *
8308 * @abstract
8309 * @class
8310 * @extends OO.ui.Widget
8311 * @mixins OO.ui.mixin.FlaggedElement
8312 * @mixins OO.ui.mixin.TabIndexedElement
8313 * @mixins OO.ui.mixin.TitledElement
8314 * @mixins OO.ui.mixin.AccessKeyedElement
8315 *
8316 * @constructor
8317 * @param {Object} [config] Configuration options
8318 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8319 * @cfg {string} [value=''] The value of the input.
8320 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8321 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8322 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8323 * before it is accepted.
8324 */
8325 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8326 // Configuration initialization
8327 config = config || {};
8328
8329 // Parent constructor
8330 OO.ui.InputWidget.parent.call( this, config );
8331
8332 // Properties
8333 // See #reusePreInfuseDOM about config.$input
8334 this.$input = config.$input || this.getInputElement( config );
8335 this.value = '';
8336 this.inputFilter = config.inputFilter;
8337
8338 // Mixin constructors
8339 OO.ui.mixin.FlaggedElement.call( this, config );
8340 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8341 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8342 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8343
8344 // Events
8345 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8346
8347 // Initialization
8348 this.$input
8349 .addClass( 'oo-ui-inputWidget-input' )
8350 .attr( 'name', config.name )
8351 .prop( 'disabled', this.isDisabled() );
8352 this.$element
8353 .addClass( 'oo-ui-inputWidget' )
8354 .append( this.$input );
8355 this.setValue( config.value );
8356 if ( config.dir ) {
8357 this.setDir( config.dir );
8358 }
8359 if ( config.inputId !== undefined ) {
8360 this.setInputId( config.inputId );
8361 }
8362 };
8363
8364 /* Setup */
8365
8366 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8367 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8368 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8369 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8370 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8371
8372 /* Static Methods */
8373
8374 /**
8375 * @inheritdoc
8376 */
8377 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8378 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8379 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8380 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8381 return config;
8382 };
8383
8384 /**
8385 * @inheritdoc
8386 */
8387 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8388 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8389 if ( config.$input && config.$input.length ) {
8390 state.value = config.$input.val();
8391 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8392 state.focus = config.$input.is( ':focus' );
8393 }
8394 return state;
8395 };
8396
8397 /* Events */
8398
8399 /**
8400 * @event change
8401 *
8402 * A change event is emitted when the value of the input changes.
8403 *
8404 * @param {string} value
8405 */
8406
8407 /* Methods */
8408
8409 /**
8410 * Get input element.
8411 *
8412 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8413 * different circumstances. The element must have a `value` property (like form elements).
8414 *
8415 * @protected
8416 * @param {Object} config Configuration options
8417 * @return {jQuery} Input element
8418 */
8419 OO.ui.InputWidget.prototype.getInputElement = function () {
8420 return $( '<input>' );
8421 };
8422
8423 /**
8424 * Handle potentially value-changing events.
8425 *
8426 * @private
8427 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8428 */
8429 OO.ui.InputWidget.prototype.onEdit = function () {
8430 var widget = this;
8431 if ( !this.isDisabled() ) {
8432 // Allow the stack to clear so the value will be updated
8433 setTimeout( function () {
8434 widget.setValue( widget.$input.val() );
8435 } );
8436 }
8437 };
8438
8439 /**
8440 * Get the value of the input.
8441 *
8442 * @return {string} Input value
8443 */
8444 OO.ui.InputWidget.prototype.getValue = function () {
8445 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8446 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8447 var value = this.$input.val();
8448 if ( this.value !== value ) {
8449 this.setValue( value );
8450 }
8451 return this.value;
8452 };
8453
8454 /**
8455 * Set the directionality of the input.
8456 *
8457 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8458 * @chainable
8459 */
8460 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8461 this.$input.prop( 'dir', dir );
8462 return this;
8463 };
8464
8465 /**
8466 * Set the value of the input.
8467 *
8468 * @param {string} value New value
8469 * @fires change
8470 * @chainable
8471 */
8472 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8473 value = this.cleanUpValue( value );
8474 // Update the DOM if it has changed. Note that with cleanUpValue, it
8475 // is possible for the DOM value to change without this.value changing.
8476 if ( this.$input.val() !== value ) {
8477 this.$input.val( value );
8478 }
8479 if ( this.value !== value ) {
8480 this.value = value;
8481 this.emit( 'change', this.value );
8482 }
8483 return this;
8484 };
8485
8486 /**
8487 * Clean up incoming value.
8488 *
8489 * Ensures value is a string, and converts undefined and null to empty string.
8490 *
8491 * @private
8492 * @param {string} value Original value
8493 * @return {string} Cleaned up value
8494 */
8495 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8496 if ( value === undefined || value === null ) {
8497 return '';
8498 } else if ( this.inputFilter ) {
8499 return this.inputFilter( String( value ) );
8500 } else {
8501 return String( value );
8502 }
8503 };
8504
8505 /**
8506 * @inheritdoc
8507 */
8508 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8509 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8510 if ( this.$input ) {
8511 this.$input.prop( 'disabled', this.isDisabled() );
8512 }
8513 return this;
8514 };
8515
8516 /**
8517 * Set the 'id' attribute of the `<input>` element.
8518 *
8519 * @param {string} id
8520 * @chainable
8521 */
8522 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8523 this.$input.attr( 'id', id );
8524 return this;
8525 };
8526
8527 /**
8528 * @inheritdoc
8529 */
8530 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8531 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8532 if ( state.value !== undefined && state.value !== this.getValue() ) {
8533 this.setValue( state.value );
8534 }
8535 if ( state.focus ) {
8536 this.focus();
8537 }
8538 };
8539
8540 /**
8541 * Data widget intended for creating 'hidden'-type inputs.
8542 *
8543 * @class
8544 * @extends OO.ui.Widget
8545 *
8546 * @constructor
8547 * @param {Object} [config] Configuration options
8548 * @cfg {string} [value=''] The value of the input.
8549 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8550 */
8551 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8552 // Configuration initialization
8553 config = $.extend( { value: '', name: '' }, config );
8554
8555 // Parent constructor
8556 OO.ui.HiddenInputWidget.parent.call( this, config );
8557
8558 // Initialization
8559 this.$element.attr( {
8560 type: 'hidden',
8561 value: config.value,
8562 name: config.name
8563 } );
8564 this.$element.removeAttr( 'aria-disabled' );
8565 };
8566
8567 /* Setup */
8568
8569 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8570
8571 /* Static Properties */
8572
8573 /**
8574 * @static
8575 * @inheritdoc
8576 */
8577 OO.ui.HiddenInputWidget.static.tagName = 'input';
8578
8579 /**
8580 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8581 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8582 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8583 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8584 * [OOjs UI documentation on MediaWiki] [1] for more information.
8585 *
8586 * @example
8587 * // A ButtonInputWidget rendered as an HTML button, the default.
8588 * var button = new OO.ui.ButtonInputWidget( {
8589 * label: 'Input button',
8590 * icon: 'check',
8591 * value: 'check'
8592 * } );
8593 * $( 'body' ).append( button.$element );
8594 *
8595 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8596 *
8597 * @class
8598 * @extends OO.ui.InputWidget
8599 * @mixins OO.ui.mixin.ButtonElement
8600 * @mixins OO.ui.mixin.IconElement
8601 * @mixins OO.ui.mixin.IndicatorElement
8602 * @mixins OO.ui.mixin.LabelElement
8603 * @mixins OO.ui.mixin.TitledElement
8604 *
8605 * @constructor
8606 * @param {Object} [config] Configuration options
8607 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8608 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8609 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8610 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8611 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8612 */
8613 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8614 // Configuration initialization
8615 config = $.extend( { type: 'button', useInputTag: false }, config );
8616
8617 // See InputWidget#reusePreInfuseDOM about config.$input
8618 if ( config.$input ) {
8619 config.$input.empty();
8620 }
8621
8622 // Properties (must be set before parent constructor, which calls #setValue)
8623 this.useInputTag = config.useInputTag;
8624
8625 // Parent constructor
8626 OO.ui.ButtonInputWidget.parent.call( this, config );
8627
8628 // Mixin constructors
8629 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8630 OO.ui.mixin.IconElement.call( this, config );
8631 OO.ui.mixin.IndicatorElement.call( this, config );
8632 OO.ui.mixin.LabelElement.call( this, config );
8633 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8634
8635 // Initialization
8636 if ( !config.useInputTag ) {
8637 this.$input.append( this.$icon, this.$label, this.$indicator );
8638 }
8639 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8640 };
8641
8642 /* Setup */
8643
8644 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8645 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8646 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8647 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8648 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8649 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8650
8651 /* Static Properties */
8652
8653 /**
8654 * @static
8655 * @inheritdoc
8656 */
8657 OO.ui.ButtonInputWidget.static.tagName = 'span';
8658
8659 /* Methods */
8660
8661 /**
8662 * @inheritdoc
8663 * @protected
8664 */
8665 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8666 var type;
8667 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8668 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8669 };
8670
8671 /**
8672 * Set label value.
8673 *
8674 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8675 *
8676 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8677 * text, or `null` for no label
8678 * @chainable
8679 */
8680 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8681 if ( typeof label === 'function' ) {
8682 label = OO.ui.resolveMsg( label );
8683 }
8684
8685 if ( this.useInputTag ) {
8686 // Discard non-plaintext labels
8687 if ( typeof label !== 'string' ) {
8688 label = '';
8689 }
8690
8691 this.$input.val( label );
8692 }
8693
8694 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8695 };
8696
8697 /**
8698 * Set the value of the input.
8699 *
8700 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8701 * they do not support {@link #value values}.
8702 *
8703 * @param {string} value New value
8704 * @chainable
8705 */
8706 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8707 if ( !this.useInputTag ) {
8708 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8709 }
8710 return this;
8711 };
8712
8713 /**
8714 * @inheritdoc
8715 */
8716 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8717 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8718 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
8719 return null;
8720 };
8721
8722 /**
8723 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
8724 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
8725 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
8726 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
8727 *
8728 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
8729 *
8730 * @example
8731 * // An example of selected, unselected, and disabled checkbox inputs
8732 * var checkbox1=new OO.ui.CheckboxInputWidget( {
8733 * value: 'a',
8734 * selected: true
8735 * } );
8736 * var checkbox2=new OO.ui.CheckboxInputWidget( {
8737 * value: 'b'
8738 * } );
8739 * var checkbox3=new OO.ui.CheckboxInputWidget( {
8740 * value:'c',
8741 * disabled: true
8742 * } );
8743 * // Create a fieldset layout with fields for each checkbox.
8744 * var fieldset = new OO.ui.FieldsetLayout( {
8745 * label: 'Checkboxes'
8746 * } );
8747 * fieldset.addItems( [
8748 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
8749 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
8750 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
8751 * ] );
8752 * $( 'body' ).append( fieldset.$element );
8753 *
8754 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8755 *
8756 * @class
8757 * @extends OO.ui.InputWidget
8758 *
8759 * @constructor
8760 * @param {Object} [config] Configuration options
8761 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
8762 */
8763 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
8764 // Configuration initialization
8765 config = config || {};
8766
8767 // Parent constructor
8768 OO.ui.CheckboxInputWidget.parent.call( this, config );
8769
8770 // Initialization
8771 this.$element
8772 .addClass( 'oo-ui-checkboxInputWidget' )
8773 // Required for pretty styling in WikimediaUI theme
8774 .append( $( '<span>' ) );
8775 this.setSelected( config.selected !== undefined ? config.selected : false );
8776 };
8777
8778 /* Setup */
8779
8780 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
8781
8782 /* Static Properties */
8783
8784 /**
8785 * @static
8786 * @inheritdoc
8787 */
8788 OO.ui.CheckboxInputWidget.static.tagName = 'span';
8789
8790 /* Static Methods */
8791
8792 /**
8793 * @inheritdoc
8794 */
8795 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
8796 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
8797 state.checked = config.$input.prop( 'checked' );
8798 return state;
8799 };
8800
8801 /* Methods */
8802
8803 /**
8804 * @inheritdoc
8805 * @protected
8806 */
8807 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
8808 return $( '<input>' ).attr( 'type', 'checkbox' );
8809 };
8810
8811 /**
8812 * @inheritdoc
8813 */
8814 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
8815 var widget = this;
8816 if ( !this.isDisabled() ) {
8817 // Allow the stack to clear so the value will be updated
8818 setTimeout( function () {
8819 widget.setSelected( widget.$input.prop( 'checked' ) );
8820 } );
8821 }
8822 };
8823
8824 /**
8825 * Set selection state of this checkbox.
8826 *
8827 * @param {boolean} state `true` for selected
8828 * @chainable
8829 */
8830 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
8831 state = !!state;
8832 if ( this.selected !== state ) {
8833 this.selected = state;
8834 this.$input.prop( 'checked', this.selected );
8835 this.emit( 'change', this.selected );
8836 }
8837 return this;
8838 };
8839
8840 /**
8841 * Check if this checkbox is selected.
8842 *
8843 * @return {boolean} Checkbox is selected
8844 */
8845 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
8846 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8847 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8848 var selected = this.$input.prop( 'checked' );
8849 if ( this.selected !== selected ) {
8850 this.setSelected( selected );
8851 }
8852 return this.selected;
8853 };
8854
8855 /**
8856 * @inheritdoc
8857 */
8858 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
8859 if ( !this.isDisabled() ) {
8860 this.$input.click();
8861 }
8862 this.focus();
8863 };
8864
8865 /**
8866 * @inheritdoc
8867 */
8868 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
8869 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8870 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
8871 this.setSelected( state.checked );
8872 }
8873 };
8874
8875 /**
8876 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
8877 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
8878 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
8879 * more information about input widgets.
8880 *
8881 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
8882 * are no options. If no `value` configuration option is provided, the first option is selected.
8883 * If you need a state representing no value (no option being selected), use a DropdownWidget.
8884 *
8885 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
8886 *
8887 * @example
8888 * // Example: A DropdownInputWidget with three options
8889 * var dropdownInput = new OO.ui.DropdownInputWidget( {
8890 * options: [
8891 * { data: 'a', label: 'First' },
8892 * { data: 'b', label: 'Second'},
8893 * { data: 'c', label: 'Third' }
8894 * ]
8895 * } );
8896 * $( 'body' ).append( dropdownInput.$element );
8897 *
8898 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8899 *
8900 * @class
8901 * @extends OO.ui.InputWidget
8902 * @mixins OO.ui.mixin.TitledElement
8903 *
8904 * @constructor
8905 * @param {Object} [config] Configuration options
8906 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
8907 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
8908 */
8909 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
8910 // Configuration initialization
8911 config = config || {};
8912
8913 // See InputWidget#reusePreInfuseDOM about config.$input
8914 if ( config.$input ) {
8915 config.$input.addClass( 'oo-ui-element-hidden' );
8916 }
8917
8918 // Properties (must be done before parent constructor which calls #setDisabled)
8919 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
8920
8921 // Parent constructor
8922 OO.ui.DropdownInputWidget.parent.call( this, config );
8923
8924 // Mixin constructors
8925 OO.ui.mixin.TitledElement.call( this, config );
8926
8927 // Events
8928 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
8929
8930 // Initialization
8931 this.setOptions( config.options || [] );
8932 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
8933 // widget has no valid options when it happens.
8934 this.setValue( config.value );
8935 this.$element
8936 .addClass( 'oo-ui-dropdownInputWidget' )
8937 .append( this.dropdownWidget.$element );
8938 this.setTabIndexedElement( null );
8939 };
8940
8941 /* Setup */
8942
8943 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
8944 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
8945
8946 /* Methods */
8947
8948 /**
8949 * @inheritdoc
8950 * @protected
8951 */
8952 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
8953 return $( '<input>' ).attr( 'type', 'hidden' );
8954 };
8955
8956 /**
8957 * Handles menu select events.
8958 *
8959 * @private
8960 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
8961 */
8962 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
8963 this.setValue( item ? item.getData() : '' );
8964 };
8965
8966 /**
8967 * @inheritdoc
8968 */
8969 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
8970 var selected;
8971 value = this.cleanUpValue( value );
8972 // Only allow setting values that are actually present in the dropdown
8973 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
8974 this.dropdownWidget.getMenu().findFirstSelectableItem();
8975 this.dropdownWidget.getMenu().selectItem( selected );
8976 value = selected ? selected.getData() : '';
8977 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
8978 return this;
8979 };
8980
8981 /**
8982 * @inheritdoc
8983 */
8984 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
8985 this.dropdownWidget.setDisabled( state );
8986 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
8987 return this;
8988 };
8989
8990 /**
8991 * Set the options available for this input.
8992 *
8993 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
8994 * @chainable
8995 */
8996 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
8997 var
8998 value = this.getValue(),
8999 widget = this;
9000
9001 // Rebuild the dropdown menu
9002 this.dropdownWidget.getMenu()
9003 .clearItems()
9004 .addItems( options.map( function ( opt ) {
9005 var optValue = widget.cleanUpValue( opt.data );
9006
9007 if ( opt.optgroup === undefined ) {
9008 return new OO.ui.MenuOptionWidget( {
9009 data: optValue,
9010 label: opt.label !== undefined ? opt.label : optValue
9011 } );
9012 } else {
9013 return new OO.ui.MenuSectionOptionWidget( {
9014 label: opt.optgroup
9015 } );
9016 }
9017 } ) );
9018
9019 // Restore the previous value, or reset to something sensible
9020 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9021 // Previous value is still available, ensure consistency with the dropdown
9022 this.setValue( value );
9023 } else {
9024 // No longer valid, reset
9025 if ( options.length ) {
9026 this.setValue( options[ 0 ].data );
9027 }
9028 }
9029
9030 return this;
9031 };
9032
9033 /**
9034 * @inheritdoc
9035 */
9036 OO.ui.DropdownInputWidget.prototype.focus = function () {
9037 this.dropdownWidget.focus();
9038 return this;
9039 };
9040
9041 /**
9042 * @inheritdoc
9043 */
9044 OO.ui.DropdownInputWidget.prototype.blur = function () {
9045 this.dropdownWidget.blur();
9046 return this;
9047 };
9048
9049 /**
9050 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9051 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9052 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9053 * please see the [OOjs UI documentation on MediaWiki][1].
9054 *
9055 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9056 *
9057 * @example
9058 * // An example of selected, unselected, and disabled radio inputs
9059 * var radio1 = new OO.ui.RadioInputWidget( {
9060 * value: 'a',
9061 * selected: true
9062 * } );
9063 * var radio2 = new OO.ui.RadioInputWidget( {
9064 * value: 'b'
9065 * } );
9066 * var radio3 = new OO.ui.RadioInputWidget( {
9067 * value: 'c',
9068 * disabled: true
9069 * } );
9070 * // Create a fieldset layout with fields for each radio button.
9071 * var fieldset = new OO.ui.FieldsetLayout( {
9072 * label: 'Radio inputs'
9073 * } );
9074 * fieldset.addItems( [
9075 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9076 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9077 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9078 * ] );
9079 * $( 'body' ).append( fieldset.$element );
9080 *
9081 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9082 *
9083 * @class
9084 * @extends OO.ui.InputWidget
9085 *
9086 * @constructor
9087 * @param {Object} [config] Configuration options
9088 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9089 */
9090 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9091 // Configuration initialization
9092 config = config || {};
9093
9094 // Parent constructor
9095 OO.ui.RadioInputWidget.parent.call( this, config );
9096
9097 // Initialization
9098 this.$element
9099 .addClass( 'oo-ui-radioInputWidget' )
9100 // Required for pretty styling in WikimediaUI theme
9101 .append( $( '<span>' ) );
9102 this.setSelected( config.selected !== undefined ? config.selected : false );
9103 };
9104
9105 /* Setup */
9106
9107 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9108
9109 /* Static Properties */
9110
9111 /**
9112 * @static
9113 * @inheritdoc
9114 */
9115 OO.ui.RadioInputWidget.static.tagName = 'span';
9116
9117 /* Static Methods */
9118
9119 /**
9120 * @inheritdoc
9121 */
9122 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9123 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9124 state.checked = config.$input.prop( 'checked' );
9125 return state;
9126 };
9127
9128 /* Methods */
9129
9130 /**
9131 * @inheritdoc
9132 * @protected
9133 */
9134 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9135 return $( '<input>' ).attr( 'type', 'radio' );
9136 };
9137
9138 /**
9139 * @inheritdoc
9140 */
9141 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9142 // RadioInputWidget doesn't track its state.
9143 };
9144
9145 /**
9146 * Set selection state of this radio button.
9147 *
9148 * @param {boolean} state `true` for selected
9149 * @chainable
9150 */
9151 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9152 // RadioInputWidget doesn't track its state.
9153 this.$input.prop( 'checked', state );
9154 return this;
9155 };
9156
9157 /**
9158 * Check if this radio button is selected.
9159 *
9160 * @return {boolean} Radio is selected
9161 */
9162 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9163 return this.$input.prop( 'checked' );
9164 };
9165
9166 /**
9167 * @inheritdoc
9168 */
9169 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9170 if ( !this.isDisabled() ) {
9171 this.$input.click();
9172 }
9173 this.focus();
9174 };
9175
9176 /**
9177 * @inheritdoc
9178 */
9179 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9180 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9181 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9182 this.setSelected( state.checked );
9183 }
9184 };
9185
9186 /**
9187 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9188 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9189 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9190 * more information about input widgets.
9191 *
9192 * This and OO.ui.DropdownInputWidget support the same configuration options.
9193 *
9194 * @example
9195 * // Example: A RadioSelectInputWidget with three options
9196 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9197 * options: [
9198 * { data: 'a', label: 'First' },
9199 * { data: 'b', label: 'Second'},
9200 * { data: 'c', label: 'Third' }
9201 * ]
9202 * } );
9203 * $( 'body' ).append( radioSelectInput.$element );
9204 *
9205 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9206 *
9207 * @class
9208 * @extends OO.ui.InputWidget
9209 *
9210 * @constructor
9211 * @param {Object} [config] Configuration options
9212 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9213 */
9214 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9215 // Configuration initialization
9216 config = config || {};
9217
9218 // Properties (must be done before parent constructor which calls #setDisabled)
9219 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9220
9221 // Parent constructor
9222 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9223
9224 // Events
9225 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9226
9227 // Initialization
9228 this.setOptions( config.options || [] );
9229 this.$element
9230 .addClass( 'oo-ui-radioSelectInputWidget' )
9231 .append( this.radioSelectWidget.$element );
9232 this.setTabIndexedElement( null );
9233 };
9234
9235 /* Setup */
9236
9237 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9238
9239 /* Static Methods */
9240
9241 /**
9242 * @inheritdoc
9243 */
9244 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9245 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9246 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9247 return state;
9248 };
9249
9250 /**
9251 * @inheritdoc
9252 */
9253 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9254 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9255 // Cannot reuse the `<input type=radio>` set
9256 delete config.$input;
9257 return config;
9258 };
9259
9260 /* Methods */
9261
9262 /**
9263 * @inheritdoc
9264 * @protected
9265 */
9266 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9267 return $( '<input>' ).attr( 'type', 'hidden' );
9268 };
9269
9270 /**
9271 * Handles menu select events.
9272 *
9273 * @private
9274 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9275 */
9276 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9277 this.setValue( item.getData() );
9278 };
9279
9280 /**
9281 * @inheritdoc
9282 */
9283 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9284 value = this.cleanUpValue( value );
9285 this.radioSelectWidget.selectItemByData( value );
9286 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9287 return this;
9288 };
9289
9290 /**
9291 * @inheritdoc
9292 */
9293 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9294 this.radioSelectWidget.setDisabled( state );
9295 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9296 return this;
9297 };
9298
9299 /**
9300 * Set the options available for this input.
9301 *
9302 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9303 * @chainable
9304 */
9305 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9306 var
9307 value = this.getValue(),
9308 widget = this;
9309
9310 // Rebuild the radioSelect menu
9311 this.radioSelectWidget
9312 .clearItems()
9313 .addItems( options.map( function ( opt ) {
9314 var optValue = widget.cleanUpValue( opt.data );
9315 return new OO.ui.RadioOptionWidget( {
9316 data: optValue,
9317 label: opt.label !== undefined ? opt.label : optValue
9318 } );
9319 } ) );
9320
9321 // Restore the previous value, or reset to something sensible
9322 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9323 // Previous value is still available, ensure consistency with the radioSelect
9324 this.setValue( value );
9325 } else {
9326 // No longer valid, reset
9327 if ( options.length ) {
9328 this.setValue( options[ 0 ].data );
9329 }
9330 }
9331
9332 return this;
9333 };
9334
9335 /**
9336 * @inheritdoc
9337 */
9338 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9339 this.radioSelectWidget.focus();
9340 return this;
9341 };
9342
9343 /**
9344 * @inheritdoc
9345 */
9346 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9347 this.radioSelectWidget.blur();
9348 return this;
9349 };
9350
9351 /**
9352 * CheckboxMultiselectInputWidget is a
9353 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9354 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9355 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9356 * more information about input widgets.
9357 *
9358 * @example
9359 * // Example: A CheckboxMultiselectInputWidget with three options
9360 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9361 * options: [
9362 * { data: 'a', label: 'First' },
9363 * { data: 'b', label: 'Second'},
9364 * { data: 'c', label: 'Third' }
9365 * ]
9366 * } );
9367 * $( 'body' ).append( multiselectInput.$element );
9368 *
9369 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9370 *
9371 * @class
9372 * @extends OO.ui.InputWidget
9373 *
9374 * @constructor
9375 * @param {Object} [config] Configuration options
9376 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9377 */
9378 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9379 // Configuration initialization
9380 config = config || {};
9381
9382 // Properties (must be done before parent constructor which calls #setDisabled)
9383 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9384
9385 // Parent constructor
9386 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9387
9388 // Properties
9389 this.inputName = config.name;
9390
9391 // Initialization
9392 this.$element
9393 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9394 .append( this.checkboxMultiselectWidget.$element );
9395 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9396 this.$input.detach();
9397 this.setOptions( config.options || [] );
9398 // Have to repeat this from parent, as we need options to be set up for this to make sense
9399 this.setValue( config.value );
9400
9401 // setValue when checkboxMultiselectWidget changes
9402 this.checkboxMultiselectWidget.on( 'change', function () {
9403 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9404 }.bind( this ) );
9405 };
9406
9407 /* Setup */
9408
9409 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9410
9411 /* Static Methods */
9412
9413 /**
9414 * @inheritdoc
9415 */
9416 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9417 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9418 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9419 .toArray().map( function ( el ) { return el.value; } );
9420 return state;
9421 };
9422
9423 /**
9424 * @inheritdoc
9425 */
9426 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9427 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9428 // Cannot reuse the `<input type=checkbox>` set
9429 delete config.$input;
9430 return config;
9431 };
9432
9433 /* Methods */
9434
9435 /**
9436 * @inheritdoc
9437 * @protected
9438 */
9439 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9440 // Actually unused
9441 return $( '<unused>' );
9442 };
9443
9444 /**
9445 * @inheritdoc
9446 */
9447 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9448 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9449 .toArray().map( function ( el ) { return el.value; } );
9450 if ( this.value !== value ) {
9451 this.setValue( value );
9452 }
9453 return this.value;
9454 };
9455
9456 /**
9457 * @inheritdoc
9458 */
9459 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9460 value = this.cleanUpValue( value );
9461 this.checkboxMultiselectWidget.selectItemsByData( value );
9462 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9463 return this;
9464 };
9465
9466 /**
9467 * Clean up incoming value.
9468 *
9469 * @param {string[]} value Original value
9470 * @return {string[]} Cleaned up value
9471 */
9472 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9473 var i, singleValue,
9474 cleanValue = [];
9475 if ( !Array.isArray( value ) ) {
9476 return cleanValue;
9477 }
9478 for ( i = 0; i < value.length; i++ ) {
9479 singleValue =
9480 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9481 // Remove options that we don't have here
9482 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9483 continue;
9484 }
9485 cleanValue.push( singleValue );
9486 }
9487 return cleanValue;
9488 };
9489
9490 /**
9491 * @inheritdoc
9492 */
9493 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9494 this.checkboxMultiselectWidget.setDisabled( state );
9495 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9496 return this;
9497 };
9498
9499 /**
9500 * Set the options available for this input.
9501 *
9502 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9503 * @chainable
9504 */
9505 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9506 var widget = this;
9507
9508 // Rebuild the checkboxMultiselectWidget menu
9509 this.checkboxMultiselectWidget
9510 .clearItems()
9511 .addItems( options.map( function ( opt ) {
9512 var optValue, item, optDisabled;
9513 optValue =
9514 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9515 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9516 item = new OO.ui.CheckboxMultioptionWidget( {
9517 data: optValue,
9518 label: opt.label !== undefined ? opt.label : optValue,
9519 disabled: optDisabled
9520 } );
9521 // Set the 'name' and 'value' for form submission
9522 item.checkbox.$input.attr( 'name', widget.inputName );
9523 item.checkbox.setValue( optValue );
9524 return item;
9525 } ) );
9526
9527 // Re-set the value, checking the checkboxes as needed.
9528 // This will also get rid of any stale options that we just removed.
9529 this.setValue( this.getValue() );
9530
9531 return this;
9532 };
9533
9534 /**
9535 * @inheritdoc
9536 */
9537 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9538 this.checkboxMultiselectWidget.focus();
9539 return this;
9540 };
9541
9542 /**
9543 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9544 * size of the field as well as its presentation. In addition, these widgets can be configured
9545 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9546 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9547 * which modifies incoming values rather than validating them.
9548 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9549 *
9550 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9551 *
9552 * @example
9553 * // Example of a text input widget
9554 * var textInput = new OO.ui.TextInputWidget( {
9555 * value: 'Text input'
9556 * } )
9557 * $( 'body' ).append( textInput.$element );
9558 *
9559 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9560 *
9561 * @class
9562 * @extends OO.ui.InputWidget
9563 * @mixins OO.ui.mixin.IconElement
9564 * @mixins OO.ui.mixin.IndicatorElement
9565 * @mixins OO.ui.mixin.PendingElement
9566 * @mixins OO.ui.mixin.LabelElement
9567 *
9568 * @constructor
9569 * @param {Object} [config] Configuration options
9570 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9571 * 'email', 'url' or 'number'.
9572 * @cfg {string} [placeholder] Placeholder text
9573 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9574 * instruct the browser to focus this widget.
9575 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9576 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9577 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9578 * the value or placeholder text: `'before'` or `'after'`
9579 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9580 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9581 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9582 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9583 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9584 * value for it to be considered valid; when Function, a function receiving the value as parameter
9585 * that must return true, or promise resolving to true, for it to be considered valid.
9586 */
9587 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9588 // Configuration initialization
9589 config = $.extend( {
9590 type: 'text',
9591 labelPosition: 'after'
9592 }, config );
9593
9594 if ( config.multiline ) {
9595 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9596 return new OO.ui.MultilineTextInputWidget( config );
9597 }
9598
9599 // Parent constructor
9600 OO.ui.TextInputWidget.parent.call( this, config );
9601
9602 // Mixin constructors
9603 OO.ui.mixin.IconElement.call( this, config );
9604 OO.ui.mixin.IndicatorElement.call( this, config );
9605 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9606 OO.ui.mixin.LabelElement.call( this, config );
9607
9608 // Properties
9609 this.type = this.getSaneType( config );
9610 this.readOnly = false;
9611 this.required = false;
9612 this.validate = null;
9613 this.styleHeight = null;
9614 this.scrollWidth = null;
9615
9616 this.setValidation( config.validate );
9617 this.setLabelPosition( config.labelPosition );
9618
9619 // Events
9620 this.$input.on( {
9621 keypress: this.onKeyPress.bind( this ),
9622 blur: this.onBlur.bind( this ),
9623 focus: this.onFocus.bind( this )
9624 } );
9625 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9626 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9627 this.on( 'labelChange', this.updatePosition.bind( this ) );
9628 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9629
9630 // Initialization
9631 this.$element
9632 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9633 .append( this.$icon, this.$indicator );
9634 this.setReadOnly( !!config.readOnly );
9635 this.setRequired( !!config.required );
9636 if ( config.placeholder !== undefined ) {
9637 this.$input.attr( 'placeholder', config.placeholder );
9638 }
9639 if ( config.maxLength !== undefined ) {
9640 this.$input.attr( 'maxlength', config.maxLength );
9641 }
9642 if ( config.autofocus ) {
9643 this.$input.attr( 'autofocus', 'autofocus' );
9644 }
9645 if ( config.autocomplete === false ) {
9646 this.$input.attr( 'autocomplete', 'off' );
9647 // Turning off autocompletion also disables "form caching" when the user navigates to a
9648 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9649 $( window ).on( {
9650 beforeunload: function () {
9651 this.$input.removeAttr( 'autocomplete' );
9652 }.bind( this ),
9653 pageshow: function () {
9654 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9655 // whole page... it shouldn't hurt, though.
9656 this.$input.attr( 'autocomplete', 'off' );
9657 }.bind( this )
9658 } );
9659 }
9660 if ( this.label ) {
9661 this.isWaitingToBeAttached = true;
9662 this.installParentChangeDetector();
9663 }
9664 };
9665
9666 /* Setup */
9667
9668 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9669 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9670 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9671 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9672 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9673
9674 /* Static Properties */
9675
9676 OO.ui.TextInputWidget.static.validationPatterns = {
9677 'non-empty': /.+/,
9678 integer: /^\d+$/
9679 };
9680
9681 /* Static Methods */
9682
9683 /**
9684 * @inheritdoc
9685 */
9686 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9687 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9688 return state;
9689 };
9690
9691 /* Events */
9692
9693 /**
9694 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9695 *
9696 * @event enter
9697 */
9698
9699 /* Methods */
9700
9701 /**
9702 * Handle icon mouse down events.
9703 *
9704 * @private
9705 * @param {jQuery.Event} e Mouse down event
9706 */
9707 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
9708 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9709 this.focus();
9710 return false;
9711 }
9712 };
9713
9714 /**
9715 * Handle indicator mouse down events.
9716 *
9717 * @private
9718 * @param {jQuery.Event} e Mouse down event
9719 */
9720 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
9721 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9722 this.focus();
9723 return false;
9724 }
9725 };
9726
9727 /**
9728 * Handle key press events.
9729 *
9730 * @private
9731 * @param {jQuery.Event} e Key press event
9732 * @fires enter If enter key is pressed
9733 */
9734 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
9735 if ( e.which === OO.ui.Keys.ENTER ) {
9736 this.emit( 'enter', e );
9737 }
9738 };
9739
9740 /**
9741 * Handle blur events.
9742 *
9743 * @private
9744 * @param {jQuery.Event} e Blur event
9745 */
9746 OO.ui.TextInputWidget.prototype.onBlur = function () {
9747 this.setValidityFlag();
9748 };
9749
9750 /**
9751 * Handle focus events.
9752 *
9753 * @private
9754 * @param {jQuery.Event} e Focus event
9755 */
9756 OO.ui.TextInputWidget.prototype.onFocus = function () {
9757 if ( this.isWaitingToBeAttached ) {
9758 // If we've received focus, then we must be attached to the document, and if
9759 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
9760 this.onElementAttach();
9761 }
9762 this.setValidityFlag( true );
9763 };
9764
9765 /**
9766 * Handle element attach events.
9767 *
9768 * @private
9769 * @param {jQuery.Event} e Element attach event
9770 */
9771 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
9772 this.isWaitingToBeAttached = false;
9773 // Any previously calculated size is now probably invalid if we reattached elsewhere
9774 this.valCache = null;
9775 this.positionLabel();
9776 };
9777
9778 /**
9779 * Handle debounced change events.
9780 *
9781 * @param {string} value
9782 * @private
9783 */
9784 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
9785 this.setValidityFlag();
9786 };
9787
9788 /**
9789 * Check if the input is {@link #readOnly read-only}.
9790 *
9791 * @return {boolean}
9792 */
9793 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
9794 return this.readOnly;
9795 };
9796
9797 /**
9798 * Set the {@link #readOnly read-only} state of the input.
9799 *
9800 * @param {boolean} state Make input read-only
9801 * @chainable
9802 */
9803 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
9804 this.readOnly = !!state;
9805 this.$input.prop( 'readOnly', this.readOnly );
9806 return this;
9807 };
9808
9809 /**
9810 * Check if the input is {@link #required required}.
9811 *
9812 * @return {boolean}
9813 */
9814 OO.ui.TextInputWidget.prototype.isRequired = function () {
9815 return this.required;
9816 };
9817
9818 /**
9819 * Set the {@link #required required} state of the input.
9820 *
9821 * @param {boolean} state Make input required
9822 * @chainable
9823 */
9824 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
9825 this.required = !!state;
9826 if ( this.required ) {
9827 this.$input
9828 .prop( 'required', true )
9829 .attr( 'aria-required', 'true' );
9830 if ( this.getIndicator() === null ) {
9831 this.setIndicator( 'required' );
9832 }
9833 } else {
9834 this.$input
9835 .prop( 'required', false )
9836 .removeAttr( 'aria-required' );
9837 if ( this.getIndicator() === 'required' ) {
9838 this.setIndicator( null );
9839 }
9840 }
9841 return this;
9842 };
9843
9844 /**
9845 * Support function for making #onElementAttach work across browsers.
9846 *
9847 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
9848 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
9849 *
9850 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
9851 * first time that the element gets attached to the documented.
9852 */
9853 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
9854 var mutationObserver, onRemove, topmostNode, fakeParentNode,
9855 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
9856 widget = this;
9857
9858 if ( MutationObserver ) {
9859 // The new way. If only it wasn't so ugly.
9860
9861 if ( this.isElementAttached() ) {
9862 // Widget is attached already, do nothing. This breaks the functionality of this function when
9863 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
9864 // would require observation of the whole document, which would hurt performance of other,
9865 // more important code.
9866 return;
9867 }
9868
9869 // Find topmost node in the tree
9870 topmostNode = this.$element[ 0 ];
9871 while ( topmostNode.parentNode ) {
9872 topmostNode = topmostNode.parentNode;
9873 }
9874
9875 // We have no way to detect the $element being attached somewhere without observing the entire
9876 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
9877 // parent node of $element, and instead detect when $element is removed from it (and thus
9878 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
9879 // doesn't get attached, we end up back here and create the parent.
9880
9881 mutationObserver = new MutationObserver( function ( mutations ) {
9882 var i, j, removedNodes;
9883 for ( i = 0; i < mutations.length; i++ ) {
9884 removedNodes = mutations[ i ].removedNodes;
9885 for ( j = 0; j < removedNodes.length; j++ ) {
9886 if ( removedNodes[ j ] === topmostNode ) {
9887 setTimeout( onRemove, 0 );
9888 return;
9889 }
9890 }
9891 }
9892 } );
9893
9894 onRemove = function () {
9895 // If the node was attached somewhere else, report it
9896 if ( widget.isElementAttached() ) {
9897 widget.onElementAttach();
9898 }
9899 mutationObserver.disconnect();
9900 widget.installParentChangeDetector();
9901 };
9902
9903 // Create a fake parent and observe it
9904 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
9905 mutationObserver.observe( fakeParentNode, { childList: true } );
9906 } else {
9907 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
9908 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
9909 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
9910 }
9911 };
9912
9913 /**
9914 * @inheritdoc
9915 * @protected
9916 */
9917 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
9918 if ( this.getSaneType( config ) === 'number' ) {
9919 return $( '<input>' )
9920 .attr( 'step', 'any' )
9921 .attr( 'type', 'number' );
9922 } else {
9923 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
9924 }
9925 };
9926
9927 /**
9928 * Get sanitized value for 'type' for given config.
9929 *
9930 * @param {Object} config Configuration options
9931 * @return {string|null}
9932 * @private
9933 */
9934 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
9935 var allowedTypes = [
9936 'text',
9937 'password',
9938 'email',
9939 'url',
9940 'number'
9941 ];
9942 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
9943 };
9944
9945 /**
9946 * Focus the input and select a specified range within the text.
9947 *
9948 * @param {number} from Select from offset
9949 * @param {number} [to] Select to offset, defaults to from
9950 * @chainable
9951 */
9952 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
9953 var isBackwards, start, end,
9954 input = this.$input[ 0 ];
9955
9956 to = to || from;
9957
9958 isBackwards = to < from;
9959 start = isBackwards ? to : from;
9960 end = isBackwards ? from : to;
9961
9962 this.focus();
9963
9964 try {
9965 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
9966 } catch ( e ) {
9967 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
9968 // Rather than expensively check if the input is attached every time, just check
9969 // if it was the cause of an error being thrown. If not, rethrow the error.
9970 if ( this.getElementDocument().body.contains( input ) ) {
9971 throw e;
9972 }
9973 }
9974 return this;
9975 };
9976
9977 /**
9978 * Get an object describing the current selection range in a directional manner
9979 *
9980 * @return {Object} Object containing 'from' and 'to' offsets
9981 */
9982 OO.ui.TextInputWidget.prototype.getRange = function () {
9983 var input = this.$input[ 0 ],
9984 start = input.selectionStart,
9985 end = input.selectionEnd,
9986 isBackwards = input.selectionDirection === 'backward';
9987
9988 return {
9989 from: isBackwards ? end : start,
9990 to: isBackwards ? start : end
9991 };
9992 };
9993
9994 /**
9995 * Get the length of the text input value.
9996 *
9997 * This could differ from the length of #getValue if the
9998 * value gets filtered
9999 *
10000 * @return {number} Input length
10001 */
10002 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10003 return this.$input[ 0 ].value.length;
10004 };
10005
10006 /**
10007 * Focus the input and select the entire text.
10008 *
10009 * @chainable
10010 */
10011 OO.ui.TextInputWidget.prototype.select = function () {
10012 return this.selectRange( 0, this.getInputLength() );
10013 };
10014
10015 /**
10016 * Focus the input and move the cursor to the start.
10017 *
10018 * @chainable
10019 */
10020 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10021 return this.selectRange( 0 );
10022 };
10023
10024 /**
10025 * Focus the input and move the cursor to the end.
10026 *
10027 * @chainable
10028 */
10029 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10030 return this.selectRange( this.getInputLength() );
10031 };
10032
10033 /**
10034 * Insert new content into the input.
10035 *
10036 * @param {string} content Content to be inserted
10037 * @chainable
10038 */
10039 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10040 var start, end,
10041 range = this.getRange(),
10042 value = this.getValue();
10043
10044 start = Math.min( range.from, range.to );
10045 end = Math.max( range.from, range.to );
10046
10047 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10048 this.selectRange( start + content.length );
10049 return this;
10050 };
10051
10052 /**
10053 * Insert new content either side of a selection.
10054 *
10055 * @param {string} pre Content to be inserted before the selection
10056 * @param {string} post Content to be inserted after the selection
10057 * @chainable
10058 */
10059 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10060 var start, end,
10061 range = this.getRange(),
10062 offset = pre.length;
10063
10064 start = Math.min( range.from, range.to );
10065 end = Math.max( range.from, range.to );
10066
10067 this.selectRange( start ).insertContent( pre );
10068 this.selectRange( offset + end ).insertContent( post );
10069
10070 this.selectRange( offset + start, offset + end );
10071 return this;
10072 };
10073
10074 /**
10075 * Set the validation pattern.
10076 *
10077 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10078 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10079 * value must contain only numbers).
10080 *
10081 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10082 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10083 */
10084 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10085 if ( validate instanceof RegExp || validate instanceof Function ) {
10086 this.validate = validate;
10087 } else {
10088 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10089 }
10090 };
10091
10092 /**
10093 * Sets the 'invalid' flag appropriately.
10094 *
10095 * @param {boolean} [isValid] Optionally override validation result
10096 */
10097 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10098 var widget = this,
10099 setFlag = function ( valid ) {
10100 if ( !valid ) {
10101 widget.$input.attr( 'aria-invalid', 'true' );
10102 } else {
10103 widget.$input.removeAttr( 'aria-invalid' );
10104 }
10105 widget.setFlags( { invalid: !valid } );
10106 };
10107
10108 if ( isValid !== undefined ) {
10109 setFlag( isValid );
10110 } else {
10111 this.getValidity().then( function () {
10112 setFlag( true );
10113 }, function () {
10114 setFlag( false );
10115 } );
10116 }
10117 };
10118
10119 /**
10120 * Get the validity of current value.
10121 *
10122 * This method returns a promise that resolves if the value is valid and rejects if
10123 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10124 *
10125 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10126 */
10127 OO.ui.TextInputWidget.prototype.getValidity = function () {
10128 var result;
10129
10130 function rejectOrResolve( valid ) {
10131 if ( valid ) {
10132 return $.Deferred().resolve().promise();
10133 } else {
10134 return $.Deferred().reject().promise();
10135 }
10136 }
10137
10138 // Check browser validity and reject if it is invalid
10139 if (
10140 this.$input[ 0 ].checkValidity !== undefined &&
10141 this.$input[ 0 ].checkValidity() === false
10142 ) {
10143 return rejectOrResolve( false );
10144 }
10145
10146 // Run our checks if the browser thinks the field is valid
10147 if ( this.validate instanceof Function ) {
10148 result = this.validate( this.getValue() );
10149 if ( result && $.isFunction( result.promise ) ) {
10150 return result.promise().then( function ( valid ) {
10151 return rejectOrResolve( valid );
10152 } );
10153 } else {
10154 return rejectOrResolve( result );
10155 }
10156 } else {
10157 return rejectOrResolve( this.getValue().match( this.validate ) );
10158 }
10159 };
10160
10161 /**
10162 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10163 *
10164 * @param {string} labelPosition Label position, 'before' or 'after'
10165 * @chainable
10166 */
10167 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10168 this.labelPosition = labelPosition;
10169 if ( this.label ) {
10170 // If there is no label and we only change the position, #updatePosition is a no-op,
10171 // but it takes really a lot of work to do nothing.
10172 this.updatePosition();
10173 }
10174 return this;
10175 };
10176
10177 /**
10178 * Update the position of the inline label.
10179 *
10180 * This method is called by #setLabelPosition, and can also be called on its own if
10181 * something causes the label to be mispositioned.
10182 *
10183 * @chainable
10184 */
10185 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10186 var after = this.labelPosition === 'after';
10187
10188 this.$element
10189 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10190 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10191
10192 this.valCache = null;
10193 this.scrollWidth = null;
10194 this.positionLabel();
10195
10196 return this;
10197 };
10198
10199 /**
10200 * Position the label by setting the correct padding on the input.
10201 *
10202 * @private
10203 * @chainable
10204 */
10205 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10206 var after, rtl, property, newCss;
10207
10208 if ( this.isWaitingToBeAttached ) {
10209 // #onElementAttach will be called soon, which calls this method
10210 return this;
10211 }
10212
10213 newCss = {
10214 'padding-right': '',
10215 'padding-left': ''
10216 };
10217
10218 if ( this.label ) {
10219 this.$element.append( this.$label );
10220 } else {
10221 this.$label.detach();
10222 // Clear old values if present
10223 this.$input.css( newCss );
10224 return;
10225 }
10226
10227 after = this.labelPosition === 'after';
10228 rtl = this.$element.css( 'direction' ) === 'rtl';
10229 property = after === rtl ? 'padding-left' : 'padding-right';
10230
10231 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10232 // We have to clear the padding on the other side, in case the element direction changed
10233 this.$input.css( newCss );
10234
10235 return this;
10236 };
10237
10238 /**
10239 * @inheritdoc
10240 */
10241 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10242 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10243 if ( state.scrollTop !== undefined ) {
10244 this.$input.scrollTop( state.scrollTop );
10245 }
10246 };
10247
10248 /**
10249 * @class
10250 * @extends OO.ui.TextInputWidget
10251 *
10252 * @constructor
10253 * @param {Object} [config] Configuration options
10254 */
10255 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10256 config = $.extend( {
10257 icon: 'search'
10258 }, config );
10259
10260 // Set type to text so that TextInputWidget doesn't
10261 // get stuck in an infinite loop.
10262 config.type = 'text';
10263
10264 // Parent constructor
10265 OO.ui.SearchInputWidget.parent.call( this, config );
10266
10267 // Events
10268 this.connect( this, {
10269 change: 'onChange'
10270 } );
10271
10272 // Initialization
10273 this.$element.addClass( 'oo-ui-textInputWidget-type-search' );
10274 this.updateSearchIndicator();
10275 this.connect( this, {
10276 disable: 'onDisable'
10277 } );
10278 };
10279
10280 /* Setup */
10281
10282 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10283
10284 /* Methods */
10285
10286 /**
10287 * @inheritdoc
10288 * @protected
10289 */
10290 OO.ui.SearchInputWidget.prototype.getInputElement = function () {
10291 return $( '<input>' ).attr( 'type', 'search' );
10292 };
10293
10294 /**
10295 * @inheritdoc
10296 */
10297 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10298 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10299 // Clear the text field
10300 this.setValue( '' );
10301 this.focus();
10302 return false;
10303 }
10304 };
10305
10306 /**
10307 * Update the 'clear' indicator displayed on type: 'search' text
10308 * fields, hiding it when the field is already empty or when it's not
10309 * editable.
10310 */
10311 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10312 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10313 this.setIndicator( null );
10314 } else {
10315 this.setIndicator( 'clear' );
10316 }
10317 };
10318
10319 /**
10320 * Handle change events.
10321 *
10322 * @private
10323 */
10324 OO.ui.SearchInputWidget.prototype.onChange = function () {
10325 this.updateSearchIndicator();
10326 };
10327
10328 /**
10329 * Handle disable events.
10330 *
10331 * @param {boolean} disabled Element is disabled
10332 * @private
10333 */
10334 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10335 this.updateSearchIndicator();
10336 };
10337
10338 /**
10339 * @inheritdoc
10340 */
10341 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10342 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10343 this.updateSearchIndicator();
10344 return this;
10345 };
10346
10347 /**
10348 * @class
10349 * @extends OO.ui.TextInputWidget
10350 *
10351 * @constructor
10352 * @param {Object} [config] Configuration options
10353 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10354 * specifies minimum number of rows to display.
10355 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10356 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10357 * Use the #maxRows config to specify a maximum number of displayed rows.
10358 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10359 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10360 */
10361 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10362 config = $.extend( {
10363 type: 'text'
10364 }, config );
10365 config.multiline = false;
10366 // Parent constructor
10367 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10368
10369 // Properties
10370 this.multiline = true;
10371 this.autosize = !!config.autosize;
10372 this.minRows = config.rows !== undefined ? config.rows : '';
10373 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10374
10375 // Clone for resizing
10376 if ( this.autosize ) {
10377 this.$clone = this.$input
10378 .clone()
10379 .insertAfter( this.$input )
10380 .attr( 'aria-hidden', 'true' )
10381 .addClass( 'oo-ui-element-hidden' );
10382 }
10383
10384 // Events
10385 this.connect( this, {
10386 change: 'onChange'
10387 } );
10388
10389 // Initialization
10390 if ( this.multiline && config.rows ) {
10391 this.$input.attr( 'rows', config.rows );
10392 }
10393 if ( this.autosize ) {
10394 this.isWaitingToBeAttached = true;
10395 this.installParentChangeDetector();
10396 }
10397 };
10398
10399 /* Setup */
10400
10401 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10402
10403 /* Static Methods */
10404
10405 /**
10406 * @inheritdoc
10407 */
10408 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10409 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10410 state.scrollTop = config.$input.scrollTop();
10411 return state;
10412 };
10413
10414 /* Methods */
10415
10416 /**
10417 * @inheritdoc
10418 */
10419 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10420 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10421 this.adjustSize();
10422 };
10423
10424 /**
10425 * Handle change events.
10426 *
10427 * @private
10428 */
10429 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10430 this.adjustSize();
10431 };
10432
10433 /**
10434 * @inheritdoc
10435 */
10436 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10437 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10438 this.adjustSize();
10439 };
10440
10441 /**
10442 * Override TextInputWidget so it doesn't emit the 'enter' event.
10443 *
10444 * @private
10445 * @param {jQuery.Event} e Key press event
10446 */
10447 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10448 return;
10449 };
10450
10451 /**
10452 * Automatically adjust the size of the text input.
10453 *
10454 * This only affects multiline inputs that are {@link #autosize autosized}.
10455 *
10456 * @chainable
10457 * @fires resize
10458 */
10459 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10460 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10461 idealHeight, newHeight, scrollWidth, property;
10462
10463 if ( this.$input.val() !== this.valCache ) {
10464 if ( this.autosize ) {
10465 this.$clone
10466 .val( this.$input.val() )
10467 .attr( 'rows', this.minRows )
10468 // Set inline height property to 0 to measure scroll height
10469 .css( 'height', 0 );
10470
10471 this.$clone.removeClass( 'oo-ui-element-hidden' );
10472
10473 this.valCache = this.$input.val();
10474
10475 scrollHeight = this.$clone[ 0 ].scrollHeight;
10476
10477 // Remove inline height property to measure natural heights
10478 this.$clone.css( 'height', '' );
10479 innerHeight = this.$clone.innerHeight();
10480 outerHeight = this.$clone.outerHeight();
10481
10482 // Measure max rows height
10483 this.$clone
10484 .attr( 'rows', this.maxRows )
10485 .css( 'height', 'auto' )
10486 .val( '' );
10487 maxInnerHeight = this.$clone.innerHeight();
10488
10489 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10490 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10491 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10492 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10493
10494 this.$clone.addClass( 'oo-ui-element-hidden' );
10495
10496 // Only apply inline height when expansion beyond natural height is needed
10497 // Use the difference between the inner and outer height as a buffer
10498 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10499 if ( newHeight !== this.styleHeight ) {
10500 this.$input.css( 'height', newHeight );
10501 this.styleHeight = newHeight;
10502 this.emit( 'resize' );
10503 }
10504 }
10505 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10506 if ( scrollWidth !== this.scrollWidth ) {
10507 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10508 // Reset
10509 this.$label.css( { right: '', left: '' } );
10510 this.$indicator.css( { right: '', left: '' } );
10511
10512 if ( scrollWidth ) {
10513 this.$indicator.css( property, scrollWidth );
10514 if ( this.labelPosition === 'after' ) {
10515 this.$label.css( property, scrollWidth );
10516 }
10517 }
10518
10519 this.scrollWidth = scrollWidth;
10520 this.positionLabel();
10521 }
10522 }
10523 return this;
10524 };
10525
10526 /**
10527 * @inheritdoc
10528 * @protected
10529 */
10530 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10531 return $( '<textarea>' );
10532 };
10533
10534 /**
10535 * Check if the input supports multiple lines.
10536 *
10537 * @return {boolean}
10538 */
10539 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10540 return !!this.multiline;
10541 };
10542
10543 /**
10544 * Check if the input automatically adjusts its size.
10545 *
10546 * @return {boolean}
10547 */
10548 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10549 return !!this.autosize;
10550 };
10551
10552 /**
10553 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10554 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10555 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10556 *
10557 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10558 * option, that option will appear to be selected.
10559 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10560 * input field.
10561 *
10562 * After the user chooses an option, its `data` will be used as a new value for the widget.
10563 * A `label` also can be specified for each option: if given, it will be shown instead of the
10564 * `data` in the dropdown menu.
10565 *
10566 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10567 *
10568 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10569 *
10570 * @example
10571 * // Example: A ComboBoxInputWidget.
10572 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10573 * value: 'Option 1',
10574 * options: [
10575 * { data: 'Option 1' },
10576 * { data: 'Option 2' },
10577 * { data: 'Option 3' }
10578 * ]
10579 * } );
10580 * $( 'body' ).append( comboBox.$element );
10581 *
10582 * @example
10583 * // Example: A ComboBoxInputWidget with additional option labels.
10584 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10585 * value: 'Option 1',
10586 * options: [
10587 * {
10588 * data: 'Option 1',
10589 * label: 'Option One'
10590 * },
10591 * {
10592 * data: 'Option 2',
10593 * label: 'Option Two'
10594 * },
10595 * {
10596 * data: 'Option 3',
10597 * label: 'Option Three'
10598 * }
10599 * ]
10600 * } );
10601 * $( 'body' ).append( comboBox.$element );
10602 *
10603 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10604 *
10605 * @class
10606 * @extends OO.ui.TextInputWidget
10607 *
10608 * @constructor
10609 * @param {Object} [config] Configuration options
10610 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10611 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10612 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10613 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10614 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10615 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10616 */
10617 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10618 // Configuration initialization
10619 config = $.extend( {
10620 autocomplete: false
10621 }, config );
10622
10623 // ComboBoxInputWidget shouldn't support `multiline`
10624 config.multiline = false;
10625
10626 // See InputWidget#reusePreInfuseDOM about `config.$input`
10627 if ( config.$input ) {
10628 config.$input.removeAttr( 'list' );
10629 }
10630
10631 // Parent constructor
10632 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10633
10634 // Properties
10635 this.$overlay = config.$overlay || this.$element;
10636 this.dropdownButton = new OO.ui.ButtonWidget( {
10637 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10638 indicator: 'down',
10639 disabled: this.disabled
10640 } );
10641 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10642 {
10643 widget: this,
10644 input: this,
10645 $floatableContainer: this.$element,
10646 disabled: this.isDisabled()
10647 },
10648 config.menu
10649 ) );
10650
10651 // Events
10652 this.connect( this, {
10653 change: 'onInputChange',
10654 enter: 'onInputEnter'
10655 } );
10656 this.dropdownButton.connect( this, {
10657 click: 'onDropdownButtonClick'
10658 } );
10659 this.menu.connect( this, {
10660 choose: 'onMenuChoose',
10661 add: 'onMenuItemsChange',
10662 remove: 'onMenuItemsChange'
10663 } );
10664
10665 // Initialization
10666 this.$input.attr( {
10667 role: 'combobox',
10668 'aria-owns': this.menu.getElementId(),
10669 'aria-autocomplete': 'list'
10670 } );
10671 // Do not override options set via config.menu.items
10672 if ( config.options !== undefined ) {
10673 this.setOptions( config.options );
10674 }
10675 this.$field = $( '<div>' )
10676 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10677 .append( this.$input, this.dropdownButton.$element );
10678 this.$element
10679 .addClass( 'oo-ui-comboBoxInputWidget' )
10680 .append( this.$field );
10681 this.$overlay.append( this.menu.$element );
10682 this.onMenuItemsChange();
10683 };
10684
10685 /* Setup */
10686
10687 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10688
10689 /* Methods */
10690
10691 /**
10692 * Get the combobox's menu.
10693 *
10694 * @return {OO.ui.MenuSelectWidget} Menu widget
10695 */
10696 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10697 return this.menu;
10698 };
10699
10700 /**
10701 * Get the combobox's text input widget.
10702 *
10703 * @return {OO.ui.TextInputWidget} Text input widget
10704 */
10705 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10706 return this;
10707 };
10708
10709 /**
10710 * Handle input change events.
10711 *
10712 * @private
10713 * @param {string} value New value
10714 */
10715 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
10716 var match = this.menu.getItemFromData( value );
10717
10718 this.menu.selectItem( match );
10719 if ( this.menu.findHighlightedItem() ) {
10720 this.menu.highlightItem( match );
10721 }
10722
10723 if ( !this.isDisabled() ) {
10724 this.menu.toggle( true );
10725 }
10726 };
10727
10728 /**
10729 * Handle input enter events.
10730 *
10731 * @private
10732 */
10733 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
10734 if ( !this.isDisabled() ) {
10735 this.menu.toggle( false );
10736 }
10737 };
10738
10739 /**
10740 * Handle button click events.
10741 *
10742 * @private
10743 */
10744 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
10745 this.menu.toggle();
10746 this.focus();
10747 };
10748
10749 /**
10750 * Handle menu choose events.
10751 *
10752 * @private
10753 * @param {OO.ui.OptionWidget} item Chosen item
10754 */
10755 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
10756 this.setValue( item.getData() );
10757 };
10758
10759 /**
10760 * Handle menu item change events.
10761 *
10762 * @private
10763 */
10764 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
10765 var match = this.menu.getItemFromData( this.getValue() );
10766 this.menu.selectItem( match );
10767 if ( this.menu.findHighlightedItem() ) {
10768 this.menu.highlightItem( match );
10769 }
10770 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
10771 };
10772
10773 /**
10774 * @inheritdoc
10775 */
10776 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
10777 // Parent method
10778 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
10779
10780 if ( this.dropdownButton ) {
10781 this.dropdownButton.setDisabled( this.isDisabled() );
10782 }
10783 if ( this.menu ) {
10784 this.menu.setDisabled( this.isDisabled() );
10785 }
10786
10787 return this;
10788 };
10789
10790 /**
10791 * Set the options available for this input.
10792 *
10793 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10794 * @chainable
10795 */
10796 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
10797 this.getMenu()
10798 .clearItems()
10799 .addItems( options.map( function ( opt ) {
10800 return new OO.ui.MenuOptionWidget( {
10801 data: opt.data,
10802 label: opt.label !== undefined ? opt.label : opt.data
10803 } );
10804 } ) );
10805
10806 return this;
10807 };
10808
10809 /**
10810 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
10811 * which is a widget that is specified by reference before any optional configuration settings.
10812 *
10813 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
10814 *
10815 * - **left**: The label is placed before the field-widget and aligned with the left margin.
10816 * A left-alignment is used for forms with many fields.
10817 * - **right**: The label is placed before the field-widget and aligned to the right margin.
10818 * A right-alignment is used for long but familiar forms which users tab through,
10819 * verifying the current field with a quick glance at the label.
10820 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
10821 * that users fill out from top to bottom.
10822 * - **inline**: The label is placed after the field-widget and aligned to the left.
10823 * An inline-alignment is best used with checkboxes or radio buttons.
10824 *
10825 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
10826 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
10827 *
10828 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
10829 *
10830 * @class
10831 * @extends OO.ui.Layout
10832 * @mixins OO.ui.mixin.LabelElement
10833 * @mixins OO.ui.mixin.TitledElement
10834 *
10835 * @constructor
10836 * @param {OO.ui.Widget} fieldWidget Field widget
10837 * @param {Object} [config] Configuration options
10838 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
10839 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
10840 * The array may contain strings or OO.ui.HtmlSnippet instances.
10841 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
10842 * The array may contain strings or OO.ui.HtmlSnippet instances.
10843 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
10844 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
10845 * For important messages, you are advised to use `notices`, as they are always shown.
10846 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
10847 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10848 *
10849 * @throws {Error} An error is thrown if no widget is specified
10850 */
10851 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
10852 // Allow passing positional parameters inside the config object
10853 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
10854 config = fieldWidget;
10855 fieldWidget = config.fieldWidget;
10856 }
10857
10858 // Make sure we have required constructor arguments
10859 if ( fieldWidget === undefined ) {
10860 throw new Error( 'Widget not found' );
10861 }
10862
10863 // Configuration initialization
10864 config = $.extend( { align: 'left' }, config );
10865
10866 // Parent constructor
10867 OO.ui.FieldLayout.parent.call( this, config );
10868
10869 // Mixin constructors
10870 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
10871 $label: $( '<label>' )
10872 } ) );
10873 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
10874
10875 // Properties
10876 this.fieldWidget = fieldWidget;
10877 this.errors = [];
10878 this.notices = [];
10879 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
10880 this.$messages = $( '<ul>' );
10881 this.$header = $( '<span>' );
10882 this.$body = $( '<div>' );
10883 this.align = null;
10884 if ( config.help ) {
10885 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
10886 $overlay: config.$overlay,
10887 popup: {
10888 padded: true
10889 },
10890 classes: [ 'oo-ui-fieldLayout-help' ],
10891 framed: false,
10892 icon: 'info'
10893 } );
10894 if ( config.help instanceof OO.ui.HtmlSnippet ) {
10895 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
10896 } else {
10897 this.popupButtonWidget.getPopup().$body.text( config.help );
10898 }
10899 this.$help = this.popupButtonWidget.$element;
10900 } else {
10901 this.$help = $( [] );
10902 }
10903
10904 // Events
10905 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
10906
10907 // Initialization
10908 if ( config.help ) {
10909 // Set the 'aria-describedby' attribute on the fieldWidget
10910 // Preference given to an input or a button
10911 (
10912 this.fieldWidget.$input ||
10913 this.fieldWidget.$button ||
10914 this.fieldWidget.$element
10915 ).attr(
10916 'aria-describedby',
10917 this.popupButtonWidget.getPopup().getBodyId()
10918 );
10919 }
10920 if ( this.fieldWidget.getInputId() ) {
10921 this.$label.attr( 'for', this.fieldWidget.getInputId() );
10922 } else {
10923 this.$label.on( 'click', function () {
10924 this.fieldWidget.simulateLabelClick();
10925 return false;
10926 }.bind( this ) );
10927 }
10928 this.$element
10929 .addClass( 'oo-ui-fieldLayout' )
10930 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
10931 .append( this.$body );
10932 this.$body.addClass( 'oo-ui-fieldLayout-body' );
10933 this.$header.addClass( 'oo-ui-fieldLayout-header' );
10934 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
10935 this.$field
10936 .addClass( 'oo-ui-fieldLayout-field' )
10937 .append( this.fieldWidget.$element );
10938
10939 this.setErrors( config.errors || [] );
10940 this.setNotices( config.notices || [] );
10941 this.setAlignment( config.align );
10942 // Call this again to take into account the widget's accessKey
10943 this.updateTitle();
10944 };
10945
10946 /* Setup */
10947
10948 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
10949 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
10950 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
10951
10952 /* Methods */
10953
10954 /**
10955 * Handle field disable events.
10956 *
10957 * @private
10958 * @param {boolean} value Field is disabled
10959 */
10960 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
10961 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
10962 };
10963
10964 /**
10965 * Get the widget contained by the field.
10966 *
10967 * @return {OO.ui.Widget} Field widget
10968 */
10969 OO.ui.FieldLayout.prototype.getField = function () {
10970 return this.fieldWidget;
10971 };
10972
10973 /**
10974 * Return `true` if the given field widget can be used with `'inline'` alignment (see
10975 * #setAlignment). Return `false` if it can't or if this can't be determined.
10976 *
10977 * @return {boolean}
10978 */
10979 OO.ui.FieldLayout.prototype.isFieldInline = function () {
10980 // This is very simplistic, but should be good enough.
10981 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
10982 };
10983
10984 /**
10985 * @protected
10986 * @param {string} kind 'error' or 'notice'
10987 * @param {string|OO.ui.HtmlSnippet} text
10988 * @return {jQuery}
10989 */
10990 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
10991 var $listItem, $icon, message;
10992 $listItem = $( '<li>' );
10993 if ( kind === 'error' ) {
10994 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
10995 $listItem.attr( 'role', 'alert' );
10996 } else if ( kind === 'notice' ) {
10997 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
10998 } else {
10999 $icon = '';
11000 }
11001 message = new OO.ui.LabelWidget( { label: text } );
11002 $listItem
11003 .append( $icon, message.$element )
11004 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11005 return $listItem;
11006 };
11007
11008 /**
11009 * Set the field alignment mode.
11010 *
11011 * @private
11012 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11013 * @chainable
11014 */
11015 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11016 if ( value !== this.align ) {
11017 // Default to 'left'
11018 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11019 value = 'left';
11020 }
11021 // Validate
11022 if ( value === 'inline' && !this.isFieldInline() ) {
11023 value = 'top';
11024 }
11025 // Reorder elements
11026 if ( value === 'top' ) {
11027 this.$header.append( this.$label, this.$help );
11028 this.$body.append( this.$header, this.$field );
11029 } else if ( value === 'inline' ) {
11030 this.$header.append( this.$label, this.$help );
11031 this.$body.append( this.$field, this.$header );
11032 } else {
11033 this.$header.append( this.$label );
11034 this.$body.append( this.$header, this.$help, this.$field );
11035 }
11036 // Set classes. The following classes can be used here:
11037 // * oo-ui-fieldLayout-align-left
11038 // * oo-ui-fieldLayout-align-right
11039 // * oo-ui-fieldLayout-align-top
11040 // * oo-ui-fieldLayout-align-inline
11041 if ( this.align ) {
11042 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11043 }
11044 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11045 this.align = value;
11046 }
11047
11048 return this;
11049 };
11050
11051 /**
11052 * Set the list of error messages.
11053 *
11054 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11055 * The array may contain strings or OO.ui.HtmlSnippet instances.
11056 * @chainable
11057 */
11058 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11059 this.errors = errors.slice();
11060 this.updateMessages();
11061 return this;
11062 };
11063
11064 /**
11065 * Set the list of notice messages.
11066 *
11067 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11068 * The array may contain strings or OO.ui.HtmlSnippet instances.
11069 * @chainable
11070 */
11071 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11072 this.notices = notices.slice();
11073 this.updateMessages();
11074 return this;
11075 };
11076
11077 /**
11078 * Update the rendering of error and notice messages.
11079 *
11080 * @private
11081 */
11082 OO.ui.FieldLayout.prototype.updateMessages = function () {
11083 var i;
11084 this.$messages.empty();
11085
11086 if ( this.errors.length || this.notices.length ) {
11087 this.$body.after( this.$messages );
11088 } else {
11089 this.$messages.remove();
11090 return;
11091 }
11092
11093 for ( i = 0; i < this.notices.length; i++ ) {
11094 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11095 }
11096 for ( i = 0; i < this.errors.length; i++ ) {
11097 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11098 }
11099 };
11100
11101 /**
11102 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11103 * (This is a bit of a hack.)
11104 *
11105 * @protected
11106 * @param {string} title Tooltip label for 'title' attribute
11107 * @return {string}
11108 */
11109 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11110 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11111 return this.fieldWidget.formatTitleWithAccessKey( title );
11112 }
11113 return title;
11114 };
11115
11116 /**
11117 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11118 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11119 * is required and is specified before any optional configuration settings.
11120 *
11121 * Labels can be aligned in one of four ways:
11122 *
11123 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11124 * A left-alignment is used for forms with many fields.
11125 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11126 * A right-alignment is used for long but familiar forms which users tab through,
11127 * verifying the current field with a quick glance at the label.
11128 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11129 * that users fill out from top to bottom.
11130 * - **inline**: The label is placed after the field-widget and aligned to the left.
11131 * An inline-alignment is best used with checkboxes or radio buttons.
11132 *
11133 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11134 * text is specified.
11135 *
11136 * @example
11137 * // Example of an ActionFieldLayout
11138 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11139 * new OO.ui.TextInputWidget( {
11140 * placeholder: 'Field widget'
11141 * } ),
11142 * new OO.ui.ButtonWidget( {
11143 * label: 'Button'
11144 * } ),
11145 * {
11146 * label: 'An ActionFieldLayout. This label is aligned top',
11147 * align: 'top',
11148 * help: 'This is help text'
11149 * }
11150 * );
11151 *
11152 * $( 'body' ).append( actionFieldLayout.$element );
11153 *
11154 * @class
11155 * @extends OO.ui.FieldLayout
11156 *
11157 * @constructor
11158 * @param {OO.ui.Widget} fieldWidget Field widget
11159 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11160 * @param {Object} config
11161 */
11162 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11163 // Allow passing positional parameters inside the config object
11164 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11165 config = fieldWidget;
11166 fieldWidget = config.fieldWidget;
11167 buttonWidget = config.buttonWidget;
11168 }
11169
11170 // Parent constructor
11171 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11172
11173 // Properties
11174 this.buttonWidget = buttonWidget;
11175 this.$button = $( '<span>' );
11176 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11177
11178 // Initialization
11179 this.$element
11180 .addClass( 'oo-ui-actionFieldLayout' );
11181 this.$button
11182 .addClass( 'oo-ui-actionFieldLayout-button' )
11183 .append( this.buttonWidget.$element );
11184 this.$input
11185 .addClass( 'oo-ui-actionFieldLayout-input' )
11186 .append( this.fieldWidget.$element );
11187 this.$field
11188 .append( this.$input, this.$button );
11189 };
11190
11191 /* Setup */
11192
11193 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11194
11195 /**
11196 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11197 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11198 * configured with a label as well. For more information and examples,
11199 * please see the [OOjs UI documentation on MediaWiki][1].
11200 *
11201 * @example
11202 * // Example of a fieldset layout
11203 * var input1 = new OO.ui.TextInputWidget( {
11204 * placeholder: 'A text input field'
11205 * } );
11206 *
11207 * var input2 = new OO.ui.TextInputWidget( {
11208 * placeholder: 'A text input field'
11209 * } );
11210 *
11211 * var fieldset = new OO.ui.FieldsetLayout( {
11212 * label: 'Example of a fieldset layout'
11213 * } );
11214 *
11215 * fieldset.addItems( [
11216 * new OO.ui.FieldLayout( input1, {
11217 * label: 'Field One'
11218 * } ),
11219 * new OO.ui.FieldLayout( input2, {
11220 * label: 'Field Two'
11221 * } )
11222 * ] );
11223 * $( 'body' ).append( fieldset.$element );
11224 *
11225 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11226 *
11227 * @class
11228 * @extends OO.ui.Layout
11229 * @mixins OO.ui.mixin.IconElement
11230 * @mixins OO.ui.mixin.LabelElement
11231 * @mixins OO.ui.mixin.GroupElement
11232 *
11233 * @constructor
11234 * @param {Object} [config] Configuration options
11235 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11236 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11237 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11238 * For important messages, you are advised to use `notices`, as they are always shown.
11239 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11240 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11241 */
11242 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11243 // Configuration initialization
11244 config = config || {};
11245
11246 // Parent constructor
11247 OO.ui.FieldsetLayout.parent.call( this, config );
11248
11249 // Mixin constructors
11250 OO.ui.mixin.IconElement.call( this, config );
11251 OO.ui.mixin.LabelElement.call( this, config );
11252 OO.ui.mixin.GroupElement.call( this, config );
11253
11254 // Properties
11255 this.$header = $( '<legend>' );
11256 if ( config.help ) {
11257 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11258 $overlay: config.$overlay,
11259 popup: {
11260 padded: true
11261 },
11262 classes: [ 'oo-ui-fieldsetLayout-help' ],
11263 framed: false,
11264 icon: 'info'
11265 } );
11266 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11267 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11268 } else {
11269 this.popupButtonWidget.getPopup().$body.text( config.help );
11270 }
11271 this.$help = this.popupButtonWidget.$element;
11272 } else {
11273 this.$help = $( [] );
11274 }
11275
11276 // Initialization
11277 this.$header
11278 .addClass( 'oo-ui-fieldsetLayout-header' )
11279 .append( this.$icon, this.$label, this.$help );
11280 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11281 this.$element
11282 .addClass( 'oo-ui-fieldsetLayout' )
11283 .prepend( this.$header, this.$group );
11284 if ( Array.isArray( config.items ) ) {
11285 this.addItems( config.items );
11286 }
11287 };
11288
11289 /* Setup */
11290
11291 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11292 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11293 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11294 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11295
11296 /* Static Properties */
11297
11298 /**
11299 * @static
11300 * @inheritdoc
11301 */
11302 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11303
11304 /**
11305 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11306 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11307 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11308 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11309 *
11310 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11311 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11312 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11313 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11314 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11315 * often have simplified APIs to match the capabilities of HTML forms.
11316 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11317 *
11318 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11319 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11320 *
11321 * @example
11322 * // Example of a form layout that wraps a fieldset layout
11323 * var input1 = new OO.ui.TextInputWidget( {
11324 * placeholder: 'Username'
11325 * } );
11326 * var input2 = new OO.ui.TextInputWidget( {
11327 * placeholder: 'Password',
11328 * type: 'password'
11329 * } );
11330 * var submit = new OO.ui.ButtonInputWidget( {
11331 * label: 'Submit'
11332 * } );
11333 *
11334 * var fieldset = new OO.ui.FieldsetLayout( {
11335 * label: 'A form layout'
11336 * } );
11337 * fieldset.addItems( [
11338 * new OO.ui.FieldLayout( input1, {
11339 * label: 'Username',
11340 * align: 'top'
11341 * } ),
11342 * new OO.ui.FieldLayout( input2, {
11343 * label: 'Password',
11344 * align: 'top'
11345 * } ),
11346 * new OO.ui.FieldLayout( submit )
11347 * ] );
11348 * var form = new OO.ui.FormLayout( {
11349 * items: [ fieldset ],
11350 * action: '/api/formhandler',
11351 * method: 'get'
11352 * } )
11353 * $( 'body' ).append( form.$element );
11354 *
11355 * @class
11356 * @extends OO.ui.Layout
11357 * @mixins OO.ui.mixin.GroupElement
11358 *
11359 * @constructor
11360 * @param {Object} [config] Configuration options
11361 * @cfg {string} [method] HTML form `method` attribute
11362 * @cfg {string} [action] HTML form `action` attribute
11363 * @cfg {string} [enctype] HTML form `enctype` attribute
11364 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11365 */
11366 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11367 var action;
11368
11369 // Configuration initialization
11370 config = config || {};
11371
11372 // Parent constructor
11373 OO.ui.FormLayout.parent.call( this, config );
11374
11375 // Mixin constructors
11376 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11377
11378 // Events
11379 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11380
11381 // Make sure the action is safe
11382 action = config.action;
11383 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11384 action = './' + action;
11385 }
11386
11387 // Initialization
11388 this.$element
11389 .addClass( 'oo-ui-formLayout' )
11390 .attr( {
11391 method: config.method,
11392 action: action,
11393 enctype: config.enctype
11394 } );
11395 if ( Array.isArray( config.items ) ) {
11396 this.addItems( config.items );
11397 }
11398 };
11399
11400 /* Setup */
11401
11402 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11403 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11404
11405 /* Events */
11406
11407 /**
11408 * A 'submit' event is emitted when the form is submitted.
11409 *
11410 * @event submit
11411 */
11412
11413 /* Static Properties */
11414
11415 /**
11416 * @static
11417 * @inheritdoc
11418 */
11419 OO.ui.FormLayout.static.tagName = 'form';
11420
11421 /* Methods */
11422
11423 /**
11424 * Handle form submit events.
11425 *
11426 * @private
11427 * @param {jQuery.Event} e Submit event
11428 * @fires submit
11429 */
11430 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11431 if ( this.emit( 'submit' ) ) {
11432 return false;
11433 }
11434 };
11435
11436 /**
11437 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11438 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11439 *
11440 * @example
11441 * // Example of a panel layout
11442 * var panel = new OO.ui.PanelLayout( {
11443 * expanded: false,
11444 * framed: true,
11445 * padded: true,
11446 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11447 * } );
11448 * $( 'body' ).append( panel.$element );
11449 *
11450 * @class
11451 * @extends OO.ui.Layout
11452 *
11453 * @constructor
11454 * @param {Object} [config] Configuration options
11455 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11456 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11457 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11458 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11459 */
11460 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11461 // Configuration initialization
11462 config = $.extend( {
11463 scrollable: false,
11464 padded: false,
11465 expanded: true,
11466 framed: false
11467 }, config );
11468
11469 // Parent constructor
11470 OO.ui.PanelLayout.parent.call( this, config );
11471
11472 // Initialization
11473 this.$element.addClass( 'oo-ui-panelLayout' );
11474 if ( config.scrollable ) {
11475 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11476 }
11477 if ( config.padded ) {
11478 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11479 }
11480 if ( config.expanded ) {
11481 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11482 }
11483 if ( config.framed ) {
11484 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11485 }
11486 };
11487
11488 /* Setup */
11489
11490 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11491
11492 /* Methods */
11493
11494 /**
11495 * Focus the panel layout
11496 *
11497 * The default implementation just focuses the first focusable element in the panel
11498 */
11499 OO.ui.PanelLayout.prototype.focus = function () {
11500 OO.ui.findFocusable( this.$element ).focus();
11501 };
11502
11503 /**
11504 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11505 * items), with small margins between them. Convenient when you need to put a number of block-level
11506 * widgets on a single line next to each other.
11507 *
11508 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11509 *
11510 * @example
11511 * // HorizontalLayout with a text input and a label
11512 * var layout = new OO.ui.HorizontalLayout( {
11513 * items: [
11514 * new OO.ui.LabelWidget( { label: 'Label' } ),
11515 * new OO.ui.TextInputWidget( { value: 'Text' } )
11516 * ]
11517 * } );
11518 * $( 'body' ).append( layout.$element );
11519 *
11520 * @class
11521 * @extends OO.ui.Layout
11522 * @mixins OO.ui.mixin.GroupElement
11523 *
11524 * @constructor
11525 * @param {Object} [config] Configuration options
11526 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11527 */
11528 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11529 // Configuration initialization
11530 config = config || {};
11531
11532 // Parent constructor
11533 OO.ui.HorizontalLayout.parent.call( this, config );
11534
11535 // Mixin constructors
11536 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11537
11538 // Initialization
11539 this.$element.addClass( 'oo-ui-horizontalLayout' );
11540 if ( Array.isArray( config.items ) ) {
11541 this.addItems( config.items );
11542 }
11543 };
11544
11545 /* Setup */
11546
11547 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11548 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11549
11550 }( OO ) );
11551
11552 //# sourceMappingURL=oojs-ui-core.js.map