Merge "Fix CreditsAction when user has no real name set but real names enabled"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.23.2
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-26T20:18:42Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
15 /**
16 * Namespace for all classes, static methods and static properties.
17 *
18 * @class
19 * @singleton
20 */
21 OO.ui = {};
22
23 OO.ui.bind = $.proxy;
24
25 /**
26 * @property {Object}
27 */
28 OO.ui.Keys = {
29 UNDEFINED: 0,
30 BACKSPACE: 8,
31 DELETE: 46,
32 LEFT: 37,
33 RIGHT: 39,
34 UP: 38,
35 DOWN: 40,
36 ENTER: 13,
37 END: 35,
38 HOME: 36,
39 TAB: 9,
40 PAGEUP: 33,
41 PAGEDOWN: 34,
42 ESCAPE: 27,
43 SHIFT: 16,
44 SPACE: 32
45 };
46
47 /**
48 * Constants for MouseEvent.which
49 *
50 * @property {Object}
51 */
52 OO.ui.MouseButtons = {
53 LEFT: 1,
54 MIDDLE: 2,
55 RIGHT: 3
56 };
57
58 /**
59 * @property {number}
60 * @private
61 */
62 OO.ui.elementId = 0;
63
64 /**
65 * Generate a unique ID for element
66 *
67 * @return {string} ID
68 */
69 OO.ui.generateElementId = function () {
70 OO.ui.elementId++;
71 return 'oojsui-' + OO.ui.elementId;
72 };
73
74 /**
75 * Check if an element is focusable.
76 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 *
78 * @param {jQuery} $element Element to test
79 * @return {boolean} Element is focusable
80 */
81 OO.ui.isFocusableElement = function ( $element ) {
82 var nodeName,
83 element = $element[ 0 ];
84
85 // Anything disabled is not focusable
86 if ( element.disabled ) {
87 return false;
88 }
89
90 // Check if the element is visible
91 if ( !(
92 // This is quicker than calling $element.is( ':visible' )
93 $.expr.pseudos.visible( element ) &&
94 // Check that all parents are visible
95 !$element.parents().addBack().filter( function () {
96 return $.css( this, 'visibility' ) === 'hidden';
97 } ).length
98 ) ) {
99 return false;
100 }
101
102 // Check if the element is ContentEditable, which is the string 'true'
103 if ( element.contentEditable === 'true' ) {
104 return true;
105 }
106
107 // Anything with a non-negative numeric tabIndex is focusable.
108 // Use .prop to avoid browser bugs
109 if ( $element.prop( 'tabIndex' ) >= 0 ) {
110 return true;
111 }
112
113 // Some element types are naturally focusable
114 // (indexOf is much faster than regex in Chrome and about the
115 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
116 nodeName = element.nodeName.toLowerCase();
117 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
118 return true;
119 }
120
121 // Links and areas are focusable if they have an href
122 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
123 return true;
124 }
125
126 return false;
127 };
128
129 /**
130 * Find a focusable child
131 *
132 * @param {jQuery} $container Container to search in
133 * @param {boolean} [backwards] Search backwards
134 * @return {jQuery} Focusable child, or an empty jQuery object if none found
135 */
136 OO.ui.findFocusable = function ( $container, backwards ) {
137 var $focusable = $( [] ),
138 // $focusableCandidates is a superset of things that
139 // could get matched by isFocusableElement
140 $focusableCandidates = $container
141 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142
143 if ( backwards ) {
144 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 }
146
147 $focusableCandidates.each( function () {
148 var $this = $( this );
149 if ( OO.ui.isFocusableElement( $this ) ) {
150 $focusable = $this;
151 return false;
152 }
153 } );
154 return $focusable;
155 };
156
157 /**
158 * Get the user's language and any fallback languages.
159 *
160 * These language codes are used to localize user interface elements in the user's language.
161 *
162 * In environments that provide a localization system, this function should be overridden to
163 * return the user's language(s). The default implementation returns English (en) only.
164 *
165 * @return {string[]} Language codes, in descending order of priority
166 */
167 OO.ui.getUserLanguages = function () {
168 return [ 'en' ];
169 };
170
171 /**
172 * Get a value in an object keyed by language code.
173 *
174 * @param {Object.<string,Mixed>} obj Object keyed by language code
175 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
176 * @param {string} [fallback] Fallback code, used if no matching language can be found
177 * @return {Mixed} Local value
178 */
179 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
180 var i, len, langs;
181
182 // Requested language
183 if ( obj[ lang ] ) {
184 return obj[ lang ];
185 }
186 // Known user language
187 langs = OO.ui.getUserLanguages();
188 for ( i = 0, len = langs.length; i < len; i++ ) {
189 lang = langs[ i ];
190 if ( obj[ lang ] ) {
191 return obj[ lang ];
192 }
193 }
194 // Fallback language
195 if ( obj[ fallback ] ) {
196 return obj[ fallback ];
197 }
198 // First existing language
199 for ( lang in obj ) {
200 return obj[ lang ];
201 }
202
203 return undefined;
204 };
205
206 /**
207 * Check if a node is contained within another node
208 *
209 * Similar to jQuery#contains except a list of containers can be supplied
210 * and a boolean argument allows you to include the container in the match list
211 *
212 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
213 * @param {HTMLElement} contained Node to find
214 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
273 }
274 };
275
276 /**
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
280 *
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @return {OO.ui.Element}
337 * The `OO.ui.Element` corresponding to this (infusable) document node.
338 */
339 OO.ui.infuse = function ( idOrNode ) {
340 return OO.ui.Element.static.infuse( idOrNode );
341 };
342
343 ( function () {
344 /**
345 * Message store for the default implementation of OO.ui.msg
346 *
347 * Environments that provide a localization system should not use this, but should override
348 * OO.ui.msg altogether.
349 *
350 * @private
351 */
352 var messages = {
353 // Tool tip for a button that moves items in a list down one place
354 'ooui-outline-control-move-down': 'Move item down',
355 // Tool tip for a button that moves items in a list up one place
356 'ooui-outline-control-move-up': 'Move item up',
357 // Tool tip for a button that removes items from a list
358 'ooui-outline-control-remove': 'Remove item',
359 // Label for the toolbar group that contains a list of all other available tools
360 'ooui-toolbar-more': 'More',
361 // Label for the fake tool that expands the full list of tools in a toolbar group
362 'ooui-toolgroup-expand': 'More',
363 // Label for the fake tool that collapses the full list of tools in a toolbar group
364 'ooui-toolgroup-collapse': 'Fewer',
365 // Default label for the tooltip for the button that removes a tag item
366 'ooui-item-remove': 'Remove',
367 // Default label for the accept button of a confirmation dialog
368 'ooui-dialog-message-accept': 'OK',
369 // Default label for the reject button of a confirmation dialog
370 'ooui-dialog-message-reject': 'Cancel',
371 // Title for process dialog error description
372 'ooui-dialog-process-error': 'Something went wrong',
373 // Label for process dialog dismiss error button, visible when describing errors
374 'ooui-dialog-process-dismiss': 'Dismiss',
375 // Label for process dialog retry action button, visible when describing only recoverable errors
376 'ooui-dialog-process-retry': 'Try again',
377 // Label for process dialog retry action button, visible when describing only warnings
378 'ooui-dialog-process-continue': 'Continue',
379 // Label for the file selection widget's select file button
380 'ooui-selectfile-button-select': 'Select a file',
381 // Label for the file selection widget if file selection is not supported
382 'ooui-selectfile-not-supported': 'File selection is not supported',
383 // Label for the file selection widget when no file is currently selected
384 'ooui-selectfile-placeholder': 'No file is selected',
385 // Label for the file selection widget's drop target
386 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
387 };
388
389 /**
390 * Get a localized message.
391 *
392 * After the message key, message parameters may optionally be passed. In the default implementation,
393 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
394 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
395 * they support unnamed, ordered message parameters.
396 *
397 * In environments that provide a localization system, this function should be overridden to
398 * return the message translated in the user's language. The default implementation always returns
399 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
400 * follows.
401 *
402 * @example
403 * var i, iLen, button,
404 * messagePath = 'oojs-ui/dist/i18n/',
405 * languages = [ $.i18n().locale, 'ur', 'en' ],
406 * languageMap = {};
407 *
408 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
409 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
410 * }
411 *
412 * $.i18n().load( languageMap ).done( function() {
413 * // Replace the built-in `msg` only once we've loaded the internationalization.
414 * // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
415 * // you put off creating any widgets until this promise is complete, no English
416 * // will be displayed.
417 * OO.ui.msg = $.i18n;
418 *
419 * // A button displaying "OK" in the default locale
420 * button = new OO.ui.ButtonWidget( {
421 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
422 * icon: 'check'
423 * } );
424 * $( 'body' ).append( button.$element );
425 *
426 * // A button displaying "OK" in Urdu
427 * $.i18n().locale = 'ur';
428 * button = new OO.ui.ButtonWidget( {
429 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
430 * icon: 'check'
431 * } );
432 * $( 'body' ).append( button.$element );
433 * } );
434 *
435 * @param {string} key Message key
436 * @param {...Mixed} [params] Message parameters
437 * @return {string} Translated message with parameters substituted
438 */
439 OO.ui.msg = function ( key ) {
440 var message = messages[ key ],
441 params = Array.prototype.slice.call( arguments, 1 );
442 if ( typeof message === 'string' ) {
443 // Perform $1 substitution
444 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
445 var i = parseInt( n, 10 );
446 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
447 } );
448 } else {
449 // Return placeholder if message not found
450 message = '[' + key + ']';
451 }
452 return message;
453 };
454 }() );
455
456 /**
457 * Package a message and arguments for deferred resolution.
458 *
459 * Use this when you are statically specifying a message and the message may not yet be present.
460 *
461 * @param {string} key Message key
462 * @param {...Mixed} [params] Message parameters
463 * @return {Function} Function that returns the resolved message when executed
464 */
465 OO.ui.deferMsg = function () {
466 var args = arguments;
467 return function () {
468 return OO.ui.msg.apply( OO.ui, args );
469 };
470 };
471
472 /**
473 * Resolve a message.
474 *
475 * If the message is a function it will be executed, otherwise it will pass through directly.
476 *
477 * @param {Function|string} msg Deferred message, or message text
478 * @return {string} Resolved message
479 */
480 OO.ui.resolveMsg = function ( msg ) {
481 if ( $.isFunction( msg ) ) {
482 return msg();
483 }
484 return msg;
485 };
486
487 /**
488 * @param {string} url
489 * @return {boolean}
490 */
491 OO.ui.isSafeUrl = function ( url ) {
492 // Keep this function in sync with php/Tag.php
493 var i, protocolWhitelist;
494
495 function stringStartsWith( haystack, needle ) {
496 return haystack.substr( 0, needle.length ) === needle;
497 }
498
499 protocolWhitelist = [
500 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
501 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
502 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
503 ];
504
505 if ( url === '' ) {
506 return true;
507 }
508
509 for ( i = 0; i < protocolWhitelist.length; i++ ) {
510 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
511 return true;
512 }
513 }
514
515 // This matches '//' too
516 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
517 return true;
518 }
519 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
520 return true;
521 }
522
523 return false;
524 };
525
526 /**
527 * Check if the user has a 'mobile' device.
528 *
529 * For our purposes this means the user is primarily using an
530 * on-screen keyboard, touch input instead of a mouse and may
531 * have a physically small display.
532 *
533 * It is left up to implementors to decide how to compute this
534 * so the default implementation always returns false.
535 *
536 * @return {boolean} Use is on a mobile device
537 */
538 OO.ui.isMobile = function () {
539 return false;
540 };
541
542 /*!
543 * Mixin namespace.
544 */
545
546 /**
547 * Namespace for OOjs UI mixins.
548 *
549 * Mixins are named according to the type of object they are intended to
550 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
551 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
552 * is intended to be mixed in to an instance of OO.ui.Widget.
553 *
554 * @class
555 * @singleton
556 */
557 OO.ui.mixin = {};
558
559 /**
560 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
561 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
562 * connected to them and can't be interacted with.
563 *
564 * @abstract
565 * @class
566 *
567 * @constructor
568 * @param {Object} [config] Configuration options
569 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
570 * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
571 * for an example.
572 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
573 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
574 * @cfg {string} [text] Text to insert
575 * @cfg {Array} [content] An array of content elements to append (after #text).
576 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
577 * Instances of OO.ui.Element will have their $element appended.
578 * @cfg {jQuery} [$content] Content elements to append (after #text).
579 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
580 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
581 * Data can also be specified with the #setData method.
582 */
583 OO.ui.Element = function OoUiElement( config ) {
584 this.initialConfig = config;
585 // Configuration initialization
586 config = config || {};
587
588 // Properties
589 this.$ = $;
590 this.elementId = null;
591 this.visible = true;
592 this.data = config.data;
593 this.$element = config.$element ||
594 $( document.createElement( this.getTagName() ) );
595 this.elementGroup = null;
596
597 // Initialization
598 if ( Array.isArray( config.classes ) ) {
599 this.$element.addClass( config.classes.join( ' ' ) );
600 }
601 if ( config.id ) {
602 this.setElementId( config.id );
603 }
604 if ( config.text ) {
605 this.$element.text( config.text );
606 }
607 if ( config.content ) {
608 // The `content` property treats plain strings as text; use an
609 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
610 // appropriate $element appended.
611 this.$element.append( config.content.map( function ( v ) {
612 if ( typeof v === 'string' ) {
613 // Escape string so it is properly represented in HTML.
614 return document.createTextNode( v );
615 } else if ( v instanceof OO.ui.HtmlSnippet ) {
616 // Bypass escaping.
617 return v.toString();
618 } else if ( v instanceof OO.ui.Element ) {
619 return v.$element;
620 }
621 return v;
622 } ) );
623 }
624 if ( config.$content ) {
625 // The `$content` property treats plain strings as HTML.
626 this.$element.append( config.$content );
627 }
628 };
629
630 /* Setup */
631
632 OO.initClass( OO.ui.Element );
633
634 /* Static Properties */
635
636 /**
637 * The name of the HTML tag used by the element.
638 *
639 * The static value may be ignored if the #getTagName method is overridden.
640 *
641 * @static
642 * @inheritable
643 * @property {string}
644 */
645 OO.ui.Element.static.tagName = 'div';
646
647 /* Static Methods */
648
649 /**
650 * Reconstitute a JavaScript object corresponding to a widget created
651 * by the PHP implementation.
652 *
653 * @param {string|HTMLElement|jQuery} idOrNode
654 * A DOM id (if a string) or node for the widget to infuse.
655 * @return {OO.ui.Element}
656 * The `OO.ui.Element` corresponding to this (infusable) document node.
657 * For `Tag` objects emitted on the HTML side (used occasionally for content)
658 * the value returned is a newly-created Element wrapping around the existing
659 * DOM node.
660 */
661 OO.ui.Element.static.infuse = function ( idOrNode ) {
662 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
663 // Verify that the type matches up.
664 // FIXME: uncomment after T89721 is fixed, see T90929.
665 /*
666 if ( !( obj instanceof this['class'] ) ) {
667 throw new Error( 'Infusion type mismatch!' );
668 }
669 */
670 return obj;
671 };
672
673 /**
674 * Implementation helper for `infuse`; skips the type check and has an
675 * extra property so that only the top-level invocation touches the DOM.
676 *
677 * @private
678 * @param {string|HTMLElement|jQuery} idOrNode
679 * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
680 * when the top-level widget of this infusion is inserted into DOM,
681 * replacing the original node; or false for top-level invocation.
682 * @return {OO.ui.Element}
683 */
684 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
685 // look for a cached result of a previous infusion.
686 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
687 if ( typeof idOrNode === 'string' ) {
688 id = idOrNode;
689 $elem = $( document.getElementById( id ) );
690 } else {
691 $elem = $( idOrNode );
692 id = $elem.attr( 'id' );
693 }
694 if ( !$elem.length ) {
695 if ( typeof idOrNode === 'string' ) {
696 error = 'Widget not found: ' + idOrNode;
697 } else if ( idOrNode && idOrNode.selector ) {
698 error = 'Widget not found: ' + idOrNode.selector;
699 } else {
700 error = 'Widget not found';
701 }
702 throw new Error( error );
703 }
704 if ( $elem[ 0 ].oouiInfused ) {
705 $elem = $elem[ 0 ].oouiInfused;
706 }
707 data = $elem.data( 'ooui-infused' );
708 if ( data ) {
709 // cached!
710 if ( data === true ) {
711 throw new Error( 'Circular dependency! ' + id );
712 }
713 if ( domPromise ) {
714 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
715 state = data.constructor.static.gatherPreInfuseState( $elem, data );
716 // restore dynamic state after the new element is re-inserted into DOM under infused parent
717 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
718 infusedChildren = $elem.data( 'ooui-infused-children' );
719 if ( infusedChildren && infusedChildren.length ) {
720 infusedChildren.forEach( function ( data ) {
721 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
722 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
723 } );
724 }
725 }
726 return data;
727 }
728 data = $elem.attr( 'data-ooui' );
729 if ( !data ) {
730 throw new Error( 'No infusion data found: ' + id );
731 }
732 try {
733 data = JSON.parse( data );
734 } catch ( _ ) {
735 data = null;
736 }
737 if ( !( data && data._ ) ) {
738 throw new Error( 'No valid infusion data found: ' + id );
739 }
740 if ( data._ === 'Tag' ) {
741 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
742 return new OO.ui.Element( { $element: $elem } );
743 }
744 parts = data._.split( '.' );
745 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
746 if ( cls === undefined ) {
747 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
748 }
749
750 // Verify that we're creating an OO.ui.Element instance
751 parent = cls.parent;
752
753 while ( parent !== undefined ) {
754 if ( parent === OO.ui.Element ) {
755 // Safe
756 break;
757 }
758
759 parent = parent.parent;
760 }
761
762 if ( parent !== OO.ui.Element ) {
763 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
764 }
765
766 if ( domPromise === false ) {
767 top = $.Deferred();
768 domPromise = top.promise();
769 }
770 $elem.data( 'ooui-infused', true ); // prevent loops
771 data.id = id; // implicit
772 infusedChildren = [];
773 data = OO.copy( data, null, function deserialize( value ) {
774 var infused;
775 if ( OO.isPlainObject( value ) ) {
776 if ( value.tag ) {
777 infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
778 infusedChildren.push( infused );
779 // Flatten the structure
780 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
781 infused.$element.removeData( 'ooui-infused-children' );
782 return infused;
783 }
784 if ( value.html !== undefined ) {
785 return new OO.ui.HtmlSnippet( value.html );
786 }
787 }
788 } );
789 // allow widgets to reuse parts of the DOM
790 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
791 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
792 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
793 // rebuild widget
794 // eslint-disable-next-line new-cap
795 obj = new cls( data );
796 // now replace old DOM with this new DOM.
797 if ( top ) {
798 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
799 // so only mutate the DOM if we need to.
800 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
801 $elem.replaceWith( obj.$element );
802 // This element is now gone from the DOM, but if anyone is holding a reference to it,
803 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
804 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
805 $elem[ 0 ].oouiInfused = obj.$element;
806 }
807 top.resolve();
808 }
809 obj.$element.data( 'ooui-infused', obj );
810 obj.$element.data( 'ooui-infused-children', infusedChildren );
811 // set the 'data-ooui' attribute so we can identify infused widgets
812 obj.$element.attr( 'data-ooui', '' );
813 // restore dynamic state after the new element is inserted into DOM
814 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
815 return obj;
816 };
817
818 /**
819 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
820 *
821 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
822 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
823 * constructor, which will be given the enhanced config.
824 *
825 * @protected
826 * @param {HTMLElement} node
827 * @param {Object} config
828 * @return {Object}
829 */
830 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
831 return config;
832 };
833
834 /**
835 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
836 * (and its children) that represent an Element of the same class and the given configuration,
837 * generated by the PHP implementation.
838 *
839 * This method is called just before `node` is detached from the DOM. The return value of this
840 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
841 * is inserted into DOM to replace `node`.
842 *
843 * @protected
844 * @param {HTMLElement} node
845 * @param {Object} config
846 * @return {Object}
847 */
848 OO.ui.Element.static.gatherPreInfuseState = function () {
849 return {};
850 };
851
852 /**
853 * Get a jQuery function within a specific document.
854 *
855 * @static
856 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
857 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
858 * not in an iframe
859 * @return {Function} Bound jQuery function
860 */
861 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
862 function wrapper( selector ) {
863 return $( selector, wrapper.context );
864 }
865
866 wrapper.context = this.getDocument( context );
867
868 if ( $iframe ) {
869 wrapper.$iframe = $iframe;
870 }
871
872 return wrapper;
873 };
874
875 /**
876 * Get the document of an element.
877 *
878 * @static
879 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
880 * @return {HTMLDocument|null} Document object
881 */
882 OO.ui.Element.static.getDocument = function ( obj ) {
883 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
884 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
885 // Empty jQuery selections might have a context
886 obj.context ||
887 // HTMLElement
888 obj.ownerDocument ||
889 // Window
890 obj.document ||
891 // HTMLDocument
892 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
893 null;
894 };
895
896 /**
897 * Get the window of an element or document.
898 *
899 * @static
900 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
901 * @return {Window} Window object
902 */
903 OO.ui.Element.static.getWindow = function ( obj ) {
904 var doc = this.getDocument( obj );
905 return doc.defaultView;
906 };
907
908 /**
909 * Get the direction of an element or document.
910 *
911 * @static
912 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
913 * @return {string} Text direction, either 'ltr' or 'rtl'
914 */
915 OO.ui.Element.static.getDir = function ( obj ) {
916 var isDoc, isWin;
917
918 if ( obj instanceof jQuery ) {
919 obj = obj[ 0 ];
920 }
921 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
922 isWin = obj.document !== undefined;
923 if ( isDoc || isWin ) {
924 if ( isWin ) {
925 obj = obj.document;
926 }
927 obj = obj.body;
928 }
929 return $( obj ).css( 'direction' );
930 };
931
932 /**
933 * Get the offset between two frames.
934 *
935 * TODO: Make this function not use recursion.
936 *
937 * @static
938 * @param {Window} from Window of the child frame
939 * @param {Window} [to=window] Window of the parent frame
940 * @param {Object} [offset] Offset to start with, used internally
941 * @return {Object} Offset object, containing left and top properties
942 */
943 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
944 var i, len, frames, frame, rect;
945
946 if ( !to ) {
947 to = window;
948 }
949 if ( !offset ) {
950 offset = { top: 0, left: 0 };
951 }
952 if ( from.parent === from ) {
953 return offset;
954 }
955
956 // Get iframe element
957 frames = from.parent.document.getElementsByTagName( 'iframe' );
958 for ( i = 0, len = frames.length; i < len; i++ ) {
959 if ( frames[ i ].contentWindow === from ) {
960 frame = frames[ i ];
961 break;
962 }
963 }
964
965 // Recursively accumulate offset values
966 if ( frame ) {
967 rect = frame.getBoundingClientRect();
968 offset.left += rect.left;
969 offset.top += rect.top;
970 if ( from !== to ) {
971 this.getFrameOffset( from.parent, offset );
972 }
973 }
974 return offset;
975 };
976
977 /**
978 * Get the offset between two elements.
979 *
980 * The two elements may be in a different frame, but in that case the frame $element is in must
981 * be contained in the frame $anchor is in.
982 *
983 * @static
984 * @param {jQuery} $element Element whose position to get
985 * @param {jQuery} $anchor Element to get $element's position relative to
986 * @return {Object} Translated position coordinates, containing top and left properties
987 */
988 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
989 var iframe, iframePos,
990 pos = $element.offset(),
991 anchorPos = $anchor.offset(),
992 elementDocument = this.getDocument( $element ),
993 anchorDocument = this.getDocument( $anchor );
994
995 // If $element isn't in the same document as $anchor, traverse up
996 while ( elementDocument !== anchorDocument ) {
997 iframe = elementDocument.defaultView.frameElement;
998 if ( !iframe ) {
999 throw new Error( '$element frame is not contained in $anchor frame' );
1000 }
1001 iframePos = $( iframe ).offset();
1002 pos.left += iframePos.left;
1003 pos.top += iframePos.top;
1004 elementDocument = iframe.ownerDocument;
1005 }
1006 pos.left -= anchorPos.left;
1007 pos.top -= anchorPos.top;
1008 return pos;
1009 };
1010
1011 /**
1012 * Get element border sizes.
1013 *
1014 * @static
1015 * @param {HTMLElement} el Element to measure
1016 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1017 */
1018 OO.ui.Element.static.getBorders = function ( el ) {
1019 var doc = el.ownerDocument,
1020 win = doc.defaultView,
1021 style = win.getComputedStyle( el, null ),
1022 $el = $( el ),
1023 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1024 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1025 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1026 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1027
1028 return {
1029 top: top,
1030 left: left,
1031 bottom: bottom,
1032 right: right
1033 };
1034 };
1035
1036 /**
1037 * Get dimensions of an element or window.
1038 *
1039 * @static
1040 * @param {HTMLElement|Window} el Element to measure
1041 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1042 */
1043 OO.ui.Element.static.getDimensions = function ( el ) {
1044 var $el, $win,
1045 doc = el.ownerDocument || el.document,
1046 win = doc.defaultView;
1047
1048 if ( win === el || el === doc.documentElement ) {
1049 $win = $( win );
1050 return {
1051 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1052 scroll: {
1053 top: $win.scrollTop(),
1054 left: $win.scrollLeft()
1055 },
1056 scrollbar: { right: 0, bottom: 0 },
1057 rect: {
1058 top: 0,
1059 left: 0,
1060 bottom: $win.innerHeight(),
1061 right: $win.innerWidth()
1062 }
1063 };
1064 } else {
1065 $el = $( el );
1066 return {
1067 borders: this.getBorders( el ),
1068 scroll: {
1069 top: $el.scrollTop(),
1070 left: $el.scrollLeft()
1071 },
1072 scrollbar: {
1073 right: $el.innerWidth() - el.clientWidth,
1074 bottom: $el.innerHeight() - el.clientHeight
1075 },
1076 rect: el.getBoundingClientRect()
1077 };
1078 }
1079 };
1080
1081 /**
1082 * Get the number of pixels that an element's content is scrolled to the left.
1083 *
1084 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1085 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1086 *
1087 * This function smooths out browser inconsistencies (nicely described in the README at
1088 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1089 * with Firefox's 'scrollLeft', which seems the sanest.
1090 *
1091 * @static
1092 * @method
1093 * @param {HTMLElement|Window} el Element to measure
1094 * @return {number} Scroll position from the left.
1095 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1096 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1097 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1098 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1099 */
1100 OO.ui.Element.static.getScrollLeft = ( function () {
1101 var rtlScrollType = null;
1102
1103 function test() {
1104 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1105 definer = $definer[ 0 ];
1106
1107 $definer.appendTo( 'body' );
1108 if ( definer.scrollLeft > 0 ) {
1109 // Safari, Chrome
1110 rtlScrollType = 'default';
1111 } else {
1112 definer.scrollLeft = 1;
1113 if ( definer.scrollLeft === 0 ) {
1114 // Firefox, old Opera
1115 rtlScrollType = 'negative';
1116 } else {
1117 // Internet Explorer, Edge
1118 rtlScrollType = 'reverse';
1119 }
1120 }
1121 $definer.remove();
1122 }
1123
1124 return function getScrollLeft( el ) {
1125 var isRoot = el.window === el ||
1126 el === el.ownerDocument.body ||
1127 el === el.ownerDocument.documentElement,
1128 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1129 // All browsers use the correct scroll type ('negative') on the root, so don't
1130 // do any fixups when looking at the root element
1131 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1132
1133 if ( direction === 'rtl' ) {
1134 if ( rtlScrollType === null ) {
1135 test();
1136 }
1137 if ( rtlScrollType === 'reverse' ) {
1138 scrollLeft = -scrollLeft;
1139 } else if ( rtlScrollType === 'default' ) {
1140 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1141 }
1142 }
1143
1144 return scrollLeft;
1145 };
1146 }() );
1147
1148 /**
1149 * Get the root scrollable element of given element's document.
1150 *
1151 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1152 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1153 * lets us use 'body' or 'documentElement' based on what is working.
1154 *
1155 * https://code.google.com/p/chromium/issues/detail?id=303131
1156 *
1157 * @static
1158 * @param {HTMLElement} el Element to find root scrollable parent for
1159 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1160 * depending on browser
1161 */
1162 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1163 var scrollTop, body;
1164
1165 if ( OO.ui.scrollableElement === undefined ) {
1166 body = el.ownerDocument.body;
1167 scrollTop = body.scrollTop;
1168 body.scrollTop = 1;
1169
1170 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1171 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1172 if ( Math.round( body.scrollTop ) === 1 ) {
1173 body.scrollTop = scrollTop;
1174 OO.ui.scrollableElement = 'body';
1175 } else {
1176 OO.ui.scrollableElement = 'documentElement';
1177 }
1178 }
1179
1180 return el.ownerDocument[ OO.ui.scrollableElement ];
1181 };
1182
1183 /**
1184 * Get closest scrollable container.
1185 *
1186 * Traverses up until either a scrollable element or the root is reached, in which case the root
1187 * scrollable element will be returned (see #getRootScrollableElement).
1188 *
1189 * @static
1190 * @param {HTMLElement} el Element to find scrollable container for
1191 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1192 * @return {HTMLElement} Closest scrollable container
1193 */
1194 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1195 var i, val,
1196 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1197 // 'overflow-y' have different values, so we need to check the separate properties.
1198 props = [ 'overflow-x', 'overflow-y' ],
1199 $parent = $( el ).parent();
1200
1201 if ( dimension === 'x' || dimension === 'y' ) {
1202 props = [ 'overflow-' + dimension ];
1203 }
1204
1205 // Special case for the document root (which doesn't really have any scrollable container, since
1206 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1207 if ( $( el ).is( 'html, body' ) ) {
1208 return this.getRootScrollableElement( el );
1209 }
1210
1211 while ( $parent.length ) {
1212 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1213 return $parent[ 0 ];
1214 }
1215 i = props.length;
1216 while ( i-- ) {
1217 val = $parent.css( props[ i ] );
1218 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1219 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1220 // unintentionally perform a scroll in such case even if the application doesn't scroll
1221 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1222 // This could cause funny issues...
1223 if ( val === 'auto' || val === 'scroll' ) {
1224 return $parent[ 0 ];
1225 }
1226 }
1227 $parent = $parent.parent();
1228 }
1229 // The element is unattached... return something mostly sane
1230 return this.getRootScrollableElement( el );
1231 };
1232
1233 /**
1234 * Scroll element into view.
1235 *
1236 * @static
1237 * @param {HTMLElement} el Element to scroll into view
1238 * @param {Object} [config] Configuration options
1239 * @param {string} [config.duration='fast'] jQuery animation duration value
1240 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1241 * to scroll in both directions
1242 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1243 */
1244 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1245 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1246 deferred = $.Deferred();
1247
1248 // Configuration initialization
1249 config = config || {};
1250
1251 animations = {};
1252 container = this.getClosestScrollableContainer( el, config.direction );
1253 $container = $( container );
1254 elementDimensions = this.getDimensions( el );
1255 containerDimensions = this.getDimensions( container );
1256 $window = $( this.getWindow( el ) );
1257
1258 // Compute the element's position relative to the container
1259 if ( $container.is( 'html, body' ) ) {
1260 // If the scrollable container is the root, this is easy
1261 position = {
1262 top: elementDimensions.rect.top,
1263 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1264 left: elementDimensions.rect.left,
1265 right: $window.innerWidth() - elementDimensions.rect.right
1266 };
1267 } else {
1268 // Otherwise, we have to subtract el's coordinates from container's coordinates
1269 position = {
1270 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1271 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1272 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1273 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1274 };
1275 }
1276
1277 if ( !config.direction || config.direction === 'y' ) {
1278 if ( position.top < 0 ) {
1279 animations.scrollTop = containerDimensions.scroll.top + position.top;
1280 } else if ( position.top > 0 && position.bottom < 0 ) {
1281 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1282 }
1283 }
1284 if ( !config.direction || config.direction === 'x' ) {
1285 if ( position.left < 0 ) {
1286 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1287 } else if ( position.left > 0 && position.right < 0 ) {
1288 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1289 }
1290 }
1291 if ( !$.isEmptyObject( animations ) ) {
1292 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1293 $container.queue( function ( next ) {
1294 deferred.resolve();
1295 next();
1296 } );
1297 } else {
1298 deferred.resolve();
1299 }
1300 return deferred.promise();
1301 };
1302
1303 /**
1304 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1305 * and reserve space for them, because it probably doesn't.
1306 *
1307 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1308 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1309 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1310 * and then reattach (or show) them back.
1311 *
1312 * @static
1313 * @param {HTMLElement} el Element to reconsider the scrollbars on
1314 */
1315 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1316 var i, len, scrollLeft, scrollTop, nodes = [];
1317 // Save scroll position
1318 scrollLeft = el.scrollLeft;
1319 scrollTop = el.scrollTop;
1320 // Detach all children
1321 while ( el.firstChild ) {
1322 nodes.push( el.firstChild );
1323 el.removeChild( el.firstChild );
1324 }
1325 // Force reflow
1326 void el.offsetHeight;
1327 // Reattach all children
1328 for ( i = 0, len = nodes.length; i < len; i++ ) {
1329 el.appendChild( nodes[ i ] );
1330 }
1331 // Restore scroll position (no-op if scrollbars disappeared)
1332 el.scrollLeft = scrollLeft;
1333 el.scrollTop = scrollTop;
1334 };
1335
1336 /* Methods */
1337
1338 /**
1339 * Toggle visibility of an element.
1340 *
1341 * @param {boolean} [show] Make element visible, omit to toggle visibility
1342 * @fires visible
1343 * @chainable
1344 */
1345 OO.ui.Element.prototype.toggle = function ( show ) {
1346 show = show === undefined ? !this.visible : !!show;
1347
1348 if ( show !== this.isVisible() ) {
1349 this.visible = show;
1350 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1351 this.emit( 'toggle', show );
1352 }
1353
1354 return this;
1355 };
1356
1357 /**
1358 * Check if element is visible.
1359 *
1360 * @return {boolean} element is visible
1361 */
1362 OO.ui.Element.prototype.isVisible = function () {
1363 return this.visible;
1364 };
1365
1366 /**
1367 * Get element data.
1368 *
1369 * @return {Mixed} Element data
1370 */
1371 OO.ui.Element.prototype.getData = function () {
1372 return this.data;
1373 };
1374
1375 /**
1376 * Set element data.
1377 *
1378 * @param {Mixed} data Element data
1379 * @chainable
1380 */
1381 OO.ui.Element.prototype.setData = function ( data ) {
1382 this.data = data;
1383 return this;
1384 };
1385
1386 /**
1387 * Set the element has an 'id' attribute.
1388 *
1389 * @param {string} id
1390 * @chainable
1391 */
1392 OO.ui.Element.prototype.setElementId = function ( id ) {
1393 this.elementId = id;
1394 this.$element.attr( 'id', id );
1395 return this;
1396 };
1397
1398 /**
1399 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1400 * and return its value.
1401 *
1402 * @return {string}
1403 */
1404 OO.ui.Element.prototype.getElementId = function () {
1405 if ( this.elementId === null ) {
1406 this.setElementId( OO.ui.generateElementId() );
1407 }
1408 return this.elementId;
1409 };
1410
1411 /**
1412 * Check if element supports one or more methods.
1413 *
1414 * @param {string|string[]} methods Method or list of methods to check
1415 * @return {boolean} All methods are supported
1416 */
1417 OO.ui.Element.prototype.supports = function ( methods ) {
1418 var i, len,
1419 support = 0;
1420
1421 methods = Array.isArray( methods ) ? methods : [ methods ];
1422 for ( i = 0, len = methods.length; i < len; i++ ) {
1423 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1424 support++;
1425 }
1426 }
1427
1428 return methods.length === support;
1429 };
1430
1431 /**
1432 * Update the theme-provided classes.
1433 *
1434 * @localdoc This is called in element mixins and widget classes any time state changes.
1435 * Updating is debounced, minimizing overhead of changing multiple attributes and
1436 * guaranteeing that theme updates do not occur within an element's constructor
1437 */
1438 OO.ui.Element.prototype.updateThemeClasses = function () {
1439 OO.ui.theme.queueUpdateElementClasses( this );
1440 };
1441
1442 /**
1443 * Get the HTML tag name.
1444 *
1445 * Override this method to base the result on instance information.
1446 *
1447 * @return {string} HTML tag name
1448 */
1449 OO.ui.Element.prototype.getTagName = function () {
1450 return this.constructor.static.tagName;
1451 };
1452
1453 /**
1454 * Check if the element is attached to the DOM
1455 *
1456 * @return {boolean} The element is attached to the DOM
1457 */
1458 OO.ui.Element.prototype.isElementAttached = function () {
1459 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1460 };
1461
1462 /**
1463 * Get the DOM document.
1464 *
1465 * @return {HTMLDocument} Document object
1466 */
1467 OO.ui.Element.prototype.getElementDocument = function () {
1468 // Don't cache this in other ways either because subclasses could can change this.$element
1469 return OO.ui.Element.static.getDocument( this.$element );
1470 };
1471
1472 /**
1473 * Get the DOM window.
1474 *
1475 * @return {Window} Window object
1476 */
1477 OO.ui.Element.prototype.getElementWindow = function () {
1478 return OO.ui.Element.static.getWindow( this.$element );
1479 };
1480
1481 /**
1482 * Get closest scrollable container.
1483 *
1484 * @return {HTMLElement} Closest scrollable container
1485 */
1486 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1487 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1488 };
1489
1490 /**
1491 * Get group element is in.
1492 *
1493 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1494 */
1495 OO.ui.Element.prototype.getElementGroup = function () {
1496 return this.elementGroup;
1497 };
1498
1499 /**
1500 * Set group element is in.
1501 *
1502 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1503 * @chainable
1504 */
1505 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1506 this.elementGroup = group;
1507 return this;
1508 };
1509
1510 /**
1511 * Scroll element into view.
1512 *
1513 * @param {Object} [config] Configuration options
1514 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1515 */
1516 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1517 if (
1518 !this.isElementAttached() ||
1519 !this.isVisible() ||
1520 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1521 ) {
1522 return $.Deferred().resolve();
1523 }
1524 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1525 };
1526
1527 /**
1528 * Restore the pre-infusion dynamic state for this widget.
1529 *
1530 * This method is called after #$element has been inserted into DOM. The parameter is the return
1531 * value of #gatherPreInfuseState.
1532 *
1533 * @protected
1534 * @param {Object} state
1535 */
1536 OO.ui.Element.prototype.restorePreInfuseState = function () {
1537 };
1538
1539 /**
1540 * Wraps an HTML snippet for use with configuration values which default
1541 * to strings. This bypasses the default html-escaping done to string
1542 * values.
1543 *
1544 * @class
1545 *
1546 * @constructor
1547 * @param {string} [content] HTML content
1548 */
1549 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1550 // Properties
1551 this.content = content;
1552 };
1553
1554 /* Setup */
1555
1556 OO.initClass( OO.ui.HtmlSnippet );
1557
1558 /* Methods */
1559
1560 /**
1561 * Render into HTML.
1562 *
1563 * @return {string} Unchanged HTML snippet.
1564 */
1565 OO.ui.HtmlSnippet.prototype.toString = function () {
1566 return this.content;
1567 };
1568
1569 /**
1570 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1571 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1572 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1573 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1574 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1575 *
1576 * @abstract
1577 * @class
1578 * @extends OO.ui.Element
1579 * @mixins OO.EventEmitter
1580 *
1581 * @constructor
1582 * @param {Object} [config] Configuration options
1583 */
1584 OO.ui.Layout = function OoUiLayout( config ) {
1585 // Configuration initialization
1586 config = config || {};
1587
1588 // Parent constructor
1589 OO.ui.Layout.parent.call( this, config );
1590
1591 // Mixin constructors
1592 OO.EventEmitter.call( this );
1593
1594 // Initialization
1595 this.$element.addClass( 'oo-ui-layout' );
1596 };
1597
1598 /* Setup */
1599
1600 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1601 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1602
1603 /**
1604 * Widgets are compositions of one or more OOjs UI elements that users can both view
1605 * and interact with. All widgets can be configured and modified via a standard API,
1606 * and their state can change dynamically according to a model.
1607 *
1608 * @abstract
1609 * @class
1610 * @extends OO.ui.Element
1611 * @mixins OO.EventEmitter
1612 *
1613 * @constructor
1614 * @param {Object} [config] Configuration options
1615 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1616 * appearance reflects this state.
1617 */
1618 OO.ui.Widget = function OoUiWidget( config ) {
1619 // Initialize config
1620 config = $.extend( { disabled: false }, config );
1621
1622 // Parent constructor
1623 OO.ui.Widget.parent.call( this, config );
1624
1625 // Mixin constructors
1626 OO.EventEmitter.call( this );
1627
1628 // Properties
1629 this.disabled = null;
1630 this.wasDisabled = null;
1631
1632 // Initialization
1633 this.$element.addClass( 'oo-ui-widget' );
1634 this.setDisabled( !!config.disabled );
1635 };
1636
1637 /* Setup */
1638
1639 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1640 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1641
1642 /* Events */
1643
1644 /**
1645 * @event disable
1646 *
1647 * A 'disable' event is emitted when the disabled state of the widget changes
1648 * (i.e. on disable **and** enable).
1649 *
1650 * @param {boolean} disabled Widget is disabled
1651 */
1652
1653 /**
1654 * @event toggle
1655 *
1656 * A 'toggle' event is emitted when the visibility of the widget changes.
1657 *
1658 * @param {boolean} visible Widget is visible
1659 */
1660
1661 /* Methods */
1662
1663 /**
1664 * Check if the widget is disabled.
1665 *
1666 * @return {boolean} Widget is disabled
1667 */
1668 OO.ui.Widget.prototype.isDisabled = function () {
1669 return this.disabled;
1670 };
1671
1672 /**
1673 * Set the 'disabled' state of the widget.
1674 *
1675 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1676 *
1677 * @param {boolean} disabled Disable widget
1678 * @chainable
1679 */
1680 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1681 var isDisabled;
1682
1683 this.disabled = !!disabled;
1684 isDisabled = this.isDisabled();
1685 if ( isDisabled !== this.wasDisabled ) {
1686 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1687 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1688 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1689 this.emit( 'disable', isDisabled );
1690 this.updateThemeClasses();
1691 }
1692 this.wasDisabled = isDisabled;
1693
1694 return this;
1695 };
1696
1697 /**
1698 * Update the disabled state, in case of changes in parent widget.
1699 *
1700 * @chainable
1701 */
1702 OO.ui.Widget.prototype.updateDisabled = function () {
1703 this.setDisabled( this.disabled );
1704 return this;
1705 };
1706
1707 /**
1708 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1709 * value.
1710 *
1711 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1712 * instead.
1713 *
1714 * @return {string|null} The ID of the labelable element
1715 */
1716 OO.ui.Widget.prototype.getInputId = function () {
1717 return null;
1718 };
1719
1720 /**
1721 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1722 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1723 * override this method to provide intuitive, accessible behavior.
1724 *
1725 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1726 * Individual widgets may override it too.
1727 *
1728 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1729 * directly.
1730 */
1731 OO.ui.Widget.prototype.simulateLabelClick = function () {
1732 };
1733
1734 /**
1735 * Theme logic.
1736 *
1737 * @abstract
1738 * @class
1739 *
1740 * @constructor
1741 */
1742 OO.ui.Theme = function OoUiTheme() {
1743 this.elementClassesQueue = [];
1744 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1745 };
1746
1747 /* Setup */
1748
1749 OO.initClass( OO.ui.Theme );
1750
1751 /* Methods */
1752
1753 /**
1754 * Get a list of classes to be applied to a widget.
1755 *
1756 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1757 * otherwise state transitions will not work properly.
1758 *
1759 * @param {OO.ui.Element} element Element for which to get classes
1760 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1761 */
1762 OO.ui.Theme.prototype.getElementClasses = function () {
1763 return { on: [], off: [] };
1764 };
1765
1766 /**
1767 * Update CSS classes provided by the theme.
1768 *
1769 * For elements with theme logic hooks, this should be called any time there's a state change.
1770 *
1771 * @param {OO.ui.Element} element Element for which to update classes
1772 */
1773 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1774 var $elements = $( [] ),
1775 classes = this.getElementClasses( element );
1776
1777 if ( element.$icon ) {
1778 $elements = $elements.add( element.$icon );
1779 }
1780 if ( element.$indicator ) {
1781 $elements = $elements.add( element.$indicator );
1782 }
1783
1784 $elements
1785 .removeClass( classes.off.join( ' ' ) )
1786 .addClass( classes.on.join( ' ' ) );
1787 };
1788
1789 /**
1790 * @private
1791 */
1792 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1793 var i;
1794 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1795 this.updateElementClasses( this.elementClassesQueue[ i ] );
1796 }
1797 // Clear the queue
1798 this.elementClassesQueue = [];
1799 };
1800
1801 /**
1802 * Queue #updateElementClasses to be called for this element.
1803 *
1804 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1805 * to make them synchronous.
1806 *
1807 * @param {OO.ui.Element} element Element for which to update classes
1808 */
1809 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1810 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1811 // the most common case (this method is often called repeatedly for the same element).
1812 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1813 return;
1814 }
1815 this.elementClassesQueue.push( element );
1816 this.debouncedUpdateQueuedElementClasses();
1817 };
1818
1819 /**
1820 * Get the transition duration in milliseconds for dialogs opening/closing
1821 *
1822 * The dialog should be fully rendered this many milliseconds after the
1823 * ready process has executed.
1824 *
1825 * @return {number} Transition duration in milliseconds
1826 */
1827 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1828 return 0;
1829 };
1830
1831 /**
1832 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1833 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1834 * order in which users will navigate through the focusable elements via the "tab" key.
1835 *
1836 * @example
1837 * // TabIndexedElement is mixed into the ButtonWidget class
1838 * // to provide a tabIndex property.
1839 * var button1 = new OO.ui.ButtonWidget( {
1840 * label: 'fourth',
1841 * tabIndex: 4
1842 * } );
1843 * var button2 = new OO.ui.ButtonWidget( {
1844 * label: 'second',
1845 * tabIndex: 2
1846 * } );
1847 * var button3 = new OO.ui.ButtonWidget( {
1848 * label: 'third',
1849 * tabIndex: 3
1850 * } );
1851 * var button4 = new OO.ui.ButtonWidget( {
1852 * label: 'first',
1853 * tabIndex: 1
1854 * } );
1855 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1856 *
1857 * @abstract
1858 * @class
1859 *
1860 * @constructor
1861 * @param {Object} [config] Configuration options
1862 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1863 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1864 * functionality will be applied to it instead.
1865 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1866 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1867 * to remove the element from the tab-navigation flow.
1868 */
1869 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1870 // Configuration initialization
1871 config = $.extend( { tabIndex: 0 }, config );
1872
1873 // Properties
1874 this.$tabIndexed = null;
1875 this.tabIndex = null;
1876
1877 // Events
1878 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1879
1880 // Initialization
1881 this.setTabIndex( config.tabIndex );
1882 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1883 };
1884
1885 /* Setup */
1886
1887 OO.initClass( OO.ui.mixin.TabIndexedElement );
1888
1889 /* Methods */
1890
1891 /**
1892 * Set the element that should use the tabindex functionality.
1893 *
1894 * This method is used to retarget a tabindex mixin so that its functionality applies
1895 * to the specified element. If an element is currently using the functionality, the mixin’s
1896 * effect on that element is removed before the new element is set up.
1897 *
1898 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1899 * @chainable
1900 */
1901 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1902 var tabIndex = this.tabIndex;
1903 // Remove attributes from old $tabIndexed
1904 this.setTabIndex( null );
1905 // Force update of new $tabIndexed
1906 this.$tabIndexed = $tabIndexed;
1907 this.tabIndex = tabIndex;
1908 return this.updateTabIndex();
1909 };
1910
1911 /**
1912 * Set the value of the tabindex.
1913 *
1914 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1915 * @chainable
1916 */
1917 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1918 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1919
1920 if ( this.tabIndex !== tabIndex ) {
1921 this.tabIndex = tabIndex;
1922 this.updateTabIndex();
1923 }
1924
1925 return this;
1926 };
1927
1928 /**
1929 * Update the `tabindex` attribute, in case of changes to tab index or
1930 * disabled state.
1931 *
1932 * @private
1933 * @chainable
1934 */
1935 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1936 if ( this.$tabIndexed ) {
1937 if ( this.tabIndex !== null ) {
1938 // Do not index over disabled elements
1939 this.$tabIndexed.attr( {
1940 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1941 // Support: ChromeVox and NVDA
1942 // These do not seem to inherit aria-disabled from parent elements
1943 'aria-disabled': this.isDisabled().toString()
1944 } );
1945 } else {
1946 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1947 }
1948 }
1949 return this;
1950 };
1951
1952 /**
1953 * Handle disable events.
1954 *
1955 * @private
1956 * @param {boolean} disabled Element is disabled
1957 */
1958 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1959 this.updateTabIndex();
1960 };
1961
1962 /**
1963 * Get the value of the tabindex.
1964 *
1965 * @return {number|null} Tabindex value
1966 */
1967 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
1968 return this.tabIndex;
1969 };
1970
1971 /**
1972 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
1973 *
1974 * If the element already has an ID then that is returned, otherwise unique ID is
1975 * generated, set on the element, and returned.
1976 *
1977 * @return {string|null} The ID of the focusable element
1978 */
1979 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
1980 var id;
1981
1982 if ( !this.$tabIndexed ) {
1983 return null;
1984 }
1985 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
1986 return null;
1987 }
1988
1989 id = this.$tabIndexed.attr( 'id' );
1990 if ( id === undefined ) {
1991 id = OO.ui.generateElementId();
1992 this.$tabIndexed.attr( 'id', id );
1993 }
1994
1995 return id;
1996 };
1997
1998 /**
1999 * Whether the node is 'labelable' according to the HTML spec
2000 * (i.e., whether it can be interacted with through a `<label for="…">`).
2001 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2002 *
2003 * @private
2004 * @param {jQuery} $node
2005 * @return {boolean}
2006 */
2007 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2008 var
2009 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2010 tagName = $node.prop( 'tagName' ).toLowerCase();
2011
2012 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2013 return true;
2014 }
2015 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2016 return true;
2017 }
2018 return false;
2019 };
2020
2021 /**
2022 * Focus this element.
2023 *
2024 * @chainable
2025 */
2026 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2027 if ( !this.isDisabled() ) {
2028 this.$tabIndexed.focus();
2029 }
2030 return this;
2031 };
2032
2033 /**
2034 * Blur this element.
2035 *
2036 * @chainable
2037 */
2038 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2039 this.$tabIndexed.blur();
2040 return this;
2041 };
2042
2043 /**
2044 * @inheritdoc OO.ui.Widget
2045 */
2046 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2047 this.focus();
2048 };
2049
2050 /**
2051 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2052 * interface element that can be configured with access keys for accessibility.
2053 * See the [OOjs UI documentation on MediaWiki] [1] for examples.
2054 *
2055 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons
2056 *
2057 * @abstract
2058 * @class
2059 *
2060 * @constructor
2061 * @param {Object} [config] Configuration options
2062 * @cfg {jQuery} [$button] The button element created by the class.
2063 * If this configuration is omitted, the button element will use a generated `<a>`.
2064 * @cfg {boolean} [framed=true] Render the button with a frame
2065 */
2066 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2067 // Configuration initialization
2068 config = config || {};
2069
2070 // Properties
2071 this.$button = null;
2072 this.framed = null;
2073 this.active = config.active !== undefined && config.active;
2074 this.onMouseUpHandler = this.onMouseUp.bind( this );
2075 this.onMouseDownHandler = this.onMouseDown.bind( this );
2076 this.onKeyDownHandler = this.onKeyDown.bind( this );
2077 this.onKeyUpHandler = this.onKeyUp.bind( this );
2078 this.onClickHandler = this.onClick.bind( this );
2079 this.onKeyPressHandler = this.onKeyPress.bind( this );
2080
2081 // Initialization
2082 this.$element.addClass( 'oo-ui-buttonElement' );
2083 this.toggleFramed( config.framed === undefined || config.framed );
2084 this.setButtonElement( config.$button || $( '<a>' ) );
2085 };
2086
2087 /* Setup */
2088
2089 OO.initClass( OO.ui.mixin.ButtonElement );
2090
2091 /* Static Properties */
2092
2093 /**
2094 * Cancel mouse down events.
2095 *
2096 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2097 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2098 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2099 * parent widget.
2100 *
2101 * @static
2102 * @inheritable
2103 * @property {boolean}
2104 */
2105 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2106
2107 /* Events */
2108
2109 /**
2110 * A 'click' event is emitted when the button element is clicked.
2111 *
2112 * @event click
2113 */
2114
2115 /* Methods */
2116
2117 /**
2118 * Set the button element.
2119 *
2120 * This method is used to retarget a button mixin so that its functionality applies to
2121 * the specified button element instead of the one created by the class. If a button element
2122 * is already set, the method will remove the mixin’s effect on that element.
2123 *
2124 * @param {jQuery} $button Element to use as button
2125 */
2126 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2127 if ( this.$button ) {
2128 this.$button
2129 .removeClass( 'oo-ui-buttonElement-button' )
2130 .removeAttr( 'role accesskey' )
2131 .off( {
2132 mousedown: this.onMouseDownHandler,
2133 keydown: this.onKeyDownHandler,
2134 click: this.onClickHandler,
2135 keypress: this.onKeyPressHandler
2136 } );
2137 }
2138
2139 this.$button = $button
2140 .addClass( 'oo-ui-buttonElement-button' )
2141 .on( {
2142 mousedown: this.onMouseDownHandler,
2143 keydown: this.onKeyDownHandler,
2144 click: this.onClickHandler,
2145 keypress: this.onKeyPressHandler
2146 } );
2147
2148 // Add `role="button"` on `<a>` elements, where it's needed
2149 // `toUppercase()` is added for XHTML documents
2150 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2151 this.$button.attr( 'role', 'button' );
2152 }
2153 };
2154
2155 /**
2156 * Handles mouse down events.
2157 *
2158 * @protected
2159 * @param {jQuery.Event} e Mouse down event
2160 */
2161 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2162 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2163 return;
2164 }
2165 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2166 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2167 // reliably remove the pressed class
2168 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2169 // Prevent change of focus unless specifically configured otherwise
2170 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2171 return false;
2172 }
2173 };
2174
2175 /**
2176 * Handles mouse up events.
2177 *
2178 * @protected
2179 * @param {MouseEvent} e Mouse up event
2180 */
2181 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2182 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2183 return;
2184 }
2185 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2186 // Stop listening for mouseup, since we only needed this once
2187 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2188 };
2189
2190 /**
2191 * Handles mouse click events.
2192 *
2193 * @protected
2194 * @param {jQuery.Event} e Mouse click event
2195 * @fires click
2196 */
2197 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2198 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2199 if ( this.emit( 'click' ) ) {
2200 return false;
2201 }
2202 }
2203 };
2204
2205 /**
2206 * Handles key down events.
2207 *
2208 * @protected
2209 * @param {jQuery.Event} e Key down event
2210 */
2211 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2212 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2213 return;
2214 }
2215 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2216 // Run the keyup handler no matter where the key is when the button is let go, so we can
2217 // reliably remove the pressed class
2218 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2219 };
2220
2221 /**
2222 * Handles key up events.
2223 *
2224 * @protected
2225 * @param {KeyboardEvent} e Key up event
2226 */
2227 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2228 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2229 return;
2230 }
2231 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2232 // Stop listening for keyup, since we only needed this once
2233 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2234 };
2235
2236 /**
2237 * Handles key press events.
2238 *
2239 * @protected
2240 * @param {jQuery.Event} e Key press event
2241 * @fires click
2242 */
2243 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2244 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2245 if ( this.emit( 'click' ) ) {
2246 return false;
2247 }
2248 }
2249 };
2250
2251 /**
2252 * Check if button has a frame.
2253 *
2254 * @return {boolean} Button is framed
2255 */
2256 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2257 return this.framed;
2258 };
2259
2260 /**
2261 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2262 *
2263 * @param {boolean} [framed] Make button framed, omit to toggle
2264 * @chainable
2265 */
2266 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2267 framed = framed === undefined ? !this.framed : !!framed;
2268 if ( framed !== this.framed ) {
2269 this.framed = framed;
2270 this.$element
2271 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2272 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2273 this.updateThemeClasses();
2274 }
2275
2276 return this;
2277 };
2278
2279 /**
2280 * Set the button's active state.
2281 *
2282 * The active state can be set on:
2283 *
2284 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2285 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2286 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2287 *
2288 * @protected
2289 * @param {boolean} value Make button active
2290 * @chainable
2291 */
2292 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2293 this.active = !!value;
2294 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2295 this.updateThemeClasses();
2296 return this;
2297 };
2298
2299 /**
2300 * Check if the button is active
2301 *
2302 * @protected
2303 * @return {boolean} The button is active
2304 */
2305 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2306 return this.active;
2307 };
2308
2309 /**
2310 * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2311 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2312 * items from the group is done through the interface the class provides.
2313 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
2314 *
2315 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups
2316 *
2317 * @abstract
2318 * @mixins OO.EmitterList
2319 * @class
2320 *
2321 * @constructor
2322 * @param {Object} [config] Configuration options
2323 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2324 * is omitted, the group element will use a generated `<div>`.
2325 */
2326 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2327 // Configuration initialization
2328 config = config || {};
2329
2330 // Mixin constructors
2331 OO.EmitterList.call( this, config );
2332
2333 // Properties
2334 this.$group = null;
2335
2336 // Initialization
2337 this.setGroupElement( config.$group || $( '<div>' ) );
2338 };
2339
2340 /* Setup */
2341
2342 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2343
2344 /* Events */
2345
2346 /**
2347 * @event change
2348 *
2349 * A change event is emitted when the set of selected items changes.
2350 *
2351 * @param {OO.ui.Element[]} items Items currently in the group
2352 */
2353
2354 /* Methods */
2355
2356 /**
2357 * Set the group element.
2358 *
2359 * If an element is already set, items will be moved to the new element.
2360 *
2361 * @param {jQuery} $group Element to use as group
2362 */
2363 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2364 var i, len;
2365
2366 this.$group = $group;
2367 for ( i = 0, len = this.items.length; i < len; i++ ) {
2368 this.$group.append( this.items[ i ].$element );
2369 }
2370 };
2371
2372 /**
2373 * Get an item by its data.
2374 *
2375 * Only the first item with matching data will be returned. To return all matching items,
2376 * use the #getItemsFromData method.
2377 *
2378 * @param {Object} data Item data to search for
2379 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2380 */
2381 OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
2382 var i, len, item,
2383 hash = OO.getHash( data );
2384
2385 for ( i = 0, len = this.items.length; i < len; i++ ) {
2386 item = this.items[ i ];
2387 if ( hash === OO.getHash( item.getData() ) ) {
2388 return item;
2389 }
2390 }
2391
2392 return null;
2393 };
2394
2395 /**
2396 * Get items by their data.
2397 *
2398 * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
2399 *
2400 * @param {Object} data Item data to search for
2401 * @return {OO.ui.Element[]} Items with equivalent data
2402 */
2403 OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
2404 var i, len, item,
2405 hash = OO.getHash( data ),
2406 items = [];
2407
2408 for ( i = 0, len = this.items.length; i < len; i++ ) {
2409 item = this.items[ i ];
2410 if ( hash === OO.getHash( item.getData() ) ) {
2411 items.push( item );
2412 }
2413 }
2414
2415 return items;
2416 };
2417
2418 /**
2419 * Add items to the group.
2420 *
2421 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2422 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2423 *
2424 * @param {OO.ui.Element[]} items An array of items to add to the group
2425 * @param {number} [index] Index of the insertion point
2426 * @chainable
2427 */
2428 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2429 // Mixin method
2430 OO.EmitterList.prototype.addItems.call( this, items, index );
2431
2432 this.emit( 'change', this.getItems() );
2433 return this;
2434 };
2435
2436 /**
2437 * @inheritdoc
2438 */
2439 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2440 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2441 this.insertItemElements( items, newIndex );
2442
2443 // Mixin method
2444 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2445
2446 return newIndex;
2447 };
2448
2449 /**
2450 * @inheritdoc
2451 */
2452 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2453 item.setElementGroup( this );
2454 this.insertItemElements( item, index );
2455
2456 // Mixin method
2457 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2458
2459 return index;
2460 };
2461
2462 /**
2463 * Insert elements into the group
2464 *
2465 * @private
2466 * @param {OO.ui.Element} itemWidget Item to insert
2467 * @param {number} index Insertion index
2468 */
2469 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2470 if ( index === undefined || index < 0 || index >= this.items.length ) {
2471 this.$group.append( itemWidget.$element );
2472 } else if ( index === 0 ) {
2473 this.$group.prepend( itemWidget.$element );
2474 } else {
2475 this.items[ index ].$element.before( itemWidget.$element );
2476 }
2477 };
2478
2479 /**
2480 * Remove the specified items from a group.
2481 *
2482 * Removed items are detached (not removed) from the DOM so that they may be reused.
2483 * To remove all items from a group, you may wish to use the #clearItems method instead.
2484 *
2485 * @param {OO.ui.Element[]} items An array of items to remove
2486 * @chainable
2487 */
2488 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2489 var i, len, item, index;
2490
2491 // Remove specific items elements
2492 for ( i = 0, len = items.length; i < len; i++ ) {
2493 item = items[ i ];
2494 index = this.items.indexOf( item );
2495 if ( index !== -1 ) {
2496 item.setElementGroup( null );
2497 item.$element.detach();
2498 }
2499 }
2500
2501 // Mixin method
2502 OO.EmitterList.prototype.removeItems.call( this, items );
2503
2504 this.emit( 'change', this.getItems() );
2505 return this;
2506 };
2507
2508 /**
2509 * Clear all items from the group.
2510 *
2511 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2512 * To remove only a subset of items from a group, use the #removeItems method.
2513 *
2514 * @chainable
2515 */
2516 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2517 var i, len;
2518
2519 // Remove all item elements
2520 for ( i = 0, len = this.items.length; i < len; i++ ) {
2521 this.items[ i ].setElementGroup( null );
2522 this.items[ i ].$element.detach();
2523 }
2524
2525 // Mixin method
2526 OO.EmitterList.prototype.clearItems.call( this );
2527
2528 this.emit( 'change', this.getItems() );
2529 return this;
2530 };
2531
2532 /**
2533 * IconElement is often mixed into other classes to generate an icon.
2534 * Icons are graphics, about the size of normal text. They are used to aid the user
2535 * in locating a control or to convey information in a space-efficient way. See the
2536 * [OOjs UI documentation on MediaWiki] [1] for a list of icons
2537 * included in the library.
2538 *
2539 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2540 *
2541 * @abstract
2542 * @class
2543 *
2544 * @constructor
2545 * @param {Object} [config] Configuration options
2546 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2547 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2548 * the icon element be set to an existing icon instead of the one generated by this class, set a
2549 * value using a jQuery selection. For example:
2550 *
2551 * // Use a <div> tag instead of a <span>
2552 * $icon: $("<div>")
2553 * // Use an existing icon element instead of the one generated by the class
2554 * $icon: this.$element
2555 * // Use an icon element from a child widget
2556 * $icon: this.childwidget.$element
2557 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2558 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2559 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2560 * by the user's language.
2561 *
2562 * Example of an i18n map:
2563 *
2564 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2565 * See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
2566 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2567 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2568 * text. The icon title is displayed when users move the mouse over the icon.
2569 */
2570 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2571 // Configuration initialization
2572 config = config || {};
2573
2574 // Properties
2575 this.$icon = null;
2576 this.icon = null;
2577 this.iconTitle = null;
2578
2579 // Initialization
2580 this.setIcon( config.icon || this.constructor.static.icon );
2581 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2582 this.setIconElement( config.$icon || $( '<span>' ) );
2583 };
2584
2585 /* Setup */
2586
2587 OO.initClass( OO.ui.mixin.IconElement );
2588
2589 /* Static Properties */
2590
2591 /**
2592 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2593 * for i18n purposes and contains a `default` icon name and additional names keyed by
2594 * language code. The `default` name is used when no icon is keyed by the user's language.
2595 *
2596 * Example of an i18n map:
2597 *
2598 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2599 *
2600 * Note: the static property will be overridden if the #icon configuration is used.
2601 *
2602 * @static
2603 * @inheritable
2604 * @property {Object|string}
2605 */
2606 OO.ui.mixin.IconElement.static.icon = null;
2607
2608 /**
2609 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2610 * function that returns title text, or `null` for no title.
2611 *
2612 * The static property will be overridden if the #iconTitle configuration is used.
2613 *
2614 * @static
2615 * @inheritable
2616 * @property {string|Function|null}
2617 */
2618 OO.ui.mixin.IconElement.static.iconTitle = null;
2619
2620 /* Methods */
2621
2622 /**
2623 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2624 * applies to the specified icon element instead of the one created by the class. If an icon
2625 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2626 * and mixin methods will no longer affect the element.
2627 *
2628 * @param {jQuery} $icon Element to use as icon
2629 */
2630 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2631 if ( this.$icon ) {
2632 this.$icon
2633 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2634 .removeAttr( 'title' );
2635 }
2636
2637 this.$icon = $icon
2638 .addClass( 'oo-ui-iconElement-icon' )
2639 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2640 if ( this.iconTitle !== null ) {
2641 this.$icon.attr( 'title', this.iconTitle );
2642 }
2643
2644 this.updateThemeClasses();
2645 };
2646
2647 /**
2648 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2649 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2650 * for an example.
2651 *
2652 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2653 * by language code, or `null` to remove the icon.
2654 * @chainable
2655 */
2656 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2657 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2658 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2659
2660 if ( this.icon !== icon ) {
2661 if ( this.$icon ) {
2662 if ( this.icon !== null ) {
2663 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2664 }
2665 if ( icon !== null ) {
2666 this.$icon.addClass( 'oo-ui-icon-' + icon );
2667 }
2668 }
2669 this.icon = icon;
2670 }
2671
2672 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2673 this.updateThemeClasses();
2674
2675 return this;
2676 };
2677
2678 /**
2679 * Set the icon title. Use `null` to remove the title.
2680 *
2681 * @param {string|Function|null} iconTitle A text string used as the icon title,
2682 * a function that returns title text, or `null` for no title.
2683 * @chainable
2684 */
2685 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2686 iconTitle =
2687 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2688 OO.ui.resolveMsg( iconTitle ) : null;
2689
2690 if ( this.iconTitle !== iconTitle ) {
2691 this.iconTitle = iconTitle;
2692 if ( this.$icon ) {
2693 if ( this.iconTitle !== null ) {
2694 this.$icon.attr( 'title', iconTitle );
2695 } else {
2696 this.$icon.removeAttr( 'title' );
2697 }
2698 }
2699 }
2700
2701 return this;
2702 };
2703
2704 /**
2705 * Get the symbolic name of the icon.
2706 *
2707 * @return {string} Icon name
2708 */
2709 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2710 return this.icon;
2711 };
2712
2713 /**
2714 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2715 *
2716 * @return {string} Icon title text
2717 */
2718 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2719 return this.iconTitle;
2720 };
2721
2722 /**
2723 * IndicatorElement is often mixed into other classes to generate an indicator.
2724 * Indicators are small graphics that are generally used in two ways:
2725 *
2726 * - To draw attention to the status of an item. For example, an indicator might be
2727 * used to show that an item in a list has errors that need to be resolved.
2728 * - To clarify the function of a control that acts in an exceptional way (a button
2729 * that opens a menu instead of performing an action directly, for example).
2730 *
2731 * For a list of indicators included in the library, please see the
2732 * [OOjs UI documentation on MediaWiki] [1].
2733 *
2734 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2735 *
2736 * @abstract
2737 * @class
2738 *
2739 * @constructor
2740 * @param {Object} [config] Configuration options
2741 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2742 * configuration is omitted, the indicator element will use a generated `<span>`.
2743 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2744 * See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
2745 * in the library.
2746 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2747 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2748 * or a function that returns title text. The indicator title is displayed when users move
2749 * the mouse over the indicator.
2750 */
2751 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2752 // Configuration initialization
2753 config = config || {};
2754
2755 // Properties
2756 this.$indicator = null;
2757 this.indicator = null;
2758 this.indicatorTitle = null;
2759
2760 // Initialization
2761 this.setIndicator( config.indicator || this.constructor.static.indicator );
2762 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2763 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2764 };
2765
2766 /* Setup */
2767
2768 OO.initClass( OO.ui.mixin.IndicatorElement );
2769
2770 /* Static Properties */
2771
2772 /**
2773 * Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2774 * The static property will be overridden if the #indicator configuration is used.
2775 *
2776 * @static
2777 * @inheritable
2778 * @property {string|null}
2779 */
2780 OO.ui.mixin.IndicatorElement.static.indicator = null;
2781
2782 /**
2783 * A text string used as the indicator title, a function that returns title text, or `null`
2784 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2785 *
2786 * @static
2787 * @inheritable
2788 * @property {string|Function|null}
2789 */
2790 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2791
2792 /* Methods */
2793
2794 /**
2795 * Set the indicator element.
2796 *
2797 * If an element is already set, it will be cleaned up before setting up the new element.
2798 *
2799 * @param {jQuery} $indicator Element to use as indicator
2800 */
2801 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2802 if ( this.$indicator ) {
2803 this.$indicator
2804 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2805 .removeAttr( 'title' );
2806 }
2807
2808 this.$indicator = $indicator
2809 .addClass( 'oo-ui-indicatorElement-indicator' )
2810 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2811 if ( this.indicatorTitle !== null ) {
2812 this.$indicator.attr( 'title', this.indicatorTitle );
2813 }
2814
2815 this.updateThemeClasses();
2816 };
2817
2818 /**
2819 * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
2820 *
2821 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2822 * @chainable
2823 */
2824 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2825 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2826
2827 if ( this.indicator !== indicator ) {
2828 if ( this.$indicator ) {
2829 if ( this.indicator !== null ) {
2830 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2831 }
2832 if ( indicator !== null ) {
2833 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2834 }
2835 }
2836 this.indicator = indicator;
2837 }
2838
2839 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2840 this.updateThemeClasses();
2841
2842 return this;
2843 };
2844
2845 /**
2846 * Set the indicator title.
2847 *
2848 * The title is displayed when a user moves the mouse over the indicator.
2849 *
2850 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2851 * `null` for no indicator title
2852 * @chainable
2853 */
2854 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2855 indicatorTitle =
2856 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2857 OO.ui.resolveMsg( indicatorTitle ) : null;
2858
2859 if ( this.indicatorTitle !== indicatorTitle ) {
2860 this.indicatorTitle = indicatorTitle;
2861 if ( this.$indicator ) {
2862 if ( this.indicatorTitle !== null ) {
2863 this.$indicator.attr( 'title', indicatorTitle );
2864 } else {
2865 this.$indicator.removeAttr( 'title' );
2866 }
2867 }
2868 }
2869
2870 return this;
2871 };
2872
2873 /**
2874 * Get the symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2875 *
2876 * @return {string} Symbolic name of indicator
2877 */
2878 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2879 return this.indicator;
2880 };
2881
2882 /**
2883 * Get the indicator title.
2884 *
2885 * The title is displayed when a user moves the mouse over the indicator.
2886 *
2887 * @return {string} Indicator title text
2888 */
2889 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2890 return this.indicatorTitle;
2891 };
2892
2893 /**
2894 * LabelElement is often mixed into other classes to generate a label, which
2895 * helps identify the function of an interface element.
2896 * See the [OOjs UI documentation on MediaWiki] [1] for more information.
2897 *
2898 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2899 *
2900 * @abstract
2901 * @class
2902 *
2903 * @constructor
2904 * @param {Object} [config] Configuration options
2905 * @cfg {jQuery} [$label] The label element created by the class. If this
2906 * configuration is omitted, the label element will use a generated `<span>`.
2907 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2908 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2909 * in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
2910 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2911 */
2912 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2913 // Configuration initialization
2914 config = config || {};
2915
2916 // Properties
2917 this.$label = null;
2918 this.label = null;
2919
2920 // Initialization
2921 this.setLabel( config.label || this.constructor.static.label );
2922 this.setLabelElement( config.$label || $( '<span>' ) );
2923 };
2924
2925 /* Setup */
2926
2927 OO.initClass( OO.ui.mixin.LabelElement );
2928
2929 /* Events */
2930
2931 /**
2932 * @event labelChange
2933 * @param {string} value
2934 */
2935
2936 /* Static Properties */
2937
2938 /**
2939 * The label text. The label can be specified as a plaintext string, a function that will
2940 * produce a string in the future, or `null` for no label. The static value will
2941 * be overridden if a label is specified with the #label config option.
2942 *
2943 * @static
2944 * @inheritable
2945 * @property {string|Function|null}
2946 */
2947 OO.ui.mixin.LabelElement.static.label = null;
2948
2949 /* Static methods */
2950
2951 /**
2952 * Highlight the first occurrence of the query in the given text
2953 *
2954 * @param {string} text Text
2955 * @param {string} query Query to find
2956 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2957 * @return {jQuery} Text with the first match of the query
2958 * sub-string wrapped in highlighted span
2959 */
2960 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2961 var i, offset, tLen, qLen,
2962 $result = $( '<span>' );
2963
2964 if ( compare ) {
2965 tLen = text.length;
2966 qLen = query.length;
2967 for ( i = 0; offset === undefined && i <= tLen - qLen; i++ ) {
2968 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2969 offset = i;
2970 }
2971 }
2972 } else {
2973 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2974 }
2975
2976 if ( !query.length || offset === -1 ) {
2977 return $result.text( text );
2978 }
2979 $result.append(
2980 document.createTextNode( text.slice( 0, offset ) ),
2981 $( '<span>' )
2982 .addClass( 'oo-ui-labelElement-label-highlight' )
2983 .text( text.slice( offset, offset + query.length ) ),
2984 document.createTextNode( text.slice( offset + query.length ) )
2985 );
2986 return $result.contents();
2987 };
2988
2989 /* Methods */
2990
2991 /**
2992 * Set the label element.
2993 *
2994 * If an element is already set, it will be cleaned up before setting up the new element.
2995 *
2996 * @param {jQuery} $label Element to use as label
2997 */
2998 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2999 if ( this.$label ) {
3000 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3001 }
3002
3003 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3004 this.setLabelContent( this.label );
3005 };
3006
3007 /**
3008 * Set the label.
3009 *
3010 * An empty string will result in the label being hidden. A string containing only whitespace will
3011 * be converted to a single `&nbsp;`.
3012 *
3013 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3014 * text; or null for no label
3015 * @chainable
3016 */
3017 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3018 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3019 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3020
3021 if ( this.label !== label ) {
3022 if ( this.$label ) {
3023 this.setLabelContent( label );
3024 }
3025 this.label = label;
3026 this.emit( 'labelChange' );
3027 }
3028
3029 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3030
3031 return this;
3032 };
3033
3034 /**
3035 * Set the label as plain text with a highlighted query
3036 *
3037 * @param {string} text Text label to set
3038 * @param {string} query Substring of text to highlight
3039 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3040 * @chainable
3041 */
3042 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3043 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3044 };
3045
3046 /**
3047 * Get the label.
3048 *
3049 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3050 * text; or null for no label
3051 */
3052 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3053 return this.label;
3054 };
3055
3056 /**
3057 * Set the content of the label.
3058 *
3059 * Do not call this method until after the label element has been set by #setLabelElement.
3060 *
3061 * @private
3062 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3063 * text; or null for no label
3064 */
3065 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3066 if ( typeof label === 'string' ) {
3067 if ( label.match( /^\s*$/ ) ) {
3068 // Convert whitespace only string to a single non-breaking space
3069 this.$label.html( '&nbsp;' );
3070 } else {
3071 this.$label.text( label );
3072 }
3073 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3074 this.$label.html( label.toString() );
3075 } else if ( label instanceof jQuery ) {
3076 this.$label.empty().append( label );
3077 } else {
3078 this.$label.empty();
3079 }
3080 };
3081
3082 /**
3083 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3084 * additional functionality to an element created by another class. The class provides
3085 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3086 * which are used to customize the look and feel of a widget to better describe its
3087 * importance and functionality.
3088 *
3089 * The library currently contains the following styling flags for general use:
3090 *
3091 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3092 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3093 * - **constructive**: Constructive styling is deprecated since v0.23.2 and equivalent to progressive.
3094 *
3095 * The flags affect the appearance of the buttons:
3096 *
3097 * @example
3098 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3099 * var button1 = new OO.ui.ButtonWidget( {
3100 * label: 'Progressive',
3101 * flags: 'progressive'
3102 * } );
3103 * var button2 = new OO.ui.ButtonWidget( {
3104 * label: 'Destructive',
3105 * flags: 'destructive'
3106 * } );
3107 * $( 'body' ).append( button1.$element, button2.$element );
3108 *
3109 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3110 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
3111 *
3112 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3113 *
3114 * @abstract
3115 * @class
3116 *
3117 * @constructor
3118 * @param {Object} [config] Configuration options
3119 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3120 * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
3121 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3122 * @cfg {jQuery} [$flagged] The flagged element. By default,
3123 * the flagged functionality is applied to the element created by the class ($element).
3124 * If a different element is specified, the flagged functionality will be applied to it instead.
3125 */
3126 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3127 // Configuration initialization
3128 config = config || {};
3129
3130 // Properties
3131 this.flags = {};
3132 this.$flagged = null;
3133
3134 // Initialization
3135 this.setFlags( config.flags );
3136 this.setFlaggedElement( config.$flagged || this.$element );
3137 };
3138
3139 /* Events */
3140
3141 /**
3142 * @event flag
3143 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3144 * parameter contains the name of each modified flag and indicates whether it was
3145 * added or removed.
3146 *
3147 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3148 * that the flag was added, `false` that the flag was removed.
3149 */
3150
3151 /* Methods */
3152
3153 /**
3154 * Set the flagged element.
3155 *
3156 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3157 * If an element is already set, the method will remove the mixin’s effect on that element.
3158 *
3159 * @param {jQuery} $flagged Element that should be flagged
3160 */
3161 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3162 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3163 return 'oo-ui-flaggedElement-' + flag;
3164 } ).join( ' ' );
3165
3166 if ( this.$flagged ) {
3167 this.$flagged.removeClass( classNames );
3168 }
3169
3170 this.$flagged = $flagged.addClass( classNames );
3171 };
3172
3173 /**
3174 * Check if the specified flag is set.
3175 *
3176 * @param {string} flag Name of flag
3177 * @return {boolean} The flag is set
3178 */
3179 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3180 // This may be called before the constructor, thus before this.flags is set
3181 return this.flags && ( flag in this.flags );
3182 };
3183
3184 /**
3185 * Get the names of all flags set.
3186 *
3187 * @return {string[]} Flag names
3188 */
3189 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3190 // This may be called before the constructor, thus before this.flags is set
3191 return Object.keys( this.flags || {} );
3192 };
3193
3194 /**
3195 * Clear all flags.
3196 *
3197 * @chainable
3198 * @fires flag
3199 */
3200 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3201 var flag, className,
3202 changes = {},
3203 remove = [],
3204 classPrefix = 'oo-ui-flaggedElement-';
3205
3206 for ( flag in this.flags ) {
3207 className = classPrefix + flag;
3208 changes[ flag ] = false;
3209 delete this.flags[ flag ];
3210 remove.push( className );
3211 }
3212
3213 if ( this.$flagged ) {
3214 this.$flagged.removeClass( remove.join( ' ' ) );
3215 }
3216
3217 this.updateThemeClasses();
3218 this.emit( 'flag', changes );
3219
3220 return this;
3221 };
3222
3223 /**
3224 * Add one or more flags.
3225 *
3226 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3227 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3228 * be added (`true`) or removed (`false`).
3229 * @chainable
3230 * @fires flag
3231 */
3232 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3233 var i, len, flag, className,
3234 changes = {},
3235 add = [],
3236 remove = [],
3237 classPrefix = 'oo-ui-flaggedElement-';
3238
3239 if ( typeof flags === 'string' ) {
3240 className = classPrefix + flags;
3241 // Set
3242 if ( !this.flags[ flags ] ) {
3243 this.flags[ flags ] = true;
3244 add.push( className );
3245 }
3246 } else if ( Array.isArray( flags ) ) {
3247 for ( i = 0, len = flags.length; i < len; i++ ) {
3248 flag = flags[ i ];
3249 className = classPrefix + flag;
3250 // Set
3251 if ( !this.flags[ flag ] ) {
3252 changes[ flag ] = true;
3253 this.flags[ flag ] = true;
3254 add.push( className );
3255 }
3256 }
3257 } else if ( OO.isPlainObject( flags ) ) {
3258 for ( flag in flags ) {
3259 className = classPrefix + flag;
3260 if ( flags[ flag ] ) {
3261 // Set
3262 if ( !this.flags[ flag ] ) {
3263 changes[ flag ] = true;
3264 this.flags[ flag ] = true;
3265 add.push( className );
3266 }
3267 } else {
3268 // Remove
3269 if ( this.flags[ flag ] ) {
3270 changes[ flag ] = false;
3271 delete this.flags[ flag ];
3272 remove.push( className );
3273 }
3274 }
3275 }
3276 }
3277
3278 if ( this.$flagged ) {
3279 this.$flagged
3280 .addClass( add.join( ' ' ) )
3281 .removeClass( remove.join( ' ' ) );
3282 }
3283
3284 this.updateThemeClasses();
3285 this.emit( 'flag', changes );
3286
3287 return this;
3288 };
3289
3290 /**
3291 * TitledElement is mixed into other classes to provide a `title` attribute.
3292 * Titles are rendered by the browser and are made visible when the user moves
3293 * the mouse over the element. Titles are not visible on touch devices.
3294 *
3295 * @example
3296 * // TitledElement provides a 'title' attribute to the
3297 * // ButtonWidget class
3298 * var button = new OO.ui.ButtonWidget( {
3299 * label: 'Button with Title',
3300 * title: 'I am a button'
3301 * } );
3302 * $( 'body' ).append( button.$element );
3303 *
3304 * @abstract
3305 * @class
3306 *
3307 * @constructor
3308 * @param {Object} [config] Configuration options
3309 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3310 * If this config is omitted, the title functionality is applied to $element, the
3311 * element created by the class.
3312 * @cfg {string|Function} [title] The title text or a function that returns text. If
3313 * this config is omitted, the value of the {@link #static-title static title} property is used.
3314 */
3315 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3316 // Configuration initialization
3317 config = config || {};
3318
3319 // Properties
3320 this.$titled = null;
3321 this.title = null;
3322
3323 // Initialization
3324 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3325 this.setTitledElement( config.$titled || this.$element );
3326 };
3327
3328 /* Setup */
3329
3330 OO.initClass( OO.ui.mixin.TitledElement );
3331
3332 /* Static Properties */
3333
3334 /**
3335 * The title text, a function that returns text, or `null` for no title. The value of the static property
3336 * is overridden if the #title config option is used.
3337 *
3338 * @static
3339 * @inheritable
3340 * @property {string|Function|null}
3341 */
3342 OO.ui.mixin.TitledElement.static.title = null;
3343
3344 /* Methods */
3345
3346 /**
3347 * Set the titled element.
3348 *
3349 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3350 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3351 *
3352 * @param {jQuery} $titled Element that should use the 'titled' functionality
3353 */
3354 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3355 if ( this.$titled ) {
3356 this.$titled.removeAttr( 'title' );
3357 }
3358
3359 this.$titled = $titled;
3360 if ( this.title ) {
3361 this.updateTitle();
3362 }
3363 };
3364
3365 /**
3366 * Set title.
3367 *
3368 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3369 * @chainable
3370 */
3371 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3372 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3373 title = ( typeof title === 'string' && title.length ) ? title : null;
3374
3375 if ( this.title !== title ) {
3376 this.title = title;
3377 this.updateTitle();
3378 }
3379
3380 return this;
3381 };
3382
3383 /**
3384 * Update the title attribute, in case of changes to title or accessKey.
3385 *
3386 * @protected
3387 * @chainable
3388 */
3389 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3390 var title = this.getTitle();
3391 if ( this.$titled ) {
3392 if ( title !== null ) {
3393 // Only if this is an AccessKeyedElement
3394 if ( this.formatTitleWithAccessKey ) {
3395 title = this.formatTitleWithAccessKey( title );
3396 }
3397 this.$titled.attr( 'title', title );
3398 } else {
3399 this.$titled.removeAttr( 'title' );
3400 }
3401 }
3402 return this;
3403 };
3404
3405 /**
3406 * Get title.
3407 *
3408 * @return {string} Title string
3409 */
3410 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3411 return this.title;
3412 };
3413
3414 /**
3415 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3416 * Accesskeys allow an user to go to a specific element by using
3417 * a shortcut combination of a browser specific keys + the key
3418 * set to the field.
3419 *
3420 * @example
3421 * // AccessKeyedElement provides an 'accesskey' attribute to the
3422 * // ButtonWidget class
3423 * var button = new OO.ui.ButtonWidget( {
3424 * label: 'Button with Accesskey',
3425 * accessKey: 'k'
3426 * } );
3427 * $( 'body' ).append( button.$element );
3428 *
3429 * @abstract
3430 * @class
3431 *
3432 * @constructor
3433 * @param {Object} [config] Configuration options
3434 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3435 * If this config is omitted, the accesskey functionality is applied to $element, the
3436 * element created by the class.
3437 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3438 * this config is omitted, no accesskey will be added.
3439 */
3440 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3441 // Configuration initialization
3442 config = config || {};
3443
3444 // Properties
3445 this.$accessKeyed = null;
3446 this.accessKey = null;
3447
3448 // Initialization
3449 this.setAccessKey( config.accessKey || null );
3450 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3451
3452 // If this is also a TitledElement and it initialized before we did, we may have
3453 // to update the title with the access key
3454 if ( this.updateTitle ) {
3455 this.updateTitle();
3456 }
3457 };
3458
3459 /* Setup */
3460
3461 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3462
3463 /* Static Properties */
3464
3465 /**
3466 * The access key, a function that returns a key, or `null` for no accesskey.
3467 *
3468 * @static
3469 * @inheritable
3470 * @property {string|Function|null}
3471 */
3472 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3473
3474 /* Methods */
3475
3476 /**
3477 * Set the accesskeyed element.
3478 *
3479 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3480 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3481 *
3482 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3483 */
3484 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3485 if ( this.$accessKeyed ) {
3486 this.$accessKeyed.removeAttr( 'accesskey' );
3487 }
3488
3489 this.$accessKeyed = $accessKeyed;
3490 if ( this.accessKey ) {
3491 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3492 }
3493 };
3494
3495 /**
3496 * Set accesskey.
3497 *
3498 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3499 * @chainable
3500 */
3501 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3502 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3503
3504 if ( this.accessKey !== accessKey ) {
3505 if ( this.$accessKeyed ) {
3506 if ( accessKey !== null ) {
3507 this.$accessKeyed.attr( 'accesskey', accessKey );
3508 } else {
3509 this.$accessKeyed.removeAttr( 'accesskey' );
3510 }
3511 }
3512 this.accessKey = accessKey;
3513
3514 // Only if this is a TitledElement
3515 if ( this.updateTitle ) {
3516 this.updateTitle();
3517 }
3518 }
3519
3520 return this;
3521 };
3522
3523 /**
3524 * Get accesskey.
3525 *
3526 * @return {string} accessKey string
3527 */
3528 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3529 return this.accessKey;
3530 };
3531
3532 /**
3533 * Add information about the access key to the element's tooltip label.
3534 * (This is only public for hacky usage in FieldLayout.)
3535 *
3536 * @param {string} title Tooltip label for `title` attribute
3537 * @return {string}
3538 */
3539 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3540 var accessKey;
3541
3542 if ( !this.$accessKeyed ) {
3543 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3544 return title;
3545 }
3546 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3547 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3548 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3549 } else {
3550 accessKey = this.getAccessKey();
3551 }
3552 if ( accessKey ) {
3553 title += ' [' + accessKey + ']';
3554 }
3555 return title;
3556 };
3557
3558 /**
3559 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3560 * feels, and functionality can be customized via the class’s configuration options
3561 * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information
3562 * and examples.
3563 *
3564 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches
3565 *
3566 * @example
3567 * // A button widget
3568 * var button = new OO.ui.ButtonWidget( {
3569 * label: 'Button with Icon',
3570 * icon: 'trash',
3571 * iconTitle: 'Remove'
3572 * } );
3573 * $( 'body' ).append( button.$element );
3574 *
3575 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3576 *
3577 * @class
3578 * @extends OO.ui.Widget
3579 * @mixins OO.ui.mixin.ButtonElement
3580 * @mixins OO.ui.mixin.IconElement
3581 * @mixins OO.ui.mixin.IndicatorElement
3582 * @mixins OO.ui.mixin.LabelElement
3583 * @mixins OO.ui.mixin.TitledElement
3584 * @mixins OO.ui.mixin.FlaggedElement
3585 * @mixins OO.ui.mixin.TabIndexedElement
3586 * @mixins OO.ui.mixin.AccessKeyedElement
3587 *
3588 * @constructor
3589 * @param {Object} [config] Configuration options
3590 * @cfg {boolean} [active=false] Whether button should be shown as active
3591 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3592 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3593 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3594 */
3595 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3596 // Configuration initialization
3597 config = config || {};
3598
3599 // Parent constructor
3600 OO.ui.ButtonWidget.parent.call( this, config );
3601
3602 // Mixin constructors
3603 OO.ui.mixin.ButtonElement.call( this, config );
3604 OO.ui.mixin.IconElement.call( this, config );
3605 OO.ui.mixin.IndicatorElement.call( this, config );
3606 OO.ui.mixin.LabelElement.call( this, config );
3607 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3608 OO.ui.mixin.FlaggedElement.call( this, config );
3609 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3610 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3611
3612 // Properties
3613 this.href = null;
3614 this.target = null;
3615 this.noFollow = false;
3616
3617 // Events
3618 this.connect( this, { disable: 'onDisable' } );
3619
3620 // Initialization
3621 this.$button.append( this.$icon, this.$label, this.$indicator );
3622 this.$element
3623 .addClass( 'oo-ui-buttonWidget' )
3624 .append( this.$button );
3625 this.setActive( config.active );
3626 this.setHref( config.href );
3627 this.setTarget( config.target );
3628 this.setNoFollow( config.noFollow );
3629 };
3630
3631 /* Setup */
3632
3633 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3634 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3635 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3636 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3637 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3638 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3639 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3640 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3641 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3642
3643 /* Static Properties */
3644
3645 /**
3646 * @static
3647 * @inheritdoc
3648 */
3649 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3650
3651 /**
3652 * @static
3653 * @inheritdoc
3654 */
3655 OO.ui.ButtonWidget.static.tagName = 'span';
3656
3657 /* Methods */
3658
3659 /**
3660 * Get hyperlink location.
3661 *
3662 * @return {string} Hyperlink location
3663 */
3664 OO.ui.ButtonWidget.prototype.getHref = function () {
3665 return this.href;
3666 };
3667
3668 /**
3669 * Get hyperlink target.
3670 *
3671 * @return {string} Hyperlink target
3672 */
3673 OO.ui.ButtonWidget.prototype.getTarget = function () {
3674 return this.target;
3675 };
3676
3677 /**
3678 * Get search engine traversal hint.
3679 *
3680 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3681 */
3682 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3683 return this.noFollow;
3684 };
3685
3686 /**
3687 * Set hyperlink location.
3688 *
3689 * @param {string|null} href Hyperlink location, null to remove
3690 */
3691 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3692 href = typeof href === 'string' ? href : null;
3693 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3694 href = './' + href;
3695 }
3696
3697 if ( href !== this.href ) {
3698 this.href = href;
3699 this.updateHref();
3700 }
3701
3702 return this;
3703 };
3704
3705 /**
3706 * Update the `href` attribute, in case of changes to href or
3707 * disabled state.
3708 *
3709 * @private
3710 * @chainable
3711 */
3712 OO.ui.ButtonWidget.prototype.updateHref = function () {
3713 if ( this.href !== null && !this.isDisabled() ) {
3714 this.$button.attr( 'href', this.href );
3715 } else {
3716 this.$button.removeAttr( 'href' );
3717 }
3718
3719 return this;
3720 };
3721
3722 /**
3723 * Handle disable events.
3724 *
3725 * @private
3726 * @param {boolean} disabled Element is disabled
3727 */
3728 OO.ui.ButtonWidget.prototype.onDisable = function () {
3729 this.updateHref();
3730 };
3731
3732 /**
3733 * Set hyperlink target.
3734 *
3735 * @param {string|null} target Hyperlink target, null to remove
3736 */
3737 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3738 target = typeof target === 'string' ? target : null;
3739
3740 if ( target !== this.target ) {
3741 this.target = target;
3742 if ( target !== null ) {
3743 this.$button.attr( 'target', target );
3744 } else {
3745 this.$button.removeAttr( 'target' );
3746 }
3747 }
3748
3749 return this;
3750 };
3751
3752 /**
3753 * Set search engine traversal hint.
3754 *
3755 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3756 */
3757 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3758 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3759
3760 if ( noFollow !== this.noFollow ) {
3761 this.noFollow = noFollow;
3762 if ( noFollow ) {
3763 this.$button.attr( 'rel', 'nofollow' );
3764 } else {
3765 this.$button.removeAttr( 'rel' );
3766 }
3767 }
3768
3769 return this;
3770 };
3771
3772 // Override method visibility hints from ButtonElement
3773 /**
3774 * @method setActive
3775 * @inheritdoc
3776 */
3777 /**
3778 * @method isActive
3779 * @inheritdoc
3780 */
3781
3782 /**
3783 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3784 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3785 * removed, and cleared from the group.
3786 *
3787 * @example
3788 * // Example: A ButtonGroupWidget with two buttons
3789 * var button1 = new OO.ui.PopupButtonWidget( {
3790 * label: 'Select a category',
3791 * icon: 'menu',
3792 * popup: {
3793 * $content: $( '<p>List of categories...</p>' ),
3794 * padded: true,
3795 * align: 'left'
3796 * }
3797 * } );
3798 * var button2 = new OO.ui.ButtonWidget( {
3799 * label: 'Add item'
3800 * });
3801 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3802 * items: [button1, button2]
3803 * } );
3804 * $( 'body' ).append( buttonGroup.$element );
3805 *
3806 * @class
3807 * @extends OO.ui.Widget
3808 * @mixins OO.ui.mixin.GroupElement
3809 *
3810 * @constructor
3811 * @param {Object} [config] Configuration options
3812 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3813 */
3814 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3815 // Configuration initialization
3816 config = config || {};
3817
3818 // Parent constructor
3819 OO.ui.ButtonGroupWidget.parent.call( this, config );
3820
3821 // Mixin constructors
3822 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3823
3824 // Initialization
3825 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3826 if ( Array.isArray( config.items ) ) {
3827 this.addItems( config.items );
3828 }
3829 };
3830
3831 /* Setup */
3832
3833 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3834 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3835
3836 /* Static Properties */
3837
3838 /**
3839 * @static
3840 * @inheritdoc
3841 */
3842 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3843
3844 /* Methods */
3845
3846 /**
3847 * Focus the widget
3848 *
3849 * @chainable
3850 */
3851 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3852 if ( !this.isDisabled() ) {
3853 if ( this.items[ 0 ] ) {
3854 this.items[ 0 ].focus();
3855 }
3856 }
3857 return this;
3858 };
3859
3860 /**
3861 * @inheritdoc
3862 */
3863 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3864 this.focus();
3865 };
3866
3867 /**
3868 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3869 * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
3870 * for a list of icons included in the library.
3871 *
3872 * @example
3873 * // An icon widget with a label
3874 * var myIcon = new OO.ui.IconWidget( {
3875 * icon: 'help',
3876 * iconTitle: 'Help'
3877 * } );
3878 * // Create a label.
3879 * var iconLabel = new OO.ui.LabelWidget( {
3880 * label: 'Help'
3881 * } );
3882 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3883 *
3884 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
3885 *
3886 * @class
3887 * @extends OO.ui.Widget
3888 * @mixins OO.ui.mixin.IconElement
3889 * @mixins OO.ui.mixin.TitledElement
3890 * @mixins OO.ui.mixin.FlaggedElement
3891 *
3892 * @constructor
3893 * @param {Object} [config] Configuration options
3894 */
3895 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3896 // Configuration initialization
3897 config = config || {};
3898
3899 // Parent constructor
3900 OO.ui.IconWidget.parent.call( this, config );
3901
3902 // Mixin constructors
3903 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3904 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3905 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3906
3907 // Initialization
3908 this.$element.addClass( 'oo-ui-iconWidget' );
3909 };
3910
3911 /* Setup */
3912
3913 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3914 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3915 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3916 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3917
3918 /* Static Properties */
3919
3920 /**
3921 * @static
3922 * @inheritdoc
3923 */
3924 OO.ui.IconWidget.static.tagName = 'span';
3925
3926 /**
3927 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3928 * attention to the status of an item or to clarify the function of a control. For a list of
3929 * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
3930 *
3931 * @example
3932 * // Example of an indicator widget
3933 * var indicator1 = new OO.ui.IndicatorWidget( {
3934 * indicator: 'alert'
3935 * } );
3936 *
3937 * // Create a fieldset layout to add a label
3938 * var fieldset = new OO.ui.FieldsetLayout();
3939 * fieldset.addItems( [
3940 * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
3941 * ] );
3942 * $( 'body' ).append( fieldset.$element );
3943 *
3944 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3945 *
3946 * @class
3947 * @extends OO.ui.Widget
3948 * @mixins OO.ui.mixin.IndicatorElement
3949 * @mixins OO.ui.mixin.TitledElement
3950 *
3951 * @constructor
3952 * @param {Object} [config] Configuration options
3953 */
3954 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
3955 // Configuration initialization
3956 config = config || {};
3957
3958 // Parent constructor
3959 OO.ui.IndicatorWidget.parent.call( this, config );
3960
3961 // Mixin constructors
3962 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
3963 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3964
3965 // Initialization
3966 this.$element.addClass( 'oo-ui-indicatorWidget' );
3967 };
3968
3969 /* Setup */
3970
3971 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
3972 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
3973 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
3974
3975 /* Static Properties */
3976
3977 /**
3978 * @static
3979 * @inheritdoc
3980 */
3981 OO.ui.IndicatorWidget.static.tagName = 'span';
3982
3983 /**
3984 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
3985 * be configured with a `label` option that is set to a string, a label node, or a function:
3986 *
3987 * - String: a plaintext string
3988 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
3989 * label that includes a link or special styling, such as a gray color or additional graphical elements.
3990 * - Function: a function that will produce a string in the future. Functions are used
3991 * in cases where the value of the label is not currently defined.
3992 *
3993 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
3994 * will come into focus when the label is clicked.
3995 *
3996 * @example
3997 * // Examples of LabelWidgets
3998 * var label1 = new OO.ui.LabelWidget( {
3999 * label: 'plaintext label'
4000 * } );
4001 * var label2 = new OO.ui.LabelWidget( {
4002 * label: $( '<a href="default.html">jQuery label</a>' )
4003 * } );
4004 * // Create a fieldset layout with fields for each example
4005 * var fieldset = new OO.ui.FieldsetLayout();
4006 * fieldset.addItems( [
4007 * new OO.ui.FieldLayout( label1 ),
4008 * new OO.ui.FieldLayout( label2 )
4009 * ] );
4010 * $( 'body' ).append( fieldset.$element );
4011 *
4012 * @class
4013 * @extends OO.ui.Widget
4014 * @mixins OO.ui.mixin.LabelElement
4015 * @mixins OO.ui.mixin.TitledElement
4016 *
4017 * @constructor
4018 * @param {Object} [config] Configuration options
4019 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4020 * Clicking the label will focus the specified input field.
4021 */
4022 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4023 // Configuration initialization
4024 config = config || {};
4025
4026 // Parent constructor
4027 OO.ui.LabelWidget.parent.call( this, config );
4028
4029 // Mixin constructors
4030 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4031 OO.ui.mixin.TitledElement.call( this, config );
4032
4033 // Properties
4034 this.input = config.input;
4035
4036 // Initialization
4037 if ( this.input ) {
4038 if ( this.input.getInputId() ) {
4039 this.$element.attr( 'for', this.input.getInputId() );
4040 } else {
4041 this.$label.on( 'click', function () {
4042 this.input.simulateLabelClick();
4043 return false;
4044 }.bind( this ) );
4045 }
4046 }
4047 this.$element.addClass( 'oo-ui-labelWidget' );
4048 };
4049
4050 /* Setup */
4051
4052 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4053 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4054 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4055
4056 /* Static Properties */
4057
4058 /**
4059 * @static
4060 * @inheritdoc
4061 */
4062 OO.ui.LabelWidget.static.tagName = 'label';
4063
4064 /**
4065 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4066 * and that they should wait before proceeding. The pending state is visually represented with a pending
4067 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4068 * field of a {@link OO.ui.TextInputWidget text input widget}.
4069 *
4070 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4071 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4072 * in process dialogs.
4073 *
4074 * @example
4075 * function MessageDialog( config ) {
4076 * MessageDialog.parent.call( this, config );
4077 * }
4078 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4079 *
4080 * MessageDialog.static.name = 'myMessageDialog';
4081 * MessageDialog.static.actions = [
4082 * { action: 'save', label: 'Done', flags: 'primary' },
4083 * { label: 'Cancel', flags: 'safe' }
4084 * ];
4085 *
4086 * MessageDialog.prototype.initialize = function () {
4087 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4088 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4089 * 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>' );
4090 * this.$body.append( this.content.$element );
4091 * };
4092 * MessageDialog.prototype.getBodyHeight = function () {
4093 * return 100;
4094 * }
4095 * MessageDialog.prototype.getActionProcess = function ( action ) {
4096 * var dialog = this;
4097 * if ( action === 'save' ) {
4098 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4099 * return new OO.ui.Process()
4100 * .next( 1000 )
4101 * .next( function () {
4102 * dialog.getActions().get({actions: 'save'})[0].popPending();
4103 * } );
4104 * }
4105 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4106 * };
4107 *
4108 * var windowManager = new OO.ui.WindowManager();
4109 * $( 'body' ).append( windowManager.$element );
4110 *
4111 * var dialog = new MessageDialog();
4112 * windowManager.addWindows( [ dialog ] );
4113 * windowManager.openWindow( dialog );
4114 *
4115 * @abstract
4116 * @class
4117 *
4118 * @constructor
4119 * @param {Object} [config] Configuration options
4120 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4121 */
4122 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4123 // Configuration initialization
4124 config = config || {};
4125
4126 // Properties
4127 this.pending = 0;
4128 this.$pending = null;
4129
4130 // Initialisation
4131 this.setPendingElement( config.$pending || this.$element );
4132 };
4133
4134 /* Setup */
4135
4136 OO.initClass( OO.ui.mixin.PendingElement );
4137
4138 /* Methods */
4139
4140 /**
4141 * Set the pending element (and clean up any existing one).
4142 *
4143 * @param {jQuery} $pending The element to set to pending.
4144 */
4145 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4146 if ( this.$pending ) {
4147 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4148 }
4149
4150 this.$pending = $pending;
4151 if ( this.pending > 0 ) {
4152 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4153 }
4154 };
4155
4156 /**
4157 * Check if an element is pending.
4158 *
4159 * @return {boolean} Element is pending
4160 */
4161 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4162 return !!this.pending;
4163 };
4164
4165 /**
4166 * Increase the pending counter. The pending state will remain active until the counter is zero
4167 * (i.e., the number of calls to #pushPending and #popPending is the same).
4168 *
4169 * @chainable
4170 */
4171 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4172 if ( this.pending === 0 ) {
4173 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4174 this.updateThemeClasses();
4175 }
4176 this.pending++;
4177
4178 return this;
4179 };
4180
4181 /**
4182 * Decrease the pending counter. The pending state will remain active until the counter is zero
4183 * (i.e., the number of calls to #pushPending and #popPending is the same).
4184 *
4185 * @chainable
4186 */
4187 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4188 if ( this.pending === 1 ) {
4189 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4190 this.updateThemeClasses();
4191 }
4192 this.pending = Math.max( 0, this.pending - 1 );
4193
4194 return this;
4195 };
4196
4197 /**
4198 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4199 * in the document (for example, in an OO.ui.Window's $overlay).
4200 *
4201 * The elements's position is automatically calculated and maintained when window is resized or the
4202 * page is scrolled. If you reposition the container manually, you have to call #position to make
4203 * sure the element is still placed correctly.
4204 *
4205 * As positioning is only possible when both the element and the container are attached to the DOM
4206 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4207 * the #toggle method to display a floating popup, for example.
4208 *
4209 * @abstract
4210 * @class
4211 *
4212 * @constructor
4213 * @param {Object} [config] Configuration options
4214 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4215 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4216 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4217 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4218 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4219 * 'top': Align the top edge with $floatableContainer's top edge
4220 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4221 * 'center': Vertically align the center with $floatableContainer's center
4222 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4223 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4224 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4225 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4226 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4227 * 'center': Horizontally align the center with $floatableContainer's center
4228 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4229 * is out of view
4230 */
4231 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4232 // Configuration initialization
4233 config = config || {};
4234
4235 // Properties
4236 this.$floatable = null;
4237 this.$floatableContainer = null;
4238 this.$floatableWindow = null;
4239 this.$floatableClosestScrollable = null;
4240 this.onFloatableScrollHandler = this.position.bind( this );
4241 this.onFloatableWindowResizeHandler = this.position.bind( this );
4242
4243 // Initialization
4244 this.setFloatableContainer( config.$floatableContainer );
4245 this.setFloatableElement( config.$floatable || this.$element );
4246 this.setVerticalPosition( config.verticalPosition || 'below' );
4247 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4248 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4249 };
4250
4251 /* Methods */
4252
4253 /**
4254 * Set floatable element.
4255 *
4256 * If an element is already set, it will be cleaned up before setting up the new element.
4257 *
4258 * @param {jQuery} $floatable Element to make floatable
4259 */
4260 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4261 if ( this.$floatable ) {
4262 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4263 this.$floatable.css( { left: '', top: '' } );
4264 }
4265
4266 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4267 this.position();
4268 };
4269
4270 /**
4271 * Set floatable container.
4272 *
4273 * The element will be positioned relative to the specified container.
4274 *
4275 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4276 */
4277 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4278 this.$floatableContainer = $floatableContainer;
4279 if ( this.$floatable ) {
4280 this.position();
4281 }
4282 };
4283
4284 /**
4285 * Change how the element is positioned vertically.
4286 *
4287 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4288 */
4289 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4290 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4291 throw new Error( 'Invalid value for vertical position: ' + position );
4292 }
4293 if ( this.verticalPosition !== position ) {
4294 this.verticalPosition = position;
4295 if ( this.$floatable ) {
4296 this.position();
4297 }
4298 }
4299 };
4300
4301 /**
4302 * Change how the element is positioned horizontally.
4303 *
4304 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4305 */
4306 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4307 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4308 throw new Error( 'Invalid value for horizontal position: ' + position );
4309 }
4310 if ( this.horizontalPosition !== position ) {
4311 this.horizontalPosition = position;
4312 if ( this.$floatable ) {
4313 this.position();
4314 }
4315 }
4316 };
4317
4318 /**
4319 * Toggle positioning.
4320 *
4321 * Do not turn positioning on until after the element is attached to the DOM and visible.
4322 *
4323 * @param {boolean} [positioning] Enable positioning, omit to toggle
4324 * @chainable
4325 */
4326 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4327 var closestScrollableOfContainer;
4328
4329 if ( !this.$floatable || !this.$floatableContainer ) {
4330 return this;
4331 }
4332
4333 positioning = positioning === undefined ? !this.positioning : !!positioning;
4334
4335 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4336 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4337 this.warnedUnattached = true;
4338 }
4339
4340 if ( this.positioning !== positioning ) {
4341 this.positioning = positioning;
4342
4343 this.needsCustomPosition =
4344 this.verticalPostion !== 'below' ||
4345 this.horizontalPosition !== 'start' ||
4346 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4347
4348 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4349 // If the scrollable is the root, we have to listen to scroll events
4350 // on the window because of browser inconsistencies.
4351 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4352 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4353 }
4354
4355 if ( positioning ) {
4356 this.$floatableWindow = $( this.getElementWindow() );
4357 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4358
4359 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4360 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4361
4362 // Initial position after visible
4363 this.position();
4364 } else {
4365 if ( this.$floatableWindow ) {
4366 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4367 this.$floatableWindow = null;
4368 }
4369
4370 if ( this.$floatableClosestScrollable ) {
4371 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4372 this.$floatableClosestScrollable = null;
4373 }
4374
4375 this.$floatable.css( { left: '', right: '', top: '' } );
4376 }
4377 }
4378
4379 return this;
4380 };
4381
4382 /**
4383 * Check whether the bottom edge of the given element is within the viewport of the given container.
4384 *
4385 * @private
4386 * @param {jQuery} $element
4387 * @param {jQuery} $container
4388 * @return {boolean}
4389 */
4390 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4391 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4392 startEdgeInBounds, endEdgeInBounds,
4393 direction = $element.css( 'direction' );
4394
4395 elemRect = $element[ 0 ].getBoundingClientRect();
4396 if ( $container[ 0 ] === window ) {
4397 contRect = {
4398 top: 0,
4399 left: 0,
4400 right: document.documentElement.clientWidth,
4401 bottom: document.documentElement.clientHeight
4402 };
4403 } else {
4404 contRect = $container[ 0 ].getBoundingClientRect();
4405 }
4406
4407 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4408 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4409 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4410 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4411 if ( direction === 'rtl' ) {
4412 startEdgeInBounds = rightEdgeInBounds;
4413 endEdgeInBounds = leftEdgeInBounds;
4414 } else {
4415 startEdgeInBounds = leftEdgeInBounds;
4416 endEdgeInBounds = rightEdgeInBounds;
4417 }
4418
4419 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4420 return false;
4421 }
4422 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4423 return false;
4424 }
4425 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4426 return false;
4427 }
4428 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4429 return false;
4430 }
4431
4432 // The other positioning values are all about being inside the container,
4433 // so in those cases all we care about is that any part of the container is visible.
4434 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4435 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4436 };
4437
4438 /**
4439 * Position the floatable below its container.
4440 *
4441 * This should only be done when both of them are attached to the DOM and visible.
4442 *
4443 * @chainable
4444 */
4445 OO.ui.mixin.FloatableElement.prototype.position = function () {
4446 if ( !this.positioning ) {
4447 return this;
4448 }
4449
4450 if ( !(
4451 // To continue, some things need to be true:
4452 // The element must actually be in the DOM
4453 this.isElementAttached() && (
4454 // The closest scrollable is the current window
4455 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4456 // OR is an element in the element's DOM
4457 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4458 )
4459 ) ) {
4460 // Abort early if important parts of the widget are no longer attached to the DOM
4461 return this;
4462 }
4463
4464 if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
4465 this.$floatable.addClass( 'oo-ui-element-hidden' );
4466 return this;
4467 } else {
4468 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4469 }
4470
4471 if ( !this.needsCustomPosition ) {
4472 return this;
4473 }
4474
4475 this.$floatable.css( this.computePosition() );
4476
4477 // We updated the position, so re-evaluate the clipping state.
4478 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4479 // will not notice the need to update itself.)
4480 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4481 // it not listen to the right events in the right places?
4482 if ( this.clip ) {
4483 this.clip();
4484 }
4485
4486 return this;
4487 };
4488
4489 /**
4490 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4491 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4492 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4493 *
4494 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4495 */
4496 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4497 var isBody, scrollableX, scrollableY, containerPos,
4498 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4499 newPos = { top: '', left: '', bottom: '', right: '' },
4500 direction = this.$floatableContainer.css( 'direction' ),
4501 $offsetParent = this.$floatable.offsetParent();
4502
4503 if ( $offsetParent.is( 'html' ) ) {
4504 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4505 // <html> element, but they do work on the <body>
4506 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4507 }
4508 isBody = $offsetParent.is( 'body' );
4509 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4510 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4511
4512 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4513 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4514 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4515 // or if it isn't scrollable
4516 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4517 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4518
4519 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4520 // if the <body> has a margin
4521 containerPos = isBody ?
4522 this.$floatableContainer.offset() :
4523 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4524 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4525 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4526 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4527 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4528
4529 if ( this.verticalPosition === 'below' ) {
4530 newPos.top = containerPos.bottom;
4531 } else if ( this.verticalPosition === 'above' ) {
4532 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4533 } else if ( this.verticalPosition === 'top' ) {
4534 newPos.top = containerPos.top;
4535 } else if ( this.verticalPosition === 'bottom' ) {
4536 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4537 } else if ( this.verticalPosition === 'center' ) {
4538 newPos.top = containerPos.top +
4539 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4540 }
4541
4542 if ( this.horizontalPosition === 'before' ) {
4543 newPos.end = containerPos.start;
4544 } else if ( this.horizontalPosition === 'after' ) {
4545 newPos.start = containerPos.end;
4546 } else if ( this.horizontalPosition === 'start' ) {
4547 newPos.start = containerPos.start;
4548 } else if ( this.horizontalPosition === 'end' ) {
4549 newPos.end = containerPos.end;
4550 } else if ( this.horizontalPosition === 'center' ) {
4551 newPos.left = containerPos.left +
4552 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4553 }
4554
4555 if ( newPos.start !== undefined ) {
4556 if ( direction === 'rtl' ) {
4557 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4558 } else {
4559 newPos.left = newPos.start;
4560 }
4561 delete newPos.start;
4562 }
4563 if ( newPos.end !== undefined ) {
4564 if ( direction === 'rtl' ) {
4565 newPos.left = newPos.end;
4566 } else {
4567 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4568 }
4569 delete newPos.end;
4570 }
4571
4572 // Account for scroll position
4573 if ( newPos.top !== '' ) {
4574 newPos.top += scrollTop;
4575 }
4576 if ( newPos.bottom !== '' ) {
4577 newPos.bottom -= scrollTop;
4578 }
4579 if ( newPos.left !== '' ) {
4580 newPos.left += scrollLeft;
4581 }
4582 if ( newPos.right !== '' ) {
4583 newPos.right -= scrollLeft;
4584 }
4585
4586 // Account for scrollbar gutter
4587 if ( newPos.bottom !== '' ) {
4588 newPos.bottom -= horizScrollbarHeight;
4589 }
4590 if ( direction === 'rtl' ) {
4591 if ( newPos.left !== '' ) {
4592 newPos.left -= vertScrollbarWidth;
4593 }
4594 } else {
4595 if ( newPos.right !== '' ) {
4596 newPos.right -= vertScrollbarWidth;
4597 }
4598 }
4599
4600 return newPos;
4601 };
4602
4603 /**
4604 * Element that can be automatically clipped to visible boundaries.
4605 *
4606 * Whenever the element's natural height changes, you have to call
4607 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4608 * clipping correctly.
4609 *
4610 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4611 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4612 * then #$clippable will be given a fixed reduced height and/or width and will be made
4613 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4614 * but you can build a static footer by setting #$clippableContainer to an element that contains
4615 * #$clippable and the footer.
4616 *
4617 * @abstract
4618 * @class
4619 *
4620 * @constructor
4621 * @param {Object} [config] Configuration options
4622 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4623 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4624 * omit to use #$clippable
4625 */
4626 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4627 // Configuration initialization
4628 config = config || {};
4629
4630 // Properties
4631 this.$clippable = null;
4632 this.$clippableContainer = null;
4633 this.clipping = false;
4634 this.clippedHorizontally = false;
4635 this.clippedVertically = false;
4636 this.$clippableScrollableContainer = null;
4637 this.$clippableScroller = null;
4638 this.$clippableWindow = null;
4639 this.idealWidth = null;
4640 this.idealHeight = null;
4641 this.onClippableScrollHandler = this.clip.bind( this );
4642 this.onClippableWindowResizeHandler = this.clip.bind( this );
4643
4644 // Initialization
4645 if ( config.$clippableContainer ) {
4646 this.setClippableContainer( config.$clippableContainer );
4647 }
4648 this.setClippableElement( config.$clippable || this.$element );
4649 };
4650
4651 /* Methods */
4652
4653 /**
4654 * Set clippable element.
4655 *
4656 * If an element is already set, it will be cleaned up before setting up the new element.
4657 *
4658 * @param {jQuery} $clippable Element to make clippable
4659 */
4660 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4661 if ( this.$clippable ) {
4662 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4663 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4664 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4665 }
4666
4667 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4668 this.clip();
4669 };
4670
4671 /**
4672 * Set clippable container.
4673 *
4674 * This is the container that will be measured when deciding whether to clip. When clipping,
4675 * #$clippable will be resized in order to keep the clippable container fully visible.
4676 *
4677 * If the clippable container is unset, #$clippable will be used.
4678 *
4679 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4680 */
4681 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4682 this.$clippableContainer = $clippableContainer;
4683 if ( this.$clippable ) {
4684 this.clip();
4685 }
4686 };
4687
4688 /**
4689 * Toggle clipping.
4690 *
4691 * Do not turn clipping on until after the element is attached to the DOM and visible.
4692 *
4693 * @param {boolean} [clipping] Enable clipping, omit to toggle
4694 * @chainable
4695 */
4696 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4697 clipping = clipping === undefined ? !this.clipping : !!clipping;
4698
4699 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4700 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4701 this.warnedUnattached = true;
4702 }
4703
4704 if ( this.clipping !== clipping ) {
4705 this.clipping = clipping;
4706 if ( clipping ) {
4707 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4708 // If the clippable container is the root, we have to listen to scroll events and check
4709 // jQuery.scrollTop on the window because of browser inconsistencies
4710 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4711 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4712 this.$clippableScrollableContainer;
4713 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4714 this.$clippableWindow = $( this.getElementWindow() )
4715 .on( 'resize', this.onClippableWindowResizeHandler );
4716 // Initial clip after visible
4717 this.clip();
4718 } else {
4719 this.$clippable.css( {
4720 width: '',
4721 height: '',
4722 maxWidth: '',
4723 maxHeight: '',
4724 overflowX: '',
4725 overflowY: ''
4726 } );
4727 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4728
4729 this.$clippableScrollableContainer = null;
4730 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4731 this.$clippableScroller = null;
4732 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4733 this.$clippableWindow = null;
4734 }
4735 }
4736
4737 return this;
4738 };
4739
4740 /**
4741 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4742 *
4743 * @return {boolean} Element will be clipped to the visible area
4744 */
4745 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4746 return this.clipping;
4747 };
4748
4749 /**
4750 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4751 *
4752 * @return {boolean} Part of the element is being clipped
4753 */
4754 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4755 return this.clippedHorizontally || this.clippedVertically;
4756 };
4757
4758 /**
4759 * Check if the right of the element is being clipped by the nearest scrollable container.
4760 *
4761 * @return {boolean} Part of the element is being clipped
4762 */
4763 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4764 return this.clippedHorizontally;
4765 };
4766
4767 /**
4768 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4769 *
4770 * @return {boolean} Part of the element is being clipped
4771 */
4772 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4773 return this.clippedVertically;
4774 };
4775
4776 /**
4777 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4778 *
4779 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4780 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4781 */
4782 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4783 this.idealWidth = width;
4784 this.idealHeight = height;
4785
4786 if ( !this.clipping ) {
4787 // Update dimensions
4788 this.$clippable.css( { width: width, height: height } );
4789 }
4790 // While clipping, idealWidth and idealHeight are not considered
4791 };
4792
4793 /**
4794 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4795 * when the element's natural height changes.
4796 *
4797 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4798 * overlapped by, the visible area of the nearest scrollable container.
4799 *
4800 * Because calling clip() when the natural height changes isn't always possible, we also set
4801 * max-height when the element isn't being clipped. This means that if the element tries to grow
4802 * beyond the edge, something reasonable will happen before clip() is called.
4803 *
4804 * @chainable
4805 */
4806 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4807 var $container, extraHeight, extraWidth, ccOffset,
4808 $scrollableContainer, scOffset, scHeight, scWidth,
4809 ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
4810 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4811 naturalWidth, naturalHeight, clipWidth, clipHeight,
4812 buffer = 7; // Chosen by fair dice roll
4813
4814 if ( !this.clipping ) {
4815 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4816 return this;
4817 }
4818
4819 $container = this.$clippableContainer || this.$clippable;
4820 extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
4821 extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
4822 ccOffset = $container.offset();
4823 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4824 $scrollableContainer = this.$clippableWindow;
4825 scOffset = { top: 0, left: 0 };
4826 } else {
4827 $scrollableContainer = this.$clippableScrollableContainer;
4828 scOffset = $scrollableContainer.offset();
4829 }
4830 scHeight = $scrollableContainer.innerHeight() - buffer;
4831 scWidth = $scrollableContainer.innerWidth() - buffer;
4832 ccWidth = $container.outerWidth() + buffer;
4833 scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
4834 scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
4835 scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
4836 desiredWidth = ccOffset.left < 0 ?
4837 ccWidth + ccOffset.left :
4838 ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
4839 desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
4840 // It should never be desirable to exceed the dimensions of the browser viewport... right?
4841 desiredWidth = Math.min( desiredWidth, document.documentElement.clientWidth );
4842 desiredHeight = Math.min( desiredHeight, document.documentElement.clientHeight );
4843 allotedWidth = Math.ceil( desiredWidth - extraWidth );
4844 allotedHeight = Math.ceil( desiredHeight - extraHeight );
4845 naturalWidth = this.$clippable.prop( 'scrollWidth' );
4846 naturalHeight = this.$clippable.prop( 'scrollHeight' );
4847 clipWidth = allotedWidth < naturalWidth;
4848 clipHeight = allotedHeight < naturalHeight;
4849
4850 if ( clipWidth ) {
4851 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4852 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4853 this.$clippable.css( 'overflowX', 'scroll' );
4854 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4855 this.$clippable.css( {
4856 width: Math.max( 0, allotedWidth ),
4857 maxWidth: ''
4858 } );
4859 } else {
4860 this.$clippable.css( {
4861 overflowX: '',
4862 width: this.idealWidth || '',
4863 maxWidth: Math.max( 0, allotedWidth )
4864 } );
4865 }
4866 if ( clipHeight ) {
4867 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4868 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4869 this.$clippable.css( 'overflowY', 'scroll' );
4870 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4871 this.$clippable.css( {
4872 height: Math.max( 0, allotedHeight ),
4873 maxHeight: ''
4874 } );
4875 } else {
4876 this.$clippable.css( {
4877 overflowY: '',
4878 height: this.idealHeight || '',
4879 maxHeight: Math.max( 0, allotedHeight )
4880 } );
4881 }
4882
4883 // If we stopped clipping in at least one of the dimensions
4884 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
4885 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4886 }
4887
4888 this.clippedHorizontally = clipWidth;
4889 this.clippedVertically = clipHeight;
4890
4891 return this;
4892 };
4893
4894 /**
4895 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
4896 * By default, each popup has an anchor that points toward its origin.
4897 * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
4898 *
4899 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
4900 *
4901 * @example
4902 * // A popup widget.
4903 * var popup = new OO.ui.PopupWidget( {
4904 * $content: $( '<p>Hi there!</p>' ),
4905 * padded: true,
4906 * width: 300
4907 * } );
4908 *
4909 * $( 'body' ).append( popup.$element );
4910 * // To display the popup, toggle the visibility to 'true'.
4911 * popup.toggle( true );
4912 *
4913 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
4914 *
4915 * @class
4916 * @extends OO.ui.Widget
4917 * @mixins OO.ui.mixin.LabelElement
4918 * @mixins OO.ui.mixin.ClippableElement
4919 * @mixins OO.ui.mixin.FloatableElement
4920 *
4921 * @constructor
4922 * @param {Object} [config] Configuration options
4923 * @cfg {number} [width=320] Width of popup in pixels
4924 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
4925 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
4926 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
4927 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
4928 * of $floatableContainer
4929 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
4930 * of $floatableContainer
4931 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
4932 * endwards (right/left) to the vertical center of $floatableContainer
4933 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
4934 * startwards (left/right) to the vertical center of $floatableContainer
4935 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
4936 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
4937 * as possible while still keeping the anchor within the popup;
4938 * if position is before/after, move the popup as far downwards as possible.
4939 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
4940 * as possible while still keeping the anchor within the popup;
4941 * if position in before/after, move the popup as far upwards as possible.
4942 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
4943 * of the popup with the center of $floatableContainer.
4944 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
4945 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
4946 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
4947 * See the [OOjs UI docs on MediaWiki][3] for an example.
4948 * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
4949 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
4950 * @cfg {jQuery} [$content] Content to append to the popup's body
4951 * @cfg {jQuery} [$footer] Content to append to the popup's footer
4952 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
4953 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
4954 * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
4955 * for an example.
4956 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
4957 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
4958 * button.
4959 * @cfg {boolean} [padded=false] Add padding to the popup's body
4960 */
4961 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
4962 // Configuration initialization
4963 config = config || {};
4964
4965 // Parent constructor
4966 OO.ui.PopupWidget.parent.call( this, config );
4967
4968 // Properties (must be set before ClippableElement constructor call)
4969 this.$body = $( '<div>' );
4970 this.$popup = $( '<div>' );
4971
4972 // Mixin constructors
4973 OO.ui.mixin.LabelElement.call( this, config );
4974 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
4975 $clippable: this.$body,
4976 $clippableContainer: this.$popup
4977 } ) );
4978 OO.ui.mixin.FloatableElement.call( this, config );
4979
4980 // Properties
4981 this.$anchor = $( '<div>' );
4982 // If undefined, will be computed lazily in computePosition()
4983 this.$container = config.$container;
4984 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
4985 this.autoClose = !!config.autoClose;
4986 this.$autoCloseIgnore = config.$autoCloseIgnore;
4987 this.transitionTimeout = null;
4988 this.anchored = false;
4989 this.width = config.width !== undefined ? config.width : 320;
4990 this.height = config.height !== undefined ? config.height : null;
4991 this.onMouseDownHandler = this.onMouseDown.bind( this );
4992 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
4993
4994 // Initialization
4995 this.toggleAnchor( config.anchor === undefined || config.anchor );
4996 this.setAlignment( config.align || 'center' );
4997 this.setPosition( config.position || 'below' );
4998 this.$body.addClass( 'oo-ui-popupWidget-body' );
4999 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5000 this.$popup
5001 .addClass( 'oo-ui-popupWidget-popup' )
5002 .append( this.$body );
5003 this.$element
5004 .addClass( 'oo-ui-popupWidget' )
5005 .append( this.$popup, this.$anchor );
5006 // Move content, which was added to #$element by OO.ui.Widget, to the body
5007 // FIXME This is gross, we should use '$body' or something for the config
5008 if ( config.$content instanceof jQuery ) {
5009 this.$body.append( config.$content );
5010 }
5011
5012 if ( config.padded ) {
5013 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5014 }
5015
5016 if ( config.head ) {
5017 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5018 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5019 this.$head = $( '<div>' )
5020 .addClass( 'oo-ui-popupWidget-head' )
5021 .append( this.$label, this.closeButton.$element );
5022 this.$popup.prepend( this.$head );
5023 }
5024
5025 if ( config.$footer ) {
5026 this.$footer = $( '<div>' )
5027 .addClass( 'oo-ui-popupWidget-footer' )
5028 .append( config.$footer );
5029 this.$popup.append( this.$footer );
5030 }
5031
5032 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5033 // that reference properties not initialized at that time of parent class construction
5034 // TODO: Find a better way to handle post-constructor setup
5035 this.visible = false;
5036 this.$element.addClass( 'oo-ui-element-hidden' );
5037 };
5038
5039 /* Setup */
5040
5041 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5042 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5043 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5044 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5045
5046 /* Events */
5047
5048 /**
5049 * @event ready
5050 *
5051 * The popup is ready: it is visible and has been positioned and clipped.
5052 */
5053
5054 /* Methods */
5055
5056 /**
5057 * Handles mouse down events.
5058 *
5059 * @private
5060 * @param {MouseEvent} e Mouse down event
5061 */
5062 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5063 if (
5064 this.isVisible() &&
5065 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5066 ) {
5067 this.toggle( false );
5068 }
5069 };
5070
5071 /**
5072 * Bind mouse down listener.
5073 *
5074 * @private
5075 */
5076 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5077 // Capture clicks outside popup
5078 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5079 };
5080
5081 /**
5082 * Handles close button click events.
5083 *
5084 * @private
5085 */
5086 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5087 if ( this.isVisible() ) {
5088 this.toggle( false );
5089 }
5090 };
5091
5092 /**
5093 * Unbind mouse down listener.
5094 *
5095 * @private
5096 */
5097 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5098 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5099 };
5100
5101 /**
5102 * Handles key down events.
5103 *
5104 * @private
5105 * @param {KeyboardEvent} e Key down event
5106 */
5107 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5108 if (
5109 e.which === OO.ui.Keys.ESCAPE &&
5110 this.isVisible()
5111 ) {
5112 this.toggle( false );
5113 e.preventDefault();
5114 e.stopPropagation();
5115 }
5116 };
5117
5118 /**
5119 * Bind key down listener.
5120 *
5121 * @private
5122 */
5123 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5124 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5125 };
5126
5127 /**
5128 * Unbind key down listener.
5129 *
5130 * @private
5131 */
5132 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5133 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5134 };
5135
5136 /**
5137 * Show, hide, or toggle the visibility of the anchor.
5138 *
5139 * @param {boolean} [show] Show anchor, omit to toggle
5140 */
5141 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5142 show = show === undefined ? !this.anchored : !!show;
5143
5144 if ( this.anchored !== show ) {
5145 if ( show ) {
5146 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5147 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5148 } else {
5149 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5150 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5151 }
5152 this.anchored = show;
5153 }
5154 };
5155 /**
5156 * Change which edge the anchor appears on.
5157 *
5158 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5159 */
5160 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5161 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5162 throw new Error( 'Invalid value for edge: ' + edge );
5163 }
5164 if ( this.anchorEdge !== null ) {
5165 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5166 }
5167 this.anchorEdge = edge;
5168 if ( this.anchored ) {
5169 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5170 }
5171 };
5172
5173 /**
5174 * Check if the anchor is visible.
5175 *
5176 * @return {boolean} Anchor is visible
5177 */
5178 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5179 return this.anchored;
5180 };
5181
5182 /**
5183 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5184 * `.toggle( true )` after its #$element is attached to the DOM.
5185 *
5186 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5187 * it in the right place and with the right dimensions only work correctly while it is attached.
5188 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5189 * strictly enforced, so currently it only generates a warning in the browser console.
5190 *
5191 * @fires ready
5192 * @inheritdoc
5193 */
5194 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5195 var change;
5196 show = show === undefined ? !this.isVisible() : !!show;
5197
5198 change = show !== this.isVisible();
5199
5200 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5201 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5202 this.warnedUnattached = true;
5203 }
5204 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5205 // Fall back to the parent node if the floatableContainer is not set
5206 this.setFloatableContainer( this.$element.parent() );
5207 }
5208
5209 // Parent method
5210 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5211
5212 if ( change ) {
5213 this.togglePositioning( show && !!this.$floatableContainer );
5214
5215 if ( show ) {
5216 if ( this.autoClose ) {
5217 this.bindMouseDownListener();
5218 this.bindKeyDownListener();
5219 }
5220 this.updateDimensions();
5221 this.toggleClipping( true );
5222 this.emit( 'ready' );
5223 } else {
5224 this.toggleClipping( false );
5225 if ( this.autoClose ) {
5226 this.unbindMouseDownListener();
5227 this.unbindKeyDownListener();
5228 }
5229 }
5230 }
5231
5232 return this;
5233 };
5234
5235 /**
5236 * Set the size of the popup.
5237 *
5238 * Changing the size may also change the popup's position depending on the alignment.
5239 *
5240 * @param {number} width Width in pixels
5241 * @param {number} height Height in pixels
5242 * @param {boolean} [transition=false] Use a smooth transition
5243 * @chainable
5244 */
5245 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5246 this.width = width;
5247 this.height = height !== undefined ? height : null;
5248 if ( this.isVisible() ) {
5249 this.updateDimensions( transition );
5250 }
5251 };
5252
5253 /**
5254 * Update the size and position.
5255 *
5256 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5257 * be called automatically.
5258 *
5259 * @param {boolean} [transition=false] Use a smooth transition
5260 * @chainable
5261 */
5262 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5263 var widget = this;
5264
5265 // Prevent transition from being interrupted
5266 clearTimeout( this.transitionTimeout );
5267 if ( transition ) {
5268 // Enable transition
5269 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5270 }
5271
5272 this.position();
5273
5274 if ( transition ) {
5275 // Prevent transitioning after transition is complete
5276 this.transitionTimeout = setTimeout( function () {
5277 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5278 }, 200 );
5279 } else {
5280 // Prevent transitioning immediately
5281 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5282 }
5283 };
5284
5285 /**
5286 * @inheritdoc
5287 */
5288 OO.ui.PopupWidget.prototype.computePosition = function () {
5289 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5290 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5291 offsetParentPos, containerPos,
5292 popupPos = {},
5293 anchorCss = { left: '', right: '', top: '', bottom: '' },
5294 alignMap = {
5295 ltr: {
5296 'force-left': 'backwards',
5297 'force-right': 'forwards'
5298 },
5299 rtl: {
5300 'force-left': 'forwards',
5301 'force-right': 'backwards'
5302 }
5303 },
5304 anchorEdgeMap = {
5305 above: 'bottom',
5306 below: 'top',
5307 before: 'end',
5308 after: 'start'
5309 },
5310 hPosMap = {
5311 forwards: 'start',
5312 center: 'center',
5313 backwards: this.anchored ? 'before' : 'end'
5314 },
5315 vPosMap = {
5316 forwards: 'top',
5317 center: 'center',
5318 backwards: 'bottom'
5319 };
5320
5321 if ( !this.$container ) {
5322 // Lazy-initialize $container if not specified in constructor
5323 this.$container = $( this.getClosestScrollableElementContainer() );
5324 }
5325 direction = this.$container.css( 'direction' );
5326
5327 // Set height and width before we do anything else, since it might cause our measurements
5328 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5329 this.$popup.css( {
5330 width: this.width,
5331 height: this.height !== null ? this.height : 'auto'
5332 } );
5333
5334 align = alignMap[ direction ][ this.align ] || this.align;
5335 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5336 vertical = this.popupPosition === 'before' || this.popupPosition === 'after';
5337 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5338 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5339 near = vertical ? 'top' : 'left';
5340 far = vertical ? 'bottom' : 'right';
5341 sizeProp = vertical ? 'Height' : 'Width';
5342 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5343
5344 this.setAnchorEdge( anchorEdgeMap[ this.popupPosition ] );
5345 this.horizontalPosition = vertical ? this.popupPosition : hPosMap[ align ];
5346 this.verticalPosition = vertical ? vPosMap[ align ] : this.popupPosition;
5347
5348 // Parent method
5349 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5350 // Find out which property FloatableElement used for positioning, and adjust that value
5351 positionProp = vertical ?
5352 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5353 ( parentPosition.left !== '' ? 'left' : 'right' );
5354
5355 // Figure out where the near and far edges of the popup and $floatableContainer are
5356 floatablePos = this.$floatableContainer.offset();
5357 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5358 // Measure where the offsetParent is and compute our position based on that and parentPosition
5359 offsetParentPos = this.$element.offsetParent().offset();
5360
5361 if ( positionProp === near ) {
5362 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5363 popupPos[ far ] = popupPos[ near ] + popupSize;
5364 } else {
5365 popupPos[ far ] = offsetParentPos[ near ] +
5366 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5367 popupPos[ near ] = popupPos[ far ] - popupSize;
5368 }
5369
5370 if ( this.anchored ) {
5371 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5372 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5373 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5374
5375 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5376 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5377 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5378 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5379 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5380 // Not enough space for the anchor on the start side; pull the popup startwards
5381 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5382 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5383 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5384 // Not enough space for the anchor on the end side; pull the popup endwards
5385 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5386 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5387 } else {
5388 positionAdjustment = 0;
5389 }
5390 } else {
5391 positionAdjustment = 0;
5392 }
5393
5394 // Check if the popup will go beyond the edge of this.$container
5395 containerPos = this.$container.offset();
5396 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5397 // Take into account how much the popup will move because of the adjustments we're going to make
5398 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5399 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5400 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5401 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5402 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5403 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5404 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5405 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5406 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5407 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5408 }
5409
5410 if ( this.anchored ) {
5411 // Adjust anchorOffset for positionAdjustment
5412 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5413
5414 // Position the anchor
5415 anchorCss[ start ] = anchorOffset;
5416 this.$anchor.css( anchorCss );
5417 }
5418
5419 // Move the popup if needed
5420 parentPosition[ positionProp ] += positionAdjustment;
5421
5422 return parentPosition;
5423 };
5424
5425 /**
5426 * Set popup alignment
5427 *
5428 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5429 * `backwards` or `forwards`.
5430 */
5431 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5432 // Validate alignment
5433 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5434 this.align = align;
5435 } else {
5436 this.align = 'center';
5437 }
5438 this.position();
5439 };
5440
5441 /**
5442 * Get popup alignment
5443 *
5444 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5445 * `backwards` or `forwards`.
5446 */
5447 OO.ui.PopupWidget.prototype.getAlignment = function () {
5448 return this.align;
5449 };
5450
5451 /**
5452 * Change the positioning of the popup.
5453 *
5454 * @param {string} position 'above', 'below', 'before' or 'after'
5455 */
5456 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5457 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5458 position = 'below';
5459 }
5460 this.popupPosition = position;
5461 this.position();
5462 };
5463
5464 /**
5465 * Get popup positioning.
5466 *
5467 * @return {string} 'above', 'below', 'before' or 'after'
5468 */
5469 OO.ui.PopupWidget.prototype.getPosition = function () {
5470 return this.popupPosition;
5471 };
5472
5473 /**
5474 * Get an ID of the body element, this can be used as the
5475 * `aria-describedby` attribute for an input field.
5476 *
5477 * @return {string} The ID of the body element
5478 */
5479 OO.ui.PopupWidget.prototype.getBodyId = function () {
5480 var id = this.$body.attr( 'id' );
5481 if ( id === undefined ) {
5482 id = OO.ui.generateElementId();
5483 this.$body.attr( 'id', id );
5484 }
5485 return id;
5486 };
5487
5488 /**
5489 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5490 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5491 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5492 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5493 *
5494 * @abstract
5495 * @class
5496 *
5497 * @constructor
5498 * @param {Object} [config] Configuration options
5499 * @cfg {Object} [popup] Configuration to pass to popup
5500 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5501 */
5502 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5503 // Configuration initialization
5504 config = config || {};
5505
5506 // Properties
5507 this.popup = new OO.ui.PopupWidget( $.extend(
5508 {
5509 autoClose: true,
5510 $floatableContainer: this.$element
5511 },
5512 config.popup,
5513 {
5514 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5515 }
5516 ) );
5517 };
5518
5519 /* Methods */
5520
5521 /**
5522 * Get popup.
5523 *
5524 * @return {OO.ui.PopupWidget} Popup widget
5525 */
5526 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5527 return this.popup;
5528 };
5529
5530 /**
5531 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5532 * which is used to display additional information or options.
5533 *
5534 * @example
5535 * // Example of a popup button.
5536 * var popupButton = new OO.ui.PopupButtonWidget( {
5537 * label: 'Popup button with options',
5538 * icon: 'menu',
5539 * popup: {
5540 * $content: $( '<p>Additional options here.</p>' ),
5541 * padded: true,
5542 * align: 'force-left'
5543 * }
5544 * } );
5545 * // Append the button to the DOM.
5546 * $( 'body' ).append( popupButton.$element );
5547 *
5548 * @class
5549 * @extends OO.ui.ButtonWidget
5550 * @mixins OO.ui.mixin.PopupElement
5551 *
5552 * @constructor
5553 * @param {Object} [config] Configuration options
5554 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5555 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5556 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5557 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
5558 */
5559 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5560 // Configuration initialization
5561 config = config || {};
5562
5563 // Parent constructor
5564 OO.ui.PopupButtonWidget.parent.call( this, config );
5565
5566 // Mixin constructors
5567 OO.ui.mixin.PopupElement.call( this, config );
5568
5569 // Properties
5570 this.$overlay = config.$overlay || this.$element;
5571
5572 // Events
5573 this.connect( this, { click: 'onAction' } );
5574
5575 // Initialization
5576 this.$element
5577 .addClass( 'oo-ui-popupButtonWidget' )
5578 .attr( 'aria-haspopup', 'true' );
5579 this.popup.$element
5580 .addClass( 'oo-ui-popupButtonWidget-popup' )
5581 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5582 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5583 this.$overlay.append( this.popup.$element );
5584 };
5585
5586 /* Setup */
5587
5588 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5589 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5590
5591 /* Methods */
5592
5593 /**
5594 * Handle the button action being triggered.
5595 *
5596 * @private
5597 */
5598 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5599 this.popup.toggle();
5600 };
5601
5602 /**
5603 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5604 *
5605 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5606 *
5607 * @private
5608 * @abstract
5609 * @class
5610 * @mixins OO.ui.mixin.GroupElement
5611 *
5612 * @constructor
5613 * @param {Object} [config] Configuration options
5614 */
5615 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5616 // Mixin constructors
5617 OO.ui.mixin.GroupElement.call( this, config );
5618 };
5619
5620 /* Setup */
5621
5622 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5623
5624 /* Methods */
5625
5626 /**
5627 * Set the disabled state of the widget.
5628 *
5629 * This will also update the disabled state of child widgets.
5630 *
5631 * @param {boolean} disabled Disable widget
5632 * @chainable
5633 */
5634 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5635 var i, len;
5636
5637 // Parent method
5638 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5639 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5640
5641 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5642 if ( this.items ) {
5643 for ( i = 0, len = this.items.length; i < len; i++ ) {
5644 this.items[ i ].updateDisabled();
5645 }
5646 }
5647
5648 return this;
5649 };
5650
5651 /**
5652 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5653 *
5654 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5655 * allows bidirectional communication.
5656 *
5657 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5658 *
5659 * @private
5660 * @abstract
5661 * @class
5662 *
5663 * @constructor
5664 */
5665 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5666 //
5667 };
5668
5669 /* Methods */
5670
5671 /**
5672 * Check if widget is disabled.
5673 *
5674 * Checks parent if present, making disabled state inheritable.
5675 *
5676 * @return {boolean} Widget is disabled
5677 */
5678 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5679 return this.disabled ||
5680 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5681 };
5682
5683 /**
5684 * Set group element is in.
5685 *
5686 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5687 * @chainable
5688 */
5689 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5690 // Parent method
5691 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5692 OO.ui.Element.prototype.setElementGroup.call( this, group );
5693
5694 // Initialize item disabled states
5695 this.updateDisabled();
5696
5697 return this;
5698 };
5699
5700 /**
5701 * OptionWidgets are special elements that can be selected and configured with data. The
5702 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5703 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5704 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
5705 *
5706 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5707 *
5708 * @class
5709 * @extends OO.ui.Widget
5710 * @mixins OO.ui.mixin.ItemWidget
5711 * @mixins OO.ui.mixin.LabelElement
5712 * @mixins OO.ui.mixin.FlaggedElement
5713 * @mixins OO.ui.mixin.AccessKeyedElement
5714 *
5715 * @constructor
5716 * @param {Object} [config] Configuration options
5717 */
5718 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5719 // Configuration initialization
5720 config = config || {};
5721
5722 // Parent constructor
5723 OO.ui.OptionWidget.parent.call( this, config );
5724
5725 // Mixin constructors
5726 OO.ui.mixin.ItemWidget.call( this );
5727 OO.ui.mixin.LabelElement.call( this, config );
5728 OO.ui.mixin.FlaggedElement.call( this, config );
5729 OO.ui.mixin.AccessKeyedElement.call( this, config );
5730
5731 // Properties
5732 this.selected = false;
5733 this.highlighted = false;
5734 this.pressed = false;
5735
5736 // Initialization
5737 this.$element
5738 .data( 'oo-ui-optionWidget', this )
5739 // Allow programmatic focussing (and by accesskey), but not tabbing
5740 .attr( 'tabindex', '-1' )
5741 .attr( 'role', 'option' )
5742 .attr( 'aria-selected', 'false' )
5743 .addClass( 'oo-ui-optionWidget' )
5744 .append( this.$label );
5745 };
5746
5747 /* Setup */
5748
5749 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
5750 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
5751 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
5752 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
5753 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
5754
5755 /* Static Properties */
5756
5757 /**
5758 * Whether this option can be selected. See #setSelected.
5759 *
5760 * @static
5761 * @inheritable
5762 * @property {boolean}
5763 */
5764 OO.ui.OptionWidget.static.selectable = true;
5765
5766 /**
5767 * Whether this option can be highlighted. See #setHighlighted.
5768 *
5769 * @static
5770 * @inheritable
5771 * @property {boolean}
5772 */
5773 OO.ui.OptionWidget.static.highlightable = true;
5774
5775 /**
5776 * Whether this option can be pressed. See #setPressed.
5777 *
5778 * @static
5779 * @inheritable
5780 * @property {boolean}
5781 */
5782 OO.ui.OptionWidget.static.pressable = true;
5783
5784 /**
5785 * Whether this option will be scrolled into view when it is selected.
5786 *
5787 * @static
5788 * @inheritable
5789 * @property {boolean}
5790 */
5791 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
5792
5793 /* Methods */
5794
5795 /**
5796 * Check if the option can be selected.
5797 *
5798 * @return {boolean} Item is selectable
5799 */
5800 OO.ui.OptionWidget.prototype.isSelectable = function () {
5801 return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
5802 };
5803
5804 /**
5805 * Check if the option can be highlighted. A highlight indicates that the option
5806 * may be selected when a user presses enter or clicks. Disabled items cannot
5807 * be highlighted.
5808 *
5809 * @return {boolean} Item is highlightable
5810 */
5811 OO.ui.OptionWidget.prototype.isHighlightable = function () {
5812 return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
5813 };
5814
5815 /**
5816 * Check if the option can be pressed. The pressed state occurs when a user mouses
5817 * down on an item, but has not yet let go of the mouse.
5818 *
5819 * @return {boolean} Item is pressable
5820 */
5821 OO.ui.OptionWidget.prototype.isPressable = function () {
5822 return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
5823 };
5824
5825 /**
5826 * Check if the option is selected.
5827 *
5828 * @return {boolean} Item is selected
5829 */
5830 OO.ui.OptionWidget.prototype.isSelected = function () {
5831 return this.selected;
5832 };
5833
5834 /**
5835 * Check if the option is highlighted. A highlight indicates that the
5836 * item may be selected when a user presses enter or clicks.
5837 *
5838 * @return {boolean} Item is highlighted
5839 */
5840 OO.ui.OptionWidget.prototype.isHighlighted = function () {
5841 return this.highlighted;
5842 };
5843
5844 /**
5845 * Check if the option is pressed. The pressed state occurs when a user mouses
5846 * down on an item, but has not yet let go of the mouse. The item may appear
5847 * selected, but it will not be selected until the user releases the mouse.
5848 *
5849 * @return {boolean} Item is pressed
5850 */
5851 OO.ui.OptionWidget.prototype.isPressed = function () {
5852 return this.pressed;
5853 };
5854
5855 /**
5856 * Set the option’s selected state. In general, all modifications to the selection
5857 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
5858 * method instead of this method.
5859 *
5860 * @param {boolean} [state=false] Select option
5861 * @chainable
5862 */
5863 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
5864 if ( this.constructor.static.selectable ) {
5865 this.selected = !!state;
5866 this.$element
5867 .toggleClass( 'oo-ui-optionWidget-selected', state )
5868 .attr( 'aria-selected', state.toString() );
5869 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
5870 this.scrollElementIntoView();
5871 }
5872 this.updateThemeClasses();
5873 }
5874 return this;
5875 };
5876
5877 /**
5878 * Set the option’s highlighted state. In general, all programmatic
5879 * modifications to the highlight should be handled by the
5880 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
5881 * method instead of this method.
5882 *
5883 * @param {boolean} [state=false] Highlight option
5884 * @chainable
5885 */
5886 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
5887 if ( this.constructor.static.highlightable ) {
5888 this.highlighted = !!state;
5889 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
5890 this.updateThemeClasses();
5891 }
5892 return this;
5893 };
5894
5895 /**
5896 * Set the option’s pressed state. In general, all
5897 * programmatic modifications to the pressed state should be handled by the
5898 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
5899 * method instead of this method.
5900 *
5901 * @param {boolean} [state=false] Press option
5902 * @chainable
5903 */
5904 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
5905 if ( this.constructor.static.pressable ) {
5906 this.pressed = !!state;
5907 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
5908 this.updateThemeClasses();
5909 }
5910 return this;
5911 };
5912
5913 /**
5914 * Get text to match search strings against.
5915 *
5916 * The default implementation returns the label text, but subclasses
5917 * can override this to provide more complex behavior.
5918 *
5919 * @return {string|boolean} String to match search string against
5920 */
5921 OO.ui.OptionWidget.prototype.getMatchText = function () {
5922 var label = this.getLabel();
5923 return typeof label === 'string' ? label : this.$label.text();
5924 };
5925
5926 /**
5927 * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
5928 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
5929 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
5930 * menu selects}.
5931 *
5932 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
5933 * information, please see the [OOjs UI documentation on MediaWiki][1].
5934 *
5935 * @example
5936 * // Example of a select widget with three options
5937 * var select = new OO.ui.SelectWidget( {
5938 * items: [
5939 * new OO.ui.OptionWidget( {
5940 * data: 'a',
5941 * label: 'Option One',
5942 * } ),
5943 * new OO.ui.OptionWidget( {
5944 * data: 'b',
5945 * label: 'Option Two',
5946 * } ),
5947 * new OO.ui.OptionWidget( {
5948 * data: 'c',
5949 * label: 'Option Three',
5950 * } )
5951 * ]
5952 * } );
5953 * $( 'body' ).append( select.$element );
5954 *
5955 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5956 *
5957 * @abstract
5958 * @class
5959 * @extends OO.ui.Widget
5960 * @mixins OO.ui.mixin.GroupWidget
5961 *
5962 * @constructor
5963 * @param {Object} [config] Configuration options
5964 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
5965 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
5966 * the [OOjs UI documentation on MediaWiki] [2] for examples.
5967 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5968 */
5969 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
5970 // Configuration initialization
5971 config = config || {};
5972
5973 // Parent constructor
5974 OO.ui.SelectWidget.parent.call( this, config );
5975
5976 // Mixin constructors
5977 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
5978
5979 // Properties
5980 this.pressed = false;
5981 this.selecting = null;
5982 this.onMouseUpHandler = this.onMouseUp.bind( this );
5983 this.onMouseMoveHandler = this.onMouseMove.bind( this );
5984 this.onKeyDownHandler = this.onKeyDown.bind( this );
5985 this.onKeyPressHandler = this.onKeyPress.bind( this );
5986 this.keyPressBuffer = '';
5987 this.keyPressBufferTimer = null;
5988 this.blockMouseOverEvents = 0;
5989
5990 // Events
5991 this.connect( this, {
5992 toggle: 'onToggle'
5993 } );
5994 this.$element.on( {
5995 focusin: this.onFocus.bind( this ),
5996 mousedown: this.onMouseDown.bind( this ),
5997 mouseover: this.onMouseOver.bind( this ),
5998 mouseleave: this.onMouseLeave.bind( this )
5999 } );
6000
6001 // Initialization
6002 this.$element
6003 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6004 .attr( 'role', 'listbox' );
6005 this.setFocusOwner( this.$element );
6006 if ( Array.isArray( config.items ) ) {
6007 this.addItems( config.items );
6008 }
6009 };
6010
6011 /* Setup */
6012
6013 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6014 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6015
6016 /* Events */
6017
6018 /**
6019 * @event highlight
6020 *
6021 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6022 *
6023 * @param {OO.ui.OptionWidget|null} item Highlighted item
6024 */
6025
6026 /**
6027 * @event press
6028 *
6029 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6030 * pressed state of an option.
6031 *
6032 * @param {OO.ui.OptionWidget|null} item Pressed item
6033 */
6034
6035 /**
6036 * @event select
6037 *
6038 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6039 *
6040 * @param {OO.ui.OptionWidget|null} item Selected item
6041 */
6042
6043 /**
6044 * @event choose
6045 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6046 * @param {OO.ui.OptionWidget} item Chosen item
6047 */
6048
6049 /**
6050 * @event add
6051 *
6052 * An `add` event is emitted when options are added to the select with the #addItems method.
6053 *
6054 * @param {OO.ui.OptionWidget[]} items Added items
6055 * @param {number} index Index of insertion point
6056 */
6057
6058 /**
6059 * @event remove
6060 *
6061 * A `remove` event is emitted when options are removed from the select with the #clearItems
6062 * or #removeItems methods.
6063 *
6064 * @param {OO.ui.OptionWidget[]} items Removed items
6065 */
6066
6067 /* Methods */
6068
6069 /**
6070 * Handle focus events
6071 *
6072 * @private
6073 * @param {jQuery.Event} event
6074 */
6075 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6076 var item;
6077 if ( event.target === this.$element[ 0 ] ) {
6078 // This widget was focussed, e.g. by the user tabbing to it.
6079 // The styles for focus state depend on one of the items being selected.
6080 if ( !this.getSelectedItem() ) {
6081 item = this.findFirstSelectableItem();
6082 }
6083 } else {
6084 // One of the options got focussed (and the event bubbled up here).
6085 // They can't be tabbed to, but they can be activated using accesskeys.
6086 item = this.findTargetItem( event );
6087 }
6088
6089 if ( item ) {
6090 if ( item.constructor.static.highlightable ) {
6091 this.highlightItem( item );
6092 } else {
6093 this.selectItem( item );
6094 }
6095 }
6096
6097 if ( event.target !== this.$element[ 0 ] ) {
6098 this.$focusOwner.focus();
6099 }
6100 };
6101
6102 /**
6103 * Handle mouse down events.
6104 *
6105 * @private
6106 * @param {jQuery.Event} e Mouse down event
6107 */
6108 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6109 var item;
6110
6111 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6112 this.togglePressed( true );
6113 item = this.findTargetItem( e );
6114 if ( item && item.isSelectable() ) {
6115 this.pressItem( item );
6116 this.selecting = item;
6117 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6118 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6119 }
6120 }
6121 return false;
6122 };
6123
6124 /**
6125 * Handle mouse up events.
6126 *
6127 * @private
6128 * @param {MouseEvent} e Mouse up event
6129 */
6130 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6131 var item;
6132
6133 this.togglePressed( false );
6134 if ( !this.selecting ) {
6135 item = this.findTargetItem( e );
6136 if ( item && item.isSelectable() ) {
6137 this.selecting = item;
6138 }
6139 }
6140 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6141 this.pressItem( null );
6142 this.chooseItem( this.selecting );
6143 this.selecting = null;
6144 }
6145
6146 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6147 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6148
6149 return false;
6150 };
6151
6152 /**
6153 * Handle mouse move events.
6154 *
6155 * @private
6156 * @param {MouseEvent} e Mouse move event
6157 */
6158 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6159 var item;
6160
6161 if ( !this.isDisabled() && this.pressed ) {
6162 item = this.findTargetItem( e );
6163 if ( item && item !== this.selecting && item.isSelectable() ) {
6164 this.pressItem( item );
6165 this.selecting = item;
6166 }
6167 }
6168 };
6169
6170 /**
6171 * Handle mouse over events.
6172 *
6173 * @private
6174 * @param {jQuery.Event} e Mouse over event
6175 */
6176 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6177 var item;
6178 if ( this.blockMouseOverEvents ) {
6179 return;
6180 }
6181 if ( !this.isDisabled() ) {
6182 item = this.findTargetItem( e );
6183 this.highlightItem( item && item.isHighlightable() ? item : null );
6184 }
6185 return false;
6186 };
6187
6188 /**
6189 * Handle mouse leave events.
6190 *
6191 * @private
6192 * @param {jQuery.Event} e Mouse over event
6193 */
6194 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6195 if ( !this.isDisabled() ) {
6196 this.highlightItem( null );
6197 }
6198 return false;
6199 };
6200
6201 /**
6202 * Handle key down events.
6203 *
6204 * @protected
6205 * @param {KeyboardEvent} e Key down event
6206 */
6207 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6208 var nextItem,
6209 handled = false,
6210 currentItem = this.findHighlightedItem() || this.getSelectedItem();
6211
6212 if ( !this.isDisabled() && this.isVisible() ) {
6213 switch ( e.keyCode ) {
6214 case OO.ui.Keys.ENTER:
6215 if ( currentItem && currentItem.constructor.static.highlightable ) {
6216 // Was only highlighted, now let's select it. No-op if already selected.
6217 this.chooseItem( currentItem );
6218 handled = true;
6219 }
6220 break;
6221 case OO.ui.Keys.UP:
6222 case OO.ui.Keys.LEFT:
6223 this.clearKeyPressBuffer();
6224 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6225 handled = true;
6226 break;
6227 case OO.ui.Keys.DOWN:
6228 case OO.ui.Keys.RIGHT:
6229 this.clearKeyPressBuffer();
6230 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6231 handled = true;
6232 break;
6233 case OO.ui.Keys.ESCAPE:
6234 case OO.ui.Keys.TAB:
6235 if ( currentItem && currentItem.constructor.static.highlightable ) {
6236 currentItem.setHighlighted( false );
6237 }
6238 this.unbindKeyDownListener();
6239 this.unbindKeyPressListener();
6240 // Don't prevent tabbing away / defocusing
6241 handled = false;
6242 break;
6243 }
6244
6245 if ( nextItem ) {
6246 if ( nextItem.constructor.static.highlightable ) {
6247 this.highlightItem( nextItem );
6248 } else {
6249 this.chooseItem( nextItem );
6250 }
6251 this.scrollItemIntoView( nextItem );
6252 }
6253
6254 if ( handled ) {
6255 e.preventDefault();
6256 e.stopPropagation();
6257 }
6258 }
6259 };
6260
6261 /**
6262 * Bind key down listener.
6263 *
6264 * @protected
6265 */
6266 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6267 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6268 };
6269
6270 /**
6271 * Unbind key down listener.
6272 *
6273 * @protected
6274 */
6275 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6276 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6277 };
6278
6279 /**
6280 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6281 *
6282 * @param {OO.ui.OptionWidget} item Item to scroll into view
6283 */
6284 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6285 var widget = this;
6286 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6287 // and around 100-150 ms after it is finished.
6288 this.blockMouseOverEvents++;
6289 item.scrollElementIntoView().done( function () {
6290 setTimeout( function () {
6291 widget.blockMouseOverEvents--;
6292 }, 200 );
6293 } );
6294 };
6295
6296 /**
6297 * Clear the key-press buffer
6298 *
6299 * @protected
6300 */
6301 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6302 if ( this.keyPressBufferTimer ) {
6303 clearTimeout( this.keyPressBufferTimer );
6304 this.keyPressBufferTimer = null;
6305 }
6306 this.keyPressBuffer = '';
6307 };
6308
6309 /**
6310 * Handle key press events.
6311 *
6312 * @protected
6313 * @param {KeyboardEvent} e Key press event
6314 */
6315 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6316 var c, filter, item;
6317
6318 if ( !e.charCode ) {
6319 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6320 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6321 return false;
6322 }
6323 return;
6324 }
6325 if ( String.fromCodePoint ) {
6326 c = String.fromCodePoint( e.charCode );
6327 } else {
6328 c = String.fromCharCode( e.charCode );
6329 }
6330
6331 if ( this.keyPressBufferTimer ) {
6332 clearTimeout( this.keyPressBufferTimer );
6333 }
6334 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6335
6336 item = this.findHighlightedItem() || this.getSelectedItem();
6337
6338 if ( this.keyPressBuffer === c ) {
6339 // Common (if weird) special case: typing "xxxx" will cycle through all
6340 // the items beginning with "x".
6341 if ( item ) {
6342 item = this.findRelativeSelectableItem( item, 1 );
6343 }
6344 } else {
6345 this.keyPressBuffer += c;
6346 }
6347
6348 filter = this.getItemMatcher( this.keyPressBuffer, false );
6349 if ( !item || !filter( item ) ) {
6350 item = this.findRelativeSelectableItem( item, 1, filter );
6351 }
6352 if ( item ) {
6353 if ( this.isVisible() && item.constructor.static.highlightable ) {
6354 this.highlightItem( item );
6355 } else {
6356 this.chooseItem( item );
6357 }
6358 this.scrollItemIntoView( item );
6359 }
6360
6361 e.preventDefault();
6362 e.stopPropagation();
6363 };
6364
6365 /**
6366 * Get a matcher for the specific string
6367 *
6368 * @protected
6369 * @param {string} s String to match against items
6370 * @param {boolean} [exact=false] Only accept exact matches
6371 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6372 */
6373 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6374 var re;
6375
6376 if ( s.normalize ) {
6377 s = s.normalize();
6378 }
6379 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6380 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6381 if ( exact ) {
6382 re += '\\s*$';
6383 }
6384 re = new RegExp( re, 'i' );
6385 return function ( item ) {
6386 var matchText = item.getMatchText();
6387 if ( matchText.normalize ) {
6388 matchText = matchText.normalize();
6389 }
6390 return re.test( matchText );
6391 };
6392 };
6393
6394 /**
6395 * Bind key press listener.
6396 *
6397 * @protected
6398 */
6399 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6400 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6401 };
6402
6403 /**
6404 * Unbind key down listener.
6405 *
6406 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6407 * implementation.
6408 *
6409 * @protected
6410 */
6411 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6412 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6413 this.clearKeyPressBuffer();
6414 };
6415
6416 /**
6417 * Visibility change handler
6418 *
6419 * @protected
6420 * @param {boolean} visible
6421 */
6422 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6423 if ( !visible ) {
6424 this.clearKeyPressBuffer();
6425 }
6426 };
6427
6428 /**
6429 * Get the closest item to a jQuery.Event.
6430 *
6431 * @private
6432 * @param {jQuery.Event} e
6433 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6434 */
6435 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6436 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6437 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6438 return null;
6439 }
6440 return $option.data( 'oo-ui-optionWidget' ) || null;
6441 };
6442
6443 /**
6444 * Get selected item.
6445 *
6446 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6447 */
6448 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6449 var i, len;
6450
6451 for ( i = 0, len = this.items.length; i < len; i++ ) {
6452 if ( this.items[ i ].isSelected() ) {
6453 return this.items[ i ];
6454 }
6455 }
6456 return null;
6457 };
6458
6459 /**
6460 * Find highlighted item.
6461 *
6462 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6463 */
6464 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6465 var i, len;
6466
6467 for ( i = 0, len = this.items.length; i < len; i++ ) {
6468 if ( this.items[ i ].isHighlighted() ) {
6469 return this.items[ i ];
6470 }
6471 }
6472 return null;
6473 };
6474
6475 /**
6476 * Get highlighted item.
6477 *
6478 * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead.
6479 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6480 */
6481 OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
6482 OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' );
6483 return this.findHighlightedItem();
6484 };
6485
6486 /**
6487 * Toggle pressed state.
6488 *
6489 * Press is a state that occurs when a user mouses down on an item, but
6490 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6491 * until the user releases the mouse.
6492 *
6493 * @param {boolean} pressed An option is being pressed
6494 */
6495 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6496 if ( pressed === undefined ) {
6497 pressed = !this.pressed;
6498 }
6499 if ( pressed !== this.pressed ) {
6500 this.$element
6501 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6502 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6503 this.pressed = pressed;
6504 }
6505 };
6506
6507 /**
6508 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6509 * and any existing highlight will be removed. The highlight is mutually exclusive.
6510 *
6511 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6512 * @fires highlight
6513 * @chainable
6514 */
6515 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6516 var i, len, highlighted,
6517 changed = false;
6518
6519 for ( i = 0, len = this.items.length; i < len; i++ ) {
6520 highlighted = this.items[ i ] === item;
6521 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6522 this.items[ i ].setHighlighted( highlighted );
6523 changed = true;
6524 }
6525 }
6526 if ( changed ) {
6527 if ( item ) {
6528 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6529 } else {
6530 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6531 }
6532 this.emit( 'highlight', item );
6533 }
6534
6535 return this;
6536 };
6537
6538 /**
6539 * Fetch an item by its label.
6540 *
6541 * @param {string} label Label of the item to select.
6542 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6543 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6544 */
6545 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6546 var i, item, found,
6547 len = this.items.length,
6548 filter = this.getItemMatcher( label, true );
6549
6550 for ( i = 0; i < len; i++ ) {
6551 item = this.items[ i ];
6552 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6553 return item;
6554 }
6555 }
6556
6557 if ( prefix ) {
6558 found = null;
6559 filter = this.getItemMatcher( label, false );
6560 for ( i = 0; i < len; i++ ) {
6561 item = this.items[ i ];
6562 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6563 if ( found ) {
6564 return null;
6565 }
6566 found = item;
6567 }
6568 }
6569 if ( found ) {
6570 return found;
6571 }
6572 }
6573
6574 return null;
6575 };
6576
6577 /**
6578 * Programmatically select an option by its label. If the item does not exist,
6579 * all options will be deselected.
6580 *
6581 * @param {string} [label] Label of the item to select.
6582 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6583 * @fires select
6584 * @chainable
6585 */
6586 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6587 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6588 if ( label === undefined || !itemFromLabel ) {
6589 return this.selectItem();
6590 }
6591 return this.selectItem( itemFromLabel );
6592 };
6593
6594 /**
6595 * Programmatically select an option by its data. If the `data` parameter is omitted,
6596 * or if the item does not exist, all options will be deselected.
6597 *
6598 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6599 * @fires select
6600 * @chainable
6601 */
6602 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6603 var itemFromData = this.getItemFromData( data );
6604 if ( data === undefined || !itemFromData ) {
6605 return this.selectItem();
6606 }
6607 return this.selectItem( itemFromData );
6608 };
6609
6610 /**
6611 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6612 * all options will be deselected.
6613 *
6614 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6615 * @fires select
6616 * @chainable
6617 */
6618 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6619 var i, len, selected,
6620 changed = false;
6621
6622 for ( i = 0, len = this.items.length; i < len; i++ ) {
6623 selected = this.items[ i ] === item;
6624 if ( this.items[ i ].isSelected() !== selected ) {
6625 this.items[ i ].setSelected( selected );
6626 changed = true;
6627 }
6628 }
6629 if ( changed ) {
6630 if ( item && !item.constructor.static.highlightable ) {
6631 if ( item ) {
6632 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6633 } else {
6634 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6635 }
6636 }
6637 this.emit( 'select', item );
6638 }
6639
6640 return this;
6641 };
6642
6643 /**
6644 * Press an item.
6645 *
6646 * Press is a state that occurs when a user mouses down on an item, but has not
6647 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6648 * releases the mouse.
6649 *
6650 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6651 * @fires press
6652 * @chainable
6653 */
6654 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6655 var i, len, pressed,
6656 changed = false;
6657
6658 for ( i = 0, len = this.items.length; i < len; i++ ) {
6659 pressed = this.items[ i ] === item;
6660 if ( this.items[ i ].isPressed() !== pressed ) {
6661 this.items[ i ].setPressed( pressed );
6662 changed = true;
6663 }
6664 }
6665 if ( changed ) {
6666 this.emit( 'press', item );
6667 }
6668
6669 return this;
6670 };
6671
6672 /**
6673 * Choose an item.
6674 *
6675 * Note that ‘choose’ should never be modified programmatically. A user can choose
6676 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6677 * use the #selectItem method.
6678 *
6679 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6680 * when users choose an item with the keyboard or mouse.
6681 *
6682 * @param {OO.ui.OptionWidget} item Item to choose
6683 * @fires choose
6684 * @chainable
6685 */
6686 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6687 if ( item ) {
6688 this.selectItem( item );
6689 this.emit( 'choose', item );
6690 }
6691
6692 return this;
6693 };
6694
6695 /**
6696 * Find an option by its position relative to the specified item (or to the start of the option array,
6697 * if item is `null`). The direction in which to search through the option array is specified with a
6698 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6699 * `null` if there are no options in the array.
6700 *
6701 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6702 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6703 * @param {Function} [filter] Only consider items for which this function returns
6704 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6705 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6706 */
6707 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6708 var currentIndex, nextIndex, i,
6709 increase = direction > 0 ? 1 : -1,
6710 len = this.items.length;
6711
6712 if ( item instanceof OO.ui.OptionWidget ) {
6713 currentIndex = this.items.indexOf( item );
6714 nextIndex = ( currentIndex + increase + len ) % len;
6715 } else {
6716 // If no item is selected and moving forward, start at the beginning.
6717 // If moving backward, start at the end.
6718 nextIndex = direction > 0 ? 0 : len - 1;
6719 }
6720
6721 for ( i = 0; i < len; i++ ) {
6722 item = this.items[ nextIndex ];
6723 if (
6724 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6725 ( !filter || filter( item ) )
6726 ) {
6727 return item;
6728 }
6729 nextIndex = ( nextIndex + increase + len ) % len;
6730 }
6731 return null;
6732 };
6733
6734 /**
6735 * Get an option by its position relative to the specified item (or to the start of the option array,
6736 * if item is `null`). The direction in which to search through the option array is specified with a
6737 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6738 * `null` if there are no options in the array.
6739 *
6740 * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead
6741 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6742 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6743 * @param {Function} [filter] Only consider items for which this function returns
6744 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6745 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6746 */
6747 OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
6748 OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' );
6749 return this.findRelativeSelectableItem( item, direction, filter );
6750 };
6751
6752 /**
6753 * Find 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 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6757 */
6758 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
6759 return this.findRelativeSelectableItem( null, 1 );
6760 };
6761
6762 /**
6763 * Get the next selectable item or `null` if there are no selectable items.
6764 * Disabled options and menu-section markers and breaks are not selectable.
6765 *
6766 * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead.
6767 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
6768 */
6769 OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
6770 OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' );
6771 return this.findFirstSelectableItem();
6772 };
6773
6774 /**
6775 * Add an array of options to the select. Optionally, an index number can be used to
6776 * specify an insertion point.
6777 *
6778 * @param {OO.ui.OptionWidget[]} items Items to add
6779 * @param {number} [index] Index to insert items after
6780 * @fires add
6781 * @chainable
6782 */
6783 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
6784 // Mixin method
6785 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
6786
6787 // Always provide an index, even if it was omitted
6788 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
6789
6790 return this;
6791 };
6792
6793 /**
6794 * Remove the specified array of options from the select. Options will be detached
6795 * from the DOM, not removed, so they can be reused later. To remove all options from
6796 * the select, you may wish to use the #clearItems method instead.
6797 *
6798 * @param {OO.ui.OptionWidget[]} items Items to remove
6799 * @fires remove
6800 * @chainable
6801 */
6802 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
6803 var i, len, item;
6804
6805 // Deselect items being removed
6806 for ( i = 0, len = items.length; i < len; i++ ) {
6807 item = items[ i ];
6808 if ( item.isSelected() ) {
6809 this.selectItem( null );
6810 }
6811 }
6812
6813 // Mixin method
6814 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
6815
6816 this.emit( 'remove', items );
6817
6818 return this;
6819 };
6820
6821 /**
6822 * Clear all options from the select. Options will be detached from the DOM, not removed,
6823 * so that they can be reused later. To remove a subset of options from the select, use
6824 * the #removeItems method.
6825 *
6826 * @fires remove
6827 * @chainable
6828 */
6829 OO.ui.SelectWidget.prototype.clearItems = function () {
6830 var items = this.items.slice();
6831
6832 // Mixin method
6833 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
6834
6835 // Clear selection
6836 this.selectItem( null );
6837
6838 this.emit( 'remove', items );
6839
6840 return this;
6841 };
6842
6843 /**
6844 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
6845 *
6846 * Currently this is just used to set `aria-activedescendant` on it.
6847 *
6848 * @protected
6849 * @param {jQuery} $focusOwner
6850 */
6851 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
6852 this.$focusOwner = $focusOwner;
6853 };
6854
6855 /**
6856 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
6857 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
6858 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
6859 * options. For more information about options and selects, please see the
6860 * [OOjs UI documentation on MediaWiki][1].
6861 *
6862 * @example
6863 * // Decorated options in a select widget
6864 * var select = new OO.ui.SelectWidget( {
6865 * items: [
6866 * new OO.ui.DecoratedOptionWidget( {
6867 * data: 'a',
6868 * label: 'Option with icon',
6869 * icon: 'help'
6870 * } ),
6871 * new OO.ui.DecoratedOptionWidget( {
6872 * data: 'b',
6873 * label: 'Option with indicator',
6874 * indicator: 'next'
6875 * } )
6876 * ]
6877 * } );
6878 * $( 'body' ).append( select.$element );
6879 *
6880 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6881 *
6882 * @class
6883 * @extends OO.ui.OptionWidget
6884 * @mixins OO.ui.mixin.IconElement
6885 * @mixins OO.ui.mixin.IndicatorElement
6886 *
6887 * @constructor
6888 * @param {Object} [config] Configuration options
6889 */
6890 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
6891 // Parent constructor
6892 OO.ui.DecoratedOptionWidget.parent.call( this, config );
6893
6894 // Mixin constructors
6895 OO.ui.mixin.IconElement.call( this, config );
6896 OO.ui.mixin.IndicatorElement.call( this, config );
6897
6898 // Initialization
6899 this.$element
6900 .addClass( 'oo-ui-decoratedOptionWidget' )
6901 .prepend( this.$icon )
6902 .append( this.$indicator );
6903 };
6904
6905 /* Setup */
6906
6907 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
6908 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
6909 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
6910
6911 /**
6912 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
6913 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
6914 * the [OOjs UI documentation on MediaWiki] [1] for more information.
6915 *
6916 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
6917 *
6918 * @class
6919 * @extends OO.ui.DecoratedOptionWidget
6920 *
6921 * @constructor
6922 * @param {Object} [config] Configuration options
6923 */
6924 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
6925 // Parent constructor
6926 OO.ui.MenuOptionWidget.parent.call( this, config );
6927
6928 // Initialization
6929 this.$element.addClass( 'oo-ui-menuOptionWidget' );
6930 };
6931
6932 /* Setup */
6933
6934 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
6935
6936 /* Static Properties */
6937
6938 /**
6939 * @static
6940 * @inheritdoc
6941 */
6942 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
6943
6944 /**
6945 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
6946 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
6947 *
6948 * @example
6949 * var myDropdown = new OO.ui.DropdownWidget( {
6950 * menu: {
6951 * items: [
6952 * new OO.ui.MenuSectionOptionWidget( {
6953 * label: 'Dogs'
6954 * } ),
6955 * new OO.ui.MenuOptionWidget( {
6956 * data: 'corgi',
6957 * label: 'Welsh Corgi'
6958 * } ),
6959 * new OO.ui.MenuOptionWidget( {
6960 * data: 'poodle',
6961 * label: 'Standard Poodle'
6962 * } ),
6963 * new OO.ui.MenuSectionOptionWidget( {
6964 * label: 'Cats'
6965 * } ),
6966 * new OO.ui.MenuOptionWidget( {
6967 * data: 'lion',
6968 * label: 'Lion'
6969 * } )
6970 * ]
6971 * }
6972 * } );
6973 * $( 'body' ).append( myDropdown.$element );
6974 *
6975 * @class
6976 * @extends OO.ui.DecoratedOptionWidget
6977 *
6978 * @constructor
6979 * @param {Object} [config] Configuration options
6980 */
6981 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
6982 // Parent constructor
6983 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
6984
6985 // Initialization
6986 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
6987 .removeAttr( 'role aria-selected' );
6988 };
6989
6990 /* Setup */
6991
6992 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
6993
6994 /* Static Properties */
6995
6996 /**
6997 * @static
6998 * @inheritdoc
6999 */
7000 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7001
7002 /**
7003 * @static
7004 * @inheritdoc
7005 */
7006 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7007
7008 /**
7009 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7010 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7011 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7012 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7013 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7014 * and customized to be opened, closed, and displayed as needed.
7015 *
7016 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7017 * mouse outside the menu.
7018 *
7019 * Menus also have support for keyboard interaction:
7020 *
7021 * - Enter/Return key: choose and select a menu option
7022 * - Up-arrow key: highlight the previous menu option
7023 * - Down-arrow key: highlight the next menu option
7024 * - Esc key: hide the menu
7025 *
7026 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7027 *
7028 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7029 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7030 *
7031 * @class
7032 * @extends OO.ui.SelectWidget
7033 * @mixins OO.ui.mixin.ClippableElement
7034 * @mixins OO.ui.mixin.FloatableElement
7035 *
7036 * @constructor
7037 * @param {Object} [config] Configuration options
7038 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7039 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7040 * and {@link OO.ui.mixin.LookupElement LookupElement}
7041 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7042 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7043 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7044 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7045 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7046 * that button, unless the button (or its parent widget) is passed in here.
7047 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7048 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7049 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7050 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7051 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7052 * @cfg {number} [width] Width of the menu
7053 */
7054 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7055 // Configuration initialization
7056 config = config || {};
7057
7058 // Parent constructor
7059 OO.ui.MenuSelectWidget.parent.call( this, config );
7060
7061 // Mixin constructors
7062 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7063 OO.ui.mixin.FloatableElement.call( this, config );
7064
7065 // Properties
7066 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7067 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7068 this.filterFromInput = !!config.filterFromInput;
7069 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7070 this.$widget = config.widget ? config.widget.$element : null;
7071 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7072 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7073 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7074 this.highlightOnFilter = !!config.highlightOnFilter;
7075 this.width = config.width;
7076
7077 // Initialization
7078 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7079 if ( config.widget ) {
7080 this.setFocusOwner( config.widget.$tabIndexed );
7081 }
7082
7083 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7084 // that reference properties not initialized at that time of parent class construction
7085 // TODO: Find a better way to handle post-constructor setup
7086 this.visible = false;
7087 this.$element.addClass( 'oo-ui-element-hidden' );
7088 };
7089
7090 /* Setup */
7091
7092 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7093 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7094 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7095
7096 /* Events */
7097
7098 /**
7099 * @event ready
7100 *
7101 * The menu is ready: it is visible and has been positioned and clipped.
7102 */
7103
7104 /* Methods */
7105
7106 /**
7107 * Handles document mouse down events.
7108 *
7109 * @protected
7110 * @param {MouseEvent} e Mouse down event
7111 */
7112 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7113 if (
7114 this.isVisible() &&
7115 !OO.ui.contains(
7116 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7117 e.target,
7118 true
7119 )
7120 ) {
7121 this.toggle( false );
7122 }
7123 };
7124
7125 /**
7126 * @inheritdoc
7127 */
7128 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7129 var currentItem = this.findHighlightedItem() || this.getSelectedItem();
7130
7131 if ( !this.isDisabled() && this.isVisible() ) {
7132 switch ( e.keyCode ) {
7133 case OO.ui.Keys.LEFT:
7134 case OO.ui.Keys.RIGHT:
7135 // Do nothing if a text field is associated, arrow keys will be handled natively
7136 if ( !this.$input ) {
7137 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7138 }
7139 break;
7140 case OO.ui.Keys.ESCAPE:
7141 case OO.ui.Keys.TAB:
7142 if ( currentItem ) {
7143 currentItem.setHighlighted( false );
7144 }
7145 this.toggle( false );
7146 // Don't prevent tabbing away, prevent defocusing
7147 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7148 e.preventDefault();
7149 e.stopPropagation();
7150 }
7151 break;
7152 default:
7153 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7154 return;
7155 }
7156 }
7157 };
7158
7159 /**
7160 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7161 * or after items were added/removed (always).
7162 *
7163 * @protected
7164 */
7165 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7166 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7167 firstItemFound = false,
7168 anyVisible = false,
7169 len = this.items.length,
7170 showAll = !this.isVisible(),
7171 exactMatch = false;
7172
7173 if ( this.$input && this.filterFromInput ) {
7174 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7175 exactFilter = this.getItemMatcher( this.$input.val(), true );
7176
7177 // Hide non-matching options, and also hide section headers if all options
7178 // in their section are hidden.
7179 for ( i = 0; i < len; i++ ) {
7180 item = this.items[ i ];
7181 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7182 if ( section ) {
7183 // If the previous section was empty, hide its header
7184 section.toggle( showAll || !sectionEmpty );
7185 }
7186 section = item;
7187 sectionEmpty = true;
7188 } else if ( item instanceof OO.ui.OptionWidget ) {
7189 visible = showAll || filter( item );
7190 exactMatch = exactMatch || exactFilter( item );
7191 anyVisible = anyVisible || visible;
7192 sectionEmpty = sectionEmpty && !visible;
7193 item.toggle( visible );
7194 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7195 // Highlight the first item in the list
7196 this.highlightItem( item );
7197 firstItemFound = true;
7198 }
7199 }
7200 }
7201 // Process the final section
7202 if ( section ) {
7203 section.toggle( showAll || !sectionEmpty );
7204 }
7205
7206 if ( anyVisible && this.items.length && !exactMatch ) {
7207 this.scrollItemIntoView( this.items[ 0 ] );
7208 }
7209
7210 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7211 }
7212
7213 // Reevaluate clipping
7214 this.clip();
7215 };
7216
7217 /**
7218 * @inheritdoc
7219 */
7220 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7221 if ( this.$input ) {
7222 this.$input.on( 'keydown', this.onKeyDownHandler );
7223 } else {
7224 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7225 }
7226 };
7227
7228 /**
7229 * @inheritdoc
7230 */
7231 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7232 if ( this.$input ) {
7233 this.$input.off( 'keydown', this.onKeyDownHandler );
7234 } else {
7235 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7236 }
7237 };
7238
7239 /**
7240 * @inheritdoc
7241 */
7242 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7243 if ( this.$input ) {
7244 if ( this.filterFromInput ) {
7245 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7246 this.updateItemVisibility();
7247 }
7248 } else {
7249 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7250 }
7251 };
7252
7253 /**
7254 * @inheritdoc
7255 */
7256 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7257 if ( this.$input ) {
7258 if ( this.filterFromInput ) {
7259 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7260 this.updateItemVisibility();
7261 }
7262 } else {
7263 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7264 }
7265 };
7266
7267 /**
7268 * Choose an item.
7269 *
7270 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7271 *
7272 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7273 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7274 *
7275 * @param {OO.ui.OptionWidget} item Item to choose
7276 * @chainable
7277 */
7278 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7279 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7280 if ( this.hideOnChoose ) {
7281 this.toggle( false );
7282 }
7283 return this;
7284 };
7285
7286 /**
7287 * @inheritdoc
7288 */
7289 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7290 // Parent method
7291 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7292
7293 this.updateItemVisibility();
7294
7295 return this;
7296 };
7297
7298 /**
7299 * @inheritdoc
7300 */
7301 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7302 // Parent method
7303 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7304
7305 this.updateItemVisibility();
7306
7307 return this;
7308 };
7309
7310 /**
7311 * @inheritdoc
7312 */
7313 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7314 // Parent method
7315 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7316
7317 this.updateItemVisibility();
7318
7319 return this;
7320 };
7321
7322 /**
7323 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7324 * `.toggle( true )` after its #$element is attached to the DOM.
7325 *
7326 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7327 * it in the right place and with the right dimensions only work correctly while it is attached.
7328 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7329 * strictly enforced, so currently it only generates a warning in the browser console.
7330 *
7331 * @fires ready
7332 * @inheritdoc
7333 */
7334 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7335 var change;
7336
7337 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7338 change = visible !== this.isVisible();
7339
7340 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7341 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7342 this.warnedUnattached = true;
7343 }
7344
7345 if ( change && visible && ( this.width || this.$floatableContainer ) ) {
7346 this.setIdealSize( this.width || this.$floatableContainer.width() );
7347 }
7348
7349 // Parent method
7350 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7351
7352 if ( change ) {
7353 if ( visible ) {
7354 this.bindKeyDownListener();
7355 this.bindKeyPressListener();
7356
7357 this.togglePositioning( !!this.$floatableContainer );
7358 this.toggleClipping( true );
7359
7360 this.$focusOwner.attr( 'aria-expanded', 'true' );
7361
7362 if ( this.getSelectedItem() ) {
7363 this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
7364 this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
7365 }
7366
7367 // Auto-hide
7368 if ( this.autoHide ) {
7369 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7370 }
7371
7372 this.emit( 'ready' );
7373 } else {
7374 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7375 this.unbindKeyDownListener();
7376 this.unbindKeyPressListener();
7377 this.$focusOwner.attr( 'aria-expanded', 'false' );
7378 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7379 this.togglePositioning( false );
7380 this.toggleClipping( false );
7381 }
7382 }
7383
7384 return this;
7385 };
7386
7387 /**
7388 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7389 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7390 * users can interact with it.
7391 *
7392 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7393 * OO.ui.DropdownInputWidget instead.
7394 *
7395 * @example
7396 * // Example: A DropdownWidget with a menu that contains three options
7397 * var dropDown = new OO.ui.DropdownWidget( {
7398 * label: 'Dropdown menu: Select a menu option',
7399 * menu: {
7400 * items: [
7401 * new OO.ui.MenuOptionWidget( {
7402 * data: 'a',
7403 * label: 'First'
7404 * } ),
7405 * new OO.ui.MenuOptionWidget( {
7406 * data: 'b',
7407 * label: 'Second'
7408 * } ),
7409 * new OO.ui.MenuOptionWidget( {
7410 * data: 'c',
7411 * label: 'Third'
7412 * } )
7413 * ]
7414 * }
7415 * } );
7416 *
7417 * $( 'body' ).append( dropDown.$element );
7418 *
7419 * dropDown.getMenu().selectItemByData( 'b' );
7420 *
7421 * dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
7422 *
7423 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
7424 *
7425 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7426 *
7427 * @class
7428 * @extends OO.ui.Widget
7429 * @mixins OO.ui.mixin.IconElement
7430 * @mixins OO.ui.mixin.IndicatorElement
7431 * @mixins OO.ui.mixin.LabelElement
7432 * @mixins OO.ui.mixin.TitledElement
7433 * @mixins OO.ui.mixin.TabIndexedElement
7434 *
7435 * @constructor
7436 * @param {Object} [config] Configuration options
7437 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7438 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7439 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7440 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7441 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
7442 */
7443 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7444 // Configuration initialization
7445 config = $.extend( { indicator: 'down' }, config );
7446
7447 // Parent constructor
7448 OO.ui.DropdownWidget.parent.call( this, config );
7449
7450 // Properties (must be set before TabIndexedElement constructor call)
7451 this.$handle = this.$( '<span>' );
7452 this.$overlay = config.$overlay || this.$element;
7453
7454 // Mixin constructors
7455 OO.ui.mixin.IconElement.call( this, config );
7456 OO.ui.mixin.IndicatorElement.call( this, config );
7457 OO.ui.mixin.LabelElement.call( this, config );
7458 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7459 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7460
7461 // Properties
7462 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7463 widget: this,
7464 $floatableContainer: this.$element
7465 }, config.menu ) );
7466
7467 // Events
7468 this.$handle.on( {
7469 click: this.onClick.bind( this ),
7470 keydown: this.onKeyDown.bind( this ),
7471 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7472 keypress: this.menu.onKeyPressHandler,
7473 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7474 } );
7475 this.menu.connect( this, {
7476 select: 'onMenuSelect',
7477 toggle: 'onMenuToggle'
7478 } );
7479
7480 // Initialization
7481 this.$handle
7482 .addClass( 'oo-ui-dropdownWidget-handle' )
7483 .attr( {
7484 role: 'combobox',
7485 'aria-owns': this.menu.getElementId(),
7486 'aria-autocomplete': 'list'
7487 } )
7488 .append( this.$icon, this.$label, this.$indicator );
7489 this.$element
7490 .addClass( 'oo-ui-dropdownWidget' )
7491 .append( this.$handle );
7492 this.$overlay.append( this.menu.$element );
7493 };
7494
7495 /* Setup */
7496
7497 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7498 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7499 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7500 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7501 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7502 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7503
7504 /* Methods */
7505
7506 /**
7507 * Get the menu.
7508 *
7509 * @return {OO.ui.MenuSelectWidget} Menu of widget
7510 */
7511 OO.ui.DropdownWidget.prototype.getMenu = function () {
7512 return this.menu;
7513 };
7514
7515 /**
7516 * Handles menu select events.
7517 *
7518 * @private
7519 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7520 */
7521 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7522 var selectedLabel;
7523
7524 if ( !item ) {
7525 this.setLabel( null );
7526 return;
7527 }
7528
7529 selectedLabel = item.getLabel();
7530
7531 // If the label is a DOM element, clone it, because setLabel will append() it
7532 if ( selectedLabel instanceof jQuery ) {
7533 selectedLabel = selectedLabel.clone();
7534 }
7535
7536 this.setLabel( selectedLabel );
7537 };
7538
7539 /**
7540 * Handle menu toggle events.
7541 *
7542 * @private
7543 * @param {boolean} isVisible Menu toggle event
7544 */
7545 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7546 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7547 this.$handle.attr(
7548 'aria-expanded',
7549 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7550 );
7551 };
7552
7553 /**
7554 * Handle mouse click events.
7555 *
7556 * @private
7557 * @param {jQuery.Event} e Mouse click event
7558 */
7559 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7560 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7561 this.menu.toggle();
7562 }
7563 return false;
7564 };
7565
7566 /**
7567 * Handle key down events.
7568 *
7569 * @private
7570 * @param {jQuery.Event} e Key down event
7571 */
7572 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7573 if (
7574 !this.isDisabled() &&
7575 (
7576 e.which === OO.ui.Keys.ENTER ||
7577 (
7578 !this.menu.isVisible() &&
7579 (
7580 e.which === OO.ui.Keys.SPACE ||
7581 e.which === OO.ui.Keys.UP ||
7582 e.which === OO.ui.Keys.DOWN
7583 )
7584 )
7585 )
7586 ) {
7587 this.menu.toggle();
7588 return false;
7589 }
7590 };
7591
7592 /**
7593 * RadioOptionWidget is an option widget that looks like a radio button.
7594 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7595 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7596 *
7597 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7598 *
7599 * @class
7600 * @extends OO.ui.OptionWidget
7601 *
7602 * @constructor
7603 * @param {Object} [config] Configuration options
7604 */
7605 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7606 // Configuration initialization
7607 config = config || {};
7608
7609 // Properties (must be done before parent constructor which calls #setDisabled)
7610 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7611
7612 // Parent constructor
7613 OO.ui.RadioOptionWidget.parent.call( this, config );
7614
7615 // Initialization
7616 // Remove implicit role, we're handling it ourselves
7617 this.radio.$input.attr( 'role', 'presentation' );
7618 this.$element
7619 .addClass( 'oo-ui-radioOptionWidget' )
7620 .attr( 'role', 'radio' )
7621 .attr( 'aria-checked', 'false' )
7622 .removeAttr( 'aria-selected' )
7623 .prepend( this.radio.$element );
7624 };
7625
7626 /* Setup */
7627
7628 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7629
7630 /* Static Properties */
7631
7632 /**
7633 * @static
7634 * @inheritdoc
7635 */
7636 OO.ui.RadioOptionWidget.static.highlightable = false;
7637
7638 /**
7639 * @static
7640 * @inheritdoc
7641 */
7642 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7643
7644 /**
7645 * @static
7646 * @inheritdoc
7647 */
7648 OO.ui.RadioOptionWidget.static.pressable = false;
7649
7650 /**
7651 * @static
7652 * @inheritdoc
7653 */
7654 OO.ui.RadioOptionWidget.static.tagName = 'label';
7655
7656 /* Methods */
7657
7658 /**
7659 * @inheritdoc
7660 */
7661 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7662 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7663
7664 this.radio.setSelected( state );
7665 this.$element
7666 .attr( 'aria-checked', state.toString() )
7667 .removeAttr( 'aria-selected' );
7668
7669 return this;
7670 };
7671
7672 /**
7673 * @inheritdoc
7674 */
7675 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7676 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7677
7678 this.radio.setDisabled( this.isDisabled() );
7679
7680 return this;
7681 };
7682
7683 /**
7684 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7685 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7686 * an interface for adding, removing and selecting options.
7687 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7688 *
7689 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7690 * OO.ui.RadioSelectInputWidget instead.
7691 *
7692 * @example
7693 * // A RadioSelectWidget with RadioOptions.
7694 * var option1 = new OO.ui.RadioOptionWidget( {
7695 * data: 'a',
7696 * label: 'Selected radio option'
7697 * } );
7698 *
7699 * var option2 = new OO.ui.RadioOptionWidget( {
7700 * data: 'b',
7701 * label: 'Unselected radio option'
7702 * } );
7703 *
7704 * var radioSelect=new OO.ui.RadioSelectWidget( {
7705 * items: [ option1, option2 ]
7706 * } );
7707 *
7708 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7709 * radioSelect.selectItem( option1 );
7710 *
7711 * $( 'body' ).append( radioSelect.$element );
7712 *
7713 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7714
7715 *
7716 * @class
7717 * @extends OO.ui.SelectWidget
7718 * @mixins OO.ui.mixin.TabIndexedElement
7719 *
7720 * @constructor
7721 * @param {Object} [config] Configuration options
7722 */
7723 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7724 // Parent constructor
7725 OO.ui.RadioSelectWidget.parent.call( this, config );
7726
7727 // Mixin constructors
7728 OO.ui.mixin.TabIndexedElement.call( this, config );
7729
7730 // Events
7731 this.$element.on( {
7732 focus: this.bindKeyDownListener.bind( this ),
7733 blur: this.unbindKeyDownListener.bind( this )
7734 } );
7735
7736 // Initialization
7737 this.$element
7738 .addClass( 'oo-ui-radioSelectWidget' )
7739 .attr( 'role', 'radiogroup' );
7740 };
7741
7742 /* Setup */
7743
7744 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
7745 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
7746
7747 /**
7748 * MultioptionWidgets are special elements that can be selected and configured with data. The
7749 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
7750 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
7751 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
7752 *
7753 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
7754 *
7755 * @class
7756 * @extends OO.ui.Widget
7757 * @mixins OO.ui.mixin.ItemWidget
7758 * @mixins OO.ui.mixin.LabelElement
7759 *
7760 * @constructor
7761 * @param {Object} [config] Configuration options
7762 * @cfg {boolean} [selected=false] Whether the option is initially selected
7763 */
7764 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
7765 // Configuration initialization
7766 config = config || {};
7767
7768 // Parent constructor
7769 OO.ui.MultioptionWidget.parent.call( this, config );
7770
7771 // Mixin constructors
7772 OO.ui.mixin.ItemWidget.call( this );
7773 OO.ui.mixin.LabelElement.call( this, config );
7774
7775 // Properties
7776 this.selected = null;
7777
7778 // Initialization
7779 this.$element
7780 .addClass( 'oo-ui-multioptionWidget' )
7781 .append( this.$label );
7782 this.setSelected( config.selected );
7783 };
7784
7785 /* Setup */
7786
7787 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
7788 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
7789 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
7790
7791 /* Events */
7792
7793 /**
7794 * @event change
7795 *
7796 * A change event is emitted when the selected state of the option changes.
7797 *
7798 * @param {boolean} selected Whether the option is now selected
7799 */
7800
7801 /* Methods */
7802
7803 /**
7804 * Check if the option is selected.
7805 *
7806 * @return {boolean} Item is selected
7807 */
7808 OO.ui.MultioptionWidget.prototype.isSelected = function () {
7809 return this.selected;
7810 };
7811
7812 /**
7813 * Set the option’s selected state. In general, all modifications to the selection
7814 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
7815 * method instead of this method.
7816 *
7817 * @param {boolean} [state=false] Select option
7818 * @chainable
7819 */
7820 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
7821 state = !!state;
7822 if ( this.selected !== state ) {
7823 this.selected = state;
7824 this.emit( 'change', state );
7825 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
7826 }
7827 return this;
7828 };
7829
7830 /**
7831 * MultiselectWidget allows selecting multiple options from a list.
7832 *
7833 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
7834 *
7835 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7836 *
7837 * @class
7838 * @abstract
7839 * @extends OO.ui.Widget
7840 * @mixins OO.ui.mixin.GroupWidget
7841 *
7842 * @constructor
7843 * @param {Object} [config] Configuration options
7844 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
7845 */
7846 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
7847 // Parent constructor
7848 OO.ui.MultiselectWidget.parent.call( this, config );
7849
7850 // Configuration initialization
7851 config = config || {};
7852
7853 // Mixin constructors
7854 OO.ui.mixin.GroupWidget.call( this, config );
7855
7856 // Events
7857 this.aggregate( { change: 'select' } );
7858 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
7859 // by GroupElement only when items are added/removed
7860 this.connect( this, { select: [ 'emit', 'change' ] } );
7861
7862 // Initialization
7863 if ( config.items ) {
7864 this.addItems( config.items );
7865 }
7866 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
7867 this.$element.addClass( 'oo-ui-multiselectWidget' )
7868 .append( this.$group );
7869 };
7870
7871 /* Setup */
7872
7873 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
7874 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
7875
7876 /* Events */
7877
7878 /**
7879 * @event change
7880 *
7881 * A change event is emitted when the set of items changes, or an item is selected or deselected.
7882 */
7883
7884 /**
7885 * @event select
7886 *
7887 * A select event is emitted when an item is selected or deselected.
7888 */
7889
7890 /* Methods */
7891
7892 /**
7893 * Get options that are selected.
7894 *
7895 * @return {OO.ui.MultioptionWidget[]} Selected options
7896 */
7897 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
7898 return this.items.filter( function ( item ) {
7899 return item.isSelected();
7900 } );
7901 };
7902
7903 /**
7904 * Get the data of options that are selected.
7905 *
7906 * @return {Object[]|string[]} Values of selected options
7907 */
7908 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
7909 return this.getSelectedItems().map( function ( item ) {
7910 return item.data;
7911 } );
7912 };
7913
7914 /**
7915 * Select options by reference. Options not mentioned in the `items` array will be deselected.
7916 *
7917 * @param {OO.ui.MultioptionWidget[]} items Items to select
7918 * @chainable
7919 */
7920 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
7921 this.items.forEach( function ( item ) {
7922 var selected = items.indexOf( item ) !== -1;
7923 item.setSelected( selected );
7924 } );
7925 return this;
7926 };
7927
7928 /**
7929 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
7930 *
7931 * @param {Object[]|string[]} datas Values of items to select
7932 * @chainable
7933 */
7934 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
7935 var items,
7936 widget = this;
7937 items = datas.map( function ( data ) {
7938 return widget.getItemFromData( data );
7939 } );
7940 this.selectItems( items );
7941 return this;
7942 };
7943
7944 /**
7945 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
7946 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
7947 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7948 *
7949 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7950 *
7951 * @class
7952 * @extends OO.ui.MultioptionWidget
7953 *
7954 * @constructor
7955 * @param {Object} [config] Configuration options
7956 */
7957 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
7958 // Configuration initialization
7959 config = config || {};
7960
7961 // Properties (must be done before parent constructor which calls #setDisabled)
7962 this.checkbox = new OO.ui.CheckboxInputWidget();
7963
7964 // Parent constructor
7965 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
7966
7967 // Events
7968 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
7969 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
7970
7971 // Initialization
7972 this.$element
7973 .addClass( 'oo-ui-checkboxMultioptionWidget' )
7974 .prepend( this.checkbox.$element );
7975 };
7976
7977 /* Setup */
7978
7979 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
7980
7981 /* Static Properties */
7982
7983 /**
7984 * @static
7985 * @inheritdoc
7986 */
7987 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
7988
7989 /* Methods */
7990
7991 /**
7992 * Handle checkbox selected state change.
7993 *
7994 * @private
7995 */
7996 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
7997 this.setSelected( this.checkbox.isSelected() );
7998 };
7999
8000 /**
8001 * @inheritdoc
8002 */
8003 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8004 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8005 this.checkbox.setSelected( state );
8006 return this;
8007 };
8008
8009 /**
8010 * @inheritdoc
8011 */
8012 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8013 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8014 this.checkbox.setDisabled( this.isDisabled() );
8015 return this;
8016 };
8017
8018 /**
8019 * Focus the widget.
8020 */
8021 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8022 this.checkbox.focus();
8023 };
8024
8025 /**
8026 * Handle key down events.
8027 *
8028 * @protected
8029 * @param {jQuery.Event} e
8030 */
8031 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8032 var
8033 element = this.getElementGroup(),
8034 nextItem;
8035
8036 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8037 nextItem = element.getRelativeFocusableItem( this, -1 );
8038 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8039 nextItem = element.getRelativeFocusableItem( this, 1 );
8040 }
8041
8042 if ( nextItem ) {
8043 e.preventDefault();
8044 nextItem.focus();
8045 }
8046 };
8047
8048 /**
8049 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8050 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8051 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8052 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8053 *
8054 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8055 * OO.ui.CheckboxMultiselectInputWidget instead.
8056 *
8057 * @example
8058 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8059 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8060 * data: 'a',
8061 * selected: true,
8062 * label: 'Selected checkbox'
8063 * } );
8064 *
8065 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8066 * data: 'b',
8067 * label: 'Unselected checkbox'
8068 * } );
8069 *
8070 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8071 * items: [ option1, option2 ]
8072 * } );
8073 *
8074 * $( 'body' ).append( multiselect.$element );
8075 *
8076 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8077 *
8078 * @class
8079 * @extends OO.ui.MultiselectWidget
8080 *
8081 * @constructor
8082 * @param {Object} [config] Configuration options
8083 */
8084 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8085 // Parent constructor
8086 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8087
8088 // Properties
8089 this.$lastClicked = null;
8090
8091 // Events
8092 this.$group.on( 'click', this.onClick.bind( this ) );
8093
8094 // Initialization
8095 this.$element
8096 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8097 };
8098
8099 /* Setup */
8100
8101 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8102
8103 /* Methods */
8104
8105 /**
8106 * Get an option by its position relative to the specified item (or to the start of the option array,
8107 * if item is `null`). The direction in which to search through the option array is specified with a
8108 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8109 * `null` if there are no options in the array.
8110 *
8111 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8112 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8113 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8114 */
8115 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8116 var currentIndex, nextIndex, i,
8117 increase = direction > 0 ? 1 : -1,
8118 len = this.items.length;
8119
8120 if ( item ) {
8121 currentIndex = this.items.indexOf( item );
8122 nextIndex = ( currentIndex + increase + len ) % len;
8123 } else {
8124 // If no item is selected and moving forward, start at the beginning.
8125 // If moving backward, start at the end.
8126 nextIndex = direction > 0 ? 0 : len - 1;
8127 }
8128
8129 for ( i = 0; i < len; i++ ) {
8130 item = this.items[ nextIndex ];
8131 if ( item && !item.isDisabled() ) {
8132 return item;
8133 }
8134 nextIndex = ( nextIndex + increase + len ) % len;
8135 }
8136 return null;
8137 };
8138
8139 /**
8140 * Handle click events on checkboxes.
8141 *
8142 * @param {jQuery.Event} e
8143 */
8144 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8145 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8146 $lastClicked = this.$lastClicked,
8147 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8148 .not( '.oo-ui-widget-disabled' );
8149
8150 // Allow selecting multiple options at once by Shift-clicking them
8151 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8152 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8153 lastClickedIndex = $options.index( $lastClicked );
8154 nowClickedIndex = $options.index( $nowClicked );
8155 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8156 // browser. In either case we don't need custom handling.
8157 if ( nowClickedIndex !== lastClickedIndex ) {
8158 items = this.items;
8159 wasSelected = items[ nowClickedIndex ].isSelected();
8160 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8161
8162 // This depends on the DOM order of the items and the order of the .items array being the same.
8163 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8164 if ( !items[ i ].isDisabled() ) {
8165 items[ i ].setSelected( !wasSelected );
8166 }
8167 }
8168 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8169 // handling first, then set our value. The order in which events happen is different for
8170 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8171 // non-click actions that change the checkboxes.
8172 e.preventDefault();
8173 setTimeout( function () {
8174 if ( !items[ nowClickedIndex ].isDisabled() ) {
8175 items[ nowClickedIndex ].setSelected( !wasSelected );
8176 }
8177 } );
8178 }
8179 }
8180
8181 if ( $nowClicked.length ) {
8182 this.$lastClicked = $nowClicked;
8183 }
8184 };
8185
8186 /**
8187 * Focus the widget
8188 *
8189 * @chainable
8190 */
8191 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8192 var item;
8193 if ( !this.isDisabled() ) {
8194 item = this.getRelativeFocusableItem( null, 1 );
8195 if ( item ) {
8196 item.focus();
8197 }
8198 }
8199 return this;
8200 };
8201
8202 /**
8203 * @inheritdoc
8204 */
8205 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8206 this.focus();
8207 };
8208
8209 /**
8210 * Progress bars visually display the status of an operation, such as a download,
8211 * and can be either determinate or indeterminate:
8212 *
8213 * - **determinate** process bars show the percent of an operation that is complete.
8214 *
8215 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8216 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8217 * not use percentages.
8218 *
8219 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8220 *
8221 * @example
8222 * // Examples of determinate and indeterminate progress bars.
8223 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8224 * progress: 33
8225 * } );
8226 * var progressBar2 = new OO.ui.ProgressBarWidget();
8227 *
8228 * // Create a FieldsetLayout to layout progress bars
8229 * var fieldset = new OO.ui.FieldsetLayout;
8230 * fieldset.addItems( [
8231 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8232 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8233 * ] );
8234 * $( 'body' ).append( fieldset.$element );
8235 *
8236 * @class
8237 * @extends OO.ui.Widget
8238 *
8239 * @constructor
8240 * @param {Object} [config] Configuration options
8241 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8242 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8243 * By default, the progress bar is indeterminate.
8244 */
8245 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8246 // Configuration initialization
8247 config = config || {};
8248
8249 // Parent constructor
8250 OO.ui.ProgressBarWidget.parent.call( this, config );
8251
8252 // Properties
8253 this.$bar = $( '<div>' );
8254 this.progress = null;
8255
8256 // Initialization
8257 this.setProgress( config.progress !== undefined ? config.progress : false );
8258 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8259 this.$element
8260 .attr( {
8261 role: 'progressbar',
8262 'aria-valuemin': 0,
8263 'aria-valuemax': 100
8264 } )
8265 .addClass( 'oo-ui-progressBarWidget' )
8266 .append( this.$bar );
8267 };
8268
8269 /* Setup */
8270
8271 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8272
8273 /* Static Properties */
8274
8275 /**
8276 * @static
8277 * @inheritdoc
8278 */
8279 OO.ui.ProgressBarWidget.static.tagName = 'div';
8280
8281 /* Methods */
8282
8283 /**
8284 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8285 *
8286 * @return {number|boolean} Progress percent
8287 */
8288 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8289 return this.progress;
8290 };
8291
8292 /**
8293 * Set the percent of the process completed or `false` for an indeterminate process.
8294 *
8295 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8296 */
8297 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8298 this.progress = progress;
8299
8300 if ( progress !== false ) {
8301 this.$bar.css( 'width', this.progress + '%' );
8302 this.$element.attr( 'aria-valuenow', this.progress );
8303 } else {
8304 this.$bar.css( 'width', '' );
8305 this.$element.removeAttr( 'aria-valuenow' );
8306 }
8307 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8308 };
8309
8310 /**
8311 * InputWidget is the base class for all input widgets, which
8312 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8313 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8314 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8315 *
8316 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8317 *
8318 * @abstract
8319 * @class
8320 * @extends OO.ui.Widget
8321 * @mixins OO.ui.mixin.FlaggedElement
8322 * @mixins OO.ui.mixin.TabIndexedElement
8323 * @mixins OO.ui.mixin.TitledElement
8324 * @mixins OO.ui.mixin.AccessKeyedElement
8325 *
8326 * @constructor
8327 * @param {Object} [config] Configuration options
8328 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8329 * @cfg {string} [value=''] The value of the input.
8330 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8331 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8332 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8333 * before it is accepted.
8334 */
8335 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8336 // Configuration initialization
8337 config = config || {};
8338
8339 // Parent constructor
8340 OO.ui.InputWidget.parent.call( this, config );
8341
8342 // Properties
8343 // See #reusePreInfuseDOM about config.$input
8344 this.$input = config.$input || this.getInputElement( config );
8345 this.value = '';
8346 this.inputFilter = config.inputFilter;
8347
8348 // Mixin constructors
8349 OO.ui.mixin.FlaggedElement.call( this, config );
8350 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8351 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8352 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8353
8354 // Events
8355 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8356
8357 // Initialization
8358 this.$input
8359 .addClass( 'oo-ui-inputWidget-input' )
8360 .attr( 'name', config.name )
8361 .prop( 'disabled', this.isDisabled() );
8362 this.$element
8363 .addClass( 'oo-ui-inputWidget' )
8364 .append( this.$input );
8365 this.setValue( config.value );
8366 if ( config.dir ) {
8367 this.setDir( config.dir );
8368 }
8369 if ( config.inputId !== undefined ) {
8370 this.setInputId( config.inputId );
8371 }
8372 };
8373
8374 /* Setup */
8375
8376 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8377 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8378 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8379 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8380 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8381
8382 /* Static Methods */
8383
8384 /**
8385 * @inheritdoc
8386 */
8387 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8388 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8389 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8390 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8391 return config;
8392 };
8393
8394 /**
8395 * @inheritdoc
8396 */
8397 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8398 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8399 if ( config.$input && config.$input.length ) {
8400 state.value = config.$input.val();
8401 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8402 state.focus = config.$input.is( ':focus' );
8403 }
8404 return state;
8405 };
8406
8407 /* Events */
8408
8409 /**
8410 * @event change
8411 *
8412 * A change event is emitted when the value of the input changes.
8413 *
8414 * @param {string} value
8415 */
8416
8417 /* Methods */
8418
8419 /**
8420 * Get input element.
8421 *
8422 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8423 * different circumstances. The element must have a `value` property (like form elements).
8424 *
8425 * @protected
8426 * @param {Object} config Configuration options
8427 * @return {jQuery} Input element
8428 */
8429 OO.ui.InputWidget.prototype.getInputElement = function () {
8430 return $( '<input>' );
8431 };
8432
8433 /**
8434 * Handle potentially value-changing events.
8435 *
8436 * @private
8437 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8438 */
8439 OO.ui.InputWidget.prototype.onEdit = function () {
8440 var widget = this;
8441 if ( !this.isDisabled() ) {
8442 // Allow the stack to clear so the value will be updated
8443 setTimeout( function () {
8444 widget.setValue( widget.$input.val() );
8445 } );
8446 }
8447 };
8448
8449 /**
8450 * Get the value of the input.
8451 *
8452 * @return {string} Input value
8453 */
8454 OO.ui.InputWidget.prototype.getValue = function () {
8455 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8456 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8457 var value = this.$input.val();
8458 if ( this.value !== value ) {
8459 this.setValue( value );
8460 }
8461 return this.value;
8462 };
8463
8464 /**
8465 * Set the directionality of the input.
8466 *
8467 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8468 * @chainable
8469 */
8470 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8471 this.$input.prop( 'dir', dir );
8472 return this;
8473 };
8474
8475 /**
8476 * Set the value of the input.
8477 *
8478 * @param {string} value New value
8479 * @fires change
8480 * @chainable
8481 */
8482 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8483 value = this.cleanUpValue( value );
8484 // Update the DOM if it has changed. Note that with cleanUpValue, it
8485 // is possible for the DOM value to change without this.value changing.
8486 if ( this.$input.val() !== value ) {
8487 this.$input.val( value );
8488 }
8489 if ( this.value !== value ) {
8490 this.value = value;
8491 this.emit( 'change', this.value );
8492 }
8493 return this;
8494 };
8495
8496 /**
8497 * Clean up incoming value.
8498 *
8499 * Ensures value is a string, and converts undefined and null to empty string.
8500 *
8501 * @private
8502 * @param {string} value Original value
8503 * @return {string} Cleaned up value
8504 */
8505 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8506 if ( value === undefined || value === null ) {
8507 return '';
8508 } else if ( this.inputFilter ) {
8509 return this.inputFilter( String( value ) );
8510 } else {
8511 return String( value );
8512 }
8513 };
8514
8515 /**
8516 * @inheritdoc
8517 */
8518 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8519 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8520 if ( this.$input ) {
8521 this.$input.prop( 'disabled', this.isDisabled() );
8522 }
8523 return this;
8524 };
8525
8526 /**
8527 * Set the 'id' attribute of the `<input>` element.
8528 *
8529 * @param {string} id
8530 * @chainable
8531 */
8532 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8533 this.$input.attr( 'id', id );
8534 return this;
8535 };
8536
8537 /**
8538 * @inheritdoc
8539 */
8540 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8541 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8542 if ( state.value !== undefined && state.value !== this.getValue() ) {
8543 this.setValue( state.value );
8544 }
8545 if ( state.focus ) {
8546 this.focus();
8547 }
8548 };
8549
8550 /**
8551 * Data widget intended for creating 'hidden'-type inputs.
8552 *
8553 * @class
8554 * @extends OO.ui.Widget
8555 *
8556 * @constructor
8557 * @param {Object} [config] Configuration options
8558 * @cfg {string} [value=''] The value of the input.
8559 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8560 */
8561 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8562 // Configuration initialization
8563 config = $.extend( { value: '', name: '' }, config );
8564
8565 // Parent constructor
8566 OO.ui.HiddenInputWidget.parent.call( this, config );
8567
8568 // Initialization
8569 this.$element.attr( {
8570 type: 'hidden',
8571 value: config.value,
8572 name: config.name
8573 } );
8574 this.$element.removeAttr( 'aria-disabled' );
8575 };
8576
8577 /* Setup */
8578
8579 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8580
8581 /* Static Properties */
8582
8583 /**
8584 * @static
8585 * @inheritdoc
8586 */
8587 OO.ui.HiddenInputWidget.static.tagName = 'input';
8588
8589 /**
8590 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8591 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8592 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8593 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8594 * [OOjs UI documentation on MediaWiki] [1] for more information.
8595 *
8596 * @example
8597 * // A ButtonInputWidget rendered as an HTML button, the default.
8598 * var button = new OO.ui.ButtonInputWidget( {
8599 * label: 'Input button',
8600 * icon: 'check',
8601 * value: 'check'
8602 * } );
8603 * $( 'body' ).append( button.$element );
8604 *
8605 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8606 *
8607 * @class
8608 * @extends OO.ui.InputWidget
8609 * @mixins OO.ui.mixin.ButtonElement
8610 * @mixins OO.ui.mixin.IconElement
8611 * @mixins OO.ui.mixin.IndicatorElement
8612 * @mixins OO.ui.mixin.LabelElement
8613 * @mixins OO.ui.mixin.TitledElement
8614 *
8615 * @constructor
8616 * @param {Object} [config] Configuration options
8617 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8618 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8619 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8620 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8621 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8622 */
8623 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8624 // Configuration initialization
8625 config = $.extend( { type: 'button', useInputTag: false }, config );
8626
8627 // See InputWidget#reusePreInfuseDOM about config.$input
8628 if ( config.$input ) {
8629 config.$input.empty();
8630 }
8631
8632 // Properties (must be set before parent constructor, which calls #setValue)
8633 this.useInputTag = config.useInputTag;
8634
8635 // Parent constructor
8636 OO.ui.ButtonInputWidget.parent.call( this, config );
8637
8638 // Mixin constructors
8639 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8640 OO.ui.mixin.IconElement.call( this, config );
8641 OO.ui.mixin.IndicatorElement.call( this, config );
8642 OO.ui.mixin.LabelElement.call( this, config );
8643 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8644
8645 // Initialization
8646 if ( !config.useInputTag ) {
8647 this.$input.append( this.$icon, this.$label, this.$indicator );
8648 }
8649 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8650 };
8651
8652 /* Setup */
8653
8654 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8655 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8656 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8657 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8658 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8659 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8660
8661 /* Static Properties */
8662
8663 /**
8664 * @static
8665 * @inheritdoc
8666 */
8667 OO.ui.ButtonInputWidget.static.tagName = 'span';
8668
8669 /* Methods */
8670
8671 /**
8672 * @inheritdoc
8673 * @protected
8674 */
8675 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8676 var type;
8677 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8678 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8679 };
8680
8681 /**
8682 * Set label value.
8683 *
8684 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8685 *
8686 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8687 * text, or `null` for no label
8688 * @chainable
8689 */
8690 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8691 if ( typeof label === 'function' ) {
8692 label = OO.ui.resolveMsg( label );
8693 }
8694
8695 if ( this.useInputTag ) {
8696 // Discard non-plaintext labels
8697 if ( typeof label !== 'string' ) {
8698 label = '';
8699 }
8700
8701 this.$input.val( label );
8702 }
8703
8704 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8705 };
8706
8707 /**
8708 * Set the value of the input.
8709 *
8710 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8711 * they do not support {@link #value values}.
8712 *
8713 * @param {string} value New value
8714 * @chainable
8715 */
8716 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8717 if ( !this.useInputTag ) {
8718 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8719 }
8720 return this;
8721 };
8722
8723 /**
8724 * @inheritdoc
8725 */
8726 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8727 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8728 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
8729 return null;
8730 };
8731
8732 /**
8733 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
8734 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
8735 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
8736 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
8737 *
8738 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
8739 *
8740 * @example
8741 * // An example of selected, unselected, and disabled checkbox inputs
8742 * var checkbox1=new OO.ui.CheckboxInputWidget( {
8743 * value: 'a',
8744 * selected: true
8745 * } );
8746 * var checkbox2=new OO.ui.CheckboxInputWidget( {
8747 * value: 'b'
8748 * } );
8749 * var checkbox3=new OO.ui.CheckboxInputWidget( {
8750 * value:'c',
8751 * disabled: true
8752 * } );
8753 * // Create a fieldset layout with fields for each checkbox.
8754 * var fieldset = new OO.ui.FieldsetLayout( {
8755 * label: 'Checkboxes'
8756 * } );
8757 * fieldset.addItems( [
8758 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
8759 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
8760 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
8761 * ] );
8762 * $( 'body' ).append( fieldset.$element );
8763 *
8764 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8765 *
8766 * @class
8767 * @extends OO.ui.InputWidget
8768 *
8769 * @constructor
8770 * @param {Object} [config] Configuration options
8771 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
8772 */
8773 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
8774 // Configuration initialization
8775 config = config || {};
8776
8777 // Parent constructor
8778 OO.ui.CheckboxInputWidget.parent.call( this, config );
8779
8780 // Initialization
8781 this.$element
8782 .addClass( 'oo-ui-checkboxInputWidget' )
8783 // Required for pretty styling in WikimediaUI theme
8784 .append( $( '<span>' ) );
8785 this.setSelected( config.selected !== undefined ? config.selected : false );
8786 };
8787
8788 /* Setup */
8789
8790 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
8791
8792 /* Static Properties */
8793
8794 /**
8795 * @static
8796 * @inheritdoc
8797 */
8798 OO.ui.CheckboxInputWidget.static.tagName = 'span';
8799
8800 /* Static Methods */
8801
8802 /**
8803 * @inheritdoc
8804 */
8805 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
8806 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
8807 state.checked = config.$input.prop( 'checked' );
8808 return state;
8809 };
8810
8811 /* Methods */
8812
8813 /**
8814 * @inheritdoc
8815 * @protected
8816 */
8817 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
8818 return $( '<input>' ).attr( 'type', 'checkbox' );
8819 };
8820
8821 /**
8822 * @inheritdoc
8823 */
8824 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
8825 var widget = this;
8826 if ( !this.isDisabled() ) {
8827 // Allow the stack to clear so the value will be updated
8828 setTimeout( function () {
8829 widget.setSelected( widget.$input.prop( 'checked' ) );
8830 } );
8831 }
8832 };
8833
8834 /**
8835 * Set selection state of this checkbox.
8836 *
8837 * @param {boolean} state `true` for selected
8838 * @chainable
8839 */
8840 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
8841 state = !!state;
8842 if ( this.selected !== state ) {
8843 this.selected = state;
8844 this.$input.prop( 'checked', this.selected );
8845 this.emit( 'change', this.selected );
8846 }
8847 return this;
8848 };
8849
8850 /**
8851 * Check if this checkbox is selected.
8852 *
8853 * @return {boolean} Checkbox is selected
8854 */
8855 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
8856 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8857 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8858 var selected = this.$input.prop( 'checked' );
8859 if ( this.selected !== selected ) {
8860 this.setSelected( selected );
8861 }
8862 return this.selected;
8863 };
8864
8865 /**
8866 * @inheritdoc
8867 */
8868 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
8869 if ( !this.isDisabled() ) {
8870 this.$input.click();
8871 }
8872 this.focus();
8873 };
8874
8875 /**
8876 * @inheritdoc
8877 */
8878 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
8879 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8880 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
8881 this.setSelected( state.checked );
8882 }
8883 };
8884
8885 /**
8886 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
8887 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
8888 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
8889 * more information about input widgets.
8890 *
8891 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
8892 * are no options. If no `value` configuration option is provided, the first option is selected.
8893 * If you need a state representing no value (no option being selected), use a DropdownWidget.
8894 *
8895 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
8896 *
8897 * @example
8898 * // Example: A DropdownInputWidget with three options
8899 * var dropdownInput = new OO.ui.DropdownInputWidget( {
8900 * options: [
8901 * { data: 'a', label: 'First' },
8902 * { data: 'b', label: 'Second'},
8903 * { data: 'c', label: 'Third' }
8904 * ]
8905 * } );
8906 * $( 'body' ).append( dropdownInput.$element );
8907 *
8908 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8909 *
8910 * @class
8911 * @extends OO.ui.InputWidget
8912 * @mixins OO.ui.mixin.TitledElement
8913 *
8914 * @constructor
8915 * @param {Object} [config] Configuration options
8916 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
8917 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
8918 */
8919 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
8920 // Configuration initialization
8921 config = config || {};
8922
8923 // See InputWidget#reusePreInfuseDOM about config.$input
8924 if ( config.$input ) {
8925 config.$input.addClass( 'oo-ui-element-hidden' );
8926 }
8927
8928 // Properties (must be done before parent constructor which calls #setDisabled)
8929 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
8930
8931 // Parent constructor
8932 OO.ui.DropdownInputWidget.parent.call( this, config );
8933
8934 // Mixin constructors
8935 OO.ui.mixin.TitledElement.call( this, config );
8936
8937 // Events
8938 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
8939
8940 // Initialization
8941 this.setOptions( config.options || [] );
8942 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
8943 // widget has no valid options when it happens.
8944 this.setValue( config.value );
8945 this.$element
8946 .addClass( 'oo-ui-dropdownInputWidget' )
8947 .append( this.dropdownWidget.$element );
8948 this.setTabIndexedElement( null );
8949 };
8950
8951 /* Setup */
8952
8953 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
8954 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
8955
8956 /* Methods */
8957
8958 /**
8959 * @inheritdoc
8960 * @protected
8961 */
8962 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
8963 return $( '<input>' ).attr( 'type', 'hidden' );
8964 };
8965
8966 /**
8967 * Handles menu select events.
8968 *
8969 * @private
8970 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
8971 */
8972 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
8973 this.setValue( item ? item.getData() : '' );
8974 };
8975
8976 /**
8977 * @inheritdoc
8978 */
8979 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
8980 var selected;
8981 value = this.cleanUpValue( value );
8982 // Only allow setting values that are actually present in the dropdown
8983 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
8984 this.dropdownWidget.getMenu().findFirstSelectableItem();
8985 this.dropdownWidget.getMenu().selectItem( selected );
8986 value = selected ? selected.getData() : '';
8987 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
8988 return this;
8989 };
8990
8991 /**
8992 * @inheritdoc
8993 */
8994 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
8995 this.dropdownWidget.setDisabled( state );
8996 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
8997 return this;
8998 };
8999
9000 /**
9001 * Set the options available for this input.
9002 *
9003 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9004 * @chainable
9005 */
9006 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9007 var
9008 value = this.getValue(),
9009 widget = this;
9010
9011 // Rebuild the dropdown menu
9012 this.dropdownWidget.getMenu()
9013 .clearItems()
9014 .addItems( options.map( function ( opt ) {
9015 var optValue = widget.cleanUpValue( opt.data );
9016
9017 if ( opt.optgroup === undefined ) {
9018 return new OO.ui.MenuOptionWidget( {
9019 data: optValue,
9020 label: opt.label !== undefined ? opt.label : optValue
9021 } );
9022 } else {
9023 return new OO.ui.MenuSectionOptionWidget( {
9024 label: opt.optgroup
9025 } );
9026 }
9027 } ) );
9028
9029 // Restore the previous value, or reset to something sensible
9030 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9031 // Previous value is still available, ensure consistency with the dropdown
9032 this.setValue( value );
9033 } else {
9034 // No longer valid, reset
9035 if ( options.length ) {
9036 this.setValue( options[ 0 ].data );
9037 }
9038 }
9039
9040 return this;
9041 };
9042
9043 /**
9044 * @inheritdoc
9045 */
9046 OO.ui.DropdownInputWidget.prototype.focus = function () {
9047 this.dropdownWidget.focus();
9048 return this;
9049 };
9050
9051 /**
9052 * @inheritdoc
9053 */
9054 OO.ui.DropdownInputWidget.prototype.blur = function () {
9055 this.dropdownWidget.blur();
9056 return this;
9057 };
9058
9059 /**
9060 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9061 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9062 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9063 * please see the [OOjs UI documentation on MediaWiki][1].
9064 *
9065 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9066 *
9067 * @example
9068 * // An example of selected, unselected, and disabled radio inputs
9069 * var radio1 = new OO.ui.RadioInputWidget( {
9070 * value: 'a',
9071 * selected: true
9072 * } );
9073 * var radio2 = new OO.ui.RadioInputWidget( {
9074 * value: 'b'
9075 * } );
9076 * var radio3 = new OO.ui.RadioInputWidget( {
9077 * value: 'c',
9078 * disabled: true
9079 * } );
9080 * // Create a fieldset layout with fields for each radio button.
9081 * var fieldset = new OO.ui.FieldsetLayout( {
9082 * label: 'Radio inputs'
9083 * } );
9084 * fieldset.addItems( [
9085 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9086 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9087 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9088 * ] );
9089 * $( 'body' ).append( fieldset.$element );
9090 *
9091 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9092 *
9093 * @class
9094 * @extends OO.ui.InputWidget
9095 *
9096 * @constructor
9097 * @param {Object} [config] Configuration options
9098 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9099 */
9100 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9101 // Configuration initialization
9102 config = config || {};
9103
9104 // Parent constructor
9105 OO.ui.RadioInputWidget.parent.call( this, config );
9106
9107 // Initialization
9108 this.$element
9109 .addClass( 'oo-ui-radioInputWidget' )
9110 // Required for pretty styling in WikimediaUI theme
9111 .append( $( '<span>' ) );
9112 this.setSelected( config.selected !== undefined ? config.selected : false );
9113 };
9114
9115 /* Setup */
9116
9117 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9118
9119 /* Static Properties */
9120
9121 /**
9122 * @static
9123 * @inheritdoc
9124 */
9125 OO.ui.RadioInputWidget.static.tagName = 'span';
9126
9127 /* Static Methods */
9128
9129 /**
9130 * @inheritdoc
9131 */
9132 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9133 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9134 state.checked = config.$input.prop( 'checked' );
9135 return state;
9136 };
9137
9138 /* Methods */
9139
9140 /**
9141 * @inheritdoc
9142 * @protected
9143 */
9144 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9145 return $( '<input>' ).attr( 'type', 'radio' );
9146 };
9147
9148 /**
9149 * @inheritdoc
9150 */
9151 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9152 // RadioInputWidget doesn't track its state.
9153 };
9154
9155 /**
9156 * Set selection state of this radio button.
9157 *
9158 * @param {boolean} state `true` for selected
9159 * @chainable
9160 */
9161 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9162 // RadioInputWidget doesn't track its state.
9163 this.$input.prop( 'checked', state );
9164 return this;
9165 };
9166
9167 /**
9168 * Check if this radio button is selected.
9169 *
9170 * @return {boolean} Radio is selected
9171 */
9172 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9173 return this.$input.prop( 'checked' );
9174 };
9175
9176 /**
9177 * @inheritdoc
9178 */
9179 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9180 if ( !this.isDisabled() ) {
9181 this.$input.click();
9182 }
9183 this.focus();
9184 };
9185
9186 /**
9187 * @inheritdoc
9188 */
9189 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9190 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9191 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9192 this.setSelected( state.checked );
9193 }
9194 };
9195
9196 /**
9197 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9198 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9199 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9200 * more information about input widgets.
9201 *
9202 * This and OO.ui.DropdownInputWidget support the same configuration options.
9203 *
9204 * @example
9205 * // Example: A RadioSelectInputWidget with three options
9206 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9207 * options: [
9208 * { data: 'a', label: 'First' },
9209 * { data: 'b', label: 'Second'},
9210 * { data: 'c', label: 'Third' }
9211 * ]
9212 * } );
9213 * $( 'body' ).append( radioSelectInput.$element );
9214 *
9215 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9216 *
9217 * @class
9218 * @extends OO.ui.InputWidget
9219 *
9220 * @constructor
9221 * @param {Object} [config] Configuration options
9222 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9223 */
9224 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9225 // Configuration initialization
9226 config = config || {};
9227
9228 // Properties (must be done before parent constructor which calls #setDisabled)
9229 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9230
9231 // Parent constructor
9232 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9233
9234 // Events
9235 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9236
9237 // Initialization
9238 this.setOptions( config.options || [] );
9239 this.$element
9240 .addClass( 'oo-ui-radioSelectInputWidget' )
9241 .append( this.radioSelectWidget.$element );
9242 this.setTabIndexedElement( null );
9243 };
9244
9245 /* Setup */
9246
9247 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9248
9249 /* Static Methods */
9250
9251 /**
9252 * @inheritdoc
9253 */
9254 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9255 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9256 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9257 return state;
9258 };
9259
9260 /**
9261 * @inheritdoc
9262 */
9263 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9264 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9265 // Cannot reuse the `<input type=radio>` set
9266 delete config.$input;
9267 return config;
9268 };
9269
9270 /* Methods */
9271
9272 /**
9273 * @inheritdoc
9274 * @protected
9275 */
9276 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9277 return $( '<input>' ).attr( 'type', 'hidden' );
9278 };
9279
9280 /**
9281 * Handles menu select events.
9282 *
9283 * @private
9284 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9285 */
9286 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9287 this.setValue( item.getData() );
9288 };
9289
9290 /**
9291 * @inheritdoc
9292 */
9293 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9294 value = this.cleanUpValue( value );
9295 this.radioSelectWidget.selectItemByData( value );
9296 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9297 return this;
9298 };
9299
9300 /**
9301 * @inheritdoc
9302 */
9303 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9304 this.radioSelectWidget.setDisabled( state );
9305 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9306 return this;
9307 };
9308
9309 /**
9310 * Set the options available for this input.
9311 *
9312 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9313 * @chainable
9314 */
9315 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9316 var
9317 value = this.getValue(),
9318 widget = this;
9319
9320 // Rebuild the radioSelect menu
9321 this.radioSelectWidget
9322 .clearItems()
9323 .addItems( options.map( function ( opt ) {
9324 var optValue = widget.cleanUpValue( opt.data );
9325 return new OO.ui.RadioOptionWidget( {
9326 data: optValue,
9327 label: opt.label !== undefined ? opt.label : optValue
9328 } );
9329 } ) );
9330
9331 // Restore the previous value, or reset to something sensible
9332 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9333 // Previous value is still available, ensure consistency with the radioSelect
9334 this.setValue( value );
9335 } else {
9336 // No longer valid, reset
9337 if ( options.length ) {
9338 this.setValue( options[ 0 ].data );
9339 }
9340 }
9341
9342 return this;
9343 };
9344
9345 /**
9346 * @inheritdoc
9347 */
9348 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9349 this.radioSelectWidget.focus();
9350 return this;
9351 };
9352
9353 /**
9354 * @inheritdoc
9355 */
9356 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9357 this.radioSelectWidget.blur();
9358 return this;
9359 };
9360
9361 /**
9362 * CheckboxMultiselectInputWidget is a
9363 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9364 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9365 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9366 * more information about input widgets.
9367 *
9368 * @example
9369 * // Example: A CheckboxMultiselectInputWidget with three options
9370 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9371 * options: [
9372 * { data: 'a', label: 'First' },
9373 * { data: 'b', label: 'Second'},
9374 * { data: 'c', label: 'Third' }
9375 * ]
9376 * } );
9377 * $( 'body' ).append( multiselectInput.$element );
9378 *
9379 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9380 *
9381 * @class
9382 * @extends OO.ui.InputWidget
9383 *
9384 * @constructor
9385 * @param {Object} [config] Configuration options
9386 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9387 */
9388 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9389 // Configuration initialization
9390 config = config || {};
9391
9392 // Properties (must be done before parent constructor which calls #setDisabled)
9393 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9394
9395 // Parent constructor
9396 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9397
9398 // Properties
9399 this.inputName = config.name;
9400
9401 // Initialization
9402 this.$element
9403 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9404 .append( this.checkboxMultiselectWidget.$element );
9405 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9406 this.$input.detach();
9407 this.setOptions( config.options || [] );
9408 // Have to repeat this from parent, as we need options to be set up for this to make sense
9409 this.setValue( config.value );
9410
9411 // setValue when checkboxMultiselectWidget changes
9412 this.checkboxMultiselectWidget.on( 'change', function () {
9413 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9414 }.bind( this ) );
9415 };
9416
9417 /* Setup */
9418
9419 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9420
9421 /* Static Methods */
9422
9423 /**
9424 * @inheritdoc
9425 */
9426 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9427 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9428 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9429 .toArray().map( function ( el ) { return el.value; } );
9430 return state;
9431 };
9432
9433 /**
9434 * @inheritdoc
9435 */
9436 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9437 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9438 // Cannot reuse the `<input type=checkbox>` set
9439 delete config.$input;
9440 return config;
9441 };
9442
9443 /* Methods */
9444
9445 /**
9446 * @inheritdoc
9447 * @protected
9448 */
9449 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9450 // Actually unused
9451 return $( '<unused>' );
9452 };
9453
9454 /**
9455 * @inheritdoc
9456 */
9457 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9458 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9459 .toArray().map( function ( el ) { return el.value; } );
9460 if ( this.value !== value ) {
9461 this.setValue( value );
9462 }
9463 return this.value;
9464 };
9465
9466 /**
9467 * @inheritdoc
9468 */
9469 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9470 value = this.cleanUpValue( value );
9471 this.checkboxMultiselectWidget.selectItemsByData( value );
9472 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9473 return this;
9474 };
9475
9476 /**
9477 * Clean up incoming value.
9478 *
9479 * @param {string[]} value Original value
9480 * @return {string[]} Cleaned up value
9481 */
9482 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9483 var i, singleValue,
9484 cleanValue = [];
9485 if ( !Array.isArray( value ) ) {
9486 return cleanValue;
9487 }
9488 for ( i = 0; i < value.length; i++ ) {
9489 singleValue =
9490 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9491 // Remove options that we don't have here
9492 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9493 continue;
9494 }
9495 cleanValue.push( singleValue );
9496 }
9497 return cleanValue;
9498 };
9499
9500 /**
9501 * @inheritdoc
9502 */
9503 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9504 this.checkboxMultiselectWidget.setDisabled( state );
9505 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9506 return this;
9507 };
9508
9509 /**
9510 * Set the options available for this input.
9511 *
9512 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9513 * @chainable
9514 */
9515 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9516 var widget = this;
9517
9518 // Rebuild the checkboxMultiselectWidget menu
9519 this.checkboxMultiselectWidget
9520 .clearItems()
9521 .addItems( options.map( function ( opt ) {
9522 var optValue, item, optDisabled;
9523 optValue =
9524 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9525 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9526 item = new OO.ui.CheckboxMultioptionWidget( {
9527 data: optValue,
9528 label: opt.label !== undefined ? opt.label : optValue,
9529 disabled: optDisabled
9530 } );
9531 // Set the 'name' and 'value' for form submission
9532 item.checkbox.$input.attr( 'name', widget.inputName );
9533 item.checkbox.setValue( optValue );
9534 return item;
9535 } ) );
9536
9537 // Re-set the value, checking the checkboxes as needed.
9538 // This will also get rid of any stale options that we just removed.
9539 this.setValue( this.getValue() );
9540
9541 return this;
9542 };
9543
9544 /**
9545 * @inheritdoc
9546 */
9547 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9548 this.checkboxMultiselectWidget.focus();
9549 return this;
9550 };
9551
9552 /**
9553 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9554 * size of the field as well as its presentation. In addition, these widgets can be configured
9555 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9556 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9557 * which modifies incoming values rather than validating them.
9558 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9559 *
9560 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9561 *
9562 * @example
9563 * // Example of a text input widget
9564 * var textInput = new OO.ui.TextInputWidget( {
9565 * value: 'Text input'
9566 * } )
9567 * $( 'body' ).append( textInput.$element );
9568 *
9569 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9570 *
9571 * @class
9572 * @extends OO.ui.InputWidget
9573 * @mixins OO.ui.mixin.IconElement
9574 * @mixins OO.ui.mixin.IndicatorElement
9575 * @mixins OO.ui.mixin.PendingElement
9576 * @mixins OO.ui.mixin.LabelElement
9577 *
9578 * @constructor
9579 * @param {Object} [config] Configuration options
9580 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9581 * 'email', 'url' or 'number'.
9582 * @cfg {string} [placeholder] Placeholder text
9583 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9584 * instruct the browser to focus this widget.
9585 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9586 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9587 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9588 * the value or placeholder text: `'before'` or `'after'`
9589 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9590 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9591 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9592 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9593 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9594 * value for it to be considered valid; when Function, a function receiving the value as parameter
9595 * that must return true, or promise resolving to true, for it to be considered valid.
9596 */
9597 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9598 // Configuration initialization
9599 config = $.extend( {
9600 type: 'text',
9601 labelPosition: 'after'
9602 }, config );
9603
9604 if ( config.multiline ) {
9605 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9606 return new OO.ui.MultilineTextInputWidget( config );
9607 }
9608
9609 // Parent constructor
9610 OO.ui.TextInputWidget.parent.call( this, config );
9611
9612 // Mixin constructors
9613 OO.ui.mixin.IconElement.call( this, config );
9614 OO.ui.mixin.IndicatorElement.call( this, config );
9615 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9616 OO.ui.mixin.LabelElement.call( this, config );
9617
9618 // Properties
9619 this.type = this.getSaneType( config );
9620 this.readOnly = false;
9621 this.required = false;
9622 this.validate = null;
9623 this.styleHeight = null;
9624 this.scrollWidth = null;
9625
9626 this.setValidation( config.validate );
9627 this.setLabelPosition( config.labelPosition );
9628
9629 // Events
9630 this.$input.on( {
9631 keypress: this.onKeyPress.bind( this ),
9632 blur: this.onBlur.bind( this ),
9633 focus: this.onFocus.bind( this )
9634 } );
9635 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9636 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9637 this.on( 'labelChange', this.updatePosition.bind( this ) );
9638 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9639
9640 // Initialization
9641 this.$element
9642 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9643 .append( this.$icon, this.$indicator );
9644 this.setReadOnly( !!config.readOnly );
9645 this.setRequired( !!config.required );
9646 if ( config.placeholder !== undefined ) {
9647 this.$input.attr( 'placeholder', config.placeholder );
9648 }
9649 if ( config.maxLength !== undefined ) {
9650 this.$input.attr( 'maxlength', config.maxLength );
9651 }
9652 if ( config.autofocus ) {
9653 this.$input.attr( 'autofocus', 'autofocus' );
9654 }
9655 if ( config.autocomplete === false ) {
9656 this.$input.attr( 'autocomplete', 'off' );
9657 // Turning off autocompletion also disables "form caching" when the user navigates to a
9658 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9659 $( window ).on( {
9660 beforeunload: function () {
9661 this.$input.removeAttr( 'autocomplete' );
9662 }.bind( this ),
9663 pageshow: function () {
9664 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9665 // whole page... it shouldn't hurt, though.
9666 this.$input.attr( 'autocomplete', 'off' );
9667 }.bind( this )
9668 } );
9669 }
9670 if ( this.label ) {
9671 this.isWaitingToBeAttached = true;
9672 this.installParentChangeDetector();
9673 }
9674 };
9675
9676 /* Setup */
9677
9678 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9679 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9680 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9681 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9682 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9683
9684 /* Static Properties */
9685
9686 OO.ui.TextInputWidget.static.validationPatterns = {
9687 'non-empty': /.+/,
9688 integer: /^\d+$/
9689 };
9690
9691 /* Static Methods */
9692
9693 /**
9694 * @inheritdoc
9695 */
9696 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9697 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9698 return state;
9699 };
9700
9701 /* Events */
9702
9703 /**
9704 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9705 *
9706 * @event enter
9707 */
9708
9709 /* Methods */
9710
9711 /**
9712 * Handle icon mouse down events.
9713 *
9714 * @private
9715 * @param {jQuery.Event} e Mouse down event
9716 */
9717 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
9718 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9719 this.focus();
9720 return false;
9721 }
9722 };
9723
9724 /**
9725 * Handle indicator mouse down events.
9726 *
9727 * @private
9728 * @param {jQuery.Event} e Mouse down event
9729 */
9730 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
9731 if ( e.which === OO.ui.MouseButtons.LEFT ) {
9732 this.focus();
9733 return false;
9734 }
9735 };
9736
9737 /**
9738 * Handle key press events.
9739 *
9740 * @private
9741 * @param {jQuery.Event} e Key press event
9742 * @fires enter If enter key is pressed
9743 */
9744 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
9745 if ( e.which === OO.ui.Keys.ENTER ) {
9746 this.emit( 'enter', e );
9747 }
9748 };
9749
9750 /**
9751 * Handle blur events.
9752 *
9753 * @private
9754 * @param {jQuery.Event} e Blur event
9755 */
9756 OO.ui.TextInputWidget.prototype.onBlur = function () {
9757 this.setValidityFlag();
9758 };
9759
9760 /**
9761 * Handle focus events.
9762 *
9763 * @private
9764 * @param {jQuery.Event} e Focus event
9765 */
9766 OO.ui.TextInputWidget.prototype.onFocus = function () {
9767 if ( this.isWaitingToBeAttached ) {
9768 // If we've received focus, then we must be attached to the document, and if
9769 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
9770 this.onElementAttach();
9771 }
9772 this.setValidityFlag( true );
9773 };
9774
9775 /**
9776 * Handle element attach events.
9777 *
9778 * @private
9779 * @param {jQuery.Event} e Element attach event
9780 */
9781 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
9782 this.isWaitingToBeAttached = false;
9783 // Any previously calculated size is now probably invalid if we reattached elsewhere
9784 this.valCache = null;
9785 this.positionLabel();
9786 };
9787
9788 /**
9789 * Handle debounced change events.
9790 *
9791 * @param {string} value
9792 * @private
9793 */
9794 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
9795 this.setValidityFlag();
9796 };
9797
9798 /**
9799 * Check if the input is {@link #readOnly read-only}.
9800 *
9801 * @return {boolean}
9802 */
9803 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
9804 return this.readOnly;
9805 };
9806
9807 /**
9808 * Set the {@link #readOnly read-only} state of the input.
9809 *
9810 * @param {boolean} state Make input read-only
9811 * @chainable
9812 */
9813 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
9814 this.readOnly = !!state;
9815 this.$input.prop( 'readOnly', this.readOnly );
9816 return this;
9817 };
9818
9819 /**
9820 * Check if the input is {@link #required required}.
9821 *
9822 * @return {boolean}
9823 */
9824 OO.ui.TextInputWidget.prototype.isRequired = function () {
9825 return this.required;
9826 };
9827
9828 /**
9829 * Set the {@link #required required} state of the input.
9830 *
9831 * @param {boolean} state Make input required
9832 * @chainable
9833 */
9834 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
9835 this.required = !!state;
9836 if ( this.required ) {
9837 this.$input
9838 .prop( 'required', true )
9839 .attr( 'aria-required', 'true' );
9840 if ( this.getIndicator() === null ) {
9841 this.setIndicator( 'required' );
9842 }
9843 } else {
9844 this.$input
9845 .prop( 'required', false )
9846 .removeAttr( 'aria-required' );
9847 if ( this.getIndicator() === 'required' ) {
9848 this.setIndicator( null );
9849 }
9850 }
9851 return this;
9852 };
9853
9854 /**
9855 * Support function for making #onElementAttach work across browsers.
9856 *
9857 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
9858 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
9859 *
9860 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
9861 * first time that the element gets attached to the documented.
9862 */
9863 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
9864 var mutationObserver, onRemove, topmostNode, fakeParentNode,
9865 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
9866 widget = this;
9867
9868 if ( MutationObserver ) {
9869 // The new way. If only it wasn't so ugly.
9870
9871 if ( this.isElementAttached() ) {
9872 // Widget is attached already, do nothing. This breaks the functionality of this function when
9873 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
9874 // would require observation of the whole document, which would hurt performance of other,
9875 // more important code.
9876 return;
9877 }
9878
9879 // Find topmost node in the tree
9880 topmostNode = this.$element[ 0 ];
9881 while ( topmostNode.parentNode ) {
9882 topmostNode = topmostNode.parentNode;
9883 }
9884
9885 // We have no way to detect the $element being attached somewhere without observing the entire
9886 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
9887 // parent node of $element, and instead detect when $element is removed from it (and thus
9888 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
9889 // doesn't get attached, we end up back here and create the parent.
9890
9891 mutationObserver = new MutationObserver( function ( mutations ) {
9892 var i, j, removedNodes;
9893 for ( i = 0; i < mutations.length; i++ ) {
9894 removedNodes = mutations[ i ].removedNodes;
9895 for ( j = 0; j < removedNodes.length; j++ ) {
9896 if ( removedNodes[ j ] === topmostNode ) {
9897 setTimeout( onRemove, 0 );
9898 return;
9899 }
9900 }
9901 }
9902 } );
9903
9904 onRemove = function () {
9905 // If the node was attached somewhere else, report it
9906 if ( widget.isElementAttached() ) {
9907 widget.onElementAttach();
9908 }
9909 mutationObserver.disconnect();
9910 widget.installParentChangeDetector();
9911 };
9912
9913 // Create a fake parent and observe it
9914 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
9915 mutationObserver.observe( fakeParentNode, { childList: true } );
9916 } else {
9917 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
9918 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
9919 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
9920 }
9921 };
9922
9923 /**
9924 * @inheritdoc
9925 * @protected
9926 */
9927 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
9928 if ( this.getSaneType( config ) === 'number' ) {
9929 return $( '<input>' )
9930 .attr( 'step', 'any' )
9931 .attr( 'type', 'number' );
9932 } else {
9933 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
9934 }
9935 };
9936
9937 /**
9938 * Get sanitized value for 'type' for given config.
9939 *
9940 * @param {Object} config Configuration options
9941 * @return {string|null}
9942 * @private
9943 */
9944 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
9945 var allowedTypes = [
9946 'text',
9947 'password',
9948 'email',
9949 'url',
9950 'number'
9951 ];
9952 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
9953 };
9954
9955 /**
9956 * Focus the input and select a specified range within the text.
9957 *
9958 * @param {number} from Select from offset
9959 * @param {number} [to] Select to offset, defaults to from
9960 * @chainable
9961 */
9962 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
9963 var isBackwards, start, end,
9964 input = this.$input[ 0 ];
9965
9966 to = to || from;
9967
9968 isBackwards = to < from;
9969 start = isBackwards ? to : from;
9970 end = isBackwards ? from : to;
9971
9972 this.focus();
9973
9974 try {
9975 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
9976 } catch ( e ) {
9977 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
9978 // Rather than expensively check if the input is attached every time, just check
9979 // if it was the cause of an error being thrown. If not, rethrow the error.
9980 if ( this.getElementDocument().body.contains( input ) ) {
9981 throw e;
9982 }
9983 }
9984 return this;
9985 };
9986
9987 /**
9988 * Get an object describing the current selection range in a directional manner
9989 *
9990 * @return {Object} Object containing 'from' and 'to' offsets
9991 */
9992 OO.ui.TextInputWidget.prototype.getRange = function () {
9993 var input = this.$input[ 0 ],
9994 start = input.selectionStart,
9995 end = input.selectionEnd,
9996 isBackwards = input.selectionDirection === 'backward';
9997
9998 return {
9999 from: isBackwards ? end : start,
10000 to: isBackwards ? start : end
10001 };
10002 };
10003
10004 /**
10005 * Get the length of the text input value.
10006 *
10007 * This could differ from the length of #getValue if the
10008 * value gets filtered
10009 *
10010 * @return {number} Input length
10011 */
10012 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10013 return this.$input[ 0 ].value.length;
10014 };
10015
10016 /**
10017 * Focus the input and select the entire text.
10018 *
10019 * @chainable
10020 */
10021 OO.ui.TextInputWidget.prototype.select = function () {
10022 return this.selectRange( 0, this.getInputLength() );
10023 };
10024
10025 /**
10026 * Focus the input and move the cursor to the start.
10027 *
10028 * @chainable
10029 */
10030 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10031 return this.selectRange( 0 );
10032 };
10033
10034 /**
10035 * Focus the input and move the cursor to the end.
10036 *
10037 * @chainable
10038 */
10039 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10040 return this.selectRange( this.getInputLength() );
10041 };
10042
10043 /**
10044 * Insert new content into the input.
10045 *
10046 * @param {string} content Content to be inserted
10047 * @chainable
10048 */
10049 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10050 var start, end,
10051 range = this.getRange(),
10052 value = this.getValue();
10053
10054 start = Math.min( range.from, range.to );
10055 end = Math.max( range.from, range.to );
10056
10057 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10058 this.selectRange( start + content.length );
10059 return this;
10060 };
10061
10062 /**
10063 * Insert new content either side of a selection.
10064 *
10065 * @param {string} pre Content to be inserted before the selection
10066 * @param {string} post Content to be inserted after the selection
10067 * @chainable
10068 */
10069 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10070 var start, end,
10071 range = this.getRange(),
10072 offset = pre.length;
10073
10074 start = Math.min( range.from, range.to );
10075 end = Math.max( range.from, range.to );
10076
10077 this.selectRange( start ).insertContent( pre );
10078 this.selectRange( offset + end ).insertContent( post );
10079
10080 this.selectRange( offset + start, offset + end );
10081 return this;
10082 };
10083
10084 /**
10085 * Set the validation pattern.
10086 *
10087 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10088 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10089 * value must contain only numbers).
10090 *
10091 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10092 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10093 */
10094 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10095 if ( validate instanceof RegExp || validate instanceof Function ) {
10096 this.validate = validate;
10097 } else {
10098 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10099 }
10100 };
10101
10102 /**
10103 * Sets the 'invalid' flag appropriately.
10104 *
10105 * @param {boolean} [isValid] Optionally override validation result
10106 */
10107 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10108 var widget = this,
10109 setFlag = function ( valid ) {
10110 if ( !valid ) {
10111 widget.$input.attr( 'aria-invalid', 'true' );
10112 } else {
10113 widget.$input.removeAttr( 'aria-invalid' );
10114 }
10115 widget.setFlags( { invalid: !valid } );
10116 };
10117
10118 if ( isValid !== undefined ) {
10119 setFlag( isValid );
10120 } else {
10121 this.getValidity().then( function () {
10122 setFlag( true );
10123 }, function () {
10124 setFlag( false );
10125 } );
10126 }
10127 };
10128
10129 /**
10130 * Get the validity of current value.
10131 *
10132 * This method returns a promise that resolves if the value is valid and rejects if
10133 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10134 *
10135 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10136 */
10137 OO.ui.TextInputWidget.prototype.getValidity = function () {
10138 var result;
10139
10140 function rejectOrResolve( valid ) {
10141 if ( valid ) {
10142 return $.Deferred().resolve().promise();
10143 } else {
10144 return $.Deferred().reject().promise();
10145 }
10146 }
10147
10148 // Check browser validity and reject if it is invalid
10149 if (
10150 this.$input[ 0 ].checkValidity !== undefined &&
10151 this.$input[ 0 ].checkValidity() === false
10152 ) {
10153 return rejectOrResolve( false );
10154 }
10155
10156 // Run our checks if the browser thinks the field is valid
10157 if ( this.validate instanceof Function ) {
10158 result = this.validate( this.getValue() );
10159 if ( result && $.isFunction( result.promise ) ) {
10160 return result.promise().then( function ( valid ) {
10161 return rejectOrResolve( valid );
10162 } );
10163 } else {
10164 return rejectOrResolve( result );
10165 }
10166 } else {
10167 return rejectOrResolve( this.getValue().match( this.validate ) );
10168 }
10169 };
10170
10171 /**
10172 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10173 *
10174 * @param {string} labelPosition Label position, 'before' or 'after'
10175 * @chainable
10176 */
10177 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10178 this.labelPosition = labelPosition;
10179 if ( this.label ) {
10180 // If there is no label and we only change the position, #updatePosition is a no-op,
10181 // but it takes really a lot of work to do nothing.
10182 this.updatePosition();
10183 }
10184 return this;
10185 };
10186
10187 /**
10188 * Update the position of the inline label.
10189 *
10190 * This method is called by #setLabelPosition, and can also be called on its own if
10191 * something causes the label to be mispositioned.
10192 *
10193 * @chainable
10194 */
10195 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10196 var after = this.labelPosition === 'after';
10197
10198 this.$element
10199 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10200 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10201
10202 this.valCache = null;
10203 this.scrollWidth = null;
10204 this.positionLabel();
10205
10206 return this;
10207 };
10208
10209 /**
10210 * Position the label by setting the correct padding on the input.
10211 *
10212 * @private
10213 * @chainable
10214 */
10215 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10216 var after, rtl, property, newCss;
10217
10218 if ( this.isWaitingToBeAttached ) {
10219 // #onElementAttach will be called soon, which calls this method
10220 return this;
10221 }
10222
10223 newCss = {
10224 'padding-right': '',
10225 'padding-left': ''
10226 };
10227
10228 if ( this.label ) {
10229 this.$element.append( this.$label );
10230 } else {
10231 this.$label.detach();
10232 // Clear old values if present
10233 this.$input.css( newCss );
10234 return;
10235 }
10236
10237 after = this.labelPosition === 'after';
10238 rtl = this.$element.css( 'direction' ) === 'rtl';
10239 property = after === rtl ? 'padding-left' : 'padding-right';
10240
10241 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10242 // We have to clear the padding on the other side, in case the element direction changed
10243 this.$input.css( newCss );
10244
10245 return this;
10246 };
10247
10248 /**
10249 * @inheritdoc
10250 */
10251 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10252 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10253 if ( state.scrollTop !== undefined ) {
10254 this.$input.scrollTop( state.scrollTop );
10255 }
10256 };
10257
10258 /**
10259 * @class
10260 * @extends OO.ui.TextInputWidget
10261 *
10262 * @constructor
10263 * @param {Object} [config] Configuration options
10264 */
10265 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10266 config = $.extend( {
10267 icon: 'search'
10268 }, config );
10269
10270 // Set type to text so that TextInputWidget doesn't
10271 // get stuck in an infinite loop.
10272 config.type = 'text';
10273
10274 // Parent constructor
10275 OO.ui.SearchInputWidget.parent.call( this, config );
10276
10277 // Events
10278 this.connect( this, {
10279 change: 'onChange'
10280 } );
10281
10282 // Initialization
10283 this.$element.addClass( 'oo-ui-textInputWidget-type-search' );
10284 this.updateSearchIndicator();
10285 this.connect( this, {
10286 disable: 'onDisable'
10287 } );
10288 };
10289
10290 /* Setup */
10291
10292 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10293
10294 /* Methods */
10295
10296 /**
10297 * @inheritdoc
10298 * @protected
10299 */
10300 OO.ui.SearchInputWidget.prototype.getInputElement = function () {
10301 return $( '<input>' ).attr( 'type', 'search' );
10302 };
10303
10304 /**
10305 * @inheritdoc
10306 */
10307 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10308 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10309 // Clear the text field
10310 this.setValue( '' );
10311 this.focus();
10312 return false;
10313 }
10314 };
10315
10316 /**
10317 * Update the 'clear' indicator displayed on type: 'search' text
10318 * fields, hiding it when the field is already empty or when it's not
10319 * editable.
10320 */
10321 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10322 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10323 this.setIndicator( null );
10324 } else {
10325 this.setIndicator( 'clear' );
10326 }
10327 };
10328
10329 /**
10330 * Handle change events.
10331 *
10332 * @private
10333 */
10334 OO.ui.SearchInputWidget.prototype.onChange = function () {
10335 this.updateSearchIndicator();
10336 };
10337
10338 /**
10339 * Handle disable events.
10340 *
10341 * @param {boolean} disabled Element is disabled
10342 * @private
10343 */
10344 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10345 this.updateSearchIndicator();
10346 };
10347
10348 /**
10349 * @inheritdoc
10350 */
10351 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10352 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10353 this.updateSearchIndicator();
10354 return this;
10355 };
10356
10357 /**
10358 * @class
10359 * @extends OO.ui.TextInputWidget
10360 *
10361 * @constructor
10362 * @param {Object} [config] Configuration options
10363 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10364 * specifies minimum number of rows to display.
10365 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10366 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10367 * Use the #maxRows config to specify a maximum number of displayed rows.
10368 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10369 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10370 */
10371 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10372 config = $.extend( {
10373 type: 'text'
10374 }, config );
10375 config.multiline = false;
10376 // Parent constructor
10377 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10378
10379 // Properties
10380 this.multiline = true;
10381 this.autosize = !!config.autosize;
10382 this.minRows = config.rows !== undefined ? config.rows : '';
10383 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10384
10385 // Clone for resizing
10386 if ( this.autosize ) {
10387 this.$clone = this.$input
10388 .clone()
10389 .insertAfter( this.$input )
10390 .attr( 'aria-hidden', 'true' )
10391 .addClass( 'oo-ui-element-hidden' );
10392 }
10393
10394 // Events
10395 this.connect( this, {
10396 change: 'onChange'
10397 } );
10398
10399 // Initialization
10400 if ( this.multiline && config.rows ) {
10401 this.$input.attr( 'rows', config.rows );
10402 }
10403 if ( this.autosize ) {
10404 this.isWaitingToBeAttached = true;
10405 this.installParentChangeDetector();
10406 }
10407 };
10408
10409 /* Setup */
10410
10411 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10412
10413 /* Static Methods */
10414
10415 /**
10416 * @inheritdoc
10417 */
10418 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10419 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10420 state.scrollTop = config.$input.scrollTop();
10421 return state;
10422 };
10423
10424 /* Methods */
10425
10426 /**
10427 * @inheritdoc
10428 */
10429 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10430 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10431 this.adjustSize();
10432 };
10433
10434 /**
10435 * Handle change events.
10436 *
10437 * @private
10438 */
10439 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10440 this.adjustSize();
10441 };
10442
10443 /**
10444 * @inheritdoc
10445 */
10446 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10447 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10448 this.adjustSize();
10449 };
10450
10451 /**
10452 * Override TextInputWidget so it doesn't emit the 'enter' event.
10453 *
10454 * @private
10455 * @param {jQuery.Event} e Key press event
10456 */
10457 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10458 return;
10459 };
10460
10461 /**
10462 * Automatically adjust the size of the text input.
10463 *
10464 * This only affects multiline inputs that are {@link #autosize autosized}.
10465 *
10466 * @chainable
10467 * @fires resize
10468 */
10469 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10470 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10471 idealHeight, newHeight, scrollWidth, property;
10472
10473 if ( this.$input.val() !== this.valCache ) {
10474 if ( this.autosize ) {
10475 this.$clone
10476 .val( this.$input.val() )
10477 .attr( 'rows', this.minRows )
10478 // Set inline height property to 0 to measure scroll height
10479 .css( 'height', 0 );
10480
10481 this.$clone.removeClass( 'oo-ui-element-hidden' );
10482
10483 this.valCache = this.$input.val();
10484
10485 scrollHeight = this.$clone[ 0 ].scrollHeight;
10486
10487 // Remove inline height property to measure natural heights
10488 this.$clone.css( 'height', '' );
10489 innerHeight = this.$clone.innerHeight();
10490 outerHeight = this.$clone.outerHeight();
10491
10492 // Measure max rows height
10493 this.$clone
10494 .attr( 'rows', this.maxRows )
10495 .css( 'height', 'auto' )
10496 .val( '' );
10497 maxInnerHeight = this.$clone.innerHeight();
10498
10499 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10500 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10501 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10502 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10503
10504 this.$clone.addClass( 'oo-ui-element-hidden' );
10505
10506 // Only apply inline height when expansion beyond natural height is needed
10507 // Use the difference between the inner and outer height as a buffer
10508 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10509 if ( newHeight !== this.styleHeight ) {
10510 this.$input.css( 'height', newHeight );
10511 this.styleHeight = newHeight;
10512 this.emit( 'resize' );
10513 }
10514 }
10515 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10516 if ( scrollWidth !== this.scrollWidth ) {
10517 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10518 // Reset
10519 this.$label.css( { right: '', left: '' } );
10520 this.$indicator.css( { right: '', left: '' } );
10521
10522 if ( scrollWidth ) {
10523 this.$indicator.css( property, scrollWidth );
10524 if ( this.labelPosition === 'after' ) {
10525 this.$label.css( property, scrollWidth );
10526 }
10527 }
10528
10529 this.scrollWidth = scrollWidth;
10530 this.positionLabel();
10531 }
10532 }
10533 return this;
10534 };
10535
10536 /**
10537 * @inheritdoc
10538 * @protected
10539 */
10540 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10541 return $( '<textarea>' );
10542 };
10543
10544 /**
10545 * Check if the input supports multiple lines.
10546 *
10547 * @return {boolean}
10548 */
10549 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10550 return !!this.multiline;
10551 };
10552
10553 /**
10554 * Check if the input automatically adjusts its size.
10555 *
10556 * @return {boolean}
10557 */
10558 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10559 return !!this.autosize;
10560 };
10561
10562 /**
10563 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10564 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10565 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10566 *
10567 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10568 * option, that option will appear to be selected.
10569 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10570 * input field.
10571 *
10572 * After the user chooses an option, its `data` will be used as a new value for the widget.
10573 * A `label` also can be specified for each option: if given, it will be shown instead of the
10574 * `data` in the dropdown menu.
10575 *
10576 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10577 *
10578 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10579 *
10580 * @example
10581 * // Example: A ComboBoxInputWidget.
10582 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10583 * value: 'Option 1',
10584 * options: [
10585 * { data: 'Option 1' },
10586 * { data: 'Option 2' },
10587 * { data: 'Option 3' }
10588 * ]
10589 * } );
10590 * $( 'body' ).append( comboBox.$element );
10591 *
10592 * @example
10593 * // Example: A ComboBoxInputWidget with additional option labels.
10594 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10595 * value: 'Option 1',
10596 * options: [
10597 * {
10598 * data: 'Option 1',
10599 * label: 'Option One'
10600 * },
10601 * {
10602 * data: 'Option 2',
10603 * label: 'Option Two'
10604 * },
10605 * {
10606 * data: 'Option 3',
10607 * label: 'Option Three'
10608 * }
10609 * ]
10610 * } );
10611 * $( 'body' ).append( comboBox.$element );
10612 *
10613 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10614 *
10615 * @class
10616 * @extends OO.ui.TextInputWidget
10617 *
10618 * @constructor
10619 * @param {Object} [config] Configuration options
10620 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10621 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10622 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10623 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10624 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10625 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10626 */
10627 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10628 // Configuration initialization
10629 config = $.extend( {
10630 autocomplete: false
10631 }, config );
10632
10633 // ComboBoxInputWidget shouldn't support `multiline`
10634 config.multiline = false;
10635
10636 // See InputWidget#reusePreInfuseDOM about `config.$input`
10637 if ( config.$input ) {
10638 config.$input.removeAttr( 'list' );
10639 }
10640
10641 // Parent constructor
10642 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10643
10644 // Properties
10645 this.$overlay = config.$overlay || this.$element;
10646 this.dropdownButton = new OO.ui.ButtonWidget( {
10647 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10648 indicator: 'down',
10649 disabled: this.disabled
10650 } );
10651 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10652 {
10653 widget: this,
10654 input: this,
10655 $floatableContainer: this.$element,
10656 disabled: this.isDisabled()
10657 },
10658 config.menu
10659 ) );
10660
10661 // Events
10662 this.connect( this, {
10663 change: 'onInputChange',
10664 enter: 'onInputEnter'
10665 } );
10666 this.dropdownButton.connect( this, {
10667 click: 'onDropdownButtonClick'
10668 } );
10669 this.menu.connect( this, {
10670 choose: 'onMenuChoose',
10671 add: 'onMenuItemsChange',
10672 remove: 'onMenuItemsChange'
10673 } );
10674
10675 // Initialization
10676 this.$input.attr( {
10677 role: 'combobox',
10678 'aria-owns': this.menu.getElementId(),
10679 'aria-autocomplete': 'list'
10680 } );
10681 // Do not override options set via config.menu.items
10682 if ( config.options !== undefined ) {
10683 this.setOptions( config.options );
10684 }
10685 this.$field = $( '<div>' )
10686 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10687 .append( this.$input, this.dropdownButton.$element );
10688 this.$element
10689 .addClass( 'oo-ui-comboBoxInputWidget' )
10690 .append( this.$field );
10691 this.$overlay.append( this.menu.$element );
10692 this.onMenuItemsChange();
10693 };
10694
10695 /* Setup */
10696
10697 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10698
10699 /* Methods */
10700
10701 /**
10702 * Get the combobox's menu.
10703 *
10704 * @return {OO.ui.MenuSelectWidget} Menu widget
10705 */
10706 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10707 return this.menu;
10708 };
10709
10710 /**
10711 * Get the combobox's text input widget.
10712 *
10713 * @return {OO.ui.TextInputWidget} Text input widget
10714 */
10715 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10716 return this;
10717 };
10718
10719 /**
10720 * Handle input change events.
10721 *
10722 * @private
10723 * @param {string} value New value
10724 */
10725 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
10726 var match = this.menu.getItemFromData( value );
10727
10728 this.menu.selectItem( match );
10729 if ( this.menu.findHighlightedItem() ) {
10730 this.menu.highlightItem( match );
10731 }
10732
10733 if ( !this.isDisabled() ) {
10734 this.menu.toggle( true );
10735 }
10736 };
10737
10738 /**
10739 * Handle input enter events.
10740 *
10741 * @private
10742 */
10743 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
10744 if ( !this.isDisabled() ) {
10745 this.menu.toggle( false );
10746 }
10747 };
10748
10749 /**
10750 * Handle button click events.
10751 *
10752 * @private
10753 */
10754 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
10755 this.menu.toggle();
10756 this.focus();
10757 };
10758
10759 /**
10760 * Handle menu choose events.
10761 *
10762 * @private
10763 * @param {OO.ui.OptionWidget} item Chosen item
10764 */
10765 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
10766 this.setValue( item.getData() );
10767 };
10768
10769 /**
10770 * Handle menu item change events.
10771 *
10772 * @private
10773 */
10774 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
10775 var match = this.menu.getItemFromData( this.getValue() );
10776 this.menu.selectItem( match );
10777 if ( this.menu.findHighlightedItem() ) {
10778 this.menu.highlightItem( match );
10779 }
10780 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
10781 };
10782
10783 /**
10784 * @inheritdoc
10785 */
10786 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
10787 // Parent method
10788 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
10789
10790 if ( this.dropdownButton ) {
10791 this.dropdownButton.setDisabled( this.isDisabled() );
10792 }
10793 if ( this.menu ) {
10794 this.menu.setDisabled( this.isDisabled() );
10795 }
10796
10797 return this;
10798 };
10799
10800 /**
10801 * Set the options available for this input.
10802 *
10803 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10804 * @chainable
10805 */
10806 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
10807 this.getMenu()
10808 .clearItems()
10809 .addItems( options.map( function ( opt ) {
10810 return new OO.ui.MenuOptionWidget( {
10811 data: opt.data,
10812 label: opt.label !== undefined ? opt.label : opt.data
10813 } );
10814 } ) );
10815
10816 return this;
10817 };
10818
10819 /**
10820 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
10821 * which is a widget that is specified by reference before any optional configuration settings.
10822 *
10823 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
10824 *
10825 * - **left**: The label is placed before the field-widget and aligned with the left margin.
10826 * A left-alignment is used for forms with many fields.
10827 * - **right**: The label is placed before the field-widget and aligned to the right margin.
10828 * A right-alignment is used for long but familiar forms which users tab through,
10829 * verifying the current field with a quick glance at the label.
10830 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
10831 * that users fill out from top to bottom.
10832 * - **inline**: The label is placed after the field-widget and aligned to the left.
10833 * An inline-alignment is best used with checkboxes or radio buttons.
10834 *
10835 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
10836 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
10837 *
10838 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
10839 *
10840 * @class
10841 * @extends OO.ui.Layout
10842 * @mixins OO.ui.mixin.LabelElement
10843 * @mixins OO.ui.mixin.TitledElement
10844 *
10845 * @constructor
10846 * @param {OO.ui.Widget} fieldWidget Field widget
10847 * @param {Object} [config] Configuration options
10848 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
10849 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
10850 * The array may contain strings or OO.ui.HtmlSnippet instances.
10851 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
10852 * The array may contain strings or OO.ui.HtmlSnippet instances.
10853 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
10854 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
10855 * For important messages, you are advised to use `notices`, as they are always shown.
10856 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
10857 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10858 *
10859 * @throws {Error} An error is thrown if no widget is specified
10860 */
10861 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
10862 // Allow passing positional parameters inside the config object
10863 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
10864 config = fieldWidget;
10865 fieldWidget = config.fieldWidget;
10866 }
10867
10868 // Make sure we have required constructor arguments
10869 if ( fieldWidget === undefined ) {
10870 throw new Error( 'Widget not found' );
10871 }
10872
10873 // Configuration initialization
10874 config = $.extend( { align: 'left' }, config );
10875
10876 // Parent constructor
10877 OO.ui.FieldLayout.parent.call( this, config );
10878
10879 // Mixin constructors
10880 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
10881 $label: $( '<label>' )
10882 } ) );
10883 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
10884
10885 // Properties
10886 this.fieldWidget = fieldWidget;
10887 this.errors = [];
10888 this.notices = [];
10889 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
10890 this.$messages = $( '<ul>' );
10891 this.$header = $( '<span>' );
10892 this.$body = $( '<div>' );
10893 this.align = null;
10894 if ( config.help ) {
10895 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
10896 $overlay: config.$overlay,
10897 popup: {
10898 padded: true
10899 },
10900 classes: [ 'oo-ui-fieldLayout-help' ],
10901 framed: false,
10902 icon: 'info'
10903 } );
10904 if ( config.help instanceof OO.ui.HtmlSnippet ) {
10905 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
10906 } else {
10907 this.popupButtonWidget.getPopup().$body.text( config.help );
10908 }
10909 this.$help = this.popupButtonWidget.$element;
10910 } else {
10911 this.$help = $( [] );
10912 }
10913
10914 // Events
10915 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
10916
10917 // Initialization
10918 if ( config.help ) {
10919 // Set the 'aria-describedby' attribute on the fieldWidget
10920 // Preference given to an input or a button
10921 (
10922 this.fieldWidget.$input ||
10923 this.fieldWidget.$button ||
10924 this.fieldWidget.$element
10925 ).attr(
10926 'aria-describedby',
10927 this.popupButtonWidget.getPopup().getBodyId()
10928 );
10929 }
10930 if ( this.fieldWidget.getInputId() ) {
10931 this.$label.attr( 'for', this.fieldWidget.getInputId() );
10932 } else {
10933 this.$label.on( 'click', function () {
10934 this.fieldWidget.simulateLabelClick();
10935 return false;
10936 }.bind( this ) );
10937 }
10938 this.$element
10939 .addClass( 'oo-ui-fieldLayout' )
10940 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
10941 .append( this.$body );
10942 this.$body.addClass( 'oo-ui-fieldLayout-body' );
10943 this.$header.addClass( 'oo-ui-fieldLayout-header' );
10944 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
10945 this.$field
10946 .addClass( 'oo-ui-fieldLayout-field' )
10947 .append( this.fieldWidget.$element );
10948
10949 this.setErrors( config.errors || [] );
10950 this.setNotices( config.notices || [] );
10951 this.setAlignment( config.align );
10952 // Call this again to take into account the widget's accessKey
10953 this.updateTitle();
10954 };
10955
10956 /* Setup */
10957
10958 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
10959 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
10960 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
10961
10962 /* Methods */
10963
10964 /**
10965 * Handle field disable events.
10966 *
10967 * @private
10968 * @param {boolean} value Field is disabled
10969 */
10970 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
10971 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
10972 };
10973
10974 /**
10975 * Get the widget contained by the field.
10976 *
10977 * @return {OO.ui.Widget} Field widget
10978 */
10979 OO.ui.FieldLayout.prototype.getField = function () {
10980 return this.fieldWidget;
10981 };
10982
10983 /**
10984 * Return `true` if the given field widget can be used with `'inline'` alignment (see
10985 * #setAlignment). Return `false` if it can't or if this can't be determined.
10986 *
10987 * @return {boolean}
10988 */
10989 OO.ui.FieldLayout.prototype.isFieldInline = function () {
10990 // This is very simplistic, but should be good enough.
10991 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
10992 };
10993
10994 /**
10995 * @protected
10996 * @param {string} kind 'error' or 'notice'
10997 * @param {string|OO.ui.HtmlSnippet} text
10998 * @return {jQuery}
10999 */
11000 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11001 var $listItem, $icon, message;
11002 $listItem = $( '<li>' );
11003 if ( kind === 'error' ) {
11004 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11005 $listItem.attr( 'role', 'alert' );
11006 } else if ( kind === 'notice' ) {
11007 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
11008 } else {
11009 $icon = '';
11010 }
11011 message = new OO.ui.LabelWidget( { label: text } );
11012 $listItem
11013 .append( $icon, message.$element )
11014 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11015 return $listItem;
11016 };
11017
11018 /**
11019 * Set the field alignment mode.
11020 *
11021 * @private
11022 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11023 * @chainable
11024 */
11025 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11026 if ( value !== this.align ) {
11027 // Default to 'left'
11028 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11029 value = 'left';
11030 }
11031 // Validate
11032 if ( value === 'inline' && !this.isFieldInline() ) {
11033 value = 'top';
11034 }
11035 // Reorder elements
11036 if ( value === 'top' ) {
11037 this.$header.append( this.$label, this.$help );
11038 this.$body.append( this.$header, this.$field );
11039 } else if ( value === 'inline' ) {
11040 this.$header.append( this.$label, this.$help );
11041 this.$body.append( this.$field, this.$header );
11042 } else {
11043 this.$header.append( this.$label );
11044 this.$body.append( this.$header, this.$help, this.$field );
11045 }
11046 // Set classes. The following classes can be used here:
11047 // * oo-ui-fieldLayout-align-left
11048 // * oo-ui-fieldLayout-align-right
11049 // * oo-ui-fieldLayout-align-top
11050 // * oo-ui-fieldLayout-align-inline
11051 if ( this.align ) {
11052 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11053 }
11054 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11055 this.align = value;
11056 }
11057
11058 return this;
11059 };
11060
11061 /**
11062 * Set the list of error messages.
11063 *
11064 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11065 * The array may contain strings or OO.ui.HtmlSnippet instances.
11066 * @chainable
11067 */
11068 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11069 this.errors = errors.slice();
11070 this.updateMessages();
11071 return this;
11072 };
11073
11074 /**
11075 * Set the list of notice messages.
11076 *
11077 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11078 * The array may contain strings or OO.ui.HtmlSnippet instances.
11079 * @chainable
11080 */
11081 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11082 this.notices = notices.slice();
11083 this.updateMessages();
11084 return this;
11085 };
11086
11087 /**
11088 * Update the rendering of error and notice messages.
11089 *
11090 * @private
11091 */
11092 OO.ui.FieldLayout.prototype.updateMessages = function () {
11093 var i;
11094 this.$messages.empty();
11095
11096 if ( this.errors.length || this.notices.length ) {
11097 this.$body.after( this.$messages );
11098 } else {
11099 this.$messages.remove();
11100 return;
11101 }
11102
11103 for ( i = 0; i < this.notices.length; i++ ) {
11104 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11105 }
11106 for ( i = 0; i < this.errors.length; i++ ) {
11107 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11108 }
11109 };
11110
11111 /**
11112 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11113 * (This is a bit of a hack.)
11114 *
11115 * @protected
11116 * @param {string} title Tooltip label for 'title' attribute
11117 * @return {string}
11118 */
11119 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11120 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11121 return this.fieldWidget.formatTitleWithAccessKey( title );
11122 }
11123 return title;
11124 };
11125
11126 /**
11127 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11128 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11129 * is required and is specified before any optional configuration settings.
11130 *
11131 * Labels can be aligned in one of four ways:
11132 *
11133 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11134 * A left-alignment is used for forms with many fields.
11135 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11136 * A right-alignment is used for long but familiar forms which users tab through,
11137 * verifying the current field with a quick glance at the label.
11138 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11139 * that users fill out from top to bottom.
11140 * - **inline**: The label is placed after the field-widget and aligned to the left.
11141 * An inline-alignment is best used with checkboxes or radio buttons.
11142 *
11143 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11144 * text is specified.
11145 *
11146 * @example
11147 * // Example of an ActionFieldLayout
11148 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11149 * new OO.ui.TextInputWidget( {
11150 * placeholder: 'Field widget'
11151 * } ),
11152 * new OO.ui.ButtonWidget( {
11153 * label: 'Button'
11154 * } ),
11155 * {
11156 * label: 'An ActionFieldLayout. This label is aligned top',
11157 * align: 'top',
11158 * help: 'This is help text'
11159 * }
11160 * );
11161 *
11162 * $( 'body' ).append( actionFieldLayout.$element );
11163 *
11164 * @class
11165 * @extends OO.ui.FieldLayout
11166 *
11167 * @constructor
11168 * @param {OO.ui.Widget} fieldWidget Field widget
11169 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11170 * @param {Object} config
11171 */
11172 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11173 // Allow passing positional parameters inside the config object
11174 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11175 config = fieldWidget;
11176 fieldWidget = config.fieldWidget;
11177 buttonWidget = config.buttonWidget;
11178 }
11179
11180 // Parent constructor
11181 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11182
11183 // Properties
11184 this.buttonWidget = buttonWidget;
11185 this.$button = $( '<span>' );
11186 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11187
11188 // Initialization
11189 this.$element
11190 .addClass( 'oo-ui-actionFieldLayout' );
11191 this.$button
11192 .addClass( 'oo-ui-actionFieldLayout-button' )
11193 .append( this.buttonWidget.$element );
11194 this.$input
11195 .addClass( 'oo-ui-actionFieldLayout-input' )
11196 .append( this.fieldWidget.$element );
11197 this.$field
11198 .append( this.$input, this.$button );
11199 };
11200
11201 /* Setup */
11202
11203 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11204
11205 /**
11206 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11207 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11208 * configured with a label as well. For more information and examples,
11209 * please see the [OOjs UI documentation on MediaWiki][1].
11210 *
11211 * @example
11212 * // Example of a fieldset layout
11213 * var input1 = new OO.ui.TextInputWidget( {
11214 * placeholder: 'A text input field'
11215 * } );
11216 *
11217 * var input2 = new OO.ui.TextInputWidget( {
11218 * placeholder: 'A text input field'
11219 * } );
11220 *
11221 * var fieldset = new OO.ui.FieldsetLayout( {
11222 * label: 'Example of a fieldset layout'
11223 * } );
11224 *
11225 * fieldset.addItems( [
11226 * new OO.ui.FieldLayout( input1, {
11227 * label: 'Field One'
11228 * } ),
11229 * new OO.ui.FieldLayout( input2, {
11230 * label: 'Field Two'
11231 * } )
11232 * ] );
11233 * $( 'body' ).append( fieldset.$element );
11234 *
11235 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11236 *
11237 * @class
11238 * @extends OO.ui.Layout
11239 * @mixins OO.ui.mixin.IconElement
11240 * @mixins OO.ui.mixin.LabelElement
11241 * @mixins OO.ui.mixin.GroupElement
11242 *
11243 * @constructor
11244 * @param {Object} [config] Configuration options
11245 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11246 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11247 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11248 * For important messages, you are advised to use `notices`, as they are always shown.
11249 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11250 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11251 */
11252 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11253 // Configuration initialization
11254 config = config || {};
11255
11256 // Parent constructor
11257 OO.ui.FieldsetLayout.parent.call( this, config );
11258
11259 // Mixin constructors
11260 OO.ui.mixin.IconElement.call( this, config );
11261 OO.ui.mixin.LabelElement.call( this, config );
11262 OO.ui.mixin.GroupElement.call( this, config );
11263
11264 // Properties
11265 this.$header = $( '<legend>' );
11266 if ( config.help ) {
11267 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11268 $overlay: config.$overlay,
11269 popup: {
11270 padded: true
11271 },
11272 classes: [ 'oo-ui-fieldsetLayout-help' ],
11273 framed: false,
11274 icon: 'info'
11275 } );
11276 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11277 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11278 } else {
11279 this.popupButtonWidget.getPopup().$body.text( config.help );
11280 }
11281 this.$help = this.popupButtonWidget.$element;
11282 } else {
11283 this.$help = $( [] );
11284 }
11285
11286 // Initialization
11287 this.$header
11288 .addClass( 'oo-ui-fieldsetLayout-header' )
11289 .append( this.$icon, this.$label, this.$help );
11290 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11291 this.$element
11292 .addClass( 'oo-ui-fieldsetLayout' )
11293 .prepend( this.$header, this.$group );
11294 if ( Array.isArray( config.items ) ) {
11295 this.addItems( config.items );
11296 }
11297 };
11298
11299 /* Setup */
11300
11301 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11302 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11303 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11304 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11305
11306 /* Static Properties */
11307
11308 /**
11309 * @static
11310 * @inheritdoc
11311 */
11312 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11313
11314 /**
11315 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11316 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11317 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11318 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11319 *
11320 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11321 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11322 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11323 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11324 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11325 * often have simplified APIs to match the capabilities of HTML forms.
11326 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11327 *
11328 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11329 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11330 *
11331 * @example
11332 * // Example of a form layout that wraps a fieldset layout
11333 * var input1 = new OO.ui.TextInputWidget( {
11334 * placeholder: 'Username'
11335 * } );
11336 * var input2 = new OO.ui.TextInputWidget( {
11337 * placeholder: 'Password',
11338 * type: 'password'
11339 * } );
11340 * var submit = new OO.ui.ButtonInputWidget( {
11341 * label: 'Submit'
11342 * } );
11343 *
11344 * var fieldset = new OO.ui.FieldsetLayout( {
11345 * label: 'A form layout'
11346 * } );
11347 * fieldset.addItems( [
11348 * new OO.ui.FieldLayout( input1, {
11349 * label: 'Username',
11350 * align: 'top'
11351 * } ),
11352 * new OO.ui.FieldLayout( input2, {
11353 * label: 'Password',
11354 * align: 'top'
11355 * } ),
11356 * new OO.ui.FieldLayout( submit )
11357 * ] );
11358 * var form = new OO.ui.FormLayout( {
11359 * items: [ fieldset ],
11360 * action: '/api/formhandler',
11361 * method: 'get'
11362 * } )
11363 * $( 'body' ).append( form.$element );
11364 *
11365 * @class
11366 * @extends OO.ui.Layout
11367 * @mixins OO.ui.mixin.GroupElement
11368 *
11369 * @constructor
11370 * @param {Object} [config] Configuration options
11371 * @cfg {string} [method] HTML form `method` attribute
11372 * @cfg {string} [action] HTML form `action` attribute
11373 * @cfg {string} [enctype] HTML form `enctype` attribute
11374 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11375 */
11376 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11377 var action;
11378
11379 // Configuration initialization
11380 config = config || {};
11381
11382 // Parent constructor
11383 OO.ui.FormLayout.parent.call( this, config );
11384
11385 // Mixin constructors
11386 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11387
11388 // Events
11389 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11390
11391 // Make sure the action is safe
11392 action = config.action;
11393 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11394 action = './' + action;
11395 }
11396
11397 // Initialization
11398 this.$element
11399 .addClass( 'oo-ui-formLayout' )
11400 .attr( {
11401 method: config.method,
11402 action: action,
11403 enctype: config.enctype
11404 } );
11405 if ( Array.isArray( config.items ) ) {
11406 this.addItems( config.items );
11407 }
11408 };
11409
11410 /* Setup */
11411
11412 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11413 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11414
11415 /* Events */
11416
11417 /**
11418 * A 'submit' event is emitted when the form is submitted.
11419 *
11420 * @event submit
11421 */
11422
11423 /* Static Properties */
11424
11425 /**
11426 * @static
11427 * @inheritdoc
11428 */
11429 OO.ui.FormLayout.static.tagName = 'form';
11430
11431 /* Methods */
11432
11433 /**
11434 * Handle form submit events.
11435 *
11436 * @private
11437 * @param {jQuery.Event} e Submit event
11438 * @fires submit
11439 */
11440 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11441 if ( this.emit( 'submit' ) ) {
11442 return false;
11443 }
11444 };
11445
11446 /**
11447 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11448 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11449 *
11450 * @example
11451 * // Example of a panel layout
11452 * var panel = new OO.ui.PanelLayout( {
11453 * expanded: false,
11454 * framed: true,
11455 * padded: true,
11456 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11457 * } );
11458 * $( 'body' ).append( panel.$element );
11459 *
11460 * @class
11461 * @extends OO.ui.Layout
11462 *
11463 * @constructor
11464 * @param {Object} [config] Configuration options
11465 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11466 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11467 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11468 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11469 */
11470 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11471 // Configuration initialization
11472 config = $.extend( {
11473 scrollable: false,
11474 padded: false,
11475 expanded: true,
11476 framed: false
11477 }, config );
11478
11479 // Parent constructor
11480 OO.ui.PanelLayout.parent.call( this, config );
11481
11482 // Initialization
11483 this.$element.addClass( 'oo-ui-panelLayout' );
11484 if ( config.scrollable ) {
11485 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11486 }
11487 if ( config.padded ) {
11488 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11489 }
11490 if ( config.expanded ) {
11491 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11492 }
11493 if ( config.framed ) {
11494 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11495 }
11496 };
11497
11498 /* Setup */
11499
11500 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11501
11502 /* Methods */
11503
11504 /**
11505 * Focus the panel layout
11506 *
11507 * The default implementation just focuses the first focusable element in the panel
11508 */
11509 OO.ui.PanelLayout.prototype.focus = function () {
11510 OO.ui.findFocusable( this.$element ).focus();
11511 };
11512
11513 /**
11514 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11515 * items), with small margins between them. Convenient when you need to put a number of block-level
11516 * widgets on a single line next to each other.
11517 *
11518 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11519 *
11520 * @example
11521 * // HorizontalLayout with a text input and a label
11522 * var layout = new OO.ui.HorizontalLayout( {
11523 * items: [
11524 * new OO.ui.LabelWidget( { label: 'Label' } ),
11525 * new OO.ui.TextInputWidget( { value: 'Text' } )
11526 * ]
11527 * } );
11528 * $( 'body' ).append( layout.$element );
11529 *
11530 * @class
11531 * @extends OO.ui.Layout
11532 * @mixins OO.ui.mixin.GroupElement
11533 *
11534 * @constructor
11535 * @param {Object} [config] Configuration options
11536 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11537 */
11538 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11539 // Configuration initialization
11540 config = config || {};
11541
11542 // Parent constructor
11543 OO.ui.HorizontalLayout.parent.call( this, config );
11544
11545 // Mixin constructors
11546 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11547
11548 // Initialization
11549 this.$element.addClass( 'oo-ui-horizontalLayout' );
11550 if ( Array.isArray( config.items ) ) {
11551 this.addItems( config.items );
11552 }
11553 };
11554
11555 /* Setup */
11556
11557 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11558 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11559
11560 }( OO ) );
11561
11562 //# sourceMappingURL=oojs-ui-core.js.map