Merge "user: Ensure returned user groups are sorted"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.30.0
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2018 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2018-12-20T06:10:28Z
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 'ooui-' + 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 * @param {Object} [config] Configuration options
337 * @return {OO.ui.Element}
338 * The `OO.ui.Element` corresponding to this (infusable) document node.
339 */
340 OO.ui.infuse = function ( idOrNode, config ) {
341 return OO.ui.Element.static.infuse( idOrNode, config );
342 };
343
344 ( function () {
345 /**
346 * Message store for the default implementation of OO.ui.msg
347 *
348 * Environments that provide a localization system should not use this, but should override
349 * OO.ui.msg altogether.
350 *
351 * @private
352 */
353 var messages = {
354 // Tool tip for a button that moves items in a list down one place
355 'ooui-outline-control-move-down': 'Move item down',
356 // Tool tip for a button that moves items in a list up one place
357 'ooui-outline-control-move-up': 'Move item up',
358 // Tool tip for a button that removes items from a list
359 'ooui-outline-control-remove': 'Remove item',
360 // Label for the toolbar group that contains a list of all other available tools
361 'ooui-toolbar-more': 'More',
362 // Label for the fake tool that expands the full list of tools in a toolbar group
363 'ooui-toolgroup-expand': 'More',
364 // Label for the fake tool that collapses the full list of tools in a toolbar group
365 'ooui-toolgroup-collapse': 'Fewer',
366 // Default label for the tooltip for the button that removes a tag item
367 'ooui-item-remove': 'Remove',
368 // Default label for the accept button of a confirmation dialog
369 'ooui-dialog-message-accept': 'OK',
370 // Default label for the reject button of a confirmation dialog
371 'ooui-dialog-message-reject': 'Cancel',
372 // Title for process dialog error description
373 'ooui-dialog-process-error': 'Something went wrong',
374 // Label for process dialog dismiss error button, visible when describing errors
375 'ooui-dialog-process-dismiss': 'Dismiss',
376 // Label for process dialog retry action button, visible when describing only recoverable errors
377 'ooui-dialog-process-retry': 'Try again',
378 // Label for process dialog retry action button, visible when describing only warnings
379 'ooui-dialog-process-continue': 'Continue',
380 // Label for button in combobox input that triggers its dropdown
381 'ooui-combobox-button-label': 'Dropdown for combobox',
382 // Label for the file selection widget's select file button
383 'ooui-selectfile-button-select': 'Select a file',
384 // Label for the file selection widget if file selection is not supported
385 'ooui-selectfile-not-supported': 'File selection is not supported',
386 // Label for the file selection widget when no file is currently selected
387 'ooui-selectfile-placeholder': 'No file is selected',
388 // Label for the file selection widget's drop target
389 'ooui-selectfile-dragdrop-placeholder': 'Drop file here',
390 // Label for the help icon attached to a form field
391 'ooui-field-help': 'Help'
392 };
393
394 /**
395 * Get a localized message.
396 *
397 * After the message key, message parameters may optionally be passed. In the default implementation,
398 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
399 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
400 * they support unnamed, ordered message parameters.
401 *
402 * In environments that provide a localization system, this function should be overridden to
403 * return the message translated in the user's language. The default implementation always returns
404 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
405 * follows.
406 *
407 * @example
408 * var i, iLen, button,
409 * messagePath = 'oojs-ui/dist/i18n/',
410 * languages = [ $.i18n().locale, 'ur', 'en' ],
411 * languageMap = {};
412 *
413 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
414 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
415 * }
416 *
417 * $.i18n().load( languageMap ).done( function() {
418 * // Replace the built-in `msg` only once we've loaded the internationalization.
419 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
420 * // you put off creating any widgets until this promise is complete, no English
421 * // will be displayed.
422 * OO.ui.msg = $.i18n;
423 *
424 * // A button displaying "OK" in the default locale
425 * button = new OO.ui.ButtonWidget( {
426 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
427 * icon: 'check'
428 * } );
429 * $( 'body' ).append( button.$element );
430 *
431 * // A button displaying "OK" in Urdu
432 * $.i18n().locale = 'ur';
433 * button = new OO.ui.ButtonWidget( {
434 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
435 * icon: 'check'
436 * } );
437 * $( 'body' ).append( button.$element );
438 * } );
439 *
440 * @param {string} key Message key
441 * @param {...Mixed} [params] Message parameters
442 * @return {string} Translated message with parameters substituted
443 */
444 OO.ui.msg = function ( key ) {
445 var message = messages[ key ],
446 params = Array.prototype.slice.call( arguments, 1 );
447 if ( typeof message === 'string' ) {
448 // Perform $1 substitution
449 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
450 var i = parseInt( n, 10 );
451 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
452 } );
453 } else {
454 // Return placeholder if message not found
455 message = '[' + key + ']';
456 }
457 return message;
458 };
459 }() );
460
461 /**
462 * Package a message and arguments for deferred resolution.
463 *
464 * Use this when you are statically specifying a message and the message may not yet be present.
465 *
466 * @param {string} key Message key
467 * @param {...Mixed} [params] Message parameters
468 * @return {Function} Function that returns the resolved message when executed
469 */
470 OO.ui.deferMsg = function () {
471 var args = arguments;
472 return function () {
473 return OO.ui.msg.apply( OO.ui, args );
474 };
475 };
476
477 /**
478 * Resolve a message.
479 *
480 * If the message is a function it will be executed, otherwise it will pass through directly.
481 *
482 * @param {Function|string} msg Deferred message, or message text
483 * @return {string} Resolved message
484 */
485 OO.ui.resolveMsg = function ( msg ) {
486 if ( typeof msg === 'function' ) {
487 return msg();
488 }
489 return msg;
490 };
491
492 /**
493 * @param {string} url
494 * @return {boolean}
495 */
496 OO.ui.isSafeUrl = function ( url ) {
497 // Keep this function in sync with php/Tag.php
498 var i, protocolWhitelist;
499
500 function stringStartsWith( haystack, needle ) {
501 return haystack.substr( 0, needle.length ) === needle;
502 }
503
504 protocolWhitelist = [
505 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
506 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
507 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
508 ];
509
510 if ( url === '' ) {
511 return true;
512 }
513
514 for ( i = 0; i < protocolWhitelist.length; i++ ) {
515 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
516 return true;
517 }
518 }
519
520 // This matches '//' too
521 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
522 return true;
523 }
524 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
525 return true;
526 }
527
528 return false;
529 };
530
531 /**
532 * Check if the user has a 'mobile' device.
533 *
534 * For our purposes this means the user is primarily using an
535 * on-screen keyboard, touch input instead of a mouse and may
536 * have a physically small display.
537 *
538 * It is left up to implementors to decide how to compute this
539 * so the default implementation always returns false.
540 *
541 * @return {boolean} User is on a mobile device
542 */
543 OO.ui.isMobile = function () {
544 return false;
545 };
546
547 /**
548 * Get the additional spacing that should be taken into account when displaying elements that are
549 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
550 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
551 *
552 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
553 * the extra spacing from that edge of viewport (in pixels)
554 */
555 OO.ui.getViewportSpacing = function () {
556 return {
557 top: 0,
558 right: 0,
559 bottom: 0,
560 left: 0
561 };
562 };
563
564 /**
565 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
566 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
567 *
568 * @return {jQuery} Default overlay node
569 */
570 OO.ui.getDefaultOverlay = function () {
571 if ( !OO.ui.$defaultOverlay ) {
572 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
573 $( 'body' ).append( OO.ui.$defaultOverlay );
574 }
575 return OO.ui.$defaultOverlay;
576 };
577
578 /*!
579 * Mixin namespace.
580 */
581
582 /**
583 * Namespace for OOUI mixins.
584 *
585 * Mixins are named according to the type of object they are intended to
586 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
587 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
588 * is intended to be mixed in to an instance of OO.ui.Widget.
589 *
590 * @class
591 * @singleton
592 */
593 OO.ui.mixin = {};
594
595 /**
596 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
597 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
598 * connected to them and can't be interacted with.
599 *
600 * @abstract
601 * @class
602 *
603 * @constructor
604 * @param {Object} [config] Configuration options
605 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
606 * to the top level (e.g., the outermost div) of the element. See the [OOUI documentation on MediaWiki][2]
607 * for an example.
608 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
609 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
610 * @cfg {string} [text] Text to insert
611 * @cfg {Array} [content] An array of content elements to append (after #text).
612 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
613 * Instances of OO.ui.Element will have their $element appended.
614 * @cfg {jQuery} [$content] Content elements to append (after #text).
615 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
616 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
617 * Data can also be specified with the #setData method.
618 */
619 OO.ui.Element = function OoUiElement( config ) {
620 if ( OO.ui.isDemo ) {
621 this.initialConfig = config;
622 }
623 // Configuration initialization
624 config = config || {};
625
626 // Properties
627 this.$ = $;
628 this.elementId = null;
629 this.visible = true;
630 this.data = config.data;
631 this.$element = config.$element ||
632 $( document.createElement( this.getTagName() ) );
633 this.elementGroup = null;
634
635 // Initialization
636 if ( Array.isArray( config.classes ) ) {
637 this.$element.addClass( config.classes );
638 }
639 if ( config.id ) {
640 this.setElementId( config.id );
641 }
642 if ( config.text ) {
643 this.$element.text( config.text );
644 }
645 if ( config.content ) {
646 // The `content` property treats plain strings as text; use an
647 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
648 // appropriate $element appended.
649 this.$element.append( config.content.map( function ( v ) {
650 if ( typeof v === 'string' ) {
651 // Escape string so it is properly represented in HTML.
652 return document.createTextNode( v );
653 } else if ( v instanceof OO.ui.HtmlSnippet ) {
654 // Bypass escaping.
655 return v.toString();
656 } else if ( v instanceof OO.ui.Element ) {
657 return v.$element;
658 }
659 return v;
660 } ) );
661 }
662 if ( config.$content ) {
663 // The `$content` property treats plain strings as HTML.
664 this.$element.append( config.$content );
665 }
666 };
667
668 /* Setup */
669
670 OO.initClass( OO.ui.Element );
671
672 /* Static Properties */
673
674 /**
675 * The name of the HTML tag used by the element.
676 *
677 * The static value may be ignored if the #getTagName method is overridden.
678 *
679 * @static
680 * @inheritable
681 * @property {string}
682 */
683 OO.ui.Element.static.tagName = 'div';
684
685 /* Static Methods */
686
687 /**
688 * Reconstitute a JavaScript object corresponding to a widget created
689 * by the PHP implementation.
690 *
691 * @param {string|HTMLElement|jQuery} idOrNode
692 * A DOM id (if a string) or node for the widget to infuse.
693 * @param {Object} [config] Configuration options
694 * @return {OO.ui.Element}
695 * The `OO.ui.Element` corresponding to this (infusable) document node.
696 * For `Tag` objects emitted on the HTML side (used occasionally for content)
697 * the value returned is a newly-created Element wrapping around the existing
698 * DOM node.
699 */
700 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
701 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
702
703 if ( typeof idOrNode === 'string' ) {
704 // IDs deprecated since 0.29.7
705 OO.ui.warnDeprecation(
706 'Passing a string ID to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
707 );
708 }
709 // Verify that the type matches up.
710 // FIXME: uncomment after T89721 is fixed, see T90929.
711 /*
712 if ( !( obj instanceof this['class'] ) ) {
713 throw new Error( 'Infusion type mismatch!' );
714 }
715 */
716 return obj;
717 };
718
719 /**
720 * Implementation helper for `infuse`; skips the type check and has an
721 * extra property so that only the top-level invocation touches the DOM.
722 *
723 * @private
724 * @param {string|HTMLElement|jQuery} idOrNode
725 * @param {Object} [config] Configuration options
726 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
727 * when the top-level widget of this infusion is inserted into DOM,
728 * replacing the original node; only used internally.
729 * @return {OO.ui.Element}
730 */
731 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
732 // look for a cached result of a previous infusion.
733 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
734 if ( typeof idOrNode === 'string' ) {
735 id = idOrNode;
736 $elem = $( document.getElementById( id ) );
737 } else {
738 $elem = $( idOrNode );
739 id = $elem.attr( 'id' );
740 }
741 if ( !$elem.length ) {
742 if ( typeof idOrNode === 'string' ) {
743 error = 'Widget not found: ' + idOrNode;
744 } else if ( idOrNode && idOrNode.selector ) {
745 error = 'Widget not found: ' + idOrNode.selector;
746 } else {
747 error = 'Widget not found';
748 }
749 throw new Error( error );
750 }
751 if ( $elem[ 0 ].oouiInfused ) {
752 $elem = $elem[ 0 ].oouiInfused;
753 }
754 data = $elem.data( 'ooui-infused' );
755 if ( data ) {
756 // cached!
757 if ( data === true ) {
758 throw new Error( 'Circular dependency! ' + id );
759 }
760 if ( domPromise ) {
761 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
762 state = data.constructor.static.gatherPreInfuseState( $elem, data );
763 // restore dynamic state after the new element is re-inserted into DOM under infused parent
764 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
765 infusedChildren = $elem.data( 'ooui-infused-children' );
766 if ( infusedChildren && infusedChildren.length ) {
767 infusedChildren.forEach( function ( data ) {
768 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
769 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
770 } );
771 }
772 }
773 return data;
774 }
775 data = $elem.attr( 'data-ooui' );
776 if ( !data ) {
777 throw new Error( 'No infusion data found: ' + id );
778 }
779 try {
780 data = JSON.parse( data );
781 } catch ( _ ) {
782 data = null;
783 }
784 if ( !( data && data._ ) ) {
785 throw new Error( 'No valid infusion data found: ' + id );
786 }
787 if ( data._ === 'Tag' ) {
788 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
789 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
790 }
791 parts = data._.split( '.' );
792 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
793 if ( cls === undefined ) {
794 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
795 }
796
797 // Verify that we're creating an OO.ui.Element instance
798 parent = cls.parent;
799
800 while ( parent !== undefined ) {
801 if ( parent === OO.ui.Element ) {
802 // Safe
803 break;
804 }
805
806 parent = parent.parent;
807 }
808
809 if ( parent !== OO.ui.Element ) {
810 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
811 }
812
813 if ( !domPromise ) {
814 top = $.Deferred();
815 domPromise = top.promise();
816 }
817 $elem.data( 'ooui-infused', true ); // prevent loops
818 data.id = id; // implicit
819 infusedChildren = [];
820 data = OO.copy( data, null, function deserialize( value ) {
821 var infused;
822 if ( OO.isPlainObject( value ) ) {
823 if ( value.tag ) {
824 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
825 infusedChildren.push( infused );
826 // Flatten the structure
827 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
828 infused.$element.removeData( 'ooui-infused-children' );
829 return infused;
830 }
831 if ( value.html !== undefined ) {
832 return new OO.ui.HtmlSnippet( value.html );
833 }
834 }
835 } );
836 // allow widgets to reuse parts of the DOM
837 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
838 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
839 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
840 // rebuild widget
841 // eslint-disable-next-line new-cap
842 obj = new cls( $.extend( {}, config, data ) );
843 // If anyone is holding a reference to the old DOM element,
844 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
845 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
846 $elem[ 0 ].oouiInfused = obj.$element;
847 // now replace old DOM with this new DOM.
848 if ( top ) {
849 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
850 // so only mutate the DOM if we need to.
851 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
852 $elem.replaceWith( obj.$element );
853 }
854 top.resolve();
855 }
856 obj.$element.data( 'ooui-infused', obj );
857 obj.$element.data( 'ooui-infused-children', infusedChildren );
858 // set the 'data-ooui' attribute so we can identify infused widgets
859 obj.$element.attr( 'data-ooui', '' );
860 // restore dynamic state after the new element is inserted into DOM
861 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
862 return obj;
863 };
864
865 /**
866 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
867 *
868 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
869 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
870 * constructor, which will be given the enhanced config.
871 *
872 * @protected
873 * @param {HTMLElement} node
874 * @param {Object} config
875 * @return {Object}
876 */
877 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
878 return config;
879 };
880
881 /**
882 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
883 * (and its children) that represent an Element of the same class and the given configuration,
884 * generated by the PHP implementation.
885 *
886 * This method is called just before `node` is detached from the DOM. The return value of this
887 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
888 * is inserted into DOM to replace `node`.
889 *
890 * @protected
891 * @param {HTMLElement} node
892 * @param {Object} config
893 * @return {Object}
894 */
895 OO.ui.Element.static.gatherPreInfuseState = function () {
896 return {};
897 };
898
899 /**
900 * Get a jQuery function within a specific document.
901 *
902 * @static
903 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
904 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
905 * not in an iframe
906 * @return {Function} Bound jQuery function
907 */
908 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
909 function wrapper( selector ) {
910 return $( selector, wrapper.context );
911 }
912
913 wrapper.context = this.getDocument( context );
914
915 if ( $iframe ) {
916 wrapper.$iframe = $iframe;
917 }
918
919 return wrapper;
920 };
921
922 /**
923 * Get the document of an element.
924 *
925 * @static
926 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
927 * @return {HTMLDocument|null} Document object
928 */
929 OO.ui.Element.static.getDocument = function ( obj ) {
930 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
931 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
932 // Empty jQuery selections might have a context
933 obj.context ||
934 // HTMLElement
935 obj.ownerDocument ||
936 // Window
937 obj.document ||
938 // HTMLDocument
939 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
940 null;
941 };
942
943 /**
944 * Get the window of an element or document.
945 *
946 * @static
947 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
948 * @return {Window} Window object
949 */
950 OO.ui.Element.static.getWindow = function ( obj ) {
951 var doc = this.getDocument( obj );
952 return doc.defaultView;
953 };
954
955 /**
956 * Get the direction of an element or document.
957 *
958 * @static
959 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
960 * @return {string} Text direction, either 'ltr' or 'rtl'
961 */
962 OO.ui.Element.static.getDir = function ( obj ) {
963 var isDoc, isWin;
964
965 if ( obj instanceof $ ) {
966 obj = obj[ 0 ];
967 }
968 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
969 isWin = obj.document !== undefined;
970 if ( isDoc || isWin ) {
971 if ( isWin ) {
972 obj = obj.document;
973 }
974 obj = obj.body;
975 }
976 return $( obj ).css( 'direction' );
977 };
978
979 /**
980 * Get the offset between two frames.
981 *
982 * TODO: Make this function not use recursion.
983 *
984 * @static
985 * @param {Window} from Window of the child frame
986 * @param {Window} [to=window] Window of the parent frame
987 * @param {Object} [offset] Offset to start with, used internally
988 * @return {Object} Offset object, containing left and top properties
989 */
990 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
991 var i, len, frames, frame, rect;
992
993 if ( !to ) {
994 to = window;
995 }
996 if ( !offset ) {
997 offset = { top: 0, left: 0 };
998 }
999 if ( from.parent === from ) {
1000 return offset;
1001 }
1002
1003 // Get iframe element
1004 frames = from.parent.document.getElementsByTagName( 'iframe' );
1005 for ( i = 0, len = frames.length; i < len; i++ ) {
1006 if ( frames[ i ].contentWindow === from ) {
1007 frame = frames[ i ];
1008 break;
1009 }
1010 }
1011
1012 // Recursively accumulate offset values
1013 if ( frame ) {
1014 rect = frame.getBoundingClientRect();
1015 offset.left += rect.left;
1016 offset.top += rect.top;
1017 if ( from !== to ) {
1018 this.getFrameOffset( from.parent, offset );
1019 }
1020 }
1021 return offset;
1022 };
1023
1024 /**
1025 * Get the offset between two elements.
1026 *
1027 * The two elements may be in a different frame, but in that case the frame $element is in must
1028 * be contained in the frame $anchor is in.
1029 *
1030 * @static
1031 * @param {jQuery} $element Element whose position to get
1032 * @param {jQuery} $anchor Element to get $element's position relative to
1033 * @return {Object} Translated position coordinates, containing top and left properties
1034 */
1035 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1036 var iframe, iframePos,
1037 pos = $element.offset(),
1038 anchorPos = $anchor.offset(),
1039 elementDocument = this.getDocument( $element ),
1040 anchorDocument = this.getDocument( $anchor );
1041
1042 // If $element isn't in the same document as $anchor, traverse up
1043 while ( elementDocument !== anchorDocument ) {
1044 iframe = elementDocument.defaultView.frameElement;
1045 if ( !iframe ) {
1046 throw new Error( '$element frame is not contained in $anchor frame' );
1047 }
1048 iframePos = $( iframe ).offset();
1049 pos.left += iframePos.left;
1050 pos.top += iframePos.top;
1051 elementDocument = iframe.ownerDocument;
1052 }
1053 pos.left -= anchorPos.left;
1054 pos.top -= anchorPos.top;
1055 return pos;
1056 };
1057
1058 /**
1059 * Get element border sizes.
1060 *
1061 * @static
1062 * @param {HTMLElement} el Element to measure
1063 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1064 */
1065 OO.ui.Element.static.getBorders = function ( el ) {
1066 var doc = el.ownerDocument,
1067 win = doc.defaultView,
1068 style = win.getComputedStyle( el, null ),
1069 $el = $( el ),
1070 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1071 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1072 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1073 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1074
1075 return {
1076 top: top,
1077 left: left,
1078 bottom: bottom,
1079 right: right
1080 };
1081 };
1082
1083 /**
1084 * Get dimensions of an element or window.
1085 *
1086 * @static
1087 * @param {HTMLElement|Window} el Element to measure
1088 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1089 */
1090 OO.ui.Element.static.getDimensions = function ( el ) {
1091 var $el, $win,
1092 doc = el.ownerDocument || el.document,
1093 win = doc.defaultView;
1094
1095 if ( win === el || el === doc.documentElement ) {
1096 $win = $( win );
1097 return {
1098 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1099 scroll: {
1100 top: $win.scrollTop(),
1101 left: $win.scrollLeft()
1102 },
1103 scrollbar: { right: 0, bottom: 0 },
1104 rect: {
1105 top: 0,
1106 left: 0,
1107 bottom: $win.innerHeight(),
1108 right: $win.innerWidth()
1109 }
1110 };
1111 } else {
1112 $el = $( el );
1113 return {
1114 borders: this.getBorders( el ),
1115 scroll: {
1116 top: $el.scrollTop(),
1117 left: $el.scrollLeft()
1118 },
1119 scrollbar: {
1120 right: $el.innerWidth() - el.clientWidth,
1121 bottom: $el.innerHeight() - el.clientHeight
1122 },
1123 rect: el.getBoundingClientRect()
1124 };
1125 }
1126 };
1127
1128 /**
1129 * Get the number of pixels that an element's content is scrolled to the left.
1130 *
1131 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1132 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1133 *
1134 * This function smooths out browser inconsistencies (nicely described in the README at
1135 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1136 * with Firefox's 'scrollLeft', which seems the sanest.
1137 *
1138 * @static
1139 * @method
1140 * @param {HTMLElement|Window} el Element to measure
1141 * @return {number} Scroll position from the left.
1142 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1143 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1144 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1145 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1146 */
1147 OO.ui.Element.static.getScrollLeft = ( function () {
1148 var rtlScrollType = null;
1149
1150 function test() {
1151 var $definer = $( '<div>' ).attr( {
1152 dir: 'rtl',
1153 style: 'font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1154 } ).text( 'A' ),
1155 definer = $definer[ 0 ];
1156
1157 $definer.appendTo( 'body' );
1158 if ( definer.scrollLeft > 0 ) {
1159 // Safari, Chrome
1160 rtlScrollType = 'default';
1161 } else {
1162 definer.scrollLeft = 1;
1163 if ( definer.scrollLeft === 0 ) {
1164 // Firefox, old Opera
1165 rtlScrollType = 'negative';
1166 } else {
1167 // Internet Explorer, Edge
1168 rtlScrollType = 'reverse';
1169 }
1170 }
1171 $definer.remove();
1172 }
1173
1174 return function getScrollLeft( el ) {
1175 var isRoot = el.window === el ||
1176 el === el.ownerDocument.body ||
1177 el === el.ownerDocument.documentElement,
1178 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1179 // All browsers use the correct scroll type ('negative') on the root, so don't
1180 // do any fixups when looking at the root element
1181 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1182
1183 if ( direction === 'rtl' ) {
1184 if ( rtlScrollType === null ) {
1185 test();
1186 }
1187 if ( rtlScrollType === 'reverse' ) {
1188 scrollLeft = -scrollLeft;
1189 } else if ( rtlScrollType === 'default' ) {
1190 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1191 }
1192 }
1193
1194 return scrollLeft;
1195 };
1196 }() );
1197
1198 /**
1199 * Get the root scrollable element of given element's document.
1200 *
1201 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1202 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1203 * lets us use 'body' or 'documentElement' based on what is working.
1204 *
1205 * https://code.google.com/p/chromium/issues/detail?id=303131
1206 *
1207 * @static
1208 * @param {HTMLElement} el Element to find root scrollable parent for
1209 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1210 * depending on browser
1211 */
1212 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1213 var scrollTop, body;
1214
1215 if ( OO.ui.scrollableElement === undefined ) {
1216 body = el.ownerDocument.body;
1217 scrollTop = body.scrollTop;
1218 body.scrollTop = 1;
1219
1220 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1221 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1222 if ( Math.round( body.scrollTop ) === 1 ) {
1223 body.scrollTop = scrollTop;
1224 OO.ui.scrollableElement = 'body';
1225 } else {
1226 OO.ui.scrollableElement = 'documentElement';
1227 }
1228 }
1229
1230 return el.ownerDocument[ OO.ui.scrollableElement ];
1231 };
1232
1233 /**
1234 * Get closest scrollable container.
1235 *
1236 * Traverses up until either a scrollable element or the root is reached, in which case the root
1237 * scrollable element will be returned (see #getRootScrollableElement).
1238 *
1239 * @static
1240 * @param {HTMLElement} el Element to find scrollable container for
1241 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1242 * @return {HTMLElement} Closest scrollable container
1243 */
1244 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1245 var i, val,
1246 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1247 // 'overflow-y' have different values, so we need to check the separate properties.
1248 props = [ 'overflow-x', 'overflow-y' ],
1249 $parent = $( el ).parent();
1250
1251 if ( dimension === 'x' || dimension === 'y' ) {
1252 props = [ 'overflow-' + dimension ];
1253 }
1254
1255 // Special case for the document root (which doesn't really have any scrollable container, since
1256 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1257 if ( $( el ).is( 'html, body' ) ) {
1258 return this.getRootScrollableElement( el );
1259 }
1260
1261 while ( $parent.length ) {
1262 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1263 return $parent[ 0 ];
1264 }
1265 i = props.length;
1266 while ( i-- ) {
1267 val = $parent.css( props[ i ] );
1268 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1269 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1270 // unintentionally perform a scroll in such case even if the application doesn't scroll
1271 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1272 // This could cause funny issues...
1273 if ( val === 'auto' || val === 'scroll' ) {
1274 return $parent[ 0 ];
1275 }
1276 }
1277 $parent = $parent.parent();
1278 }
1279 // The element is unattached... return something mostly sane
1280 return this.getRootScrollableElement( el );
1281 };
1282
1283 /**
1284 * Scroll element into view.
1285 *
1286 * @static
1287 * @param {HTMLElement} el Element to scroll into view
1288 * @param {Object} [config] Configuration options
1289 * @param {string} [config.duration='fast'] jQuery animation duration value
1290 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1291 * to scroll in both directions
1292 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1293 */
1294 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1295 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1296 deferred = $.Deferred();
1297
1298 // Configuration initialization
1299 config = config || {};
1300
1301 animations = {};
1302 container = this.getClosestScrollableContainer( el, config.direction );
1303 $container = $( container );
1304 elementDimensions = this.getDimensions( el );
1305 containerDimensions = this.getDimensions( container );
1306 $window = $( this.getWindow( el ) );
1307
1308 // Compute the element's position relative to the container
1309 if ( $container.is( 'html, body' ) ) {
1310 // If the scrollable container is the root, this is easy
1311 position = {
1312 top: elementDimensions.rect.top,
1313 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1314 left: elementDimensions.rect.left,
1315 right: $window.innerWidth() - elementDimensions.rect.right
1316 };
1317 } else {
1318 // Otherwise, we have to subtract el's coordinates from container's coordinates
1319 position = {
1320 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1321 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1322 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1323 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1324 };
1325 }
1326
1327 if ( !config.direction || config.direction === 'y' ) {
1328 if ( position.top < 0 ) {
1329 animations.scrollTop = containerDimensions.scroll.top + position.top;
1330 } else if ( position.top > 0 && position.bottom < 0 ) {
1331 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1332 }
1333 }
1334 if ( !config.direction || config.direction === 'x' ) {
1335 if ( position.left < 0 ) {
1336 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1337 } else if ( position.left > 0 && position.right < 0 ) {
1338 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1339 }
1340 }
1341 if ( !$.isEmptyObject( animations ) ) {
1342 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1343 $container.queue( function ( next ) {
1344 deferred.resolve();
1345 next();
1346 } );
1347 } else {
1348 deferred.resolve();
1349 }
1350 return deferred.promise();
1351 };
1352
1353 /**
1354 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1355 * and reserve space for them, because it probably doesn't.
1356 *
1357 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1358 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1359 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1360 * and then reattach (or show) them back.
1361 *
1362 * @static
1363 * @param {HTMLElement} el Element to reconsider the scrollbars on
1364 */
1365 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1366 var i, len, scrollLeft, scrollTop, nodes = [];
1367 // Save scroll position
1368 scrollLeft = el.scrollLeft;
1369 scrollTop = el.scrollTop;
1370 // Detach all children
1371 while ( el.firstChild ) {
1372 nodes.push( el.firstChild );
1373 el.removeChild( el.firstChild );
1374 }
1375 // Force reflow
1376 // eslint-disable-next-line no-void
1377 void el.offsetHeight;
1378 // Reattach all children
1379 for ( i = 0, len = nodes.length; i < len; i++ ) {
1380 el.appendChild( nodes[ i ] );
1381 }
1382 // Restore scroll position (no-op if scrollbars disappeared)
1383 el.scrollLeft = scrollLeft;
1384 el.scrollTop = scrollTop;
1385 };
1386
1387 /* Methods */
1388
1389 /**
1390 * Toggle visibility of an element.
1391 *
1392 * @param {boolean} [show] Make element visible, omit to toggle visibility
1393 * @fires visible
1394 * @chainable
1395 * @return {OO.ui.Element} The element, for chaining
1396 */
1397 OO.ui.Element.prototype.toggle = function ( show ) {
1398 show = show === undefined ? !this.visible : !!show;
1399
1400 if ( show !== this.isVisible() ) {
1401 this.visible = show;
1402 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1403 this.emit( 'toggle', show );
1404 }
1405
1406 return this;
1407 };
1408
1409 /**
1410 * Check if element is visible.
1411 *
1412 * @return {boolean} element is visible
1413 */
1414 OO.ui.Element.prototype.isVisible = function () {
1415 return this.visible;
1416 };
1417
1418 /**
1419 * Get element data.
1420 *
1421 * @return {Mixed} Element data
1422 */
1423 OO.ui.Element.prototype.getData = function () {
1424 return this.data;
1425 };
1426
1427 /**
1428 * Set element data.
1429 *
1430 * @param {Mixed} data Element data
1431 * @chainable
1432 * @return {OO.ui.Element} The element, for chaining
1433 */
1434 OO.ui.Element.prototype.setData = function ( data ) {
1435 this.data = data;
1436 return this;
1437 };
1438
1439 /**
1440 * Set the element has an 'id' attribute.
1441 *
1442 * @param {string} id
1443 * @chainable
1444 * @return {OO.ui.Element} The element, for chaining
1445 */
1446 OO.ui.Element.prototype.setElementId = function ( id ) {
1447 this.elementId = id;
1448 this.$element.attr( 'id', id );
1449 return this;
1450 };
1451
1452 /**
1453 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1454 * and return its value.
1455 *
1456 * @return {string}
1457 */
1458 OO.ui.Element.prototype.getElementId = function () {
1459 if ( this.elementId === null ) {
1460 this.setElementId( OO.ui.generateElementId() );
1461 }
1462 return this.elementId;
1463 };
1464
1465 /**
1466 * Check if element supports one or more methods.
1467 *
1468 * @param {string|string[]} methods Method or list of methods to check
1469 * @return {boolean} All methods are supported
1470 */
1471 OO.ui.Element.prototype.supports = function ( methods ) {
1472 var i, len,
1473 support = 0;
1474
1475 methods = Array.isArray( methods ) ? methods : [ methods ];
1476 for ( i = 0, len = methods.length; i < len; i++ ) {
1477 if ( typeof this[ methods[ i ] ] === 'function' ) {
1478 support++;
1479 }
1480 }
1481
1482 return methods.length === support;
1483 };
1484
1485 /**
1486 * Update the theme-provided classes.
1487 *
1488 * @localdoc This is called in element mixins and widget classes any time state changes.
1489 * Updating is debounced, minimizing overhead of changing multiple attributes and
1490 * guaranteeing that theme updates do not occur within an element's constructor
1491 */
1492 OO.ui.Element.prototype.updateThemeClasses = function () {
1493 OO.ui.theme.queueUpdateElementClasses( this );
1494 };
1495
1496 /**
1497 * Get the HTML tag name.
1498 *
1499 * Override this method to base the result on instance information.
1500 *
1501 * @return {string} HTML tag name
1502 */
1503 OO.ui.Element.prototype.getTagName = function () {
1504 return this.constructor.static.tagName;
1505 };
1506
1507 /**
1508 * Check if the element is attached to the DOM
1509 *
1510 * @return {boolean} The element is attached to the DOM
1511 */
1512 OO.ui.Element.prototype.isElementAttached = function () {
1513 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1514 };
1515
1516 /**
1517 * Get the DOM document.
1518 *
1519 * @return {HTMLDocument} Document object
1520 */
1521 OO.ui.Element.prototype.getElementDocument = function () {
1522 // Don't cache this in other ways either because subclasses could can change this.$element
1523 return OO.ui.Element.static.getDocument( this.$element );
1524 };
1525
1526 /**
1527 * Get the DOM window.
1528 *
1529 * @return {Window} Window object
1530 */
1531 OO.ui.Element.prototype.getElementWindow = function () {
1532 return OO.ui.Element.static.getWindow( this.$element );
1533 };
1534
1535 /**
1536 * Get closest scrollable container.
1537 *
1538 * @return {HTMLElement} Closest scrollable container
1539 */
1540 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1541 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1542 };
1543
1544 /**
1545 * Get group element is in.
1546 *
1547 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1548 */
1549 OO.ui.Element.prototype.getElementGroup = function () {
1550 return this.elementGroup;
1551 };
1552
1553 /**
1554 * Set group element is in.
1555 *
1556 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1557 * @chainable
1558 * @return {OO.ui.Element} The element, for chaining
1559 */
1560 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1561 this.elementGroup = group;
1562 return this;
1563 };
1564
1565 /**
1566 * Scroll element into view.
1567 *
1568 * @param {Object} [config] Configuration options
1569 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1570 */
1571 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1572 if (
1573 !this.isElementAttached() ||
1574 !this.isVisible() ||
1575 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1576 ) {
1577 return $.Deferred().resolve();
1578 }
1579 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1580 };
1581
1582 /**
1583 * Restore the pre-infusion dynamic state for this widget.
1584 *
1585 * This method is called after #$element has been inserted into DOM. The parameter is the return
1586 * value of #gatherPreInfuseState.
1587 *
1588 * @protected
1589 * @param {Object} state
1590 */
1591 OO.ui.Element.prototype.restorePreInfuseState = function () {
1592 };
1593
1594 /**
1595 * Wraps an HTML snippet for use with configuration values which default
1596 * to strings. This bypasses the default html-escaping done to string
1597 * values.
1598 *
1599 * @class
1600 *
1601 * @constructor
1602 * @param {string} [content] HTML content
1603 */
1604 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1605 // Properties
1606 this.content = content;
1607 };
1608
1609 /* Setup */
1610
1611 OO.initClass( OO.ui.HtmlSnippet );
1612
1613 /* Methods */
1614
1615 /**
1616 * Render into HTML.
1617 *
1618 * @return {string} Unchanged HTML snippet.
1619 */
1620 OO.ui.HtmlSnippet.prototype.toString = function () {
1621 return this.content;
1622 };
1623
1624 /**
1625 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1626 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1627 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1628 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1629 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1630 *
1631 * @abstract
1632 * @class
1633 * @extends OO.ui.Element
1634 * @mixins OO.EventEmitter
1635 *
1636 * @constructor
1637 * @param {Object} [config] Configuration options
1638 */
1639 OO.ui.Layout = function OoUiLayout( config ) {
1640 // Configuration initialization
1641 config = config || {};
1642
1643 // Parent constructor
1644 OO.ui.Layout.parent.call( this, config );
1645
1646 // Mixin constructors
1647 OO.EventEmitter.call( this );
1648
1649 // Initialization
1650 this.$element.addClass( 'oo-ui-layout' );
1651 };
1652
1653 /* Setup */
1654
1655 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1656 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1657
1658 /* Methods */
1659
1660 /**
1661 * Reset scroll offsets
1662 *
1663 * @chainable
1664 * @return {OO.ui.Layout} The layout, for chaining
1665 */
1666 OO.ui.Layout.prototype.resetScroll = function () {
1667 this.$element[ 0 ].scrollTop = 0;
1668 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1669
1670 return this;
1671 };
1672
1673 /**
1674 * Widgets are compositions of one or more OOUI elements that users can both view
1675 * and interact with. All widgets can be configured and modified via a standard API,
1676 * and their state can change dynamically according to a model.
1677 *
1678 * @abstract
1679 * @class
1680 * @extends OO.ui.Element
1681 * @mixins OO.EventEmitter
1682 *
1683 * @constructor
1684 * @param {Object} [config] Configuration options
1685 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1686 * appearance reflects this state.
1687 */
1688 OO.ui.Widget = function OoUiWidget( config ) {
1689 // Initialize config
1690 config = $.extend( { disabled: false }, config );
1691
1692 // Parent constructor
1693 OO.ui.Widget.parent.call( this, config );
1694
1695 // Mixin constructors
1696 OO.EventEmitter.call( this );
1697
1698 // Properties
1699 this.disabled = null;
1700 this.wasDisabled = null;
1701
1702 // Initialization
1703 this.$element.addClass( 'oo-ui-widget' );
1704 this.setDisabled( !!config.disabled );
1705 };
1706
1707 /* Setup */
1708
1709 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1710 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1711
1712 /* Events */
1713
1714 /**
1715 * @event disable
1716 *
1717 * A 'disable' event is emitted when the disabled state of the widget changes
1718 * (i.e. on disable **and** enable).
1719 *
1720 * @param {boolean} disabled Widget is disabled
1721 */
1722
1723 /**
1724 * @event toggle
1725 *
1726 * A 'toggle' event is emitted when the visibility of the widget changes.
1727 *
1728 * @param {boolean} visible Widget is visible
1729 */
1730
1731 /* Methods */
1732
1733 /**
1734 * Check if the widget is disabled.
1735 *
1736 * @return {boolean} Widget is disabled
1737 */
1738 OO.ui.Widget.prototype.isDisabled = function () {
1739 return this.disabled;
1740 };
1741
1742 /**
1743 * Set the 'disabled' state of the widget.
1744 *
1745 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1746 *
1747 * @param {boolean} disabled Disable widget
1748 * @chainable
1749 * @return {OO.ui.Widget} The widget, for chaining
1750 */
1751 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1752 var isDisabled;
1753
1754 this.disabled = !!disabled;
1755 isDisabled = this.isDisabled();
1756 if ( isDisabled !== this.wasDisabled ) {
1757 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1758 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1759 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1760 this.emit( 'disable', isDisabled );
1761 this.updateThemeClasses();
1762 }
1763 this.wasDisabled = isDisabled;
1764
1765 return this;
1766 };
1767
1768 /**
1769 * Update the disabled state, in case of changes in parent widget.
1770 *
1771 * @chainable
1772 * @return {OO.ui.Widget} The widget, for chaining
1773 */
1774 OO.ui.Widget.prototype.updateDisabled = function () {
1775 this.setDisabled( this.disabled );
1776 return this;
1777 };
1778
1779 /**
1780 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1781 * value.
1782 *
1783 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1784 * instead.
1785 *
1786 * @return {string|null} The ID of the labelable element
1787 */
1788 OO.ui.Widget.prototype.getInputId = function () {
1789 return null;
1790 };
1791
1792 /**
1793 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1794 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1795 * override this method to provide intuitive, accessible behavior.
1796 *
1797 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1798 * Individual widgets may override it too.
1799 *
1800 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1801 * directly.
1802 */
1803 OO.ui.Widget.prototype.simulateLabelClick = function () {
1804 };
1805
1806 /**
1807 * Theme logic.
1808 *
1809 * @abstract
1810 * @class
1811 *
1812 * @constructor
1813 */
1814 OO.ui.Theme = function OoUiTheme() {
1815 this.elementClassesQueue = [];
1816 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1817 };
1818
1819 /* Setup */
1820
1821 OO.initClass( OO.ui.Theme );
1822
1823 /* Methods */
1824
1825 /**
1826 * Get a list of classes to be applied to a widget.
1827 *
1828 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1829 * otherwise state transitions will not work properly.
1830 *
1831 * @param {OO.ui.Element} element Element for which to get classes
1832 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1833 */
1834 OO.ui.Theme.prototype.getElementClasses = function () {
1835 return { on: [], off: [] };
1836 };
1837
1838 /**
1839 * Update CSS classes provided by the theme.
1840 *
1841 * For elements with theme logic hooks, this should be called any time there's a state change.
1842 *
1843 * @param {OO.ui.Element} element Element for which to update classes
1844 */
1845 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1846 var $elements = $( [] ),
1847 classes = this.getElementClasses( element );
1848
1849 if ( element.$icon ) {
1850 $elements = $elements.add( element.$icon );
1851 }
1852 if ( element.$indicator ) {
1853 $elements = $elements.add( element.$indicator );
1854 }
1855
1856 $elements
1857 .removeClass( classes.off )
1858 .addClass( classes.on );
1859 };
1860
1861 /**
1862 * @private
1863 */
1864 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1865 var i;
1866 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1867 this.updateElementClasses( this.elementClassesQueue[ i ] );
1868 }
1869 // Clear the queue
1870 this.elementClassesQueue = [];
1871 };
1872
1873 /**
1874 * Queue #updateElementClasses to be called for this element.
1875 *
1876 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1877 * to make them synchronous.
1878 *
1879 * @param {OO.ui.Element} element Element for which to update classes
1880 */
1881 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1882 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1883 // the most common case (this method is often called repeatedly for the same element).
1884 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1885 return;
1886 }
1887 this.elementClassesQueue.push( element );
1888 this.debouncedUpdateQueuedElementClasses();
1889 };
1890
1891 /**
1892 * Get the transition duration in milliseconds for dialogs opening/closing
1893 *
1894 * The dialog should be fully rendered this many milliseconds after the
1895 * ready process has executed.
1896 *
1897 * @return {number} Transition duration in milliseconds
1898 */
1899 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1900 return 0;
1901 };
1902
1903 /**
1904 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1905 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1906 * order in which users will navigate through the focusable elements via the "tab" key.
1907 *
1908 * @example
1909 * // TabIndexedElement is mixed into the ButtonWidget class
1910 * // to provide a tabIndex property.
1911 * var button1 = new OO.ui.ButtonWidget( {
1912 * label: 'fourth',
1913 * tabIndex: 4
1914 * } );
1915 * var button2 = new OO.ui.ButtonWidget( {
1916 * label: 'second',
1917 * tabIndex: 2
1918 * } );
1919 * var button3 = new OO.ui.ButtonWidget( {
1920 * label: 'third',
1921 * tabIndex: 3
1922 * } );
1923 * var button4 = new OO.ui.ButtonWidget( {
1924 * label: 'first',
1925 * tabIndex: 1
1926 * } );
1927 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1928 *
1929 * @abstract
1930 * @class
1931 *
1932 * @constructor
1933 * @param {Object} [config] Configuration options
1934 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1935 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1936 * functionality will be applied to it instead.
1937 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1938 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1939 * to remove the element from the tab-navigation flow.
1940 */
1941 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1942 // Configuration initialization
1943 config = $.extend( { tabIndex: 0 }, config );
1944
1945 // Properties
1946 this.$tabIndexed = null;
1947 this.tabIndex = null;
1948
1949 // Events
1950 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1951
1952 // Initialization
1953 this.setTabIndex( config.tabIndex );
1954 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1955 };
1956
1957 /* Setup */
1958
1959 OO.initClass( OO.ui.mixin.TabIndexedElement );
1960
1961 /* Methods */
1962
1963 /**
1964 * Set the element that should use the tabindex functionality.
1965 *
1966 * This method is used to retarget a tabindex mixin so that its functionality applies
1967 * to the specified element. If an element is currently using the functionality, the mixin’s
1968 * effect on that element is removed before the new element is set up.
1969 *
1970 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1971 * @chainable
1972 * @return {OO.ui.Element} The element, for chaining
1973 */
1974 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1975 var tabIndex = this.tabIndex;
1976 // Remove attributes from old $tabIndexed
1977 this.setTabIndex( null );
1978 // Force update of new $tabIndexed
1979 this.$tabIndexed = $tabIndexed;
1980 this.tabIndex = tabIndex;
1981 return this.updateTabIndex();
1982 };
1983
1984 /**
1985 * Set the value of the tabindex.
1986 *
1987 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1988 * @chainable
1989 * @return {OO.ui.Element} The element, for chaining
1990 */
1991 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1992 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1993
1994 if ( this.tabIndex !== tabIndex ) {
1995 this.tabIndex = tabIndex;
1996 this.updateTabIndex();
1997 }
1998
1999 return this;
2000 };
2001
2002 /**
2003 * Update the `tabindex` attribute, in case of changes to tab index or
2004 * disabled state.
2005 *
2006 * @private
2007 * @chainable
2008 * @return {OO.ui.Element} The element, for chaining
2009 */
2010 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2011 if ( this.$tabIndexed ) {
2012 if ( this.tabIndex !== null ) {
2013 // Do not index over disabled elements
2014 this.$tabIndexed.attr( {
2015 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2016 // Support: ChromeVox and NVDA
2017 // These do not seem to inherit aria-disabled from parent elements
2018 'aria-disabled': this.isDisabled().toString()
2019 } );
2020 } else {
2021 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2022 }
2023 }
2024 return this;
2025 };
2026
2027 /**
2028 * Handle disable events.
2029 *
2030 * @private
2031 * @param {boolean} disabled Element is disabled
2032 */
2033 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2034 this.updateTabIndex();
2035 };
2036
2037 /**
2038 * Get the value of the tabindex.
2039 *
2040 * @return {number|null} Tabindex value
2041 */
2042 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2043 return this.tabIndex;
2044 };
2045
2046 /**
2047 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2048 *
2049 * If the element already has an ID then that is returned, otherwise unique ID is
2050 * generated, set on the element, and returned.
2051 *
2052 * @return {string|null} The ID of the focusable element
2053 */
2054 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2055 var id;
2056
2057 if ( !this.$tabIndexed ) {
2058 return null;
2059 }
2060 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2061 return null;
2062 }
2063
2064 id = this.$tabIndexed.attr( 'id' );
2065 if ( id === undefined ) {
2066 id = OO.ui.generateElementId();
2067 this.$tabIndexed.attr( 'id', id );
2068 }
2069
2070 return id;
2071 };
2072
2073 /**
2074 * Whether the node is 'labelable' according to the HTML spec
2075 * (i.e., whether it can be interacted with through a `<label for="…">`).
2076 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2077 *
2078 * @private
2079 * @param {jQuery} $node
2080 * @return {boolean}
2081 */
2082 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2083 var
2084 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2085 tagName = $node.prop( 'tagName' ).toLowerCase();
2086
2087 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2088 return true;
2089 }
2090 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2091 return true;
2092 }
2093 return false;
2094 };
2095
2096 /**
2097 * Focus this element.
2098 *
2099 * @chainable
2100 * @return {OO.ui.Element} The element, for chaining
2101 */
2102 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2103 if ( !this.isDisabled() ) {
2104 this.$tabIndexed.focus();
2105 }
2106 return this;
2107 };
2108
2109 /**
2110 * Blur this element.
2111 *
2112 * @chainable
2113 * @return {OO.ui.Element} The element, for chaining
2114 */
2115 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2116 this.$tabIndexed.blur();
2117 return this;
2118 };
2119
2120 /**
2121 * @inheritdoc OO.ui.Widget
2122 */
2123 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2124 this.focus();
2125 };
2126
2127 /**
2128 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2129 * interface element that can be configured with access keys for accessibility.
2130 * See the [OOUI documentation on MediaWiki] [1] for examples.
2131 *
2132 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2133 *
2134 * @abstract
2135 * @class
2136 *
2137 * @constructor
2138 * @param {Object} [config] Configuration options
2139 * @cfg {jQuery} [$button] The button element created by the class.
2140 * If this configuration is omitted, the button element will use a generated `<a>`.
2141 * @cfg {boolean} [framed=true] Render the button with a frame
2142 */
2143 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2144 // Configuration initialization
2145 config = config || {};
2146
2147 // Properties
2148 this.$button = null;
2149 this.framed = null;
2150 this.active = config.active !== undefined && config.active;
2151 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2152 this.onMouseDownHandler = this.onMouseDown.bind( this );
2153 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2154 this.onKeyDownHandler = this.onKeyDown.bind( this );
2155 this.onClickHandler = this.onClick.bind( this );
2156 this.onKeyPressHandler = this.onKeyPress.bind( this );
2157
2158 // Initialization
2159 this.$element.addClass( 'oo-ui-buttonElement' );
2160 this.toggleFramed( config.framed === undefined || config.framed );
2161 this.setButtonElement( config.$button || $( '<a>' ) );
2162 };
2163
2164 /* Setup */
2165
2166 OO.initClass( OO.ui.mixin.ButtonElement );
2167
2168 /* Static Properties */
2169
2170 /**
2171 * Cancel mouse down events.
2172 *
2173 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2174 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2175 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2176 * parent widget.
2177 *
2178 * @static
2179 * @inheritable
2180 * @property {boolean}
2181 */
2182 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2183
2184 /* Events */
2185
2186 /**
2187 * A 'click' event is emitted when the button element is clicked.
2188 *
2189 * @event click
2190 */
2191
2192 /* Methods */
2193
2194 /**
2195 * Set the button element.
2196 *
2197 * This method is used to retarget a button mixin so that its functionality applies to
2198 * the specified button element instead of the one created by the class. If a button element
2199 * is already set, the method will remove the mixin’s effect on that element.
2200 *
2201 * @param {jQuery} $button Element to use as button
2202 */
2203 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2204 if ( this.$button ) {
2205 this.$button
2206 .removeClass( 'oo-ui-buttonElement-button' )
2207 .removeAttr( 'role accesskey' )
2208 .off( {
2209 mousedown: this.onMouseDownHandler,
2210 keydown: this.onKeyDownHandler,
2211 click: this.onClickHandler,
2212 keypress: this.onKeyPressHandler
2213 } );
2214 }
2215
2216 this.$button = $button
2217 .addClass( 'oo-ui-buttonElement-button' )
2218 .on( {
2219 mousedown: this.onMouseDownHandler,
2220 keydown: this.onKeyDownHandler,
2221 click: this.onClickHandler,
2222 keypress: this.onKeyPressHandler
2223 } );
2224
2225 // Add `role="button"` on `<a>` elements, where it's needed
2226 // `toUpperCase()` is added for XHTML documents
2227 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2228 this.$button.attr( 'role', 'button' );
2229 }
2230 };
2231
2232 /**
2233 * Handles mouse down events.
2234 *
2235 * @protected
2236 * @param {jQuery.Event} e Mouse down event
2237 * @return {undefined/boolean} False to prevent default if event is handled
2238 */
2239 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2240 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2241 return;
2242 }
2243 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2244 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2245 // reliably remove the pressed class
2246 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2247 // Prevent change of focus unless specifically configured otherwise
2248 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2249 return false;
2250 }
2251 };
2252
2253 /**
2254 * Handles document mouse up events.
2255 *
2256 * @protected
2257 * @param {MouseEvent} e Mouse up event
2258 */
2259 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2260 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2261 return;
2262 }
2263 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2264 // Stop listening for mouseup, since we only needed this once
2265 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2266 };
2267
2268 // Deprecated alias since 0.28.3
2269 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function () {
2270 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
2271 this.onDocumentMouseUp.apply( this, arguments );
2272 };
2273
2274 /**
2275 * Handles mouse click events.
2276 *
2277 * @protected
2278 * @param {jQuery.Event} e Mouse click event
2279 * @fires click
2280 * @return {undefined/boolean} False to prevent default if event is handled
2281 */
2282 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2283 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2284 if ( this.emit( 'click' ) ) {
2285 return false;
2286 }
2287 }
2288 };
2289
2290 /**
2291 * Handles key down events.
2292 *
2293 * @protected
2294 * @param {jQuery.Event} e Key down event
2295 */
2296 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2297 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2298 return;
2299 }
2300 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2301 // Run the keyup handler no matter where the key is when the button is let go, so we can
2302 // reliably remove the pressed class
2303 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2304 };
2305
2306 /**
2307 * Handles document key up events.
2308 *
2309 * @protected
2310 * @param {KeyboardEvent} e Key up event
2311 */
2312 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2313 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2314 return;
2315 }
2316 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2317 // Stop listening for keyup, since we only needed this once
2318 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2319 };
2320
2321 // Deprecated alias since 0.28.3
2322 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function () {
2323 OO.ui.warnDeprecation( 'onKeyUp is deprecated, use onDocumentKeyUp instead' );
2324 this.onDocumentKeyUp.apply( this, arguments );
2325 };
2326
2327 /**
2328 * Handles key press events.
2329 *
2330 * @protected
2331 * @param {jQuery.Event} e Key press event
2332 * @fires click
2333 * @return {undefined/boolean} False to prevent default if event is handled
2334 */
2335 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2336 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2337 if ( this.emit( 'click' ) ) {
2338 return false;
2339 }
2340 }
2341 };
2342
2343 /**
2344 * Check if button has a frame.
2345 *
2346 * @return {boolean} Button is framed
2347 */
2348 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2349 return this.framed;
2350 };
2351
2352 /**
2353 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2354 *
2355 * @param {boolean} [framed] Make button framed, omit to toggle
2356 * @chainable
2357 * @return {OO.ui.Element} The element, for chaining
2358 */
2359 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2360 framed = framed === undefined ? !this.framed : !!framed;
2361 if ( framed !== this.framed ) {
2362 this.framed = framed;
2363 this.$element
2364 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2365 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2366 this.updateThemeClasses();
2367 }
2368
2369 return this;
2370 };
2371
2372 /**
2373 * Set the button's active state.
2374 *
2375 * The active state can be set on:
2376 *
2377 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2378 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2379 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2380 *
2381 * @protected
2382 * @param {boolean} value Make button active
2383 * @chainable
2384 * @return {OO.ui.Element} The element, for chaining
2385 */
2386 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2387 this.active = !!value;
2388 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2389 this.updateThemeClasses();
2390 return this;
2391 };
2392
2393 /**
2394 * Check if the button is active
2395 *
2396 * @protected
2397 * @return {boolean} The button is active
2398 */
2399 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2400 return this.active;
2401 };
2402
2403 /**
2404 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2405 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2406 * items from the group is done through the interface the class provides.
2407 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2408 *
2409 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2410 *
2411 * @abstract
2412 * @mixins OO.EmitterList
2413 * @class
2414 *
2415 * @constructor
2416 * @param {Object} [config] Configuration options
2417 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2418 * is omitted, the group element will use a generated `<div>`.
2419 */
2420 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2421 // Configuration initialization
2422 config = config || {};
2423
2424 // Mixin constructors
2425 OO.EmitterList.call( this, config );
2426
2427 // Properties
2428 this.$group = null;
2429
2430 // Initialization
2431 this.setGroupElement( config.$group || $( '<div>' ) );
2432 };
2433
2434 /* Setup */
2435
2436 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2437
2438 /* Events */
2439
2440 /**
2441 * @event change
2442 *
2443 * A change event is emitted when the set of selected items changes.
2444 *
2445 * @param {OO.ui.Element[]} items Items currently in the group
2446 */
2447
2448 /* Methods */
2449
2450 /**
2451 * Set the group element.
2452 *
2453 * If an element is already set, items will be moved to the new element.
2454 *
2455 * @param {jQuery} $group Element to use as group
2456 */
2457 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2458 var i, len;
2459
2460 this.$group = $group;
2461 for ( i = 0, len = this.items.length; i < len; i++ ) {
2462 this.$group.append( this.items[ i ].$element );
2463 }
2464 };
2465
2466 /**
2467 * Find an item by its data.
2468 *
2469 * Only the first item with matching data will be returned. To return all matching items,
2470 * use the #findItemsFromData method.
2471 *
2472 * @param {Object} data Item data to search for
2473 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2474 */
2475 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2476 var i, len, item,
2477 hash = OO.getHash( data );
2478
2479 for ( i = 0, len = this.items.length; i < len; i++ ) {
2480 item = this.items[ i ];
2481 if ( hash === OO.getHash( item.getData() ) ) {
2482 return item;
2483 }
2484 }
2485
2486 return null;
2487 };
2488
2489 /**
2490 * Find items by their data.
2491 *
2492 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2493 *
2494 * @param {Object} data Item data to search for
2495 * @return {OO.ui.Element[]} Items with equivalent data
2496 */
2497 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2498 var i, len, item,
2499 hash = OO.getHash( data ),
2500 items = [];
2501
2502 for ( i = 0, len = this.items.length; i < len; i++ ) {
2503 item = this.items[ i ];
2504 if ( hash === OO.getHash( item.getData() ) ) {
2505 items.push( item );
2506 }
2507 }
2508
2509 return items;
2510 };
2511
2512 /**
2513 * Add items to the group.
2514 *
2515 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2516 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2517 *
2518 * @param {OO.ui.Element[]} items An array of items to add to the group
2519 * @param {number} [index] Index of the insertion point
2520 * @chainable
2521 * @return {OO.ui.Element} The element, for chaining
2522 */
2523 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2524 // Mixin method
2525 OO.EmitterList.prototype.addItems.call( this, items, index );
2526
2527 this.emit( 'change', this.getItems() );
2528 return this;
2529 };
2530
2531 /**
2532 * @inheritdoc
2533 */
2534 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2535 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2536 this.insertItemElements( items, newIndex );
2537
2538 // Mixin method
2539 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2540
2541 return newIndex;
2542 };
2543
2544 /**
2545 * @inheritdoc
2546 */
2547 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2548 item.setElementGroup( this );
2549 this.insertItemElements( item, index );
2550
2551 // Mixin method
2552 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2553
2554 return index;
2555 };
2556
2557 /**
2558 * Insert elements into the group
2559 *
2560 * @private
2561 * @param {OO.ui.Element} itemWidget Item to insert
2562 * @param {number} index Insertion index
2563 */
2564 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2565 if ( index === undefined || index < 0 || index >= this.items.length ) {
2566 this.$group.append( itemWidget.$element );
2567 } else if ( index === 0 ) {
2568 this.$group.prepend( itemWidget.$element );
2569 } else {
2570 this.items[ index ].$element.before( itemWidget.$element );
2571 }
2572 };
2573
2574 /**
2575 * Remove the specified items from a group.
2576 *
2577 * Removed items are detached (not removed) from the DOM so that they may be reused.
2578 * To remove all items from a group, you may wish to use the #clearItems method instead.
2579 *
2580 * @param {OO.ui.Element[]} items An array of items to remove
2581 * @chainable
2582 * @return {OO.ui.Element} The element, for chaining
2583 */
2584 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2585 var i, len, item, index;
2586
2587 // Remove specific items elements
2588 for ( i = 0, len = items.length; i < len; i++ ) {
2589 item = items[ i ];
2590 index = this.items.indexOf( item );
2591 if ( index !== -1 ) {
2592 item.setElementGroup( null );
2593 item.$element.detach();
2594 }
2595 }
2596
2597 // Mixin method
2598 OO.EmitterList.prototype.removeItems.call( this, items );
2599
2600 this.emit( 'change', this.getItems() );
2601 return this;
2602 };
2603
2604 /**
2605 * Clear all items from the group.
2606 *
2607 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2608 * To remove only a subset of items from a group, use the #removeItems method.
2609 *
2610 * @chainable
2611 * @return {OO.ui.Element} The element, for chaining
2612 */
2613 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2614 var i, len;
2615
2616 // Remove all item elements
2617 for ( i = 0, len = this.items.length; i < len; i++ ) {
2618 this.items[ i ].setElementGroup( null );
2619 this.items[ i ].$element.detach();
2620 }
2621
2622 // Mixin method
2623 OO.EmitterList.prototype.clearItems.call( this );
2624
2625 this.emit( 'change', this.getItems() );
2626 return this;
2627 };
2628
2629 /**
2630 * LabelElement is often mixed into other classes to generate a label, which
2631 * helps identify the function of an interface element.
2632 * See the [OOUI documentation on MediaWiki] [1] for more information.
2633 *
2634 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2635 *
2636 * @abstract
2637 * @class
2638 *
2639 * @constructor
2640 * @param {Object} [config] Configuration options
2641 * @cfg {jQuery} [$label] The label element created by the class. If this
2642 * configuration is omitted, the label element will use a generated `<span>`.
2643 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2644 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2645 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2646 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2647 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still accessible
2648 * to screen-readers).
2649 */
2650 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2651 // Configuration initialization
2652 config = config || {};
2653
2654 // Properties
2655 this.$label = null;
2656 this.label = null;
2657 this.invisibleLabel = null;
2658
2659 // Initialization
2660 this.setLabel( config.label || this.constructor.static.label );
2661 this.setLabelElement( config.$label || $( '<span>' ) );
2662 this.setInvisibleLabel( config.invisibleLabel );
2663 };
2664
2665 /* Setup */
2666
2667 OO.initClass( OO.ui.mixin.LabelElement );
2668
2669 /* Events */
2670
2671 /**
2672 * @event labelChange
2673 * @param {string} value
2674 */
2675
2676 /* Static Properties */
2677
2678 /**
2679 * The label text. The label can be specified as a plaintext string, a function that will
2680 * produce a string in the future, or `null` for no label. The static value will
2681 * be overridden if a label is specified with the #label config option.
2682 *
2683 * @static
2684 * @inheritable
2685 * @property {string|Function|null}
2686 */
2687 OO.ui.mixin.LabelElement.static.label = null;
2688
2689 /* Static methods */
2690
2691 /**
2692 * Highlight the first occurrence of the query in the given text
2693 *
2694 * @param {string} text Text
2695 * @param {string} query Query to find
2696 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2697 * @return {jQuery} Text with the first match of the query
2698 * sub-string wrapped in highlighted span
2699 */
2700 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2701 var i, tLen, qLen,
2702 offset = -1,
2703 $result = $( '<span>' );
2704
2705 if ( compare ) {
2706 tLen = text.length;
2707 qLen = query.length;
2708 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2709 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2710 offset = i;
2711 }
2712 }
2713 } else {
2714 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2715 }
2716
2717 if ( !query.length || offset === -1 ) {
2718 $result.text( text );
2719 } else {
2720 $result.append(
2721 document.createTextNode( text.slice( 0, offset ) ),
2722 $( '<span>' )
2723 .addClass( 'oo-ui-labelElement-label-highlight' )
2724 .text( text.slice( offset, offset + query.length ) ),
2725 document.createTextNode( text.slice( offset + query.length ) )
2726 );
2727 }
2728 return $result.contents();
2729 };
2730
2731 /* Methods */
2732
2733 /**
2734 * Set the label element.
2735 *
2736 * If an element is already set, it will be cleaned up before setting up the new element.
2737 *
2738 * @param {jQuery} $label Element to use as label
2739 */
2740 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2741 if ( this.$label ) {
2742 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2743 }
2744
2745 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2746 this.setLabelContent( this.label );
2747 };
2748
2749 /**
2750 * Set the label.
2751 *
2752 * An empty string will result in the label being hidden. A string containing only whitespace will
2753 * be converted to a single `&nbsp;`.
2754 *
2755 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
2756 * text; or null for no label
2757 * @chainable
2758 * @return {OO.ui.Element} The element, for chaining
2759 */
2760 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2761 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2762 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2763
2764 if ( this.label !== label ) {
2765 if ( this.$label ) {
2766 this.setLabelContent( label );
2767 }
2768 this.label = label;
2769 this.emit( 'labelChange' );
2770 }
2771
2772 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2773
2774 return this;
2775 };
2776
2777 /**
2778 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2779 *
2780 * @param {boolean} invisibleLabel
2781 * @chainable
2782 * @return {OO.ui.Element} The element, for chaining
2783 */
2784 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2785 invisibleLabel = !!invisibleLabel;
2786
2787 if ( this.invisibleLabel !== invisibleLabel ) {
2788 this.invisibleLabel = invisibleLabel;
2789 this.emit( 'labelChange' );
2790 }
2791
2792 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2793 // Pretend that there is no label, a lot of CSS has been written with this assumption
2794 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2795
2796 return this;
2797 };
2798
2799 /**
2800 * Set the label as plain text with a highlighted query
2801 *
2802 * @param {string} text Text label to set
2803 * @param {string} query Substring of text to highlight
2804 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2805 * @chainable
2806 * @return {OO.ui.Element} The element, for chaining
2807 */
2808 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2809 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2810 };
2811
2812 /**
2813 * Get the label.
2814 *
2815 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2816 * text; or null for no label
2817 */
2818 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2819 return this.label;
2820 };
2821
2822 /**
2823 * Set the content of the label.
2824 *
2825 * Do not call this method until after the label element has been set by #setLabelElement.
2826 *
2827 * @private
2828 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2829 * text; or null for no label
2830 */
2831 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2832 if ( typeof label === 'string' ) {
2833 if ( label.match( /^\s*$/ ) ) {
2834 // Convert whitespace only string to a single non-breaking space
2835 this.$label.html( '&nbsp;' );
2836 } else {
2837 this.$label.text( label );
2838 }
2839 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2840 this.$label.html( label.toString() );
2841 } else if ( label instanceof $ ) {
2842 this.$label.empty().append( label );
2843 } else {
2844 this.$label.empty();
2845 }
2846 };
2847
2848 /**
2849 * IconElement is often mixed into other classes to generate an icon.
2850 * Icons are graphics, about the size of normal text. They are used to aid the user
2851 * in locating a control or to convey information in a space-efficient way. See the
2852 * [OOUI documentation on MediaWiki] [1] for a list of icons
2853 * included in the library.
2854 *
2855 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2856 *
2857 * @abstract
2858 * @class
2859 *
2860 * @constructor
2861 * @param {Object} [config] Configuration options
2862 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2863 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2864 * the icon element be set to an existing icon instead of the one generated by this class, set a
2865 * value using a jQuery selection. For example:
2866 *
2867 * // Use a <div> tag instead of a <span>
2868 * $icon: $("<div>")
2869 * // Use an existing icon element instead of the one generated by the class
2870 * $icon: this.$element
2871 * // Use an icon element from a child widget
2872 * $icon: this.childwidget.$element
2873 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2874 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2875 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2876 * by the user's language.
2877 *
2878 * Example of an i18n map:
2879 *
2880 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2881 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2882 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2883 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2884 * text. The icon title is displayed when users move the mouse over the icon.
2885 */
2886 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2887 // Configuration initialization
2888 config = config || {};
2889
2890 // Properties
2891 this.$icon = null;
2892 this.icon = null;
2893 this.iconTitle = null;
2894
2895 // Initialization
2896 this.setIcon( config.icon || this.constructor.static.icon );
2897 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2898 this.setIconElement( config.$icon || $( '<span>' ) );
2899 };
2900
2901 /* Setup */
2902
2903 OO.initClass( OO.ui.mixin.IconElement );
2904
2905 /* Static Properties */
2906
2907 /**
2908 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2909 * for i18n purposes and contains a `default` icon name and additional names keyed by
2910 * language code. The `default` name is used when no icon is keyed by the user's language.
2911 *
2912 * Example of an i18n map:
2913 *
2914 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2915 *
2916 * Note: the static property will be overridden if the #icon configuration is used.
2917 *
2918 * @static
2919 * @inheritable
2920 * @property {Object|string}
2921 */
2922 OO.ui.mixin.IconElement.static.icon = null;
2923
2924 /**
2925 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2926 * function that returns title text, or `null` for no title.
2927 *
2928 * The static property will be overridden if the #iconTitle configuration is used.
2929 *
2930 * @static
2931 * @inheritable
2932 * @property {string|Function|null}
2933 */
2934 OO.ui.mixin.IconElement.static.iconTitle = null;
2935
2936 /* Methods */
2937
2938 /**
2939 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2940 * applies to the specified icon element instead of the one created by the class. If an icon
2941 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2942 * and mixin methods will no longer affect the element.
2943 *
2944 * @param {jQuery} $icon Element to use as icon
2945 */
2946 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2947 if ( this.$icon ) {
2948 this.$icon
2949 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2950 .removeAttr( 'title' );
2951 }
2952
2953 this.$icon = $icon
2954 .addClass( 'oo-ui-iconElement-icon' )
2955 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2956 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2957 if ( this.iconTitle !== null ) {
2958 this.$icon.attr( 'title', this.iconTitle );
2959 }
2960
2961 this.updateThemeClasses();
2962 };
2963
2964 /**
2965 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2966 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2967 * for an example.
2968 *
2969 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2970 * by language code, or `null` to remove the icon.
2971 * @chainable
2972 * @return {OO.ui.Element} The element, for chaining
2973 */
2974 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2975 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2976 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2977
2978 if ( this.icon !== icon ) {
2979 if ( this.$icon ) {
2980 if ( this.icon !== null ) {
2981 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2982 }
2983 if ( icon !== null ) {
2984 this.$icon.addClass( 'oo-ui-icon-' + icon );
2985 }
2986 }
2987 this.icon = icon;
2988 }
2989
2990 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2991 if ( this.$icon ) {
2992 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2993 }
2994 this.updateThemeClasses();
2995
2996 return this;
2997 };
2998
2999 /**
3000 * Set the icon title. Use `null` to remove the title.
3001 *
3002 * @param {string|Function|null} iconTitle A text string used as the icon title,
3003 * a function that returns title text, or `null` for no title.
3004 * @chainable
3005 * @return {OO.ui.Element} The element, for chaining
3006 */
3007 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
3008 iconTitle =
3009 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
3010 OO.ui.resolveMsg( iconTitle ) : null;
3011
3012 if ( this.iconTitle !== iconTitle ) {
3013 this.iconTitle = iconTitle;
3014 if ( this.$icon ) {
3015 if ( this.iconTitle !== null ) {
3016 this.$icon.attr( 'title', iconTitle );
3017 } else {
3018 this.$icon.removeAttr( 'title' );
3019 }
3020 }
3021 }
3022
3023 return this;
3024 };
3025
3026 /**
3027 * Get the symbolic name of the icon.
3028 *
3029 * @return {string} Icon name
3030 */
3031 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3032 return this.icon;
3033 };
3034
3035 /**
3036 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3037 *
3038 * @return {string} Icon title text
3039 */
3040 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3041 return this.iconTitle;
3042 };
3043
3044 /**
3045 * IndicatorElement is often mixed into other classes to generate an indicator.
3046 * Indicators are small graphics that are generally used in two ways:
3047 *
3048 * - To draw attention to the status of an item. For example, an indicator might be
3049 * used to show that an item in a list has errors that need to be resolved.
3050 * - To clarify the function of a control that acts in an exceptional way (a button
3051 * that opens a menu instead of performing an action directly, for example).
3052 *
3053 * For a list of indicators included in the library, please see the
3054 * [OOUI documentation on MediaWiki] [1].
3055 *
3056 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3057 *
3058 * @abstract
3059 * @class
3060 *
3061 * @constructor
3062 * @param {Object} [config] Configuration options
3063 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3064 * configuration is omitted, the indicator element will use a generated `<span>`.
3065 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3066 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3067 * in the library.
3068 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3069 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
3070 * or a function that returns title text. The indicator title is displayed when users move
3071 * the mouse over the indicator.
3072 */
3073 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3074 // Configuration initialization
3075 config = config || {};
3076
3077 // Properties
3078 this.$indicator = null;
3079 this.indicator = null;
3080 this.indicatorTitle = null;
3081
3082 // Initialization
3083 this.setIndicator( config.indicator || this.constructor.static.indicator );
3084 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
3085 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3086 };
3087
3088 /* Setup */
3089
3090 OO.initClass( OO.ui.mixin.IndicatorElement );
3091
3092 /* Static Properties */
3093
3094 /**
3095 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3096 * The static property will be overridden if the #indicator configuration is used.
3097 *
3098 * @static
3099 * @inheritable
3100 * @property {string|null}
3101 */
3102 OO.ui.mixin.IndicatorElement.static.indicator = null;
3103
3104 /**
3105 * A text string used as the indicator title, a function that returns title text, or `null`
3106 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
3107 *
3108 * @static
3109 * @inheritable
3110 * @property {string|Function|null}
3111 */
3112 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3113
3114 /* Methods */
3115
3116 /**
3117 * Set the indicator element.
3118 *
3119 * If an element is already set, it will be cleaned up before setting up the new element.
3120 *
3121 * @param {jQuery} $indicator Element to use as indicator
3122 */
3123 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3124 if ( this.$indicator ) {
3125 this.$indicator
3126 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3127 .removeAttr( 'title' );
3128 }
3129
3130 this.$indicator = $indicator
3131 .addClass( 'oo-ui-indicatorElement-indicator' )
3132 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3133 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3134 if ( this.indicatorTitle !== null ) {
3135 this.$indicator.attr( 'title', this.indicatorTitle );
3136 }
3137
3138 this.updateThemeClasses();
3139 };
3140
3141 /**
3142 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
3143 *
3144 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3145 * @chainable
3146 * @return {OO.ui.Element} The element, for chaining
3147 */
3148 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3149 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3150
3151 if ( this.indicator !== indicator ) {
3152 if ( this.$indicator ) {
3153 if ( this.indicator !== null ) {
3154 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3155 }
3156 if ( indicator !== null ) {
3157 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3158 }
3159 }
3160 this.indicator = indicator;
3161 }
3162
3163 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3164 if ( this.$indicator ) {
3165 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3166 }
3167 this.updateThemeClasses();
3168
3169 return this;
3170 };
3171
3172 /**
3173 * Set the indicator title.
3174 *
3175 * The title is displayed when a user moves the mouse over the indicator.
3176 *
3177 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
3178 * `null` for no indicator title
3179 * @chainable
3180 * @return {OO.ui.Element} The element, for chaining
3181 */
3182 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
3183 indicatorTitle =
3184 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
3185 OO.ui.resolveMsg( indicatorTitle ) : null;
3186
3187 if ( this.indicatorTitle !== indicatorTitle ) {
3188 this.indicatorTitle = indicatorTitle;
3189 if ( this.$indicator ) {
3190 if ( this.indicatorTitle !== null ) {
3191 this.$indicator.attr( 'title', indicatorTitle );
3192 } else {
3193 this.$indicator.removeAttr( 'title' );
3194 }
3195 }
3196 }
3197
3198 return this;
3199 };
3200
3201 /**
3202 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3203 *
3204 * @return {string} Symbolic name of indicator
3205 */
3206 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3207 return this.indicator;
3208 };
3209
3210 /**
3211 * Get the indicator title.
3212 *
3213 * The title is displayed when a user moves the mouse over the indicator.
3214 *
3215 * @return {string} Indicator title text
3216 */
3217 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3218 return this.indicatorTitle;
3219 };
3220
3221 /**
3222 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3223 * additional functionality to an element created by another class. The class provides
3224 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3225 * which are used to customize the look and feel of a widget to better describe its
3226 * importance and functionality.
3227 *
3228 * The library currently contains the following styling flags for general use:
3229 *
3230 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3231 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3232 *
3233 * The flags affect the appearance of the buttons:
3234 *
3235 * @example
3236 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3237 * var button1 = new OO.ui.ButtonWidget( {
3238 * label: 'Progressive',
3239 * flags: 'progressive'
3240 * } );
3241 * var button2 = new OO.ui.ButtonWidget( {
3242 * label: 'Destructive',
3243 * flags: 'destructive'
3244 * } );
3245 * $( 'body' ).append( button1.$element, button2.$element );
3246 *
3247 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3248 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3249 *
3250 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3251 *
3252 * @abstract
3253 * @class
3254 *
3255 * @constructor
3256 * @param {Object} [config] Configuration options
3257 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3258 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3259 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3260 * @cfg {jQuery} [$flagged] The flagged element. By default,
3261 * the flagged functionality is applied to the element created by the class ($element).
3262 * If a different element is specified, the flagged functionality will be applied to it instead.
3263 */
3264 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3265 // Configuration initialization
3266 config = config || {};
3267
3268 // Properties
3269 this.flags = {};
3270 this.$flagged = null;
3271
3272 // Initialization
3273 this.setFlags( config.flags );
3274 this.setFlaggedElement( config.$flagged || this.$element );
3275 };
3276
3277 /* Events */
3278
3279 /**
3280 * @event flag
3281 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3282 * parameter contains the name of each modified flag and indicates whether it was
3283 * added or removed.
3284 *
3285 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3286 * that the flag was added, `false` that the flag was removed.
3287 */
3288
3289 /* Methods */
3290
3291 /**
3292 * Set the flagged element.
3293 *
3294 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3295 * If an element is already set, the method will remove the mixin’s effect on that element.
3296 *
3297 * @param {jQuery} $flagged Element that should be flagged
3298 */
3299 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3300 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3301 return 'oo-ui-flaggedElement-' + flag;
3302 } );
3303
3304 if ( this.$flagged ) {
3305 this.$flagged.removeClass( classNames );
3306 }
3307
3308 this.$flagged = $flagged.addClass( classNames );
3309 };
3310
3311 /**
3312 * Check if the specified flag is set.
3313 *
3314 * @param {string} flag Name of flag
3315 * @return {boolean} The flag is set
3316 */
3317 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3318 // This may be called before the constructor, thus before this.flags is set
3319 return this.flags && ( flag in this.flags );
3320 };
3321
3322 /**
3323 * Get the names of all flags set.
3324 *
3325 * @return {string[]} Flag names
3326 */
3327 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3328 // This may be called before the constructor, thus before this.flags is set
3329 return Object.keys( this.flags || {} );
3330 };
3331
3332 /**
3333 * Clear all flags.
3334 *
3335 * @chainable
3336 * @return {OO.ui.Element} The element, for chaining
3337 * @fires flag
3338 */
3339 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3340 var flag, className,
3341 changes = {},
3342 remove = [],
3343 classPrefix = 'oo-ui-flaggedElement-';
3344
3345 for ( flag in this.flags ) {
3346 className = classPrefix + flag;
3347 changes[ flag ] = false;
3348 delete this.flags[ flag ];
3349 remove.push( className );
3350 }
3351
3352 if ( this.$flagged ) {
3353 this.$flagged.removeClass( remove );
3354 }
3355
3356 this.updateThemeClasses();
3357 this.emit( 'flag', changes );
3358
3359 return this;
3360 };
3361
3362 /**
3363 * Add one or more flags.
3364 *
3365 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3366 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3367 * be added (`true`) or removed (`false`).
3368 * @chainable
3369 * @return {OO.ui.Element} The element, for chaining
3370 * @fires flag
3371 */
3372 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3373 var i, len, flag, className,
3374 changes = {},
3375 add = [],
3376 remove = [],
3377 classPrefix = 'oo-ui-flaggedElement-';
3378
3379 if ( typeof flags === 'string' ) {
3380 className = classPrefix + flags;
3381 // Set
3382 if ( !this.flags[ flags ] ) {
3383 this.flags[ flags ] = true;
3384 add.push( className );
3385 }
3386 } else if ( Array.isArray( flags ) ) {
3387 for ( i = 0, len = flags.length; i < len; i++ ) {
3388 flag = flags[ i ];
3389 className = classPrefix + flag;
3390 // Set
3391 if ( !this.flags[ flag ] ) {
3392 changes[ flag ] = true;
3393 this.flags[ flag ] = true;
3394 add.push( className );
3395 }
3396 }
3397 } else if ( OO.isPlainObject( flags ) ) {
3398 for ( flag in flags ) {
3399 className = classPrefix + flag;
3400 if ( flags[ flag ] ) {
3401 // Set
3402 if ( !this.flags[ flag ] ) {
3403 changes[ flag ] = true;
3404 this.flags[ flag ] = true;
3405 add.push( className );
3406 }
3407 } else {
3408 // Remove
3409 if ( this.flags[ flag ] ) {
3410 changes[ flag ] = false;
3411 delete this.flags[ flag ];
3412 remove.push( className );
3413 }
3414 }
3415 }
3416 }
3417
3418 if ( this.$flagged ) {
3419 this.$flagged
3420 .addClass( add )
3421 .removeClass( remove );
3422 }
3423
3424 this.updateThemeClasses();
3425 this.emit( 'flag', changes );
3426
3427 return this;
3428 };
3429
3430 /**
3431 * TitledElement is mixed into other classes to provide a `title` attribute.
3432 * Titles are rendered by the browser and are made visible when the user moves
3433 * the mouse over the element. Titles are not visible on touch devices.
3434 *
3435 * @example
3436 * // TitledElement provides a 'title' attribute to the
3437 * // ButtonWidget class
3438 * var button = new OO.ui.ButtonWidget( {
3439 * label: 'Button with Title',
3440 * title: 'I am a button'
3441 * } );
3442 * $( 'body' ).append( button.$element );
3443 *
3444 * @abstract
3445 * @class
3446 *
3447 * @constructor
3448 * @param {Object} [config] Configuration options
3449 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3450 * If this config is omitted, the title functionality is applied to $element, the
3451 * element created by the class.
3452 * @cfg {string|Function} [title] The title text or a function that returns text. If
3453 * this config is omitted, the value of the {@link #static-title static title} property is used.
3454 */
3455 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3456 // Configuration initialization
3457 config = config || {};
3458
3459 // Properties
3460 this.$titled = null;
3461 this.title = null;
3462
3463 // Initialization
3464 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3465 this.setTitledElement( config.$titled || this.$element );
3466 };
3467
3468 /* Setup */
3469
3470 OO.initClass( OO.ui.mixin.TitledElement );
3471
3472 /* Static Properties */
3473
3474 /**
3475 * The title text, a function that returns text, or `null` for no title. The value of the static property
3476 * is overridden if the #title config option is used.
3477 *
3478 * @static
3479 * @inheritable
3480 * @property {string|Function|null}
3481 */
3482 OO.ui.mixin.TitledElement.static.title = null;
3483
3484 /* Methods */
3485
3486 /**
3487 * Set the titled element.
3488 *
3489 * This method is used to retarget a TitledElement mixin so that its functionality applies to the specified element.
3490 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3491 *
3492 * @param {jQuery} $titled Element that should use the 'titled' functionality
3493 */
3494 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3495 if ( this.$titled ) {
3496 this.$titled.removeAttr( 'title' );
3497 }
3498
3499 this.$titled = $titled;
3500 if ( this.title ) {
3501 this.updateTitle();
3502 }
3503 };
3504
3505 /**
3506 * Set title.
3507 *
3508 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3509 * @chainable
3510 * @return {OO.ui.Element} The element, for chaining
3511 */
3512 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3513 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3514 title = ( typeof title === 'string' && title.length ) ? title : null;
3515
3516 if ( this.title !== title ) {
3517 this.title = title;
3518 this.updateTitle();
3519 }
3520
3521 return this;
3522 };
3523
3524 /**
3525 * Update the title attribute, in case of changes to title or accessKey.
3526 *
3527 * @protected
3528 * @chainable
3529 * @return {OO.ui.Element} The element, for chaining
3530 */
3531 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3532 var title = this.getTitle();
3533 if ( this.$titled ) {
3534 if ( title !== null ) {
3535 // Only if this is an AccessKeyedElement
3536 if ( this.formatTitleWithAccessKey ) {
3537 title = this.formatTitleWithAccessKey( title );
3538 }
3539 this.$titled.attr( 'title', title );
3540 } else {
3541 this.$titled.removeAttr( 'title' );
3542 }
3543 }
3544 return this;
3545 };
3546
3547 /**
3548 * Get title.
3549 *
3550 * @return {string} Title string
3551 */
3552 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3553 return this.title;
3554 };
3555
3556 /**
3557 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3558 * Accesskeys allow an user to go to a specific element by using
3559 * a shortcut combination of a browser specific keys + the key
3560 * set to the field.
3561 *
3562 * @example
3563 * // AccessKeyedElement provides an 'accesskey' attribute to the
3564 * // ButtonWidget class
3565 * var button = new OO.ui.ButtonWidget( {
3566 * label: 'Button with Accesskey',
3567 * accessKey: 'k'
3568 * } );
3569 * $( 'body' ).append( button.$element );
3570 *
3571 * @abstract
3572 * @class
3573 *
3574 * @constructor
3575 * @param {Object} [config] Configuration options
3576 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3577 * If this config is omitted, the accesskey functionality is applied to $element, the
3578 * element created by the class.
3579 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3580 * this config is omitted, no accesskey will be added.
3581 */
3582 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3583 // Configuration initialization
3584 config = config || {};
3585
3586 // Properties
3587 this.$accessKeyed = null;
3588 this.accessKey = null;
3589
3590 // Initialization
3591 this.setAccessKey( config.accessKey || null );
3592 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3593
3594 // If this is also a TitledElement and it initialized before we did, we may have
3595 // to update the title with the access key
3596 if ( this.updateTitle ) {
3597 this.updateTitle();
3598 }
3599 };
3600
3601 /* Setup */
3602
3603 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3604
3605 /* Static Properties */
3606
3607 /**
3608 * The access key, a function that returns a key, or `null` for no accesskey.
3609 *
3610 * @static
3611 * @inheritable
3612 * @property {string|Function|null}
3613 */
3614 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3615
3616 /* Methods */
3617
3618 /**
3619 * Set the accesskeyed element.
3620 *
3621 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3622 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3623 *
3624 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyed' functionality
3625 */
3626 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3627 if ( this.$accessKeyed ) {
3628 this.$accessKeyed.removeAttr( 'accesskey' );
3629 }
3630
3631 this.$accessKeyed = $accessKeyed;
3632 if ( this.accessKey ) {
3633 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3634 }
3635 };
3636
3637 /**
3638 * Set accesskey.
3639 *
3640 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3641 * @chainable
3642 * @return {OO.ui.Element} The element, for chaining
3643 */
3644 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3645 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3646
3647 if ( this.accessKey !== accessKey ) {
3648 if ( this.$accessKeyed ) {
3649 if ( accessKey !== null ) {
3650 this.$accessKeyed.attr( 'accesskey', accessKey );
3651 } else {
3652 this.$accessKeyed.removeAttr( 'accesskey' );
3653 }
3654 }
3655 this.accessKey = accessKey;
3656
3657 // Only if this is a TitledElement
3658 if ( this.updateTitle ) {
3659 this.updateTitle();
3660 }
3661 }
3662
3663 return this;
3664 };
3665
3666 /**
3667 * Get accesskey.
3668 *
3669 * @return {string} accessKey string
3670 */
3671 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3672 return this.accessKey;
3673 };
3674
3675 /**
3676 * Add information about the access key to the element's tooltip label.
3677 * (This is only public for hacky usage in FieldLayout.)
3678 *
3679 * @param {string} title Tooltip label for `title` attribute
3680 * @return {string}
3681 */
3682 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3683 var accessKey;
3684
3685 if ( !this.$accessKeyed ) {
3686 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3687 return title;
3688 }
3689 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3690 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3691 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3692 } else {
3693 accessKey = this.getAccessKey();
3694 }
3695 if ( accessKey ) {
3696 title += ' [' + accessKey + ']';
3697 }
3698 return title;
3699 };
3700
3701 /**
3702 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3703 * feels, and functionality can be customized via the class’s configuration options
3704 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3705 * and examples.
3706 *
3707 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3708 *
3709 * @example
3710 * // A button widget
3711 * var button = new OO.ui.ButtonWidget( {
3712 * label: 'Button with Icon',
3713 * icon: 'trash',
3714 * title: 'Remove'
3715 * } );
3716 * $( 'body' ).append( button.$element );
3717 *
3718 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3719 *
3720 * @class
3721 * @extends OO.ui.Widget
3722 * @mixins OO.ui.mixin.ButtonElement
3723 * @mixins OO.ui.mixin.IconElement
3724 * @mixins OO.ui.mixin.IndicatorElement
3725 * @mixins OO.ui.mixin.LabelElement
3726 * @mixins OO.ui.mixin.TitledElement
3727 * @mixins OO.ui.mixin.FlaggedElement
3728 * @mixins OO.ui.mixin.TabIndexedElement
3729 * @mixins OO.ui.mixin.AccessKeyedElement
3730 *
3731 * @constructor
3732 * @param {Object} [config] Configuration options
3733 * @cfg {boolean} [active=false] Whether button should be shown as active
3734 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3735 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3736 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3737 */
3738 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3739 // Configuration initialization
3740 config = config || {};
3741
3742 // Parent constructor
3743 OO.ui.ButtonWidget.parent.call( this, config );
3744
3745 // Mixin constructors
3746 OO.ui.mixin.ButtonElement.call( this, config );
3747 OO.ui.mixin.IconElement.call( this, config );
3748 OO.ui.mixin.IndicatorElement.call( this, config );
3749 OO.ui.mixin.LabelElement.call( this, config );
3750 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3751 OO.ui.mixin.FlaggedElement.call( this, config );
3752 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3753 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3754
3755 // Properties
3756 this.href = null;
3757 this.target = null;
3758 this.noFollow = false;
3759
3760 // Events
3761 this.connect( this, { disable: 'onDisable' } );
3762
3763 // Initialization
3764 this.$button.append( this.$icon, this.$label, this.$indicator );
3765 this.$element
3766 .addClass( 'oo-ui-buttonWidget' )
3767 .append( this.$button );
3768 this.setActive( config.active );
3769 this.setHref( config.href );
3770 this.setTarget( config.target );
3771 this.setNoFollow( config.noFollow );
3772 };
3773
3774 /* Setup */
3775
3776 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3777 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3778 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3779 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3780 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3781 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3782 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3783 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3784 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3785
3786 /* Static Properties */
3787
3788 /**
3789 * @static
3790 * @inheritdoc
3791 */
3792 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3793
3794 /**
3795 * @static
3796 * @inheritdoc
3797 */
3798 OO.ui.ButtonWidget.static.tagName = 'span';
3799
3800 /* Methods */
3801
3802 /**
3803 * Get hyperlink location.
3804 *
3805 * @return {string} Hyperlink location
3806 */
3807 OO.ui.ButtonWidget.prototype.getHref = function () {
3808 return this.href;
3809 };
3810
3811 /**
3812 * Get hyperlink target.
3813 *
3814 * @return {string} Hyperlink target
3815 */
3816 OO.ui.ButtonWidget.prototype.getTarget = function () {
3817 return this.target;
3818 };
3819
3820 /**
3821 * Get search engine traversal hint.
3822 *
3823 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3824 */
3825 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3826 return this.noFollow;
3827 };
3828
3829 /**
3830 * Set hyperlink location.
3831 *
3832 * @param {string|null} href Hyperlink location, null to remove
3833 * @chainable
3834 * @return {OO.ui.Widget} The widget, for chaining
3835 */
3836 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3837 href = typeof href === 'string' ? href : null;
3838 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3839 href = './' + href;
3840 }
3841
3842 if ( href !== this.href ) {
3843 this.href = href;
3844 this.updateHref();
3845 }
3846
3847 return this;
3848 };
3849
3850 /**
3851 * Update the `href` attribute, in case of changes to href or
3852 * disabled state.
3853 *
3854 * @private
3855 * @chainable
3856 * @return {OO.ui.Widget} The widget, for chaining
3857 */
3858 OO.ui.ButtonWidget.prototype.updateHref = function () {
3859 if ( this.href !== null && !this.isDisabled() ) {
3860 this.$button.attr( 'href', this.href );
3861 } else {
3862 this.$button.removeAttr( 'href' );
3863 }
3864
3865 return this;
3866 };
3867
3868 /**
3869 * Handle disable events.
3870 *
3871 * @private
3872 * @param {boolean} disabled Element is disabled
3873 */
3874 OO.ui.ButtonWidget.prototype.onDisable = function () {
3875 this.updateHref();
3876 };
3877
3878 /**
3879 * Set hyperlink target.
3880 *
3881 * @param {string|null} target Hyperlink target, null to remove
3882 * @return {OO.ui.Widget} The widget, for chaining
3883 */
3884 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3885 target = typeof target === 'string' ? target : null;
3886
3887 if ( target !== this.target ) {
3888 this.target = target;
3889 if ( target !== null ) {
3890 this.$button.attr( 'target', target );
3891 } else {
3892 this.$button.removeAttr( 'target' );
3893 }
3894 }
3895
3896 return this;
3897 };
3898
3899 /**
3900 * Set search engine traversal hint.
3901 *
3902 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3903 * @return {OO.ui.Widget} The widget, for chaining
3904 */
3905 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3906 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3907
3908 if ( noFollow !== this.noFollow ) {
3909 this.noFollow = noFollow;
3910 if ( noFollow ) {
3911 this.$button.attr( 'rel', 'nofollow' );
3912 } else {
3913 this.$button.removeAttr( 'rel' );
3914 }
3915 }
3916
3917 return this;
3918 };
3919
3920 // Override method visibility hints from ButtonElement
3921 /**
3922 * @method setActive
3923 * @inheritdoc
3924 */
3925 /**
3926 * @method isActive
3927 * @inheritdoc
3928 */
3929
3930 /**
3931 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3932 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3933 * removed, and cleared from the group.
3934 *
3935 * @example
3936 * // Example: A ButtonGroupWidget with two buttons
3937 * var button1 = new OO.ui.PopupButtonWidget( {
3938 * label: 'Select a category',
3939 * icon: 'menu',
3940 * popup: {
3941 * $content: $( '<p>List of categories...</p>' ),
3942 * padded: true,
3943 * align: 'left'
3944 * }
3945 * } );
3946 * var button2 = new OO.ui.ButtonWidget( {
3947 * label: 'Add item'
3948 * });
3949 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3950 * items: [button1, button2]
3951 * } );
3952 * $( 'body' ).append( buttonGroup.$element );
3953 *
3954 * @class
3955 * @extends OO.ui.Widget
3956 * @mixins OO.ui.mixin.GroupElement
3957 *
3958 * @constructor
3959 * @param {Object} [config] Configuration options
3960 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3961 */
3962 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3963 // Configuration initialization
3964 config = config || {};
3965
3966 // Parent constructor
3967 OO.ui.ButtonGroupWidget.parent.call( this, config );
3968
3969 // Mixin constructors
3970 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3971
3972 // Initialization
3973 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3974 if ( Array.isArray( config.items ) ) {
3975 this.addItems( config.items );
3976 }
3977 };
3978
3979 /* Setup */
3980
3981 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3982 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3983
3984 /* Static Properties */
3985
3986 /**
3987 * @static
3988 * @inheritdoc
3989 */
3990 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3991
3992 /* Methods */
3993
3994 /**
3995 * Focus the widget
3996 *
3997 * @chainable
3998 * @return {OO.ui.Widget} The widget, for chaining
3999 */
4000 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4001 if ( !this.isDisabled() ) {
4002 if ( this.items[ 0 ] ) {
4003 this.items[ 0 ].focus();
4004 }
4005 }
4006 return this;
4007 };
4008
4009 /**
4010 * @inheritdoc
4011 */
4012 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4013 this.focus();
4014 };
4015
4016 /**
4017 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
4018 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4019 * for a list of icons included in the library.
4020 *
4021 * @example
4022 * // An icon widget with a label
4023 * var myIcon = new OO.ui.IconWidget( {
4024 * icon: 'help',
4025 * title: 'Help'
4026 * } );
4027 * // Create a label.
4028 * var iconLabel = new OO.ui.LabelWidget( {
4029 * label: 'Help'
4030 * } );
4031 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
4032 *
4033 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4034 *
4035 * @class
4036 * @extends OO.ui.Widget
4037 * @mixins OO.ui.mixin.IconElement
4038 * @mixins OO.ui.mixin.TitledElement
4039 * @mixins OO.ui.mixin.LabelElement
4040 * @mixins OO.ui.mixin.FlaggedElement
4041 *
4042 * @constructor
4043 * @param {Object} [config] Configuration options
4044 */
4045 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4046 // Configuration initialization
4047 config = config || {};
4048
4049 // Parent constructor
4050 OO.ui.IconWidget.parent.call( this, config );
4051
4052 // Mixin constructors
4053 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
4054 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4055 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4056 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
4057
4058 // Initialization
4059 this.$element.addClass( 'oo-ui-iconWidget' );
4060 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4061 // nested in other widgets, because this widget used to not mix in LabelElement.
4062 this.$element.removeClass( 'oo-ui-labelElement-label' );
4063 };
4064
4065 /* Setup */
4066
4067 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4068 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4069 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4070 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4071 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4072
4073 /* Static Properties */
4074
4075 /**
4076 * @static
4077 * @inheritdoc
4078 */
4079 OO.ui.IconWidget.static.tagName = 'span';
4080
4081 /**
4082 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4083 * attention to the status of an item or to clarify the function within a control. For a list of
4084 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4085 *
4086 * @example
4087 * // Example of an indicator widget
4088 * var indicator1 = new OO.ui.IndicatorWidget( {
4089 * indicator: 'required'
4090 * } );
4091 *
4092 * // Create a fieldset layout to add a label
4093 * var fieldset = new OO.ui.FieldsetLayout();
4094 * fieldset.addItems( [
4095 * new OO.ui.FieldLayout( indicator1, { label: 'A required indicator:' } )
4096 * ] );
4097 * $( 'body' ).append( fieldset.$element );
4098 *
4099 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4100 *
4101 * @class
4102 * @extends OO.ui.Widget
4103 * @mixins OO.ui.mixin.IndicatorElement
4104 * @mixins OO.ui.mixin.TitledElement
4105 * @mixins OO.ui.mixin.LabelElement
4106 *
4107 * @constructor
4108 * @param {Object} [config] Configuration options
4109 */
4110 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4111 // Configuration initialization
4112 config = config || {};
4113
4114 // Parent constructor
4115 OO.ui.IndicatorWidget.parent.call( this, config );
4116
4117 // Mixin constructors
4118 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4119 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4120 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4121
4122 // Initialization
4123 this.$element.addClass( 'oo-ui-indicatorWidget' );
4124 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4125 // nested in other widgets, because this widget used to not mix in LabelElement.
4126 this.$element.removeClass( 'oo-ui-labelElement-label' );
4127 };
4128
4129 /* Setup */
4130
4131 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4132 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4133 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4134 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4135
4136 /* Static Properties */
4137
4138 /**
4139 * @static
4140 * @inheritdoc
4141 */
4142 OO.ui.IndicatorWidget.static.tagName = 'span';
4143
4144 /**
4145 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4146 * be configured with a `label` option that is set to a string, a label node, or a function:
4147 *
4148 * - String: a plaintext string
4149 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4150 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4151 * - Function: a function that will produce a string in the future. Functions are used
4152 * in cases where the value of the label is not currently defined.
4153 *
4154 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4155 * will come into focus when the label is clicked.
4156 *
4157 * @example
4158 * // Examples of LabelWidgets
4159 * var label1 = new OO.ui.LabelWidget( {
4160 * label: 'plaintext label'
4161 * } );
4162 * var label2 = new OO.ui.LabelWidget( {
4163 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4164 * } );
4165 * // Create a fieldset layout with fields for each example
4166 * var fieldset = new OO.ui.FieldsetLayout();
4167 * fieldset.addItems( [
4168 * new OO.ui.FieldLayout( label1 ),
4169 * new OO.ui.FieldLayout( label2 )
4170 * ] );
4171 * $( 'body' ).append( fieldset.$element );
4172 *
4173 * @class
4174 * @extends OO.ui.Widget
4175 * @mixins OO.ui.mixin.LabelElement
4176 * @mixins OO.ui.mixin.TitledElement
4177 *
4178 * @constructor
4179 * @param {Object} [config] Configuration options
4180 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4181 * Clicking the label will focus the specified input field.
4182 */
4183 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4184 // Configuration initialization
4185 config = config || {};
4186
4187 // Parent constructor
4188 OO.ui.LabelWidget.parent.call( this, config );
4189
4190 // Mixin constructors
4191 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4192 OO.ui.mixin.TitledElement.call( this, config );
4193
4194 // Properties
4195 this.input = config.input;
4196
4197 // Initialization
4198 if ( this.input ) {
4199 if ( this.input.getInputId() ) {
4200 this.$element.attr( 'for', this.input.getInputId() );
4201 } else {
4202 this.$label.on( 'click', function () {
4203 this.input.simulateLabelClick();
4204 }.bind( this ) );
4205 }
4206 }
4207 this.$element.addClass( 'oo-ui-labelWidget' );
4208 };
4209
4210 /* Setup */
4211
4212 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4213 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4214 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4215
4216 /* Static Properties */
4217
4218 /**
4219 * @static
4220 * @inheritdoc
4221 */
4222 OO.ui.LabelWidget.static.tagName = 'label';
4223
4224 /**
4225 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4226 * and that they should wait before proceeding. The pending state is visually represented with a pending
4227 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4228 * field of a {@link OO.ui.TextInputWidget text input widget}.
4229 *
4230 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4231 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4232 * in process dialogs.
4233 *
4234 * @example
4235 * function MessageDialog( config ) {
4236 * MessageDialog.parent.call( this, config );
4237 * }
4238 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4239 *
4240 * MessageDialog.static.name = 'myMessageDialog';
4241 * MessageDialog.static.actions = [
4242 * { action: 'save', label: 'Done', flags: 'primary' },
4243 * { label: 'Cancel', flags: 'safe' }
4244 * ];
4245 *
4246 * MessageDialog.prototype.initialize = function () {
4247 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4248 * this.content = new OO.ui.PanelLayout( { padded: true } );
4249 * 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>' );
4250 * this.$body.append( this.content.$element );
4251 * };
4252 * MessageDialog.prototype.getBodyHeight = function () {
4253 * return 100;
4254 * }
4255 * MessageDialog.prototype.getActionProcess = function ( action ) {
4256 * var dialog = this;
4257 * if ( action === 'save' ) {
4258 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4259 * return new OO.ui.Process()
4260 * .next( 1000 )
4261 * .next( function () {
4262 * dialog.getActions().get({actions: 'save'})[0].popPending();
4263 * } );
4264 * }
4265 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4266 * };
4267 *
4268 * var windowManager = new OO.ui.WindowManager();
4269 * $( 'body' ).append( windowManager.$element );
4270 *
4271 * var dialog = new MessageDialog();
4272 * windowManager.addWindows( [ dialog ] );
4273 * windowManager.openWindow( dialog );
4274 *
4275 * @abstract
4276 * @class
4277 *
4278 * @constructor
4279 * @param {Object} [config] Configuration options
4280 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4281 */
4282 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4283 // Configuration initialization
4284 config = config || {};
4285
4286 // Properties
4287 this.pending = 0;
4288 this.$pending = null;
4289
4290 // Initialisation
4291 this.setPendingElement( config.$pending || this.$element );
4292 };
4293
4294 /* Setup */
4295
4296 OO.initClass( OO.ui.mixin.PendingElement );
4297
4298 /* Methods */
4299
4300 /**
4301 * Set the pending element (and clean up any existing one).
4302 *
4303 * @param {jQuery} $pending The element to set to pending.
4304 */
4305 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4306 if ( this.$pending ) {
4307 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4308 }
4309
4310 this.$pending = $pending;
4311 if ( this.pending > 0 ) {
4312 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4313 }
4314 };
4315
4316 /**
4317 * Check if an element is pending.
4318 *
4319 * @return {boolean} Element is pending
4320 */
4321 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4322 return !!this.pending;
4323 };
4324
4325 /**
4326 * Increase the pending counter. The pending state will remain active until the counter is zero
4327 * (i.e., the number of calls to #pushPending and #popPending is the same).
4328 *
4329 * @chainable
4330 * @return {OO.ui.Element} The element, for chaining
4331 */
4332 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4333 if ( this.pending === 0 ) {
4334 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4335 this.updateThemeClasses();
4336 }
4337 this.pending++;
4338
4339 return this;
4340 };
4341
4342 /**
4343 * Decrease the pending counter. The pending state will remain active until the counter is zero
4344 * (i.e., the number of calls to #pushPending and #popPending is the same).
4345 *
4346 * @chainable
4347 * @return {OO.ui.Element} The element, for chaining
4348 */
4349 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4350 if ( this.pending === 1 ) {
4351 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4352 this.updateThemeClasses();
4353 }
4354 this.pending = Math.max( 0, this.pending - 1 );
4355
4356 return this;
4357 };
4358
4359 /**
4360 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4361 * in the document (for example, in an OO.ui.Window's $overlay).
4362 *
4363 * The elements's position is automatically calculated and maintained when window is resized or the
4364 * page is scrolled. If you reposition the container manually, you have to call #position to make
4365 * sure the element is still placed correctly.
4366 *
4367 * As positioning is only possible when both the element and the container are attached to the DOM
4368 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4369 * the #toggle method to display a floating popup, for example.
4370 *
4371 * @abstract
4372 * @class
4373 *
4374 * @constructor
4375 * @param {Object} [config] Configuration options
4376 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4377 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4378 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4379 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4380 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4381 * 'top': Align the top edge with $floatableContainer's top edge
4382 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4383 * 'center': Vertically align the center with $floatableContainer's center
4384 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4385 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4386 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4387 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4388 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4389 * 'center': Horizontally align the center with $floatableContainer's center
4390 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4391 * is out of view
4392 */
4393 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4394 // Configuration initialization
4395 config = config || {};
4396
4397 // Properties
4398 this.$floatable = null;
4399 this.$floatableContainer = null;
4400 this.$floatableWindow = null;
4401 this.$floatableClosestScrollable = null;
4402 this.floatableOutOfView = false;
4403 this.onFloatableScrollHandler = this.position.bind( this );
4404 this.onFloatableWindowResizeHandler = this.position.bind( this );
4405
4406 // Initialization
4407 this.setFloatableContainer( config.$floatableContainer );
4408 this.setFloatableElement( config.$floatable || this.$element );
4409 this.setVerticalPosition( config.verticalPosition || 'below' );
4410 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4411 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4412 };
4413
4414 /* Methods */
4415
4416 /**
4417 * Set floatable element.
4418 *
4419 * If an element is already set, it will be cleaned up before setting up the new element.
4420 *
4421 * @param {jQuery} $floatable Element to make floatable
4422 */
4423 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4424 if ( this.$floatable ) {
4425 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4426 this.$floatable.css( { left: '', top: '' } );
4427 }
4428
4429 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4430 this.position();
4431 };
4432
4433 /**
4434 * Set floatable container.
4435 *
4436 * The element will be positioned relative to the specified container.
4437 *
4438 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4439 */
4440 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4441 this.$floatableContainer = $floatableContainer;
4442 if ( this.$floatable ) {
4443 this.position();
4444 }
4445 };
4446
4447 /**
4448 * Change how the element is positioned vertically.
4449 *
4450 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4451 */
4452 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4453 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4454 throw new Error( 'Invalid value for vertical position: ' + position );
4455 }
4456 if ( this.verticalPosition !== position ) {
4457 this.verticalPosition = position;
4458 if ( this.$floatable ) {
4459 this.position();
4460 }
4461 }
4462 };
4463
4464 /**
4465 * Change how the element is positioned horizontally.
4466 *
4467 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4468 */
4469 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4470 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4471 throw new Error( 'Invalid value for horizontal position: ' + position );
4472 }
4473 if ( this.horizontalPosition !== position ) {
4474 this.horizontalPosition = position;
4475 if ( this.$floatable ) {
4476 this.position();
4477 }
4478 }
4479 };
4480
4481 /**
4482 * Toggle positioning.
4483 *
4484 * Do not turn positioning on until after the element is attached to the DOM and visible.
4485 *
4486 * @param {boolean} [positioning] Enable positioning, omit to toggle
4487 * @chainable
4488 * @return {OO.ui.Element} The element, for chaining
4489 */
4490 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4491 var closestScrollableOfContainer;
4492
4493 if ( !this.$floatable || !this.$floatableContainer ) {
4494 return this;
4495 }
4496
4497 positioning = positioning === undefined ? !this.positioning : !!positioning;
4498
4499 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4500 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4501 this.warnedUnattached = true;
4502 }
4503
4504 if ( this.positioning !== positioning ) {
4505 this.positioning = positioning;
4506
4507 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4508 // If the scrollable is the root, we have to listen to scroll events
4509 // on the window because of browser inconsistencies.
4510 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4511 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4512 }
4513
4514 if ( positioning ) {
4515 this.$floatableWindow = $( this.getElementWindow() );
4516 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4517
4518 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4519 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4520
4521 // Initial position after visible
4522 this.position();
4523 } else {
4524 if ( this.$floatableWindow ) {
4525 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4526 this.$floatableWindow = null;
4527 }
4528
4529 if ( this.$floatableClosestScrollable ) {
4530 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4531 this.$floatableClosestScrollable = null;
4532 }
4533
4534 this.$floatable.css( { left: '', right: '', top: '' } );
4535 }
4536 }
4537
4538 return this;
4539 };
4540
4541 /**
4542 * Check whether the bottom edge of the given element is within the viewport of the given container.
4543 *
4544 * @private
4545 * @param {jQuery} $element
4546 * @param {jQuery} $container
4547 * @return {boolean}
4548 */
4549 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4550 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4551 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4552 direction = $element.css( 'direction' );
4553
4554 elemRect = $element[ 0 ].getBoundingClientRect();
4555 if ( $container[ 0 ] === window ) {
4556 viewportSpacing = OO.ui.getViewportSpacing();
4557 contRect = {
4558 top: 0,
4559 left: 0,
4560 right: document.documentElement.clientWidth,
4561 bottom: document.documentElement.clientHeight
4562 };
4563 contRect.top += viewportSpacing.top;
4564 contRect.left += viewportSpacing.left;
4565 contRect.right -= viewportSpacing.right;
4566 contRect.bottom -= viewportSpacing.bottom;
4567 } else {
4568 contRect = $container[ 0 ].getBoundingClientRect();
4569 }
4570
4571 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4572 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4573 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4574 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4575 if ( direction === 'rtl' ) {
4576 startEdgeInBounds = rightEdgeInBounds;
4577 endEdgeInBounds = leftEdgeInBounds;
4578 } else {
4579 startEdgeInBounds = leftEdgeInBounds;
4580 endEdgeInBounds = rightEdgeInBounds;
4581 }
4582
4583 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4584 return false;
4585 }
4586 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4587 return false;
4588 }
4589 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4590 return false;
4591 }
4592 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4593 return false;
4594 }
4595
4596 // The other positioning values are all about being inside the container,
4597 // so in those cases all we care about is that any part of the container is visible.
4598 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4599 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4600 };
4601
4602 /**
4603 * Check if the floatable is hidden to the user because it was offscreen.
4604 *
4605 * @return {boolean} Floatable is out of view
4606 */
4607 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4608 return this.floatableOutOfView;
4609 };
4610
4611 /**
4612 * Position the floatable below its container.
4613 *
4614 * This should only be done when both of them are attached to the DOM and visible.
4615 *
4616 * @chainable
4617 * @return {OO.ui.Element} The element, for chaining
4618 */
4619 OO.ui.mixin.FloatableElement.prototype.position = function () {
4620 if ( !this.positioning ) {
4621 return this;
4622 }
4623
4624 if ( !(
4625 // To continue, some things need to be true:
4626 // The element must actually be in the DOM
4627 this.isElementAttached() && (
4628 // The closest scrollable is the current window
4629 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4630 // OR is an element in the element's DOM
4631 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4632 )
4633 ) ) {
4634 // Abort early if important parts of the widget are no longer attached to the DOM
4635 return this;
4636 }
4637
4638 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4639 if ( this.floatableOutOfView ) {
4640 this.$floatable.addClass( 'oo-ui-element-hidden' );
4641 return this;
4642 } else {
4643 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4644 }
4645
4646 this.$floatable.css( this.computePosition() );
4647
4648 // We updated the position, so re-evaluate the clipping state.
4649 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4650 // will not notice the need to update itself.)
4651 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4652 // it not listen to the right events in the right places?
4653 if ( this.clip ) {
4654 this.clip();
4655 }
4656
4657 return this;
4658 };
4659
4660 /**
4661 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4662 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4663 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4664 *
4665 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4666 */
4667 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4668 var isBody, scrollableX, scrollableY, containerPos,
4669 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4670 newPos = { top: '', left: '', bottom: '', right: '' },
4671 direction = this.$floatableContainer.css( 'direction' ),
4672 $offsetParent = this.$floatable.offsetParent();
4673
4674 if ( $offsetParent.is( 'html' ) ) {
4675 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4676 // <html> element, but they do work on the <body>
4677 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4678 }
4679 isBody = $offsetParent.is( 'body' );
4680 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4681 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4682
4683 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4684 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4685 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4686 // or if it isn't scrollable
4687 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4688 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4689
4690 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4691 // if the <body> has a margin
4692 containerPos = isBody ?
4693 this.$floatableContainer.offset() :
4694 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4695 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4696 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4697 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4698 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4699
4700 if ( this.verticalPosition === 'below' ) {
4701 newPos.top = containerPos.bottom;
4702 } else if ( this.verticalPosition === 'above' ) {
4703 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4704 } else if ( this.verticalPosition === 'top' ) {
4705 newPos.top = containerPos.top;
4706 } else if ( this.verticalPosition === 'bottom' ) {
4707 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4708 } else if ( this.verticalPosition === 'center' ) {
4709 newPos.top = containerPos.top +
4710 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4711 }
4712
4713 if ( this.horizontalPosition === 'before' ) {
4714 newPos.end = containerPos.start;
4715 } else if ( this.horizontalPosition === 'after' ) {
4716 newPos.start = containerPos.end;
4717 } else if ( this.horizontalPosition === 'start' ) {
4718 newPos.start = containerPos.start;
4719 } else if ( this.horizontalPosition === 'end' ) {
4720 newPos.end = containerPos.end;
4721 } else if ( this.horizontalPosition === 'center' ) {
4722 newPos.left = containerPos.left +
4723 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4724 }
4725
4726 if ( newPos.start !== undefined ) {
4727 if ( direction === 'rtl' ) {
4728 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4729 } else {
4730 newPos.left = newPos.start;
4731 }
4732 delete newPos.start;
4733 }
4734 if ( newPos.end !== undefined ) {
4735 if ( direction === 'rtl' ) {
4736 newPos.left = newPos.end;
4737 } else {
4738 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4739 }
4740 delete newPos.end;
4741 }
4742
4743 // Account for scroll position
4744 if ( newPos.top !== '' ) {
4745 newPos.top += scrollTop;
4746 }
4747 if ( newPos.bottom !== '' ) {
4748 newPos.bottom -= scrollTop;
4749 }
4750 if ( newPos.left !== '' ) {
4751 newPos.left += scrollLeft;
4752 }
4753 if ( newPos.right !== '' ) {
4754 newPos.right -= scrollLeft;
4755 }
4756
4757 // Account for scrollbar gutter
4758 if ( newPos.bottom !== '' ) {
4759 newPos.bottom -= horizScrollbarHeight;
4760 }
4761 if ( direction === 'rtl' ) {
4762 if ( newPos.left !== '' ) {
4763 newPos.left -= vertScrollbarWidth;
4764 }
4765 } else {
4766 if ( newPos.right !== '' ) {
4767 newPos.right -= vertScrollbarWidth;
4768 }
4769 }
4770
4771 return newPos;
4772 };
4773
4774 /**
4775 * Element that can be automatically clipped to visible boundaries.
4776 *
4777 * Whenever the element's natural height changes, you have to call
4778 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4779 * clipping correctly.
4780 *
4781 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4782 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4783 * then #$clippable will be given a fixed reduced height and/or width and will be made
4784 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4785 * but you can build a static footer by setting #$clippableContainer to an element that contains
4786 * #$clippable and the footer.
4787 *
4788 * @abstract
4789 * @class
4790 *
4791 * @constructor
4792 * @param {Object} [config] Configuration options
4793 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4794 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4795 * omit to use #$clippable
4796 */
4797 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4798 // Configuration initialization
4799 config = config || {};
4800
4801 // Properties
4802 this.$clippable = null;
4803 this.$clippableContainer = null;
4804 this.clipping = false;
4805 this.clippedHorizontally = false;
4806 this.clippedVertically = false;
4807 this.$clippableScrollableContainer = null;
4808 this.$clippableScroller = null;
4809 this.$clippableWindow = null;
4810 this.idealWidth = null;
4811 this.idealHeight = null;
4812 this.onClippableScrollHandler = this.clip.bind( this );
4813 this.onClippableWindowResizeHandler = this.clip.bind( this );
4814
4815 // Initialization
4816 if ( config.$clippableContainer ) {
4817 this.setClippableContainer( config.$clippableContainer );
4818 }
4819 this.setClippableElement( config.$clippable || this.$element );
4820 };
4821
4822 /* Methods */
4823
4824 /**
4825 * Set clippable element.
4826 *
4827 * If an element is already set, it will be cleaned up before setting up the new element.
4828 *
4829 * @param {jQuery} $clippable Element to make clippable
4830 */
4831 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4832 if ( this.$clippable ) {
4833 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4834 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4835 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4836 }
4837
4838 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4839 this.clip();
4840 };
4841
4842 /**
4843 * Set clippable container.
4844 *
4845 * This is the container that will be measured when deciding whether to clip. When clipping,
4846 * #$clippable will be resized in order to keep the clippable container fully visible.
4847 *
4848 * If the clippable container is unset, #$clippable will be used.
4849 *
4850 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4851 */
4852 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4853 this.$clippableContainer = $clippableContainer;
4854 if ( this.$clippable ) {
4855 this.clip();
4856 }
4857 };
4858
4859 /**
4860 * Toggle clipping.
4861 *
4862 * Do not turn clipping on until after the element is attached to the DOM and visible.
4863 *
4864 * @param {boolean} [clipping] Enable clipping, omit to toggle
4865 * @chainable
4866 * @return {OO.ui.Element} The element, for chaining
4867 */
4868 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4869 clipping = clipping === undefined ? !this.clipping : !!clipping;
4870
4871 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4872 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4873 this.warnedUnattached = true;
4874 }
4875
4876 if ( this.clipping !== clipping ) {
4877 this.clipping = clipping;
4878 if ( clipping ) {
4879 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4880 // If the clippable container is the root, we have to listen to scroll events and check
4881 // jQuery.scrollTop on the window because of browser inconsistencies
4882 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4883 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4884 this.$clippableScrollableContainer;
4885 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4886 this.$clippableWindow = $( this.getElementWindow() )
4887 .on( 'resize', this.onClippableWindowResizeHandler );
4888 // Initial clip after visible
4889 this.clip();
4890 } else {
4891 this.$clippable.css( {
4892 width: '',
4893 height: '',
4894 maxWidth: '',
4895 maxHeight: '',
4896 overflowX: '',
4897 overflowY: ''
4898 } );
4899 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4900
4901 this.$clippableScrollableContainer = null;
4902 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4903 this.$clippableScroller = null;
4904 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4905 this.$clippableWindow = null;
4906 }
4907 }
4908
4909 return this;
4910 };
4911
4912 /**
4913 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4914 *
4915 * @return {boolean} Element will be clipped to the visible area
4916 */
4917 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4918 return this.clipping;
4919 };
4920
4921 /**
4922 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4923 *
4924 * @return {boolean} Part of the element is being clipped
4925 */
4926 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4927 return this.clippedHorizontally || this.clippedVertically;
4928 };
4929
4930 /**
4931 * Check if the right of the element is being clipped by the nearest scrollable container.
4932 *
4933 * @return {boolean} Part of the element is being clipped
4934 */
4935 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4936 return this.clippedHorizontally;
4937 };
4938
4939 /**
4940 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4941 *
4942 * @return {boolean} Part of the element is being clipped
4943 */
4944 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4945 return this.clippedVertically;
4946 };
4947
4948 /**
4949 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4950 *
4951 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4952 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4953 */
4954 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4955 this.idealWidth = width;
4956 this.idealHeight = height;
4957
4958 if ( !this.clipping ) {
4959 // Update dimensions
4960 this.$clippable.css( { width: width, height: height } );
4961 }
4962 // While clipping, idealWidth and idealHeight are not considered
4963 };
4964
4965 /**
4966 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4967 * ClippableElement will clip the opposite side when reducing element's width.
4968 *
4969 * Classes that mix in ClippableElement should override this to return 'right' if their
4970 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4971 * If your class also mixes in FloatableElement, this is handled automatically.
4972 *
4973 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4974 * always in pixels, even if they were unset or set to 'auto'.)
4975 *
4976 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4977 *
4978 * @return {string} 'left' or 'right'
4979 */
4980 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4981 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4982 return 'right';
4983 }
4984 return 'left';
4985 };
4986
4987 /**
4988 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4989 * ClippableElement will clip the opposite side when reducing element's width.
4990 *
4991 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4992 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4993 * If your class also mixes in FloatableElement, this is handled automatically.
4994 *
4995 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4996 * always in pixels, even if they were unset or set to 'auto'.)
4997 *
4998 * When in doubt, 'top' is a sane fallback.
4999 *
5000 * @return {string} 'top' or 'bottom'
5001 */
5002 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5003 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5004 return 'bottom';
5005 }
5006 return 'top';
5007 };
5008
5009 /**
5010 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5011 * when the element's natural height changes.
5012 *
5013 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5014 * overlapped by, the visible area of the nearest scrollable container.
5015 *
5016 * Because calling clip() when the natural height changes isn't always possible, we also set
5017 * max-height when the element isn't being clipped. This means that if the element tries to grow
5018 * beyond the edge, something reasonable will happen before clip() is called.
5019 *
5020 * @chainable
5021 * @return {OO.ui.Element} The element, for chaining
5022 */
5023 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5024 var extraHeight, extraWidth, viewportSpacing,
5025 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5026 naturalWidth, naturalHeight, clipWidth, clipHeight,
5027 $item, itemRect, $viewport, viewportRect, availableRect,
5028 direction, vertScrollbarWidth, horizScrollbarHeight,
5029 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5030 // by one or two pixels. (And also so that we have space to display drop shadows.)
5031 // Chosen by fair dice roll.
5032 buffer = 7;
5033
5034 if ( !this.clipping ) {
5035 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
5036 return this;
5037 }
5038
5039 function rectIntersection( a, b ) {
5040 var out = {};
5041 out.top = Math.max( a.top, b.top );
5042 out.left = Math.max( a.left, b.left );
5043 out.bottom = Math.min( a.bottom, b.bottom );
5044 out.right = Math.min( a.right, b.right );
5045 return out;
5046 }
5047
5048 viewportSpacing = OO.ui.getViewportSpacing();
5049
5050 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5051 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5052 // Dimensions of the browser window, rather than the element!
5053 viewportRect = {
5054 top: 0,
5055 left: 0,
5056 right: document.documentElement.clientWidth,
5057 bottom: document.documentElement.clientHeight
5058 };
5059 viewportRect.top += viewportSpacing.top;
5060 viewportRect.left += viewportSpacing.left;
5061 viewportRect.right -= viewportSpacing.right;
5062 viewportRect.bottom -= viewportSpacing.bottom;
5063 } else {
5064 $viewport = this.$clippableScrollableContainer;
5065 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5066 // Convert into a plain object
5067 viewportRect = $.extend( {}, viewportRect );
5068 }
5069
5070 // Account for scrollbar gutter
5071 direction = $viewport.css( 'direction' );
5072 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5073 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5074 viewportRect.bottom -= horizScrollbarHeight;
5075 if ( direction === 'rtl' ) {
5076 viewportRect.left += vertScrollbarWidth;
5077 } else {
5078 viewportRect.right -= vertScrollbarWidth;
5079 }
5080
5081 // Add arbitrary tolerance
5082 viewportRect.top += buffer;
5083 viewportRect.left += buffer;
5084 viewportRect.right -= buffer;
5085 viewportRect.bottom -= buffer;
5086
5087 $item = this.$clippableContainer || this.$clippable;
5088
5089 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5090 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5091
5092 itemRect = $item[ 0 ].getBoundingClientRect();
5093 // Convert into a plain object
5094 itemRect = $.extend( {}, itemRect );
5095
5096 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5097 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5098 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5099 itemRect.left = viewportRect.left;
5100 } else {
5101 itemRect.right = viewportRect.right;
5102 }
5103 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5104 itemRect.top = viewportRect.top;
5105 } else {
5106 itemRect.bottom = viewportRect.bottom;
5107 }
5108
5109 availableRect = rectIntersection( viewportRect, itemRect );
5110
5111 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5112 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5113 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5114 desiredWidth = Math.min( desiredWidth,
5115 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5116 desiredHeight = Math.min( desiredHeight,
5117 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5118 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5119 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5120 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5121 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5122 clipWidth = allotedWidth < naturalWidth;
5123 clipHeight = allotedHeight < naturalHeight;
5124
5125 if ( clipWidth ) {
5126 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5127 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5128 this.$clippable.css( 'overflowX', 'scroll' );
5129 // eslint-disable-next-line no-void
5130 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5131 this.$clippable.css( {
5132 width: Math.max( 0, allotedWidth ),
5133 maxWidth: ''
5134 } );
5135 } else {
5136 this.$clippable.css( {
5137 overflowX: '',
5138 width: this.idealWidth || '',
5139 maxWidth: Math.max( 0, allotedWidth )
5140 } );
5141 }
5142 if ( clipHeight ) {
5143 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5144 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5145 this.$clippable.css( 'overflowY', 'scroll' );
5146 // eslint-disable-next-line no-void
5147 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5148 this.$clippable.css( {
5149 height: Math.max( 0, allotedHeight ),
5150 maxHeight: ''
5151 } );
5152 } else {
5153 this.$clippable.css( {
5154 overflowY: '',
5155 height: this.idealHeight || '',
5156 maxHeight: Math.max( 0, allotedHeight )
5157 } );
5158 }
5159
5160 // If we stopped clipping in at least one of the dimensions
5161 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5162 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5163 }
5164
5165 this.clippedHorizontally = clipWidth;
5166 this.clippedVertically = clipHeight;
5167
5168 return this;
5169 };
5170
5171 /**
5172 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5173 * By default, each popup has an anchor that points toward its origin.
5174 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5175 *
5176 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5177 *
5178 * @example
5179 * // A popup widget.
5180 * var popup = new OO.ui.PopupWidget( {
5181 * $content: $( '<p>Hi there!</p>' ),
5182 * padded: true,
5183 * width: 300
5184 * } );
5185 *
5186 * $( 'body' ).append( popup.$element );
5187 * // To display the popup, toggle the visibility to 'true'.
5188 * popup.toggle( true );
5189 *
5190 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5191 *
5192 * @class
5193 * @extends OO.ui.Widget
5194 * @mixins OO.ui.mixin.LabelElement
5195 * @mixins OO.ui.mixin.ClippableElement
5196 * @mixins OO.ui.mixin.FloatableElement
5197 *
5198 * @constructor
5199 * @param {Object} [config] Configuration options
5200 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5201 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5202 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5203 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5204 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5205 * of $floatableContainer
5206 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5207 * of $floatableContainer
5208 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5209 * endwards (right/left) to the vertical center of $floatableContainer
5210 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5211 * startwards (left/right) to the vertical center of $floatableContainer
5212 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5213 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5214 * as possible while still keeping the anchor within the popup;
5215 * if position is before/after, move the popup as far downwards as possible.
5216 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5217 * as possible while still keeping the anchor within the popup;
5218 * if position in before/after, move the popup as far upwards as possible.
5219 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5220 * of the popup with the center of $floatableContainer.
5221 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5222 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5223 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5224 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5225 * desired direction to display the popup without clipping
5226 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5227 * See the [OOUI docs on MediaWiki][3] for an example.
5228 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5229 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5230 * @cfg {jQuery} [$content] Content to append to the popup's body
5231 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5232 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5233 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5234 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5235 * for an example.
5236 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5237 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5238 * button.
5239 * @cfg {boolean} [padded=false] Add padding to the popup's body
5240 */
5241 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5242 // Configuration initialization
5243 config = config || {};
5244
5245 // Parent constructor
5246 OO.ui.PopupWidget.parent.call( this, config );
5247
5248 // Properties (must be set before ClippableElement constructor call)
5249 this.$body = $( '<div>' );
5250 this.$popup = $( '<div>' );
5251
5252 // Mixin constructors
5253 OO.ui.mixin.LabelElement.call( this, config );
5254 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5255 $clippable: this.$body,
5256 $clippableContainer: this.$popup
5257 } ) );
5258 OO.ui.mixin.FloatableElement.call( this, config );
5259
5260 // Properties
5261 this.$anchor = $( '<div>' );
5262 // If undefined, will be computed lazily in computePosition()
5263 this.$container = config.$container;
5264 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5265 this.autoClose = !!config.autoClose;
5266 this.transitionTimeout = null;
5267 this.anchored = false;
5268 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5269 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5270
5271 // Initialization
5272 this.setSize( config.width, config.height );
5273 this.toggleAnchor( config.anchor === undefined || config.anchor );
5274 this.setAlignment( config.align || 'center' );
5275 this.setPosition( config.position || 'below' );
5276 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5277 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5278 this.$body.addClass( 'oo-ui-popupWidget-body' );
5279 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5280 this.$popup
5281 .addClass( 'oo-ui-popupWidget-popup' )
5282 .append( this.$body );
5283 this.$element
5284 .addClass( 'oo-ui-popupWidget' )
5285 .append( this.$popup, this.$anchor );
5286 // Move content, which was added to #$element by OO.ui.Widget, to the body
5287 // FIXME This is gross, we should use '$body' or something for the config
5288 if ( config.$content instanceof $ ) {
5289 this.$body.append( config.$content );
5290 }
5291
5292 if ( config.padded ) {
5293 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5294 }
5295
5296 if ( config.head ) {
5297 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5298 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5299 this.$head = $( '<div>' )
5300 .addClass( 'oo-ui-popupWidget-head' )
5301 .append( this.$label, this.closeButton.$element );
5302 this.$popup.prepend( this.$head );
5303 }
5304
5305 if ( config.$footer ) {
5306 this.$footer = $( '<div>' )
5307 .addClass( 'oo-ui-popupWidget-footer' )
5308 .append( config.$footer );
5309 this.$popup.append( this.$footer );
5310 }
5311
5312 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5313 // that reference properties not initialized at that time of parent class construction
5314 // TODO: Find a better way to handle post-constructor setup
5315 this.visible = false;
5316 this.$element.addClass( 'oo-ui-element-hidden' );
5317 };
5318
5319 /* Setup */
5320
5321 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5322 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5323 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5324 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5325
5326 /* Events */
5327
5328 /**
5329 * @event ready
5330 *
5331 * The popup is ready: it is visible and has been positioned and clipped.
5332 */
5333
5334 /* Methods */
5335
5336 /**
5337 * Handles document mouse down events.
5338 *
5339 * @private
5340 * @param {MouseEvent} e Mouse down event
5341 */
5342 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5343 if (
5344 this.isVisible() &&
5345 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5346 ) {
5347 this.toggle( false );
5348 }
5349 };
5350
5351 // Deprecated alias since 0.28.3
5352 OO.ui.PopupWidget.prototype.onMouseDown = function () {
5353 OO.ui.warnDeprecation( 'onMouseDown is deprecated, use onDocumentMouseDown instead' );
5354 this.onDocumentMouseDown.apply( this, arguments );
5355 };
5356
5357 /**
5358 * Bind document mouse down listener.
5359 *
5360 * @private
5361 */
5362 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5363 // Capture clicks outside popup
5364 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5365 // We add 'click' event because iOS safari needs to respond to this event.
5366 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5367 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5368 // of occasionally not emitting 'click' properly, that event seems to be the standard
5369 // that it should be emitting, so we add it to this and will operate the event handler
5370 // on whichever of these events was triggered first
5371 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5372 };
5373
5374 // Deprecated alias since 0.28.3
5375 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5376 OO.ui.warnDeprecation( 'bindMouseDownListener is deprecated, use bindDocumentMouseDownListener instead' );
5377 this.bindDocumentMouseDownListener.apply( this, arguments );
5378 };
5379
5380 /**
5381 * Handles close button click events.
5382 *
5383 * @private
5384 */
5385 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5386 if ( this.isVisible() ) {
5387 this.toggle( false );
5388 }
5389 };
5390
5391 /**
5392 * Unbind document mouse down listener.
5393 *
5394 * @private
5395 */
5396 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5397 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5398 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5399 };
5400
5401 // Deprecated alias since 0.28.3
5402 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5403 OO.ui.warnDeprecation( 'unbindMouseDownListener is deprecated, use unbindDocumentMouseDownListener instead' );
5404 this.unbindDocumentMouseDownListener.apply( this, arguments );
5405 };
5406
5407 /**
5408 * Handles document key down events.
5409 *
5410 * @private
5411 * @param {KeyboardEvent} e Key down event
5412 */
5413 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5414 if (
5415 e.which === OO.ui.Keys.ESCAPE &&
5416 this.isVisible()
5417 ) {
5418 this.toggle( false );
5419 e.preventDefault();
5420 e.stopPropagation();
5421 }
5422 };
5423
5424 /**
5425 * Bind document key down listener.
5426 *
5427 * @private
5428 */
5429 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5430 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5431 };
5432
5433 // Deprecated alias since 0.28.3
5434 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5435 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
5436 this.bindDocumentKeyDownListener.apply( this, arguments );
5437 };
5438
5439 /**
5440 * Unbind document key down listener.
5441 *
5442 * @private
5443 */
5444 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5445 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5446 };
5447
5448 // Deprecated alias since 0.28.3
5449 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5450 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
5451 this.unbindDocumentKeyDownListener.apply( this, arguments );
5452 };
5453
5454 /**
5455 * Show, hide, or toggle the visibility of the anchor.
5456 *
5457 * @param {boolean} [show] Show anchor, omit to toggle
5458 */
5459 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5460 show = show === undefined ? !this.anchored : !!show;
5461
5462 if ( this.anchored !== show ) {
5463 if ( show ) {
5464 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5465 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5466 } else {
5467 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5468 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5469 }
5470 this.anchored = show;
5471 }
5472 };
5473
5474 /**
5475 * Change which edge the anchor appears on.
5476 *
5477 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5478 */
5479 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5480 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5481 throw new Error( 'Invalid value for edge: ' + edge );
5482 }
5483 if ( this.anchorEdge !== null ) {
5484 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5485 }
5486 this.anchorEdge = edge;
5487 if ( this.anchored ) {
5488 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5489 }
5490 };
5491
5492 /**
5493 * Check if the anchor is visible.
5494 *
5495 * @return {boolean} Anchor is visible
5496 */
5497 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5498 return this.anchored;
5499 };
5500
5501 /**
5502 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5503 * `.toggle( true )` after its #$element is attached to the DOM.
5504 *
5505 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5506 * it in the right place and with the right dimensions only work correctly while it is attached.
5507 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5508 * strictly enforced, so currently it only generates a warning in the browser console.
5509 *
5510 * @fires ready
5511 * @inheritdoc
5512 */
5513 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5514 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5515 show = show === undefined ? !this.isVisible() : !!show;
5516
5517 change = show !== this.isVisible();
5518
5519 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5520 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5521 this.warnedUnattached = true;
5522 }
5523 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5524 // Fall back to the parent node if the floatableContainer is not set
5525 this.setFloatableContainer( this.$element.parent() );
5526 }
5527
5528 if ( change && show && this.autoFlip ) {
5529 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5530 // (e.g. if the user scrolled).
5531 this.isAutoFlipped = false;
5532 }
5533
5534 // Parent method
5535 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5536
5537 if ( change ) {
5538 this.togglePositioning( show && !!this.$floatableContainer );
5539
5540 if ( show ) {
5541 if ( this.autoClose ) {
5542 this.bindDocumentMouseDownListener();
5543 this.bindDocumentKeyDownListener();
5544 }
5545 this.updateDimensions();
5546 this.toggleClipping( true );
5547
5548 if ( this.autoFlip ) {
5549 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5550 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5551 // If opening the popup in the normal direction causes it to be clipped, open
5552 // in the opposite one instead
5553 normalHeight = this.$element.height();
5554 this.isAutoFlipped = !this.isAutoFlipped;
5555 this.position();
5556 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5557 // If that also causes it to be clipped, open in whichever direction
5558 // we have more space
5559 oppositeHeight = this.$element.height();
5560 if ( oppositeHeight < normalHeight ) {
5561 this.isAutoFlipped = !this.isAutoFlipped;
5562 this.position();
5563 }
5564 }
5565 }
5566 }
5567 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5568 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5569 // If opening the popup in the normal direction causes it to be clipped, open
5570 // in the opposite one instead
5571 normalWidth = this.$element.width();
5572 this.isAutoFlipped = !this.isAutoFlipped;
5573 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5574 // which causes positioning to be off. Toggle clipping back and fort to work around.
5575 this.toggleClipping( false );
5576 this.position();
5577 this.toggleClipping( true );
5578 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5579 // If that also causes it to be clipped, open in whichever direction
5580 // we have more space
5581 oppositeWidth = this.$element.width();
5582 if ( oppositeWidth < normalWidth ) {
5583 this.isAutoFlipped = !this.isAutoFlipped;
5584 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5585 // which causes positioning to be off. Toggle clipping back and fort to work around.
5586 this.toggleClipping( false );
5587 this.position();
5588 this.toggleClipping( true );
5589 }
5590 }
5591 }
5592 }
5593 }
5594
5595 this.emit( 'ready' );
5596 } else {
5597 this.toggleClipping( false );
5598 if ( this.autoClose ) {
5599 this.unbindDocumentMouseDownListener();
5600 this.unbindDocumentKeyDownListener();
5601 }
5602 }
5603 }
5604
5605 return this;
5606 };
5607
5608 /**
5609 * Set the size of the popup.
5610 *
5611 * Changing the size may also change the popup's position depending on the alignment.
5612 *
5613 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5614 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5615 * @param {boolean} [transition=false] Use a smooth transition
5616 * @chainable
5617 */
5618 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5619 this.width = width !== undefined ? width : 320;
5620 this.height = height !== undefined ? height : null;
5621 if ( this.isVisible() ) {
5622 this.updateDimensions( transition );
5623 }
5624 };
5625
5626 /**
5627 * Update the size and position.
5628 *
5629 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5630 * be called automatically.
5631 *
5632 * @param {boolean} [transition=false] Use a smooth transition
5633 * @chainable
5634 */
5635 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5636 var widget = this;
5637
5638 // Prevent transition from being interrupted
5639 clearTimeout( this.transitionTimeout );
5640 if ( transition ) {
5641 // Enable transition
5642 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5643 }
5644
5645 this.position();
5646
5647 if ( transition ) {
5648 // Prevent transitioning after transition is complete
5649 this.transitionTimeout = setTimeout( function () {
5650 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5651 }, 200 );
5652 } else {
5653 // Prevent transitioning immediately
5654 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5655 }
5656 };
5657
5658 /**
5659 * @inheritdoc
5660 */
5661 OO.ui.PopupWidget.prototype.computePosition = function () {
5662 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5663 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5664 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5665 popupPos = {},
5666 anchorCss = { left: '', right: '', top: '', bottom: '' },
5667 popupPositionOppositeMap = {
5668 above: 'below',
5669 below: 'above',
5670 before: 'after',
5671 after: 'before'
5672 },
5673 alignMap = {
5674 ltr: {
5675 'force-left': 'backwards',
5676 'force-right': 'forwards'
5677 },
5678 rtl: {
5679 'force-left': 'forwards',
5680 'force-right': 'backwards'
5681 }
5682 },
5683 anchorEdgeMap = {
5684 above: 'bottom',
5685 below: 'top',
5686 before: 'end',
5687 after: 'start'
5688 },
5689 hPosMap = {
5690 forwards: 'start',
5691 center: 'center',
5692 backwards: this.anchored ? 'before' : 'end'
5693 },
5694 vPosMap = {
5695 forwards: 'top',
5696 center: 'center',
5697 backwards: 'bottom'
5698 };
5699
5700 if ( !this.$container ) {
5701 // Lazy-initialize $container if not specified in constructor
5702 this.$container = $( this.getClosestScrollableElementContainer() );
5703 }
5704 direction = this.$container.css( 'direction' );
5705
5706 // Set height and width before we do anything else, since it might cause our measurements
5707 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5708 this.$popup.css( {
5709 width: this.width !== null ? this.width : 'auto',
5710 height: this.height !== null ? this.height : 'auto'
5711 } );
5712
5713 align = alignMap[ direction ][ this.align ] || this.align;
5714 popupPosition = this.popupPosition;
5715 if ( this.isAutoFlipped ) {
5716 popupPosition = popupPositionOppositeMap[ popupPosition ];
5717 }
5718
5719 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5720 vertical = popupPosition === 'before' || popupPosition === 'after';
5721 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5722 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5723 near = vertical ? 'top' : 'left';
5724 far = vertical ? 'bottom' : 'right';
5725 sizeProp = vertical ? 'Height' : 'Width';
5726 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5727
5728 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5729 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5730 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5731
5732 // Parent method
5733 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5734 // Find out which property FloatableElement used for positioning, and adjust that value
5735 positionProp = vertical ?
5736 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5737 ( parentPosition.left !== '' ? 'left' : 'right' );
5738
5739 // Figure out where the near and far edges of the popup and $floatableContainer are
5740 floatablePos = this.$floatableContainer.offset();
5741 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5742 // Measure where the offsetParent is and compute our position based on that and parentPosition
5743 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5744 { top: 0, left: 0 } :
5745 this.$element.offsetParent().offset();
5746
5747 if ( positionProp === near ) {
5748 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5749 popupPos[ far ] = popupPos[ near ] + popupSize;
5750 } else {
5751 popupPos[ far ] = offsetParentPos[ near ] +
5752 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5753 popupPos[ near ] = popupPos[ far ] - popupSize;
5754 }
5755
5756 if ( this.anchored ) {
5757 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5758 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5759 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5760
5761 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5762 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5763 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5764 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5765 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5766 // Not enough space for the anchor on the start side; pull the popup startwards
5767 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5768 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5769 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5770 // Not enough space for the anchor on the end side; pull the popup endwards
5771 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5772 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5773 } else {
5774 positionAdjustment = 0;
5775 }
5776 } else {
5777 positionAdjustment = 0;
5778 }
5779
5780 // Check if the popup will go beyond the edge of this.$container
5781 containerPos = this.$container[ 0 ] === document.documentElement ?
5782 { top: 0, left: 0 } :
5783 this.$container.offset();
5784 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5785 if ( this.$container[ 0 ] === document.documentElement ) {
5786 viewportSpacing = OO.ui.getViewportSpacing();
5787 containerPos[ near ] += viewportSpacing[ near ];
5788 containerPos[ far ] -= viewportSpacing[ far ];
5789 }
5790 // Take into account how much the popup will move because of the adjustments we're going to make
5791 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5792 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5793 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5794 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5795 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5796 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5797 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5798 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5799 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5800 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5801 }
5802
5803 if ( this.anchored ) {
5804 // Adjust anchorOffset for positionAdjustment
5805 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5806
5807 // Position the anchor
5808 anchorCss[ start ] = anchorOffset;
5809 this.$anchor.css( anchorCss );
5810 }
5811
5812 // Move the popup if needed
5813 parentPosition[ positionProp ] += positionAdjustment;
5814
5815 return parentPosition;
5816 };
5817
5818 /**
5819 * Set popup alignment
5820 *
5821 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5822 * `backwards` or `forwards`.
5823 */
5824 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5825 // Validate alignment
5826 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5827 this.align = align;
5828 } else {
5829 this.align = 'center';
5830 }
5831 this.position();
5832 };
5833
5834 /**
5835 * Get popup alignment
5836 *
5837 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5838 * `backwards` or `forwards`.
5839 */
5840 OO.ui.PopupWidget.prototype.getAlignment = function () {
5841 return this.align;
5842 };
5843
5844 /**
5845 * Change the positioning of the popup.
5846 *
5847 * @param {string} position 'above', 'below', 'before' or 'after'
5848 */
5849 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5850 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5851 position = 'below';
5852 }
5853 this.popupPosition = position;
5854 this.position();
5855 };
5856
5857 /**
5858 * Get popup positioning.
5859 *
5860 * @return {string} 'above', 'below', 'before' or 'after'
5861 */
5862 OO.ui.PopupWidget.prototype.getPosition = function () {
5863 return this.popupPosition;
5864 };
5865
5866 /**
5867 * Set popup auto-flipping.
5868 *
5869 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5870 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5871 * desired direction to display the popup without clipping
5872 */
5873 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5874 autoFlip = !!autoFlip;
5875
5876 if ( this.autoFlip !== autoFlip ) {
5877 this.autoFlip = autoFlip;
5878 }
5879 };
5880
5881 /**
5882 * Set which elements will not close the popup when clicked.
5883 *
5884 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5885 *
5886 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5887 */
5888 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5889 this.$autoCloseIgnore = $autoCloseIgnore;
5890 };
5891
5892 /**
5893 * Get an ID of the body element, this can be used as the
5894 * `aria-describedby` attribute for an input field.
5895 *
5896 * @return {string} The ID of the body element
5897 */
5898 OO.ui.PopupWidget.prototype.getBodyId = function () {
5899 var id = this.$body.attr( 'id' );
5900 if ( id === undefined ) {
5901 id = OO.ui.generateElementId();
5902 this.$body.attr( 'id', id );
5903 }
5904 return id;
5905 };
5906
5907 /**
5908 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5909 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5910 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5911 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5912 *
5913 * @abstract
5914 * @class
5915 *
5916 * @constructor
5917 * @param {Object} [config] Configuration options
5918 * @cfg {Object} [popup] Configuration to pass to popup
5919 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5920 */
5921 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5922 // Configuration initialization
5923 config = config || {};
5924
5925 // Properties
5926 this.popup = new OO.ui.PopupWidget( $.extend(
5927 {
5928 autoClose: true,
5929 $floatableContainer: this.$element
5930 },
5931 config.popup,
5932 {
5933 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5934 }
5935 ) );
5936 };
5937
5938 /* Methods */
5939
5940 /**
5941 * Get popup.
5942 *
5943 * @return {OO.ui.PopupWidget} Popup widget
5944 */
5945 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5946 return this.popup;
5947 };
5948
5949 /**
5950 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5951 * which is used to display additional information or options.
5952 *
5953 * @example
5954 * // Example of a popup button.
5955 * var popupButton = new OO.ui.PopupButtonWidget( {
5956 * label: 'Popup button with options',
5957 * icon: 'menu',
5958 * popup: {
5959 * $content: $( '<p>Additional options here.</p>' ),
5960 * padded: true,
5961 * align: 'force-left'
5962 * }
5963 * } );
5964 * // Append the button to the DOM.
5965 * $( 'body' ).append( popupButton.$element );
5966 *
5967 * @class
5968 * @extends OO.ui.ButtonWidget
5969 * @mixins OO.ui.mixin.PopupElement
5970 *
5971 * @constructor
5972 * @param {Object} [config] Configuration options
5973 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5974 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5975 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5976 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5977 */
5978 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5979 // Configuration initialization
5980 config = config || {};
5981
5982 // Parent constructor
5983 OO.ui.PopupButtonWidget.parent.call( this, config );
5984
5985 // Mixin constructors
5986 OO.ui.mixin.PopupElement.call( this, config );
5987
5988 // Properties
5989 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5990
5991 // Events
5992 this.connect( this, { click: 'onAction' } );
5993
5994 // Initialization
5995 this.$element
5996 .addClass( 'oo-ui-popupButtonWidget' );
5997 this.popup.$element
5998 .addClass( 'oo-ui-popupButtonWidget-popup' )
5999 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6000 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6001 this.$overlay.append( this.popup.$element );
6002 };
6003
6004 /* Setup */
6005
6006 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6007 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6008
6009 /* Methods */
6010
6011 /**
6012 * Handle the button action being triggered.
6013 *
6014 * @private
6015 */
6016 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6017 this.popup.toggle();
6018 };
6019
6020 /**
6021 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6022 *
6023 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6024 *
6025 * @private
6026 * @abstract
6027 * @class
6028 * @mixins OO.ui.mixin.GroupElement
6029 *
6030 * @constructor
6031 * @param {Object} [config] Configuration options
6032 */
6033 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6034 // Mixin constructors
6035 OO.ui.mixin.GroupElement.call( this, config );
6036 };
6037
6038 /* Setup */
6039
6040 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6041
6042 /* Methods */
6043
6044 /**
6045 * Set the disabled state of the widget.
6046 *
6047 * This will also update the disabled state of child widgets.
6048 *
6049 * @param {boolean} disabled Disable widget
6050 * @chainable
6051 * @return {OO.ui.Widget} The widget, for chaining
6052 */
6053 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6054 var i, len;
6055
6056 // Parent method
6057 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6058 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6059
6060 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6061 if ( this.items ) {
6062 for ( i = 0, len = this.items.length; i < len; i++ ) {
6063 this.items[ i ].updateDisabled();
6064 }
6065 }
6066
6067 return this;
6068 };
6069
6070 /**
6071 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6072 *
6073 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
6074 * allows bidirectional communication.
6075 *
6076 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6077 *
6078 * @private
6079 * @abstract
6080 * @class
6081 *
6082 * @constructor
6083 */
6084 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6085 //
6086 };
6087
6088 /* Methods */
6089
6090 /**
6091 * Check if widget is disabled.
6092 *
6093 * Checks parent if present, making disabled state inheritable.
6094 *
6095 * @return {boolean} Widget is disabled
6096 */
6097 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6098 return this.disabled ||
6099 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6100 };
6101
6102 /**
6103 * Set group element is in.
6104 *
6105 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6106 * @chainable
6107 * @return {OO.ui.Widget} The widget, for chaining
6108 */
6109 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6110 // Parent method
6111 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6112 OO.ui.Element.prototype.setElementGroup.call( this, group );
6113
6114 // Initialize item disabled states
6115 this.updateDisabled();
6116
6117 return this;
6118 };
6119
6120 /**
6121 * OptionWidgets are special elements that can be selected and configured with data. The
6122 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6123 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6124 * and examples, please see the [OOUI documentation on MediaWiki][1].
6125 *
6126 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6127 *
6128 * @class
6129 * @extends OO.ui.Widget
6130 * @mixins OO.ui.mixin.ItemWidget
6131 * @mixins OO.ui.mixin.LabelElement
6132 * @mixins OO.ui.mixin.FlaggedElement
6133 * @mixins OO.ui.mixin.AccessKeyedElement
6134 *
6135 * @constructor
6136 * @param {Object} [config] Configuration options
6137 */
6138 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6139 // Configuration initialization
6140 config = config || {};
6141
6142 // Parent constructor
6143 OO.ui.OptionWidget.parent.call( this, config );
6144
6145 // Mixin constructors
6146 OO.ui.mixin.ItemWidget.call( this );
6147 OO.ui.mixin.LabelElement.call( this, config );
6148 OO.ui.mixin.FlaggedElement.call( this, config );
6149 OO.ui.mixin.AccessKeyedElement.call( this, config );
6150
6151 // Properties
6152 this.selected = false;
6153 this.highlighted = false;
6154 this.pressed = false;
6155
6156 // Initialization
6157 this.$element
6158 .data( 'oo-ui-optionWidget', this )
6159 // Allow programmatic focussing (and by accesskey), but not tabbing
6160 .attr( 'tabindex', '-1' )
6161 .attr( 'role', 'option' )
6162 .attr( 'aria-selected', 'false' )
6163 .addClass( 'oo-ui-optionWidget' )
6164 .append( this.$label );
6165 };
6166
6167 /* Setup */
6168
6169 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6170 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6171 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6172 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6173 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6174
6175 /* Static Properties */
6176
6177 /**
6178 * Whether this option can be selected. See #setSelected.
6179 *
6180 * @static
6181 * @inheritable
6182 * @property {boolean}
6183 */
6184 OO.ui.OptionWidget.static.selectable = true;
6185
6186 /**
6187 * Whether this option can be highlighted. See #setHighlighted.
6188 *
6189 * @static
6190 * @inheritable
6191 * @property {boolean}
6192 */
6193 OO.ui.OptionWidget.static.highlightable = true;
6194
6195 /**
6196 * Whether this option can be pressed. See #setPressed.
6197 *
6198 * @static
6199 * @inheritable
6200 * @property {boolean}
6201 */
6202 OO.ui.OptionWidget.static.pressable = true;
6203
6204 /**
6205 * Whether this option will be scrolled into view when it is selected.
6206 *
6207 * @static
6208 * @inheritable
6209 * @property {boolean}
6210 */
6211 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6212
6213 /* Methods */
6214
6215 /**
6216 * Check if the option can be selected.
6217 *
6218 * @return {boolean} Item is selectable
6219 */
6220 OO.ui.OptionWidget.prototype.isSelectable = function () {
6221 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6222 };
6223
6224 /**
6225 * Check if the option can be highlighted. A highlight indicates that the option
6226 * may be selected when a user presses enter or clicks. Disabled items cannot
6227 * be highlighted.
6228 *
6229 * @return {boolean} Item is highlightable
6230 */
6231 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6232 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6233 };
6234
6235 /**
6236 * Check if the option can be pressed. The pressed state occurs when a user mouses
6237 * down on an item, but has not yet let go of the mouse.
6238 *
6239 * @return {boolean} Item is pressable
6240 */
6241 OO.ui.OptionWidget.prototype.isPressable = function () {
6242 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6243 };
6244
6245 /**
6246 * Check if the option is selected.
6247 *
6248 * @return {boolean} Item is selected
6249 */
6250 OO.ui.OptionWidget.prototype.isSelected = function () {
6251 return this.selected;
6252 };
6253
6254 /**
6255 * Check if the option is highlighted. A highlight indicates that the
6256 * item may be selected when a user presses enter or clicks.
6257 *
6258 * @return {boolean} Item is highlighted
6259 */
6260 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6261 return this.highlighted;
6262 };
6263
6264 /**
6265 * Check if the option is pressed. The pressed state occurs when a user mouses
6266 * down on an item, but has not yet let go of the mouse. The item may appear
6267 * selected, but it will not be selected until the user releases the mouse.
6268 *
6269 * @return {boolean} Item is pressed
6270 */
6271 OO.ui.OptionWidget.prototype.isPressed = function () {
6272 return this.pressed;
6273 };
6274
6275 /**
6276 * Set the option’s selected state. In general, all modifications to the selection
6277 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6278 * method instead of this method.
6279 *
6280 * @param {boolean} [state=false] Select option
6281 * @chainable
6282 * @return {OO.ui.Widget} The widget, for chaining
6283 */
6284 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6285 if ( this.constructor.static.selectable ) {
6286 this.selected = !!state;
6287 this.$element
6288 .toggleClass( 'oo-ui-optionWidget-selected', state )
6289 .attr( 'aria-selected', state.toString() );
6290 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6291 this.scrollElementIntoView();
6292 }
6293 this.updateThemeClasses();
6294 }
6295 return this;
6296 };
6297
6298 /**
6299 * Set the option’s highlighted state. In general, all programmatic
6300 * modifications to the highlight should be handled by the
6301 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6302 * method instead of this method.
6303 *
6304 * @param {boolean} [state=false] Highlight option
6305 * @chainable
6306 * @return {OO.ui.Widget} The widget, for chaining
6307 */
6308 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6309 if ( this.constructor.static.highlightable ) {
6310 this.highlighted = !!state;
6311 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6312 this.updateThemeClasses();
6313 }
6314 return this;
6315 };
6316
6317 /**
6318 * Set the option’s pressed state. In general, all
6319 * programmatic modifications to the pressed state should be handled by the
6320 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6321 * method instead of this method.
6322 *
6323 * @param {boolean} [state=false] Press option
6324 * @chainable
6325 * @return {OO.ui.Widget} The widget, for chaining
6326 */
6327 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6328 if ( this.constructor.static.pressable ) {
6329 this.pressed = !!state;
6330 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6331 this.updateThemeClasses();
6332 }
6333 return this;
6334 };
6335
6336 /**
6337 * Get text to match search strings against.
6338 *
6339 * The default implementation returns the label text, but subclasses
6340 * can override this to provide more complex behavior.
6341 *
6342 * @return {string|boolean} String to match search string against
6343 */
6344 OO.ui.OptionWidget.prototype.getMatchText = function () {
6345 var label = this.getLabel();
6346 return typeof label === 'string' ? label : this.$label.text();
6347 };
6348
6349 /**
6350 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6351 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6352 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6353 * menu selects}.
6354 *
6355 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6356 * information, please see the [OOUI documentation on MediaWiki][1].
6357 *
6358 * @example
6359 * // Example of a select widget with three options
6360 * var select = new OO.ui.SelectWidget( {
6361 * items: [
6362 * new OO.ui.OptionWidget( {
6363 * data: 'a',
6364 * label: 'Option One',
6365 * } ),
6366 * new OO.ui.OptionWidget( {
6367 * data: 'b',
6368 * label: 'Option Two',
6369 * } ),
6370 * new OO.ui.OptionWidget( {
6371 * data: 'c',
6372 * label: 'Option Three',
6373 * } )
6374 * ]
6375 * } );
6376 * $( 'body' ).append( select.$element );
6377 *
6378 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6379 *
6380 * @abstract
6381 * @class
6382 * @extends OO.ui.Widget
6383 * @mixins OO.ui.mixin.GroupWidget
6384 *
6385 * @constructor
6386 * @param {Object} [config] Configuration options
6387 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6388 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6389 * the [OOUI documentation on MediaWiki] [2] for examples.
6390 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6391 */
6392 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6393 // Configuration initialization
6394 config = config || {};
6395
6396 // Parent constructor
6397 OO.ui.SelectWidget.parent.call( this, config );
6398
6399 // Mixin constructors
6400 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6401
6402 // Properties
6403 this.pressed = false;
6404 this.selecting = null;
6405 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6406 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6407 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6408 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6409 this.keyPressBuffer = '';
6410 this.keyPressBufferTimer = null;
6411 this.blockMouseOverEvents = 0;
6412
6413 // Events
6414 this.connect( this, {
6415 toggle: 'onToggle'
6416 } );
6417 this.$element.on( {
6418 focusin: this.onFocus.bind( this ),
6419 mousedown: this.onMouseDown.bind( this ),
6420 mouseover: this.onMouseOver.bind( this ),
6421 mouseleave: this.onMouseLeave.bind( this )
6422 } );
6423
6424 // Initialization
6425 this.$element
6426 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6427 .attr( 'role', 'listbox' );
6428 this.setFocusOwner( this.$element );
6429 if ( Array.isArray( config.items ) ) {
6430 this.addItems( config.items );
6431 }
6432 };
6433
6434 /* Setup */
6435
6436 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6437 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6438
6439 /* Events */
6440
6441 /**
6442 * @event highlight
6443 *
6444 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6445 *
6446 * @param {OO.ui.OptionWidget|null} item Highlighted item
6447 */
6448
6449 /**
6450 * @event press
6451 *
6452 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6453 * pressed state of an option.
6454 *
6455 * @param {OO.ui.OptionWidget|null} item Pressed item
6456 */
6457
6458 /**
6459 * @event select
6460 *
6461 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6462 *
6463 * @param {OO.ui.OptionWidget|null} item Selected item
6464 */
6465
6466 /**
6467 * @event choose
6468 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6469 * @param {OO.ui.OptionWidget} item Chosen item
6470 */
6471
6472 /**
6473 * @event add
6474 *
6475 * An `add` event is emitted when options are added to the select with the #addItems method.
6476 *
6477 * @param {OO.ui.OptionWidget[]} items Added items
6478 * @param {number} index Index of insertion point
6479 */
6480
6481 /**
6482 * @event remove
6483 *
6484 * A `remove` event is emitted when options are removed from the select with the #clearItems
6485 * or #removeItems methods.
6486 *
6487 * @param {OO.ui.OptionWidget[]} items Removed items
6488 */
6489
6490 /* Methods */
6491
6492 /**
6493 * Handle focus events
6494 *
6495 * @private
6496 * @param {jQuery.Event} event
6497 */
6498 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6499 var item;
6500 if ( event.target === this.$element[ 0 ] ) {
6501 // This widget was focussed, e.g. by the user tabbing to it.
6502 // The styles for focus state depend on one of the items being selected.
6503 if ( !this.findSelectedItem() ) {
6504 item = this.findFirstSelectableItem();
6505 }
6506 } else {
6507 if ( event.target.tabIndex === -1 ) {
6508 // One of the options got focussed (and the event bubbled up here).
6509 // They can't be tabbed to, but they can be activated using accesskeys.
6510 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6511 item = this.findTargetItem( event );
6512 } else {
6513 // There is something actually user-focusable in one of the labels of the options, and the
6514 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6515 return;
6516 }
6517 }
6518
6519 if ( item ) {
6520 if ( item.constructor.static.highlightable ) {
6521 this.highlightItem( item );
6522 } else {
6523 this.selectItem( item );
6524 }
6525 }
6526
6527 if ( event.target !== this.$element[ 0 ] ) {
6528 this.$focusOwner.focus();
6529 }
6530 };
6531
6532 /**
6533 * Handle mouse down events.
6534 *
6535 * @private
6536 * @param {jQuery.Event} e Mouse down event
6537 * @return {undefined/boolean} False to prevent default if event is handled
6538 */
6539 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6540 var item;
6541
6542 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6543 this.togglePressed( true );
6544 item = this.findTargetItem( e );
6545 if ( item && item.isSelectable() ) {
6546 this.pressItem( item );
6547 this.selecting = item;
6548 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6549 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6550 }
6551 }
6552 return false;
6553 };
6554
6555 /**
6556 * Handle document mouse up events.
6557 *
6558 * @private
6559 * @param {MouseEvent} e Mouse up event
6560 * @return {undefined/boolean} False to prevent default if event is handled
6561 */
6562 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6563 var item;
6564
6565 this.togglePressed( false );
6566 if ( !this.selecting ) {
6567 item = this.findTargetItem( e );
6568 if ( item && item.isSelectable() ) {
6569 this.selecting = item;
6570 }
6571 }
6572 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6573 this.pressItem( null );
6574 this.chooseItem( this.selecting );
6575 this.selecting = null;
6576 }
6577
6578 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6579 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6580
6581 return false;
6582 };
6583
6584 // Deprecated alias since 0.28.3
6585 OO.ui.SelectWidget.prototype.onMouseUp = function () {
6586 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
6587 this.onDocumentMouseUp.apply( this, arguments );
6588 };
6589
6590 /**
6591 * Handle document mouse move events.
6592 *
6593 * @private
6594 * @param {MouseEvent} e Mouse move event
6595 */
6596 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6597 var item;
6598
6599 if ( !this.isDisabled() && this.pressed ) {
6600 item = this.findTargetItem( e );
6601 if ( item && item !== this.selecting && item.isSelectable() ) {
6602 this.pressItem( item );
6603 this.selecting = item;
6604 }
6605 }
6606 };
6607
6608 // Deprecated alias since 0.28.3
6609 OO.ui.SelectWidget.prototype.onMouseMove = function () {
6610 OO.ui.warnDeprecation( 'onMouseMove is deprecated, use onDocumentMouseMove instead' );
6611 this.onDocumentMouseMove.apply( this, arguments );
6612 };
6613
6614 /**
6615 * Handle mouse over events.
6616 *
6617 * @private
6618 * @param {jQuery.Event} e Mouse over event
6619 * @return {undefined/boolean} False to prevent default if event is handled
6620 */
6621 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6622 var item;
6623 if ( this.blockMouseOverEvents ) {
6624 return;
6625 }
6626 if ( !this.isDisabled() ) {
6627 item = this.findTargetItem( e );
6628 this.highlightItem( item && item.isHighlightable() ? item : null );
6629 }
6630 return false;
6631 };
6632
6633 /**
6634 * Handle mouse leave events.
6635 *
6636 * @private
6637 * @param {jQuery.Event} e Mouse over event
6638 * @return {undefined/boolean} False to prevent default if event is handled
6639 */
6640 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6641 if ( !this.isDisabled() ) {
6642 this.highlightItem( null );
6643 }
6644 return false;
6645 };
6646
6647 /**
6648 * Handle document key down events.
6649 *
6650 * @protected
6651 * @param {KeyboardEvent} e Key down event
6652 */
6653 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6654 var nextItem,
6655 handled = false,
6656 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6657
6658 if ( !this.isDisabled() && this.isVisible() ) {
6659 switch ( e.keyCode ) {
6660 case OO.ui.Keys.ENTER:
6661 if ( currentItem && currentItem.constructor.static.highlightable ) {
6662 // Was only highlighted, now let's select it. No-op if already selected.
6663 this.chooseItem( currentItem );
6664 handled = true;
6665 }
6666 break;
6667 case OO.ui.Keys.UP:
6668 case OO.ui.Keys.LEFT:
6669 this.clearKeyPressBuffer();
6670 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6671 handled = true;
6672 break;
6673 case OO.ui.Keys.DOWN:
6674 case OO.ui.Keys.RIGHT:
6675 this.clearKeyPressBuffer();
6676 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6677 handled = true;
6678 break;
6679 case OO.ui.Keys.ESCAPE:
6680 case OO.ui.Keys.TAB:
6681 if ( currentItem && currentItem.constructor.static.highlightable ) {
6682 currentItem.setHighlighted( false );
6683 }
6684 this.unbindDocumentKeyDownListener();
6685 this.unbindDocumentKeyPressListener();
6686 // Don't prevent tabbing away / defocusing
6687 handled = false;
6688 break;
6689 }
6690
6691 if ( nextItem ) {
6692 if ( nextItem.constructor.static.highlightable ) {
6693 this.highlightItem( nextItem );
6694 } else {
6695 this.chooseItem( nextItem );
6696 }
6697 this.scrollItemIntoView( nextItem );
6698 }
6699
6700 if ( handled ) {
6701 e.preventDefault();
6702 e.stopPropagation();
6703 }
6704 }
6705 };
6706
6707 // Deprecated alias since 0.28.3
6708 OO.ui.SelectWidget.prototype.onKeyDown = function () {
6709 OO.ui.warnDeprecation( 'onKeyDown is deprecated, use onDocumentKeyDown instead' );
6710 this.onDocumentKeyDown.apply( this, arguments );
6711 };
6712
6713 /**
6714 * Bind document key down listener.
6715 *
6716 * @protected
6717 */
6718 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6719 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6720 };
6721
6722 // Deprecated alias since 0.28.3
6723 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6724 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
6725 this.bindDocumentKeyDownListener.apply( this, arguments );
6726 };
6727
6728 /**
6729 * Unbind document key down listener.
6730 *
6731 * @protected
6732 */
6733 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6734 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6735 };
6736
6737 // Deprecated alias since 0.28.3
6738 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6739 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
6740 this.unbindDocumentKeyDownListener.apply( this, arguments );
6741 };
6742
6743 /**
6744 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6745 *
6746 * @param {OO.ui.OptionWidget} item Item to scroll into view
6747 */
6748 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6749 var widget = this;
6750 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6751 // and around 100-150 ms after it is finished.
6752 this.blockMouseOverEvents++;
6753 item.scrollElementIntoView().done( function () {
6754 setTimeout( function () {
6755 widget.blockMouseOverEvents--;
6756 }, 200 );
6757 } );
6758 };
6759
6760 /**
6761 * Clear the key-press buffer
6762 *
6763 * @protected
6764 */
6765 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6766 if ( this.keyPressBufferTimer ) {
6767 clearTimeout( this.keyPressBufferTimer );
6768 this.keyPressBufferTimer = null;
6769 }
6770 this.keyPressBuffer = '';
6771 };
6772
6773 /**
6774 * Handle key press events.
6775 *
6776 * @protected
6777 * @param {KeyboardEvent} e Key press event
6778 * @return {undefined/boolean} False to prevent default if event is handled
6779 */
6780 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6781 var c, filter, item;
6782
6783 if ( !e.charCode ) {
6784 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6785 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6786 return false;
6787 }
6788 return;
6789 }
6790 // eslint-disable-next-line no-restricted-properties
6791 if ( String.fromCodePoint ) {
6792 // eslint-disable-next-line no-restricted-properties
6793 c = String.fromCodePoint( e.charCode );
6794 } else {
6795 c = String.fromCharCode( e.charCode );
6796 }
6797
6798 if ( this.keyPressBufferTimer ) {
6799 clearTimeout( this.keyPressBufferTimer );
6800 }
6801 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6802
6803 item = this.findHighlightedItem() || this.findSelectedItem();
6804
6805 if ( this.keyPressBuffer === c ) {
6806 // Common (if weird) special case: typing "xxxx" will cycle through all
6807 // the items beginning with "x".
6808 if ( item ) {
6809 item = this.findRelativeSelectableItem( item, 1 );
6810 }
6811 } else {
6812 this.keyPressBuffer += c;
6813 }
6814
6815 filter = this.getItemMatcher( this.keyPressBuffer, false );
6816 if ( !item || !filter( item ) ) {
6817 item = this.findRelativeSelectableItem( item, 1, filter );
6818 }
6819 if ( item ) {
6820 if ( this.isVisible() && item.constructor.static.highlightable ) {
6821 this.highlightItem( item );
6822 } else {
6823 this.chooseItem( item );
6824 }
6825 this.scrollItemIntoView( item );
6826 }
6827
6828 e.preventDefault();
6829 e.stopPropagation();
6830 };
6831
6832 // Deprecated alias since 0.28.3
6833 OO.ui.SelectWidget.prototype.onKeyPress = function () {
6834 OO.ui.warnDeprecation( 'onKeyPress is deprecated, use onDocumentKeyPress instead' );
6835 this.onDocumentKeyPress.apply( this, arguments );
6836 };
6837
6838 /**
6839 * Get a matcher for the specific string
6840 *
6841 * @protected
6842 * @param {string} s String to match against items
6843 * @param {boolean} [exact=false] Only accept exact matches
6844 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6845 */
6846 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6847 var re;
6848
6849 // eslint-disable-next-line no-restricted-properties
6850 if ( s.normalize ) {
6851 // eslint-disable-next-line no-restricted-properties
6852 s = s.normalize();
6853 }
6854 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6855 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6856 if ( exact ) {
6857 re += '\\s*$';
6858 }
6859 re = new RegExp( re, 'i' );
6860 return function ( item ) {
6861 var matchText = item.getMatchText();
6862 // eslint-disable-next-line no-restricted-properties
6863 if ( matchText.normalize ) {
6864 // eslint-disable-next-line no-restricted-properties
6865 matchText = matchText.normalize();
6866 }
6867 return re.test( matchText );
6868 };
6869 };
6870
6871 /**
6872 * Bind document key press listener.
6873 *
6874 * @protected
6875 */
6876 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6877 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6878 };
6879
6880 // Deprecated alias since 0.28.3
6881 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6882 OO.ui.warnDeprecation( 'bindKeyPressListener is deprecated, use bindDocumentKeyPressListener instead' );
6883 this.bindDocumentKeyPressListener.apply( this, arguments );
6884 };
6885
6886 /**
6887 * Unbind document key down listener.
6888 *
6889 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6890 * implementation.
6891 *
6892 * @protected
6893 */
6894 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6895 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6896 this.clearKeyPressBuffer();
6897 };
6898
6899 // Deprecated alias since 0.28.3
6900 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6901 OO.ui.warnDeprecation( 'unbindKeyPressListener is deprecated, use unbindDocumentKeyPressListener instead' );
6902 this.unbindDocumentKeyPressListener.apply( this, arguments );
6903 };
6904
6905 /**
6906 * Visibility change handler
6907 *
6908 * @protected
6909 * @param {boolean} visible
6910 */
6911 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6912 if ( !visible ) {
6913 this.clearKeyPressBuffer();
6914 }
6915 };
6916
6917 /**
6918 * Get the closest item to a jQuery.Event.
6919 *
6920 * @private
6921 * @param {jQuery.Event} e
6922 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6923 */
6924 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6925 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6926 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6927 return null;
6928 }
6929 return $option.data( 'oo-ui-optionWidget' ) || null;
6930 };
6931
6932 /**
6933 * Find selected item.
6934 *
6935 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6936 */
6937 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6938 var i, len;
6939
6940 for ( i = 0, len = this.items.length; i < len; i++ ) {
6941 if ( this.items[ i ].isSelected() ) {
6942 return this.items[ i ];
6943 }
6944 }
6945 return null;
6946 };
6947
6948 /**
6949 * Find highlighted item.
6950 *
6951 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6952 */
6953 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6954 var i, len;
6955
6956 for ( i = 0, len = this.items.length; i < len; i++ ) {
6957 if ( this.items[ i ].isHighlighted() ) {
6958 return this.items[ i ];
6959 }
6960 }
6961 return null;
6962 };
6963
6964 /**
6965 * Toggle pressed state.
6966 *
6967 * Press is a state that occurs when a user mouses down on an item, but
6968 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6969 * until the user releases the mouse.
6970 *
6971 * @param {boolean} pressed An option is being pressed
6972 */
6973 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6974 if ( pressed === undefined ) {
6975 pressed = !this.pressed;
6976 }
6977 if ( pressed !== this.pressed ) {
6978 this.$element
6979 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6980 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6981 this.pressed = pressed;
6982 }
6983 };
6984
6985 /**
6986 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6987 * and any existing highlight will be removed. The highlight is mutually exclusive.
6988 *
6989 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6990 * @fires highlight
6991 * @chainable
6992 * @return {OO.ui.Widget} The widget, for chaining
6993 */
6994 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6995 var i, len, highlighted,
6996 changed = false;
6997
6998 for ( i = 0, len = this.items.length; i < len; i++ ) {
6999 highlighted = this.items[ i ] === item;
7000 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7001 this.items[ i ].setHighlighted( highlighted );
7002 changed = true;
7003 }
7004 }
7005 if ( changed ) {
7006 if ( item ) {
7007 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7008 } else {
7009 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7010 }
7011 this.emit( 'highlight', item );
7012 }
7013
7014 return this;
7015 };
7016
7017 /**
7018 * Fetch an item by its label.
7019 *
7020 * @param {string} label Label of the item to select.
7021 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7022 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7023 */
7024 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7025 var i, item, found,
7026 len = this.items.length,
7027 filter = this.getItemMatcher( label, true );
7028
7029 for ( i = 0; i < len; i++ ) {
7030 item = this.items[ i ];
7031 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7032 return item;
7033 }
7034 }
7035
7036 if ( prefix ) {
7037 found = null;
7038 filter = this.getItemMatcher( label, false );
7039 for ( i = 0; i < len; i++ ) {
7040 item = this.items[ i ];
7041 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7042 if ( found ) {
7043 return null;
7044 }
7045 found = item;
7046 }
7047 }
7048 if ( found ) {
7049 return found;
7050 }
7051 }
7052
7053 return null;
7054 };
7055
7056 /**
7057 * Programmatically select an option by its label. If the item does not exist,
7058 * all options will be deselected.
7059 *
7060 * @param {string} [label] Label of the item to select.
7061 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7062 * @fires select
7063 * @chainable
7064 * @return {OO.ui.Widget} The widget, for chaining
7065 */
7066 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7067 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7068 if ( label === undefined || !itemFromLabel ) {
7069 return this.selectItem();
7070 }
7071 return this.selectItem( itemFromLabel );
7072 };
7073
7074 /**
7075 * Programmatically select an option by its data. If the `data` parameter is omitted,
7076 * or if the item does not exist, all options will be deselected.
7077 *
7078 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7079 * @fires select
7080 * @chainable
7081 * @return {OO.ui.Widget} The widget, for chaining
7082 */
7083 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7084 var itemFromData = this.findItemFromData( data );
7085 if ( data === undefined || !itemFromData ) {
7086 return this.selectItem();
7087 }
7088 return this.selectItem( itemFromData );
7089 };
7090
7091 /**
7092 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7093 * all options will be deselected.
7094 *
7095 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7096 * @fires select
7097 * @chainable
7098 * @return {OO.ui.Widget} The widget, for chaining
7099 */
7100 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7101 var i, len, selected,
7102 changed = false;
7103
7104 for ( i = 0, len = this.items.length; i < len; i++ ) {
7105 selected = this.items[ i ] === item;
7106 if ( this.items[ i ].isSelected() !== selected ) {
7107 this.items[ i ].setSelected( selected );
7108 changed = true;
7109 }
7110 }
7111 if ( changed ) {
7112 if ( item && !item.constructor.static.highlightable ) {
7113 if ( item ) {
7114 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7115 } else {
7116 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7117 }
7118 }
7119 this.emit( 'select', item );
7120 }
7121
7122 return this;
7123 };
7124
7125 /**
7126 * Press an item.
7127 *
7128 * Press is a state that occurs when a user mouses down on an item, but has not
7129 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7130 * releases the mouse.
7131 *
7132 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7133 * @fires press
7134 * @chainable
7135 * @return {OO.ui.Widget} The widget, for chaining
7136 */
7137 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7138 var i, len, pressed,
7139 changed = false;
7140
7141 for ( i = 0, len = this.items.length; i < len; i++ ) {
7142 pressed = this.items[ i ] === item;
7143 if ( this.items[ i ].isPressed() !== pressed ) {
7144 this.items[ i ].setPressed( pressed );
7145 changed = true;
7146 }
7147 }
7148 if ( changed ) {
7149 this.emit( 'press', item );
7150 }
7151
7152 return this;
7153 };
7154
7155 /**
7156 * Choose an item.
7157 *
7158 * Note that ‘choose’ should never be modified programmatically. A user can choose
7159 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7160 * use the #selectItem method.
7161 *
7162 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7163 * when users choose an item with the keyboard or mouse.
7164 *
7165 * @param {OO.ui.OptionWidget} item Item to choose
7166 * @fires choose
7167 * @chainable
7168 * @return {OO.ui.Widget} The widget, for chaining
7169 */
7170 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7171 if ( item ) {
7172 this.selectItem( item );
7173 this.emit( 'choose', item );
7174 }
7175
7176 return this;
7177 };
7178
7179 /**
7180 * Find an option by its position relative to the specified item (or to the start of the option array,
7181 * if item is `null`). The direction in which to search through the option array is specified with a
7182 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
7183 * `null` if there are no options in the array.
7184 *
7185 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
7186 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7187 * @param {Function} [filter] Only consider items for which this function returns
7188 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7189 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7190 */
7191 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7192 var currentIndex, nextIndex, i,
7193 increase = direction > 0 ? 1 : -1,
7194 len = this.items.length;
7195
7196 if ( item instanceof OO.ui.OptionWidget ) {
7197 currentIndex = this.items.indexOf( item );
7198 nextIndex = ( currentIndex + increase + len ) % len;
7199 } else {
7200 // If no item is selected and moving forward, start at the beginning.
7201 // If moving backward, start at the end.
7202 nextIndex = direction > 0 ? 0 : len - 1;
7203 }
7204
7205 for ( i = 0; i < len; i++ ) {
7206 item = this.items[ nextIndex ];
7207 if (
7208 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7209 ( !filter || filter( item ) )
7210 ) {
7211 return item;
7212 }
7213 nextIndex = ( nextIndex + increase + len ) % len;
7214 }
7215 return null;
7216 };
7217
7218 /**
7219 * Find the next selectable item or `null` if there are no selectable items.
7220 * Disabled options and menu-section markers and breaks are not selectable.
7221 *
7222 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7223 */
7224 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7225 return this.findRelativeSelectableItem( null, 1 );
7226 };
7227
7228 /**
7229 * Add an array of options to the select. Optionally, an index number can be used to
7230 * specify an insertion point.
7231 *
7232 * @param {OO.ui.OptionWidget[]} items Items to add
7233 * @param {number} [index] Index to insert items after
7234 * @fires add
7235 * @chainable
7236 * @return {OO.ui.Widget} The widget, for chaining
7237 */
7238 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7239 // Mixin method
7240 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7241
7242 // Always provide an index, even if it was omitted
7243 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7244
7245 return this;
7246 };
7247
7248 /**
7249 * Remove the specified array of options from the select. Options will be detached
7250 * from the DOM, not removed, so they can be reused later. To remove all options from
7251 * the select, you may wish to use the #clearItems method instead.
7252 *
7253 * @param {OO.ui.OptionWidget[]} items Items to remove
7254 * @fires remove
7255 * @chainable
7256 * @return {OO.ui.Widget} The widget, for chaining
7257 */
7258 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7259 var i, len, item;
7260
7261 // Deselect items being removed
7262 for ( i = 0, len = items.length; i < len; i++ ) {
7263 item = items[ i ];
7264 if ( item.isSelected() ) {
7265 this.selectItem( null );
7266 }
7267 }
7268
7269 // Mixin method
7270 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7271
7272 this.emit( 'remove', items );
7273
7274 return this;
7275 };
7276
7277 /**
7278 * Clear all options from the select. Options will be detached from the DOM, not removed,
7279 * so that they can be reused later. To remove a subset of options from the select, use
7280 * the #removeItems method.
7281 *
7282 * @fires remove
7283 * @chainable
7284 * @return {OO.ui.Widget} The widget, for chaining
7285 */
7286 OO.ui.SelectWidget.prototype.clearItems = function () {
7287 var items = this.items.slice();
7288
7289 // Mixin method
7290 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7291
7292 // Clear selection
7293 this.selectItem( null );
7294
7295 this.emit( 'remove', items );
7296
7297 return this;
7298 };
7299
7300 /**
7301 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7302 *
7303 * Currently this is just used to set `aria-activedescendant` on it.
7304 *
7305 * @protected
7306 * @param {jQuery} $focusOwner
7307 */
7308 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7309 this.$focusOwner = $focusOwner;
7310 };
7311
7312 /**
7313 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7314 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7315 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7316 * options. For more information about options and selects, please see the
7317 * [OOUI documentation on MediaWiki][1].
7318 *
7319 * @example
7320 * // Decorated options in a select widget
7321 * var select = new OO.ui.SelectWidget( {
7322 * items: [
7323 * new OO.ui.DecoratedOptionWidget( {
7324 * data: 'a',
7325 * label: 'Option with icon',
7326 * icon: 'help'
7327 * } ),
7328 * new OO.ui.DecoratedOptionWidget( {
7329 * data: 'b',
7330 * label: 'Option with indicator',
7331 * indicator: 'next'
7332 * } )
7333 * ]
7334 * } );
7335 * $( 'body' ).append( select.$element );
7336 *
7337 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7338 *
7339 * @class
7340 * @extends OO.ui.OptionWidget
7341 * @mixins OO.ui.mixin.IconElement
7342 * @mixins OO.ui.mixin.IndicatorElement
7343 *
7344 * @constructor
7345 * @param {Object} [config] Configuration options
7346 */
7347 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7348 // Parent constructor
7349 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7350
7351 // Mixin constructors
7352 OO.ui.mixin.IconElement.call( this, config );
7353 OO.ui.mixin.IndicatorElement.call( this, config );
7354
7355 // Initialization
7356 this.$element
7357 .addClass( 'oo-ui-decoratedOptionWidget' )
7358 .prepend( this.$icon )
7359 .append( this.$indicator );
7360 };
7361
7362 /* Setup */
7363
7364 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7365 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7366 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7367
7368 /**
7369 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7370 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7371 * the [OOUI documentation on MediaWiki] [1] for more information.
7372 *
7373 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7374 *
7375 * @class
7376 * @extends OO.ui.DecoratedOptionWidget
7377 *
7378 * @constructor
7379 * @param {Object} [config] Configuration options
7380 */
7381 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7382 // Parent constructor
7383 OO.ui.MenuOptionWidget.parent.call( this, config );
7384
7385 // Properties
7386 this.checkIcon = new OO.ui.IconWidget( {
7387 icon: 'check',
7388 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7389 } );
7390
7391 // Initialization
7392 this.$element
7393 .prepend( this.checkIcon.$element )
7394 .addClass( 'oo-ui-menuOptionWidget' );
7395 };
7396
7397 /* Setup */
7398
7399 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7400
7401 /* Static Properties */
7402
7403 /**
7404 * @static
7405 * @inheritdoc
7406 */
7407 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7408
7409 /**
7410 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7411 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7412 *
7413 * @example
7414 * var myDropdown = new OO.ui.DropdownWidget( {
7415 * menu: {
7416 * items: [
7417 * new OO.ui.MenuSectionOptionWidget( {
7418 * label: 'Dogs'
7419 * } ),
7420 * new OO.ui.MenuOptionWidget( {
7421 * data: 'corgi',
7422 * label: 'Welsh Corgi'
7423 * } ),
7424 * new OO.ui.MenuOptionWidget( {
7425 * data: 'poodle',
7426 * label: 'Standard Poodle'
7427 * } ),
7428 * new OO.ui.MenuSectionOptionWidget( {
7429 * label: 'Cats'
7430 * } ),
7431 * new OO.ui.MenuOptionWidget( {
7432 * data: 'lion',
7433 * label: 'Lion'
7434 * } )
7435 * ]
7436 * }
7437 * } );
7438 * $( 'body' ).append( myDropdown.$element );
7439 *
7440 * @class
7441 * @extends OO.ui.DecoratedOptionWidget
7442 *
7443 * @constructor
7444 * @param {Object} [config] Configuration options
7445 */
7446 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7447 // Parent constructor
7448 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7449
7450 // Initialization
7451 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7452 .removeAttr( 'role aria-selected' );
7453 };
7454
7455 /* Setup */
7456
7457 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7458
7459 /* Static Properties */
7460
7461 /**
7462 * @static
7463 * @inheritdoc
7464 */
7465 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7466
7467 /**
7468 * @static
7469 * @inheritdoc
7470 */
7471 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7472
7473 /**
7474 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7475 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7476 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7477 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7478 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7479 * and customized to be opened, closed, and displayed as needed.
7480 *
7481 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7482 * mouse outside the menu.
7483 *
7484 * Menus also have support for keyboard interaction:
7485 *
7486 * - Enter/Return key: choose and select a menu option
7487 * - Up-arrow key: highlight the previous menu option
7488 * - Down-arrow key: highlight the next menu option
7489 * - Esc key: hide the menu
7490 *
7491 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7492 *
7493 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7494 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7495 *
7496 * @class
7497 * @extends OO.ui.SelectWidget
7498 * @mixins OO.ui.mixin.ClippableElement
7499 * @mixins OO.ui.mixin.FloatableElement
7500 *
7501 * @constructor
7502 * @param {Object} [config] Configuration options
7503 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7504 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7505 * and {@link OO.ui.mixin.LookupElement LookupElement}
7506 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7507 * the text the user types. This config is used by {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7508 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7509 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7510 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7511 * that button, unless the button (or its parent widget) is passed in here.
7512 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7513 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7514 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7515 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7516 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7517 * @cfg {number} [width] Width of the menu
7518 */
7519 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7520 // Configuration initialization
7521 config = config || {};
7522
7523 // Parent constructor
7524 OO.ui.MenuSelectWidget.parent.call( this, config );
7525
7526 // Mixin constructors
7527 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7528 OO.ui.mixin.FloatableElement.call( this, config );
7529
7530 // Initial vertical positions other than 'center' will result in
7531 // the menu being flipped if there is not enough space in the container.
7532 // Store the original position so we know what to reset to.
7533 this.originalVerticalPosition = this.verticalPosition;
7534
7535 // Properties
7536 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7537 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7538 this.filterFromInput = !!config.filterFromInput;
7539 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7540 this.$widget = config.widget ? config.widget.$element : null;
7541 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7542 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7543 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7544 this.highlightOnFilter = !!config.highlightOnFilter;
7545 this.width = config.width;
7546
7547 // Initialization
7548 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7549 if ( config.widget ) {
7550 this.setFocusOwner( config.widget.$tabIndexed );
7551 }
7552
7553 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7554 // that reference properties not initialized at that time of parent class construction
7555 // TODO: Find a better way to handle post-constructor setup
7556 this.visible = false;
7557 this.$element.addClass( 'oo-ui-element-hidden' );
7558 };
7559
7560 /* Setup */
7561
7562 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7563 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7564 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7565
7566 /* Events */
7567
7568 /**
7569 * @event ready
7570 *
7571 * The menu is ready: it is visible and has been positioned and clipped.
7572 */
7573
7574 /* Static properties */
7575
7576 /**
7577 * Positions to flip to if there isn't room in the container for the
7578 * menu in a specific direction.
7579 *
7580 * @property {Object.<string,string>}
7581 */
7582 OO.ui.MenuSelectWidget.static.flippedPositions = {
7583 below: 'above',
7584 above: 'below',
7585 top: 'bottom',
7586 bottom: 'top'
7587 };
7588
7589 /* Methods */
7590
7591 /**
7592 * Handles document mouse down events.
7593 *
7594 * @protected
7595 * @param {MouseEvent} e Mouse down event
7596 */
7597 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7598 if (
7599 this.isVisible() &&
7600 !OO.ui.contains(
7601 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7602 e.target,
7603 true
7604 )
7605 ) {
7606 this.toggle( false );
7607 }
7608 };
7609
7610 /**
7611 * @inheritdoc
7612 */
7613 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7614 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7615
7616 if ( !this.isDisabled() && this.isVisible() ) {
7617 switch ( e.keyCode ) {
7618 case OO.ui.Keys.LEFT:
7619 case OO.ui.Keys.RIGHT:
7620 // Do nothing if a text field is associated, arrow keys will be handled natively
7621 if ( !this.$input ) {
7622 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7623 }
7624 break;
7625 case OO.ui.Keys.ESCAPE:
7626 case OO.ui.Keys.TAB:
7627 if ( currentItem ) {
7628 currentItem.setHighlighted( false );
7629 }
7630 this.toggle( false );
7631 // Don't prevent tabbing away, prevent defocusing
7632 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7633 e.preventDefault();
7634 e.stopPropagation();
7635 }
7636 break;
7637 default:
7638 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7639 return;
7640 }
7641 }
7642 };
7643
7644 /**
7645 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7646 * or after items were added/removed (always).
7647 *
7648 * @protected
7649 */
7650 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7651 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7652 anyVisible = false,
7653 len = this.items.length,
7654 showAll = !this.isVisible(),
7655 exactMatch = false;
7656
7657 if ( this.$input && this.filterFromInput ) {
7658 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7659 exactFilter = this.getItemMatcher( this.$input.val(), true );
7660 // Hide non-matching options, and also hide section headers if all options
7661 // in their section are hidden.
7662 for ( i = 0; i < len; i++ ) {
7663 item = this.items[ i ];
7664 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7665 if ( section ) {
7666 // If the previous section was empty, hide its header
7667 section.toggle( showAll || !sectionEmpty );
7668 }
7669 section = item;
7670 sectionEmpty = true;
7671 } else if ( item instanceof OO.ui.OptionWidget ) {
7672 visible = showAll || filter( item );
7673 exactMatch = exactMatch || exactFilter( item );
7674 anyVisible = anyVisible || visible;
7675 sectionEmpty = sectionEmpty && !visible;
7676 item.toggle( visible );
7677 }
7678 }
7679 // Process the final section
7680 if ( section ) {
7681 section.toggle( showAll || !sectionEmpty );
7682 }
7683
7684 if ( anyVisible && this.items.length && !exactMatch ) {
7685 this.scrollItemIntoView( this.items[ 0 ] );
7686 }
7687
7688 if ( !anyVisible ) {
7689 this.highlightItem( null );
7690 }
7691
7692 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7693
7694 if ( this.highlightOnFilter ) {
7695 // Highlight the first item on the list
7696 item = null;
7697 items = this.getItems();
7698 for ( i = 0; i < items.length; i++ ) {
7699 if ( items[ i ].isVisible() ) {
7700 item = items[ i ];
7701 break;
7702 }
7703 }
7704 this.highlightItem( item );
7705 }
7706
7707 }
7708
7709 // Reevaluate clipping
7710 this.clip();
7711 };
7712
7713 /**
7714 * @inheritdoc
7715 */
7716 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7717 if ( this.$input ) {
7718 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7719 } else {
7720 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7721 }
7722 };
7723
7724 /**
7725 * @inheritdoc
7726 */
7727 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7728 if ( this.$input ) {
7729 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7730 } else {
7731 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7732 }
7733 };
7734
7735 /**
7736 * @inheritdoc
7737 */
7738 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7739 if ( this.$input ) {
7740 if ( this.filterFromInput ) {
7741 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7742 this.updateItemVisibility();
7743 }
7744 } else {
7745 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7746 }
7747 };
7748
7749 /**
7750 * @inheritdoc
7751 */
7752 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7753 if ( this.$input ) {
7754 if ( this.filterFromInput ) {
7755 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7756 this.updateItemVisibility();
7757 }
7758 } else {
7759 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7760 }
7761 };
7762
7763 /**
7764 * Choose an item.
7765 *
7766 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7767 *
7768 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7769 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7770 *
7771 * @param {OO.ui.OptionWidget} item Item to choose
7772 * @chainable
7773 * @return {OO.ui.Widget} The widget, for chaining
7774 */
7775 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7776 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7777 if ( this.hideOnChoose ) {
7778 this.toggle( false );
7779 }
7780 return this;
7781 };
7782
7783 /**
7784 * @inheritdoc
7785 */
7786 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7787 // Parent method
7788 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7789
7790 this.updateItemVisibility();
7791
7792 return this;
7793 };
7794
7795 /**
7796 * @inheritdoc
7797 */
7798 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7799 // Parent method
7800 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7801
7802 this.updateItemVisibility();
7803
7804 return this;
7805 };
7806
7807 /**
7808 * @inheritdoc
7809 */
7810 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7811 // Parent method
7812 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7813
7814 this.updateItemVisibility();
7815
7816 return this;
7817 };
7818
7819 /**
7820 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7821 * `.toggle( true )` after its #$element is attached to the DOM.
7822 *
7823 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7824 * it in the right place and with the right dimensions only work correctly while it is attached.
7825 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7826 * strictly enforced, so currently it only generates a warning in the browser console.
7827 *
7828 * @fires ready
7829 * @inheritdoc
7830 */
7831 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7832 var change, originalHeight, flippedHeight;
7833
7834 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7835 change = visible !== this.isVisible();
7836
7837 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7838 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7839 this.warnedUnattached = true;
7840 }
7841
7842 if ( change && visible ) {
7843 // Reset position before showing the popup again. It's possible we no longer need to flip
7844 // (e.g. if the user scrolled).
7845 this.setVerticalPosition( this.originalVerticalPosition );
7846 }
7847
7848 // Parent method
7849 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7850
7851 if ( change ) {
7852 if ( visible ) {
7853
7854 if ( this.width ) {
7855 this.setIdealSize( this.width );
7856 } else if ( this.$floatableContainer ) {
7857 this.$clippable.css( 'width', 'auto' );
7858 this.setIdealSize(
7859 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7860 // Dropdown is smaller than handle so expand to width
7861 this.$floatableContainer[ 0 ].offsetWidth :
7862 // Dropdown is larger than handle so auto size
7863 'auto'
7864 );
7865 this.$clippable.css( 'width', '' );
7866 }
7867
7868 this.togglePositioning( !!this.$floatableContainer );
7869 this.toggleClipping( true );
7870
7871 this.bindDocumentKeyDownListener();
7872 this.bindDocumentKeyPressListener();
7873
7874 if (
7875 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7876 this.originalVerticalPosition !== 'center'
7877 ) {
7878 // If opening the menu in one direction causes it to be clipped, flip it
7879 originalHeight = this.$element.height();
7880 this.setVerticalPosition(
7881 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7882 );
7883 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7884 // If flipping also causes it to be clipped, open in whichever direction
7885 // we have more space
7886 flippedHeight = this.$element.height();
7887 if ( originalHeight > flippedHeight ) {
7888 this.setVerticalPosition( this.originalVerticalPosition );
7889 }
7890 }
7891 }
7892 // Note that we do not flip the menu's opening direction if the clipping changes
7893 // later (e.g. after the user scrolls), that seems like it would be annoying
7894
7895 this.$focusOwner.attr( 'aria-expanded', 'true' );
7896
7897 if ( this.findSelectedItem() ) {
7898 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7899 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7900 }
7901
7902 // Auto-hide
7903 if ( this.autoHide ) {
7904 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7905 }
7906
7907 this.emit( 'ready' );
7908 } else {
7909 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7910 this.unbindDocumentKeyDownListener();
7911 this.unbindDocumentKeyPressListener();
7912 this.$focusOwner.attr( 'aria-expanded', 'false' );
7913 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7914 this.togglePositioning( false );
7915 this.toggleClipping( false );
7916 }
7917 }
7918
7919 return this;
7920 };
7921
7922 /**
7923 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7924 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7925 * users can interact with it.
7926 *
7927 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7928 * OO.ui.DropdownInputWidget instead.
7929 *
7930 * @example
7931 * // Example: A DropdownWidget with a menu that contains three options
7932 * var dropDown = new OO.ui.DropdownWidget( {
7933 * label: 'Dropdown menu: Select a menu option',
7934 * menu: {
7935 * items: [
7936 * new OO.ui.MenuOptionWidget( {
7937 * data: 'a',
7938 * label: 'First'
7939 * } ),
7940 * new OO.ui.MenuOptionWidget( {
7941 * data: 'b',
7942 * label: 'Second'
7943 * } ),
7944 * new OO.ui.MenuOptionWidget( {
7945 * data: 'c',
7946 * label: 'Third'
7947 * } )
7948 * ]
7949 * }
7950 * } );
7951 *
7952 * $( 'body' ).append( dropDown.$element );
7953 *
7954 * dropDown.getMenu().selectItemByData( 'b' );
7955 *
7956 * dropDown.getMenu().findSelectedItem().getData(); // returns 'b'
7957 *
7958 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7959 *
7960 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7961 *
7962 * @class
7963 * @extends OO.ui.Widget
7964 * @mixins OO.ui.mixin.IconElement
7965 * @mixins OO.ui.mixin.IndicatorElement
7966 * @mixins OO.ui.mixin.LabelElement
7967 * @mixins OO.ui.mixin.TitledElement
7968 * @mixins OO.ui.mixin.TabIndexedElement
7969 *
7970 * @constructor
7971 * @param {Object} [config] Configuration options
7972 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7973 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7974 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7975 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7976 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
7977 */
7978 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7979 // Configuration initialization
7980 config = $.extend( { indicator: 'down' }, config );
7981
7982 // Parent constructor
7983 OO.ui.DropdownWidget.parent.call( this, config );
7984
7985 // Properties (must be set before TabIndexedElement constructor call)
7986 this.$handle = $( '<button>' );
7987 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7988
7989 // Mixin constructors
7990 OO.ui.mixin.IconElement.call( this, config );
7991 OO.ui.mixin.IndicatorElement.call( this, config );
7992 OO.ui.mixin.LabelElement.call( this, config );
7993 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7994 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7995
7996 // Properties
7997 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7998 widget: this,
7999 $floatableContainer: this.$element
8000 }, config.menu ) );
8001
8002 // Events
8003 this.$handle.on( {
8004 click: this.onClick.bind( this ),
8005 keydown: this.onKeyDown.bind( this ),
8006 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
8007 keypress: this.menu.onDocumentKeyPressHandler,
8008 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8009 } );
8010 this.menu.connect( this, {
8011 select: 'onMenuSelect',
8012 toggle: 'onMenuToggle'
8013 } );
8014
8015 // Initialization
8016 this.$handle
8017 .addClass( 'oo-ui-dropdownWidget-handle' )
8018 .attr( {
8019 'aria-owns': this.menu.getElementId(),
8020 'aria-haspopup': 'listbox'
8021 } )
8022 .append( this.$icon, this.$label, this.$indicator );
8023 this.$element
8024 .addClass( 'oo-ui-dropdownWidget' )
8025 .append( this.$handle );
8026 this.$overlay.append( this.menu.$element );
8027 };
8028
8029 /* Setup */
8030
8031 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8032 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8033 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8034 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8035 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8036 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8037
8038 /* Methods */
8039
8040 /**
8041 * Get the menu.
8042 *
8043 * @return {OO.ui.MenuSelectWidget} Menu of widget
8044 */
8045 OO.ui.DropdownWidget.prototype.getMenu = function () {
8046 return this.menu;
8047 };
8048
8049 /**
8050 * Handles menu select events.
8051 *
8052 * @private
8053 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8054 */
8055 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8056 var selectedLabel;
8057
8058 if ( !item ) {
8059 this.setLabel( null );
8060 return;
8061 }
8062
8063 selectedLabel = item.getLabel();
8064
8065 // If the label is a DOM element, clone it, because setLabel will append() it
8066 if ( selectedLabel instanceof $ ) {
8067 selectedLabel = selectedLabel.clone();
8068 }
8069
8070 this.setLabel( selectedLabel );
8071 };
8072
8073 /**
8074 * Handle menu toggle events.
8075 *
8076 * @private
8077 * @param {boolean} isVisible Open state of the menu
8078 */
8079 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8080 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8081 this.$handle.attr(
8082 'aria-expanded',
8083 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
8084 );
8085 };
8086
8087 /**
8088 * Handle mouse click events.
8089 *
8090 * @private
8091 * @param {jQuery.Event} e Mouse click event
8092 * @return {undefined/boolean} False to prevent default if event is handled
8093 */
8094 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8095 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8096 this.menu.toggle();
8097 }
8098 return false;
8099 };
8100
8101 /**
8102 * Handle key down events.
8103 *
8104 * @private
8105 * @param {jQuery.Event} e Key down event
8106 * @return {undefined/boolean} False to prevent default if event is handled
8107 */
8108 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8109 if (
8110 !this.isDisabled() &&
8111 (
8112 e.which === OO.ui.Keys.ENTER ||
8113 (
8114 e.which === OO.ui.Keys.SPACE &&
8115 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8116 // Space only closes the menu is the user is not typing to search.
8117 this.menu.keyPressBuffer === ''
8118 ) ||
8119 (
8120 !this.menu.isVisible() &&
8121 (
8122 e.which === OO.ui.Keys.UP ||
8123 e.which === OO.ui.Keys.DOWN
8124 )
8125 )
8126 )
8127 ) {
8128 this.menu.toggle();
8129 return false;
8130 }
8131 };
8132
8133 /**
8134 * RadioOptionWidget is an option widget that looks like a radio button.
8135 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8136 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8137 *
8138 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8139 *
8140 * @class
8141 * @extends OO.ui.OptionWidget
8142 *
8143 * @constructor
8144 * @param {Object} [config] Configuration options
8145 */
8146 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8147 // Configuration initialization
8148 config = config || {};
8149
8150 // Properties (must be done before parent constructor which calls #setDisabled)
8151 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8152
8153 // Parent constructor
8154 OO.ui.RadioOptionWidget.parent.call( this, config );
8155
8156 // Initialization
8157 // Remove implicit role, we're handling it ourselves
8158 this.radio.$input.attr( 'role', 'presentation' );
8159 this.$element
8160 .addClass( 'oo-ui-radioOptionWidget' )
8161 .attr( 'role', 'radio' )
8162 .attr( 'aria-checked', 'false' )
8163 .removeAttr( 'aria-selected' )
8164 .prepend( this.radio.$element );
8165 };
8166
8167 /* Setup */
8168
8169 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8170
8171 /* Static Properties */
8172
8173 /**
8174 * @static
8175 * @inheritdoc
8176 */
8177 OO.ui.RadioOptionWidget.static.highlightable = false;
8178
8179 /**
8180 * @static
8181 * @inheritdoc
8182 */
8183 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8184
8185 /**
8186 * @static
8187 * @inheritdoc
8188 */
8189 OO.ui.RadioOptionWidget.static.pressable = false;
8190
8191 /**
8192 * @static
8193 * @inheritdoc
8194 */
8195 OO.ui.RadioOptionWidget.static.tagName = 'label';
8196
8197 /* Methods */
8198
8199 /**
8200 * @inheritdoc
8201 */
8202 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8203 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8204
8205 this.radio.setSelected( state );
8206 this.$element
8207 .attr( 'aria-checked', state.toString() )
8208 .removeAttr( 'aria-selected' );
8209
8210 return this;
8211 };
8212
8213 /**
8214 * @inheritdoc
8215 */
8216 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8217 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8218
8219 this.radio.setDisabled( this.isDisabled() );
8220
8221 return this;
8222 };
8223
8224 /**
8225 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8226 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8227 * an interface for adding, removing and selecting options.
8228 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8229 *
8230 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8231 * OO.ui.RadioSelectInputWidget instead.
8232 *
8233 * @example
8234 * // A RadioSelectWidget with RadioOptions.
8235 * var option1 = new OO.ui.RadioOptionWidget( {
8236 * data: 'a',
8237 * label: 'Selected radio option'
8238 * } );
8239 *
8240 * var option2 = new OO.ui.RadioOptionWidget( {
8241 * data: 'b',
8242 * label: 'Unselected radio option'
8243 * } );
8244 *
8245 * var radioSelect=new OO.ui.RadioSelectWidget( {
8246 * items: [ option1, option2 ]
8247 * } );
8248 *
8249 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8250 * radioSelect.selectItem( option1 );
8251 *
8252 * $( 'body' ).append( radioSelect.$element );
8253 *
8254 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8255
8256 *
8257 * @class
8258 * @extends OO.ui.SelectWidget
8259 * @mixins OO.ui.mixin.TabIndexedElement
8260 *
8261 * @constructor
8262 * @param {Object} [config] Configuration options
8263 */
8264 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8265 // Parent constructor
8266 OO.ui.RadioSelectWidget.parent.call( this, config );
8267
8268 // Mixin constructors
8269 OO.ui.mixin.TabIndexedElement.call( this, config );
8270
8271 // Events
8272 this.$element.on( {
8273 focus: this.bindDocumentKeyDownListener.bind( this ),
8274 blur: this.unbindDocumentKeyDownListener.bind( this )
8275 } );
8276
8277 // Initialization
8278 this.$element
8279 .addClass( 'oo-ui-radioSelectWidget' )
8280 .attr( 'role', 'radiogroup' );
8281 };
8282
8283 /* Setup */
8284
8285 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8286 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8287
8288 /**
8289 * MultioptionWidgets are special elements that can be selected and configured with data. The
8290 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8291 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8292 * and examples, please see the [OOUI documentation on MediaWiki][1].
8293 *
8294 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8295 *
8296 * @class
8297 * @extends OO.ui.Widget
8298 * @mixins OO.ui.mixin.ItemWidget
8299 * @mixins OO.ui.mixin.LabelElement
8300 *
8301 * @constructor
8302 * @param {Object} [config] Configuration options
8303 * @cfg {boolean} [selected=false] Whether the option is initially selected
8304 */
8305 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8306 // Configuration initialization
8307 config = config || {};
8308
8309 // Parent constructor
8310 OO.ui.MultioptionWidget.parent.call( this, config );
8311
8312 // Mixin constructors
8313 OO.ui.mixin.ItemWidget.call( this );
8314 OO.ui.mixin.LabelElement.call( this, config );
8315
8316 // Properties
8317 this.selected = null;
8318
8319 // Initialization
8320 this.$element
8321 .addClass( 'oo-ui-multioptionWidget' )
8322 .append( this.$label );
8323 this.setSelected( config.selected );
8324 };
8325
8326 /* Setup */
8327
8328 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8329 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8330 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8331
8332 /* Events */
8333
8334 /**
8335 * @event change
8336 *
8337 * A change event is emitted when the selected state of the option changes.
8338 *
8339 * @param {boolean} selected Whether the option is now selected
8340 */
8341
8342 /* Methods */
8343
8344 /**
8345 * Check if the option is selected.
8346 *
8347 * @return {boolean} Item is selected
8348 */
8349 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8350 return this.selected;
8351 };
8352
8353 /**
8354 * Set the option’s selected state. In general, all modifications to the selection
8355 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8356 * method instead of this method.
8357 *
8358 * @param {boolean} [state=false] Select option
8359 * @chainable
8360 * @return {OO.ui.Widget} The widget, for chaining
8361 */
8362 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8363 state = !!state;
8364 if ( this.selected !== state ) {
8365 this.selected = state;
8366 this.emit( 'change', state );
8367 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8368 }
8369 return this;
8370 };
8371
8372 /**
8373 * MultiselectWidget allows selecting multiple options from a list.
8374 *
8375 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8376 *
8377 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8378 *
8379 * @class
8380 * @abstract
8381 * @extends OO.ui.Widget
8382 * @mixins OO.ui.mixin.GroupWidget
8383 *
8384 * @constructor
8385 * @param {Object} [config] Configuration options
8386 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8387 */
8388 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8389 // Parent constructor
8390 OO.ui.MultiselectWidget.parent.call( this, config );
8391
8392 // Configuration initialization
8393 config = config || {};
8394
8395 // Mixin constructors
8396 OO.ui.mixin.GroupWidget.call( this, config );
8397
8398 // Events
8399 this.aggregate( { change: 'select' } );
8400 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8401 // by GroupElement only when items are added/removed
8402 this.connect( this, { select: [ 'emit', 'change' ] } );
8403
8404 // Initialization
8405 if ( config.items ) {
8406 this.addItems( config.items );
8407 }
8408 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8409 this.$element.addClass( 'oo-ui-multiselectWidget' )
8410 .append( this.$group );
8411 };
8412
8413 /* Setup */
8414
8415 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8416 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8417
8418 /* Events */
8419
8420 /**
8421 * @event change
8422 *
8423 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8424 */
8425
8426 /**
8427 * @event select
8428 *
8429 * A select event is emitted when an item is selected or deselected.
8430 */
8431
8432 /* Methods */
8433
8434 /**
8435 * Find options that are selected.
8436 *
8437 * @return {OO.ui.MultioptionWidget[]} Selected options
8438 */
8439 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8440 return this.items.filter( function ( item ) {
8441 return item.isSelected();
8442 } );
8443 };
8444
8445 /**
8446 * Find the data of options that are selected.
8447 *
8448 * @return {Object[]|string[]} Values of selected options
8449 */
8450 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8451 return this.findSelectedItems().map( function ( item ) {
8452 return item.data;
8453 } );
8454 };
8455
8456 /**
8457 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8458 *
8459 * @param {OO.ui.MultioptionWidget[]} items Items to select
8460 * @chainable
8461 * @return {OO.ui.Widget} The widget, for chaining
8462 */
8463 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8464 this.items.forEach( function ( item ) {
8465 var selected = items.indexOf( item ) !== -1;
8466 item.setSelected( selected );
8467 } );
8468 return this;
8469 };
8470
8471 /**
8472 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8473 *
8474 * @param {Object[]|string[]} datas Values of items to select
8475 * @chainable
8476 * @return {OO.ui.Widget} The widget, for chaining
8477 */
8478 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8479 var items,
8480 widget = this;
8481 items = datas.map( function ( data ) {
8482 return widget.findItemFromData( data );
8483 } );
8484 this.selectItems( items );
8485 return this;
8486 };
8487
8488 /**
8489 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8490 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8491 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8492 *
8493 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8494 *
8495 * @class
8496 * @extends OO.ui.MultioptionWidget
8497 *
8498 * @constructor
8499 * @param {Object} [config] Configuration options
8500 */
8501 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8502 // Configuration initialization
8503 config = config || {};
8504
8505 // Properties (must be done before parent constructor which calls #setDisabled)
8506 this.checkbox = new OO.ui.CheckboxInputWidget();
8507
8508 // Parent constructor
8509 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8510
8511 // Events
8512 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8513 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8514
8515 // Initialization
8516 this.$element
8517 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8518 .prepend( this.checkbox.$element );
8519 };
8520
8521 /* Setup */
8522
8523 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8524
8525 /* Static Properties */
8526
8527 /**
8528 * @static
8529 * @inheritdoc
8530 */
8531 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8532
8533 /* Methods */
8534
8535 /**
8536 * Handle checkbox selected state change.
8537 *
8538 * @private
8539 */
8540 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8541 this.setSelected( this.checkbox.isSelected() );
8542 };
8543
8544 /**
8545 * @inheritdoc
8546 */
8547 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8548 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8549 this.checkbox.setSelected( state );
8550 return this;
8551 };
8552
8553 /**
8554 * @inheritdoc
8555 */
8556 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8557 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8558 this.checkbox.setDisabled( this.isDisabled() );
8559 return this;
8560 };
8561
8562 /**
8563 * Focus the widget.
8564 */
8565 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8566 this.checkbox.focus();
8567 };
8568
8569 /**
8570 * Handle key down events.
8571 *
8572 * @protected
8573 * @param {jQuery.Event} e
8574 */
8575 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8576 var
8577 element = this.getElementGroup(),
8578 nextItem;
8579
8580 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8581 nextItem = element.getRelativeFocusableItem( this, -1 );
8582 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8583 nextItem = element.getRelativeFocusableItem( this, 1 );
8584 }
8585
8586 if ( nextItem ) {
8587 e.preventDefault();
8588 nextItem.focus();
8589 }
8590 };
8591
8592 /**
8593 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8594 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8595 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8596 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8597 *
8598 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8599 * OO.ui.CheckboxMultiselectInputWidget instead.
8600 *
8601 * @example
8602 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8603 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8604 * data: 'a',
8605 * selected: true,
8606 * label: 'Selected checkbox'
8607 * } );
8608 *
8609 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8610 * data: 'b',
8611 * label: 'Unselected checkbox'
8612 * } );
8613 *
8614 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8615 * items: [ option1, option2 ]
8616 * } );
8617 *
8618 * $( 'body' ).append( multiselect.$element );
8619 *
8620 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8621 *
8622 * @class
8623 * @extends OO.ui.MultiselectWidget
8624 *
8625 * @constructor
8626 * @param {Object} [config] Configuration options
8627 */
8628 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8629 // Parent constructor
8630 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8631
8632 // Properties
8633 this.$lastClicked = null;
8634
8635 // Events
8636 this.$group.on( 'click', this.onClick.bind( this ) );
8637
8638 // Initialization
8639 this.$element
8640 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8641 };
8642
8643 /* Setup */
8644
8645 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8646
8647 /* Methods */
8648
8649 /**
8650 * Get an option by its position relative to the specified item (or to the start of the option array,
8651 * if item is `null`). The direction in which to search through the option array is specified with a
8652 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8653 * `null` if there are no options in the array.
8654 *
8655 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8656 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8657 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8658 */
8659 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8660 var currentIndex, nextIndex, i,
8661 increase = direction > 0 ? 1 : -1,
8662 len = this.items.length;
8663
8664 if ( item ) {
8665 currentIndex = this.items.indexOf( item );
8666 nextIndex = ( currentIndex + increase + len ) % len;
8667 } else {
8668 // If no item is selected and moving forward, start at the beginning.
8669 // If moving backward, start at the end.
8670 nextIndex = direction > 0 ? 0 : len - 1;
8671 }
8672
8673 for ( i = 0; i < len; i++ ) {
8674 item = this.items[ nextIndex ];
8675 if ( item && !item.isDisabled() ) {
8676 return item;
8677 }
8678 nextIndex = ( nextIndex + increase + len ) % len;
8679 }
8680 return null;
8681 };
8682
8683 /**
8684 * Handle click events on checkboxes.
8685 *
8686 * @param {jQuery.Event} e
8687 */
8688 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8689 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8690 $lastClicked = this.$lastClicked,
8691 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8692 .not( '.oo-ui-widget-disabled' );
8693
8694 // Allow selecting multiple options at once by Shift-clicking them
8695 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8696 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8697 lastClickedIndex = $options.index( $lastClicked );
8698 nowClickedIndex = $options.index( $nowClicked );
8699 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8700 // browser. In either case we don't need custom handling.
8701 if ( nowClickedIndex !== lastClickedIndex ) {
8702 items = this.items;
8703 wasSelected = items[ nowClickedIndex ].isSelected();
8704 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8705
8706 // This depends on the DOM order of the items and the order of the .items array being the same.
8707 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8708 if ( !items[ i ].isDisabled() ) {
8709 items[ i ].setSelected( !wasSelected );
8710 }
8711 }
8712 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8713 // handling first, then set our value. The order in which events happen is different for
8714 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8715 // non-click actions that change the checkboxes.
8716 e.preventDefault();
8717 setTimeout( function () {
8718 if ( !items[ nowClickedIndex ].isDisabled() ) {
8719 items[ nowClickedIndex ].setSelected( !wasSelected );
8720 }
8721 } );
8722 }
8723 }
8724
8725 if ( $nowClicked.length ) {
8726 this.$lastClicked = $nowClicked;
8727 }
8728 };
8729
8730 /**
8731 * Focus the widget
8732 *
8733 * @chainable
8734 * @return {OO.ui.Widget} The widget, for chaining
8735 */
8736 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8737 var item;
8738 if ( !this.isDisabled() ) {
8739 item = this.getRelativeFocusableItem( null, 1 );
8740 if ( item ) {
8741 item.focus();
8742 }
8743 }
8744 return this;
8745 };
8746
8747 /**
8748 * @inheritdoc
8749 */
8750 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8751 this.focus();
8752 };
8753
8754 /**
8755 * Progress bars visually display the status of an operation, such as a download,
8756 * and can be either determinate or indeterminate:
8757 *
8758 * - **determinate** process bars show the percent of an operation that is complete.
8759 *
8760 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8761 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8762 * not use percentages.
8763 *
8764 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8765 *
8766 * @example
8767 * // Examples of determinate and indeterminate progress bars.
8768 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8769 * progress: 33
8770 * } );
8771 * var progressBar2 = new OO.ui.ProgressBarWidget();
8772 *
8773 * // Create a FieldsetLayout to layout progress bars
8774 * var fieldset = new OO.ui.FieldsetLayout;
8775 * fieldset.addItems( [
8776 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8777 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8778 * ] );
8779 * $( 'body' ).append( fieldset.$element );
8780 *
8781 * @class
8782 * @extends OO.ui.Widget
8783 *
8784 * @constructor
8785 * @param {Object} [config] Configuration options
8786 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8787 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8788 * By default, the progress bar is indeterminate.
8789 */
8790 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8791 // Configuration initialization
8792 config = config || {};
8793
8794 // Parent constructor
8795 OO.ui.ProgressBarWidget.parent.call( this, config );
8796
8797 // Properties
8798 this.$bar = $( '<div>' );
8799 this.progress = null;
8800
8801 // Initialization
8802 this.setProgress( config.progress !== undefined ? config.progress : false );
8803 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8804 this.$element
8805 .attr( {
8806 role: 'progressbar',
8807 'aria-valuemin': 0,
8808 'aria-valuemax': 100
8809 } )
8810 .addClass( 'oo-ui-progressBarWidget' )
8811 .append( this.$bar );
8812 };
8813
8814 /* Setup */
8815
8816 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8817
8818 /* Static Properties */
8819
8820 /**
8821 * @static
8822 * @inheritdoc
8823 */
8824 OO.ui.ProgressBarWidget.static.tagName = 'div';
8825
8826 /* Methods */
8827
8828 /**
8829 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8830 *
8831 * @return {number|boolean} Progress percent
8832 */
8833 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8834 return this.progress;
8835 };
8836
8837 /**
8838 * Set the percent of the process completed or `false` for an indeterminate process.
8839 *
8840 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8841 */
8842 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8843 this.progress = progress;
8844
8845 if ( progress !== false ) {
8846 this.$bar.css( 'width', this.progress + '%' );
8847 this.$element.attr( 'aria-valuenow', this.progress );
8848 } else {
8849 this.$bar.css( 'width', '' );
8850 this.$element.removeAttr( 'aria-valuenow' );
8851 }
8852 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8853 };
8854
8855 /**
8856 * InputWidget is the base class for all input widgets, which
8857 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8858 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8859 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8860 *
8861 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8862 *
8863 * @abstract
8864 * @class
8865 * @extends OO.ui.Widget
8866 * @mixins OO.ui.mixin.FlaggedElement
8867 * @mixins OO.ui.mixin.TabIndexedElement
8868 * @mixins OO.ui.mixin.TitledElement
8869 * @mixins OO.ui.mixin.AccessKeyedElement
8870 *
8871 * @constructor
8872 * @param {Object} [config] Configuration options
8873 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8874 * @cfg {string} [value=''] The value of the input.
8875 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8876 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8877 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8878 * before it is accepted.
8879 */
8880 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8881 // Configuration initialization
8882 config = config || {};
8883
8884 // Parent constructor
8885 OO.ui.InputWidget.parent.call( this, config );
8886
8887 // Properties
8888 // See #reusePreInfuseDOM about config.$input
8889 this.$input = config.$input || this.getInputElement( config );
8890 this.value = '';
8891 this.inputFilter = config.inputFilter;
8892
8893 // Mixin constructors
8894 OO.ui.mixin.FlaggedElement.call( this, config );
8895 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8896 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8897 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8898
8899 // Events
8900 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8901
8902 // Initialization
8903 this.$input
8904 .addClass( 'oo-ui-inputWidget-input' )
8905 .attr( 'name', config.name )
8906 .prop( 'disabled', this.isDisabled() );
8907 this.$element
8908 .addClass( 'oo-ui-inputWidget' )
8909 .append( this.$input );
8910 this.setValue( config.value );
8911 if ( config.dir ) {
8912 this.setDir( config.dir );
8913 }
8914 if ( config.inputId !== undefined ) {
8915 this.setInputId( config.inputId );
8916 }
8917 };
8918
8919 /* Setup */
8920
8921 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8922 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8923 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8924 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8925 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8926
8927 /* Static Methods */
8928
8929 /**
8930 * @inheritdoc
8931 */
8932 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8933 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8934 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8935 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8936 return config;
8937 };
8938
8939 /**
8940 * @inheritdoc
8941 */
8942 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8943 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8944 if ( config.$input && config.$input.length ) {
8945 state.value = config.$input.val();
8946 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8947 state.focus = config.$input.is( ':focus' );
8948 }
8949 return state;
8950 };
8951
8952 /* Events */
8953
8954 /**
8955 * @event change
8956 *
8957 * A change event is emitted when the value of the input changes.
8958 *
8959 * @param {string} value
8960 */
8961
8962 /* Methods */
8963
8964 /**
8965 * Get input element.
8966 *
8967 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8968 * different circumstances. The element must have a `value` property (like form elements).
8969 *
8970 * @protected
8971 * @param {Object} config Configuration options
8972 * @return {jQuery} Input element
8973 */
8974 OO.ui.InputWidget.prototype.getInputElement = function () {
8975 return $( '<input>' );
8976 };
8977
8978 /**
8979 * Handle potentially value-changing events.
8980 *
8981 * @private
8982 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8983 */
8984 OO.ui.InputWidget.prototype.onEdit = function () {
8985 var widget = this;
8986 if ( !this.isDisabled() ) {
8987 // Allow the stack to clear so the value will be updated
8988 setTimeout( function () {
8989 widget.setValue( widget.$input.val() );
8990 } );
8991 }
8992 };
8993
8994 /**
8995 * Get the value of the input.
8996 *
8997 * @return {string} Input value
8998 */
8999 OO.ui.InputWidget.prototype.getValue = function () {
9000 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9001 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9002 var value = this.$input.val();
9003 if ( this.value !== value ) {
9004 this.setValue( value );
9005 }
9006 return this.value;
9007 };
9008
9009 /**
9010 * Set the directionality of the input.
9011 *
9012 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9013 * @chainable
9014 * @return {OO.ui.Widget} The widget, for chaining
9015 */
9016 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9017 this.$input.prop( 'dir', dir );
9018 return this;
9019 };
9020
9021 /**
9022 * Set the value of the input.
9023 *
9024 * @param {string} value New value
9025 * @fires change
9026 * @chainable
9027 * @return {OO.ui.Widget} The widget, for chaining
9028 */
9029 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9030 value = this.cleanUpValue( value );
9031 // Update the DOM if it has changed. Note that with cleanUpValue, it
9032 // is possible for the DOM value to change without this.value changing.
9033 if ( this.$input.val() !== value ) {
9034 this.$input.val( value );
9035 }
9036 if ( this.value !== value ) {
9037 this.value = value;
9038 this.emit( 'change', this.value );
9039 }
9040 // The first time that the value is set (probably while constructing the widget),
9041 // remember it in defaultValue. This property can be later used to check whether
9042 // the value of the input has been changed since it was created.
9043 if ( this.defaultValue === undefined ) {
9044 this.defaultValue = this.value;
9045 this.$input[ 0 ].defaultValue = this.defaultValue;
9046 }
9047 return this;
9048 };
9049
9050 /**
9051 * Clean up incoming value.
9052 *
9053 * Ensures value is a string, and converts undefined and null to empty string.
9054 *
9055 * @private
9056 * @param {string} value Original value
9057 * @return {string} Cleaned up value
9058 */
9059 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9060 if ( value === undefined || value === null ) {
9061 return '';
9062 } else if ( this.inputFilter ) {
9063 return this.inputFilter( String( value ) );
9064 } else {
9065 return String( value );
9066 }
9067 };
9068
9069 /**
9070 * @inheritdoc
9071 */
9072 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9073 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9074 if ( this.$input ) {
9075 this.$input.prop( 'disabled', this.isDisabled() );
9076 }
9077 return this;
9078 };
9079
9080 /**
9081 * Set the 'id' attribute of the `<input>` element.
9082 *
9083 * @param {string} id
9084 * @chainable
9085 * @return {OO.ui.Widget} The widget, for chaining
9086 */
9087 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9088 this.$input.attr( 'id', id );
9089 return this;
9090 };
9091
9092 /**
9093 * @inheritdoc
9094 */
9095 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9096 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9097 if ( state.value !== undefined && state.value !== this.getValue() ) {
9098 this.setValue( state.value );
9099 }
9100 if ( state.focus ) {
9101 this.focus();
9102 }
9103 };
9104
9105 /**
9106 * Data widget intended for creating 'hidden'-type inputs.
9107 *
9108 * @class
9109 * @extends OO.ui.Widget
9110 *
9111 * @constructor
9112 * @param {Object} [config] Configuration options
9113 * @cfg {string} [value=''] The value of the input.
9114 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9115 */
9116 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9117 // Configuration initialization
9118 config = $.extend( { value: '', name: '' }, config );
9119
9120 // Parent constructor
9121 OO.ui.HiddenInputWidget.parent.call( this, config );
9122
9123 // Initialization
9124 this.$element.attr( {
9125 type: 'hidden',
9126 value: config.value,
9127 name: config.name
9128 } );
9129 this.$element.removeAttr( 'aria-disabled' );
9130 };
9131
9132 /* Setup */
9133
9134 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9135
9136 /* Static Properties */
9137
9138 /**
9139 * @static
9140 * @inheritdoc
9141 */
9142 OO.ui.HiddenInputWidget.static.tagName = 'input';
9143
9144 /**
9145 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9146 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9147 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9148 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9149 * [OOUI documentation on MediaWiki] [1] for more information.
9150 *
9151 * @example
9152 * // A ButtonInputWidget rendered as an HTML button, the default.
9153 * var button = new OO.ui.ButtonInputWidget( {
9154 * label: 'Input button',
9155 * icon: 'check',
9156 * value: 'check'
9157 * } );
9158 * $( 'body' ).append( button.$element );
9159 *
9160 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9161 *
9162 * @class
9163 * @extends OO.ui.InputWidget
9164 * @mixins OO.ui.mixin.ButtonElement
9165 * @mixins OO.ui.mixin.IconElement
9166 * @mixins OO.ui.mixin.IndicatorElement
9167 * @mixins OO.ui.mixin.LabelElement
9168 * @mixins OO.ui.mixin.TitledElement
9169 *
9170 * @constructor
9171 * @param {Object} [config] Configuration options
9172 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
9173 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9174 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
9175 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
9176 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9177 */
9178 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9179 // Configuration initialization
9180 config = $.extend( { type: 'button', useInputTag: false }, config );
9181
9182 // See InputWidget#reusePreInfuseDOM about config.$input
9183 if ( config.$input ) {
9184 config.$input.empty();
9185 }
9186
9187 // Properties (must be set before parent constructor, which calls #setValue)
9188 this.useInputTag = config.useInputTag;
9189
9190 // Parent constructor
9191 OO.ui.ButtonInputWidget.parent.call( this, config );
9192
9193 // Mixin constructors
9194 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
9195 OO.ui.mixin.IconElement.call( this, config );
9196 OO.ui.mixin.IndicatorElement.call( this, config );
9197 OO.ui.mixin.LabelElement.call( this, config );
9198 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
9199
9200 // Initialization
9201 if ( !config.useInputTag ) {
9202 this.$input.append( this.$icon, this.$label, this.$indicator );
9203 }
9204 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9205 };
9206
9207 /* Setup */
9208
9209 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9210 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9211 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9212 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9213 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9214 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
9215
9216 /* Static Properties */
9217
9218 /**
9219 * @static
9220 * @inheritdoc
9221 */
9222 OO.ui.ButtonInputWidget.static.tagName = 'span';
9223
9224 /* Methods */
9225
9226 /**
9227 * @inheritdoc
9228 * @protected
9229 */
9230 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9231 var type;
9232 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9233 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9234 };
9235
9236 /**
9237 * Set label value.
9238 *
9239 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9240 *
9241 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9242 * text, or `null` for no label
9243 * @chainable
9244 * @return {OO.ui.Widget} The widget, for chaining
9245 */
9246 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9247 if ( typeof label === 'function' ) {
9248 label = OO.ui.resolveMsg( label );
9249 }
9250
9251 if ( this.useInputTag ) {
9252 // Discard non-plaintext labels
9253 if ( typeof label !== 'string' ) {
9254 label = '';
9255 }
9256
9257 this.$input.val( label );
9258 }
9259
9260 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9261 };
9262
9263 /**
9264 * Set the value of the input.
9265 *
9266 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9267 * they do not support {@link #value values}.
9268 *
9269 * @param {string} value New value
9270 * @chainable
9271 * @return {OO.ui.Widget} The widget, for chaining
9272 */
9273 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9274 if ( !this.useInputTag ) {
9275 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9276 }
9277 return this;
9278 };
9279
9280 /**
9281 * @inheritdoc
9282 */
9283 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9284 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9285 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9286 return null;
9287 };
9288
9289 /**
9290 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9291 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9292 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9293 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9294 *
9295 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9296 *
9297 * @example
9298 * // An example of selected, unselected, and disabled checkbox inputs
9299 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9300 * value: 'a',
9301 * selected: true
9302 * } );
9303 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9304 * value: 'b'
9305 * } );
9306 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9307 * value:'c',
9308 * disabled: true
9309 * } );
9310 * // Create a fieldset layout with fields for each checkbox.
9311 * var fieldset = new OO.ui.FieldsetLayout( {
9312 * label: 'Checkboxes'
9313 * } );
9314 * fieldset.addItems( [
9315 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9316 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9317 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9318 * ] );
9319 * $( 'body' ).append( fieldset.$element );
9320 *
9321 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9322 *
9323 * @class
9324 * @extends OO.ui.InputWidget
9325 *
9326 * @constructor
9327 * @param {Object} [config] Configuration options
9328 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9329 */
9330 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9331 // Configuration initialization
9332 config = config || {};
9333
9334 // Parent constructor
9335 OO.ui.CheckboxInputWidget.parent.call( this, config );
9336
9337 // Properties
9338 this.checkIcon = new OO.ui.IconWidget( {
9339 icon: 'check',
9340 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9341 } );
9342
9343 // Initialization
9344 this.$element
9345 .addClass( 'oo-ui-checkboxInputWidget' )
9346 // Required for pretty styling in WikimediaUI theme
9347 .append( this.checkIcon.$element );
9348 this.setSelected( config.selected !== undefined ? config.selected : false );
9349 };
9350
9351 /* Setup */
9352
9353 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9354
9355 /* Static Properties */
9356
9357 /**
9358 * @static
9359 * @inheritdoc
9360 */
9361 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9362
9363 /* Static Methods */
9364
9365 /**
9366 * @inheritdoc
9367 */
9368 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9369 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9370 state.checked = config.$input.prop( 'checked' );
9371 return state;
9372 };
9373
9374 /* Methods */
9375
9376 /**
9377 * @inheritdoc
9378 * @protected
9379 */
9380 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9381 return $( '<input>' ).attr( 'type', 'checkbox' );
9382 };
9383
9384 /**
9385 * @inheritdoc
9386 */
9387 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9388 var widget = this;
9389 if ( !this.isDisabled() ) {
9390 // Allow the stack to clear so the value will be updated
9391 setTimeout( function () {
9392 widget.setSelected( widget.$input.prop( 'checked' ) );
9393 } );
9394 }
9395 };
9396
9397 /**
9398 * Set selection state of this checkbox.
9399 *
9400 * @param {boolean} state `true` for selected
9401 * @chainable
9402 * @return {OO.ui.Widget} The widget, for chaining
9403 */
9404 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9405 state = !!state;
9406 if ( this.selected !== state ) {
9407 this.selected = state;
9408 this.$input.prop( 'checked', this.selected );
9409 this.emit( 'change', this.selected );
9410 }
9411 // The first time that the selection state is set (probably while constructing the widget),
9412 // remember it in defaultSelected. This property can be later used to check whether
9413 // the selection state of the input has been changed since it was created.
9414 if ( this.defaultSelected === undefined ) {
9415 this.defaultSelected = this.selected;
9416 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9417 }
9418 return this;
9419 };
9420
9421 /**
9422 * Check if this checkbox is selected.
9423 *
9424 * @return {boolean} Checkbox is selected
9425 */
9426 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9427 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9428 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9429 var selected = this.$input.prop( 'checked' );
9430 if ( this.selected !== selected ) {
9431 this.setSelected( selected );
9432 }
9433 return this.selected;
9434 };
9435
9436 /**
9437 * @inheritdoc
9438 */
9439 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9440 if ( !this.isDisabled() ) {
9441 this.$input.click();
9442 }
9443 this.focus();
9444 };
9445
9446 /**
9447 * @inheritdoc
9448 */
9449 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9450 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9451 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9452 this.setSelected( state.checked );
9453 }
9454 };
9455
9456 /**
9457 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9458 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9459 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9460 * more information about input widgets.
9461 *
9462 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9463 * are no options. If no `value` configuration option is provided, the first option is selected.
9464 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9465 *
9466 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9467 *
9468 * @example
9469 * // Example: A DropdownInputWidget with three options
9470 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9471 * options: [
9472 * { data: 'a', label: 'First' },
9473 * { data: 'b', label: 'Second'},
9474 * { data: 'c', label: 'Third' }
9475 * ]
9476 * } );
9477 * $( 'body' ).append( dropdownInput.$element );
9478 *
9479 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9480 *
9481 * @class
9482 * @extends OO.ui.InputWidget
9483 * @mixins OO.ui.mixin.TitledElement
9484 *
9485 * @constructor
9486 * @param {Object} [config] Configuration options
9487 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9488 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9489 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9490 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9491 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9492 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9493 */
9494 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9495 // Configuration initialization
9496 config = config || {};
9497
9498 // Properties (must be done before parent constructor which calls #setDisabled)
9499 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9500 {
9501 $overlay: config.$overlay
9502 },
9503 config.dropdown
9504 ) );
9505 // Set up the options before parent constructor, which uses them to validate config.value.
9506 // Use this instead of setOptions() because this.$input is not set up yet.
9507 this.setOptionsData( config.options || [] );
9508
9509 // Mixin constructors
9510 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.dropdownWidget.$handle } ) );
9511
9512 // Parent constructor
9513 OO.ui.DropdownInputWidget.parent.call( this, config );
9514
9515 // Events
9516 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9517
9518 // Initialization
9519 this.$element
9520 .addClass( 'oo-ui-dropdownInputWidget' )
9521 .append( this.dropdownWidget.$element );
9522 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9523 };
9524
9525 /* Setup */
9526
9527 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9528 OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
9529
9530 /* Methods */
9531
9532 /**
9533 * @inheritdoc
9534 * @protected
9535 */
9536 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9537 return $( '<select>' );
9538 };
9539
9540 /**
9541 * Handles menu select events.
9542 *
9543 * @private
9544 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9545 */
9546 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9547 this.setValue( item ? item.getData() : '' );
9548 };
9549
9550 /**
9551 * @inheritdoc
9552 */
9553 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9554 var selected;
9555 value = this.cleanUpValue( value );
9556 // Only allow setting values that are actually present in the dropdown
9557 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9558 this.dropdownWidget.getMenu().findFirstSelectableItem();
9559 this.dropdownWidget.getMenu().selectItem( selected );
9560 value = selected ? selected.getData() : '';
9561 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9562 if ( this.optionsDirty ) {
9563 // We reached this from the constructor or from #setOptions.
9564 // We have to update the <select> element.
9565 this.updateOptionsInterface();
9566 }
9567 return this;
9568 };
9569
9570 /**
9571 * @inheritdoc
9572 */
9573 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9574 this.dropdownWidget.setDisabled( state );
9575 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9576 return this;
9577 };
9578
9579 /**
9580 * Set the options available for this input.
9581 *
9582 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9583 * @chainable
9584 * @return {OO.ui.Widget} The widget, for chaining
9585 */
9586 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9587 var value = this.getValue();
9588
9589 this.setOptionsData( options );
9590
9591 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9592 // In case the previous value is no longer an available option, select the first valid one.
9593 this.setValue( value );
9594
9595 return this;
9596 };
9597
9598 /**
9599 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9600 *
9601 * This method may be called before the parent constructor, so various properties may not be
9602 * intialized yet.
9603 *
9604 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9605 * @private
9606 */
9607 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9608 var
9609 optionWidgets,
9610 widget = this;
9611
9612 this.optionsDirty = true;
9613
9614 optionWidgets = options.map( function ( opt ) {
9615 var optValue;
9616
9617 if ( opt.optgroup !== undefined ) {
9618 return widget.createMenuSectionOptionWidget( opt.optgroup );
9619 }
9620
9621 optValue = widget.cleanUpValue( opt.data );
9622 return widget.createMenuOptionWidget(
9623 optValue,
9624 opt.label !== undefined ? opt.label : optValue
9625 );
9626
9627 } );
9628
9629 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9630 };
9631
9632 /**
9633 * Create a menu option widget.
9634 *
9635 * @protected
9636 * @param {string} data Item data
9637 * @param {string} label Item label
9638 * @return {OO.ui.MenuOptionWidget} Option widget
9639 */
9640 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9641 return new OO.ui.MenuOptionWidget( {
9642 data: data,
9643 label: label
9644 } );
9645 };
9646
9647 /**
9648 * Create a menu section option widget.
9649 *
9650 * @protected
9651 * @param {string} label Section item label
9652 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9653 */
9654 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9655 return new OO.ui.MenuSectionOptionWidget( {
9656 label: label
9657 } );
9658 };
9659
9660 /**
9661 * Update the user-visible interface to match the internal list of options and value.
9662 *
9663 * This method must only be called after the parent constructor.
9664 *
9665 * @private
9666 */
9667 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9668 var
9669 $optionsContainer = this.$input,
9670 defaultValue = this.defaultValue,
9671 widget = this;
9672
9673 this.$input.empty();
9674
9675 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9676 var $optionNode;
9677
9678 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9679 $optionNode = $( '<option>' )
9680 .attr( 'value', optionWidget.getData() )
9681 .text( optionWidget.getLabel() );
9682
9683 // Remember original selection state. This property can be later used to check whether
9684 // the selection state of the input has been changed since it was created.
9685 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9686
9687 $optionsContainer.append( $optionNode );
9688 } else {
9689 $optionNode = $( '<optgroup>' )
9690 .attr( 'label', optionWidget.getLabel() );
9691 widget.$input.append( $optionNode );
9692 $optionsContainer = $optionNode;
9693 }
9694 } );
9695
9696 this.optionsDirty = false;
9697 };
9698
9699 /**
9700 * @inheritdoc
9701 */
9702 OO.ui.DropdownInputWidget.prototype.focus = function () {
9703 this.dropdownWidget.focus();
9704 return this;
9705 };
9706
9707 /**
9708 * @inheritdoc
9709 */
9710 OO.ui.DropdownInputWidget.prototype.blur = function () {
9711 this.dropdownWidget.blur();
9712 return this;
9713 };
9714
9715 /**
9716 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9717 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9718 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9719 * please see the [OOUI documentation on MediaWiki][1].
9720 *
9721 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9722 *
9723 * @example
9724 * // An example of selected, unselected, and disabled radio inputs
9725 * var radio1 = new OO.ui.RadioInputWidget( {
9726 * value: 'a',
9727 * selected: true
9728 * } );
9729 * var radio2 = new OO.ui.RadioInputWidget( {
9730 * value: 'b'
9731 * } );
9732 * var radio3 = new OO.ui.RadioInputWidget( {
9733 * value: 'c',
9734 * disabled: true
9735 * } );
9736 * // Create a fieldset layout with fields for each radio button.
9737 * var fieldset = new OO.ui.FieldsetLayout( {
9738 * label: 'Radio inputs'
9739 * } );
9740 * fieldset.addItems( [
9741 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9742 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9743 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9744 * ] );
9745 * $( 'body' ).append( fieldset.$element );
9746 *
9747 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9748 *
9749 * @class
9750 * @extends OO.ui.InputWidget
9751 *
9752 * @constructor
9753 * @param {Object} [config] Configuration options
9754 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9755 */
9756 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9757 // Configuration initialization
9758 config = config || {};
9759
9760 // Parent constructor
9761 OO.ui.RadioInputWidget.parent.call( this, config );
9762
9763 // Initialization
9764 this.$element
9765 .addClass( 'oo-ui-radioInputWidget' )
9766 // Required for pretty styling in WikimediaUI theme
9767 .append( $( '<span>' ) );
9768 this.setSelected( config.selected !== undefined ? config.selected : false );
9769 };
9770
9771 /* Setup */
9772
9773 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9774
9775 /* Static Properties */
9776
9777 /**
9778 * @static
9779 * @inheritdoc
9780 */
9781 OO.ui.RadioInputWidget.static.tagName = 'span';
9782
9783 /* Static Methods */
9784
9785 /**
9786 * @inheritdoc
9787 */
9788 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9789 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9790 state.checked = config.$input.prop( 'checked' );
9791 return state;
9792 };
9793
9794 /* Methods */
9795
9796 /**
9797 * @inheritdoc
9798 * @protected
9799 */
9800 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9801 return $( '<input>' ).attr( 'type', 'radio' );
9802 };
9803
9804 /**
9805 * @inheritdoc
9806 */
9807 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9808 // RadioInputWidget doesn't track its state.
9809 };
9810
9811 /**
9812 * Set selection state of this radio button.
9813 *
9814 * @param {boolean} state `true` for selected
9815 * @chainable
9816 * @return {OO.ui.Widget} The widget, for chaining
9817 */
9818 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9819 // RadioInputWidget doesn't track its state.
9820 this.$input.prop( 'checked', state );
9821 // The first time that the selection state is set (probably while constructing the widget),
9822 // remember it in defaultSelected. This property can be later used to check whether
9823 // the selection state of the input has been changed since it was created.
9824 if ( this.defaultSelected === undefined ) {
9825 this.defaultSelected = state;
9826 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9827 }
9828 return this;
9829 };
9830
9831 /**
9832 * Check if this radio button is selected.
9833 *
9834 * @return {boolean} Radio is selected
9835 */
9836 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9837 return this.$input.prop( 'checked' );
9838 };
9839
9840 /**
9841 * @inheritdoc
9842 */
9843 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9844 if ( !this.isDisabled() ) {
9845 this.$input.click();
9846 }
9847 this.focus();
9848 };
9849
9850 /**
9851 * @inheritdoc
9852 */
9853 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9854 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9855 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9856 this.setSelected( state.checked );
9857 }
9858 };
9859
9860 /**
9861 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9862 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9863 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9864 * more information about input widgets.
9865 *
9866 * This and OO.ui.DropdownInputWidget support the same configuration options.
9867 *
9868 * @example
9869 * // Example: A RadioSelectInputWidget with three options
9870 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9871 * options: [
9872 * { data: 'a', label: 'First' },
9873 * { data: 'b', label: 'Second'},
9874 * { data: 'c', label: 'Third' }
9875 * ]
9876 * } );
9877 * $( 'body' ).append( radioSelectInput.$element );
9878 *
9879 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9880 *
9881 * @class
9882 * @extends OO.ui.InputWidget
9883 *
9884 * @constructor
9885 * @param {Object} [config] Configuration options
9886 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9887 */
9888 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9889 // Configuration initialization
9890 config = config || {};
9891
9892 // Properties (must be done before parent constructor which calls #setDisabled)
9893 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9894 // Set up the options before parent constructor, which uses them to validate config.value.
9895 // Use this instead of setOptions() because this.$input is not set up yet
9896 this.setOptionsData( config.options || [] );
9897
9898 // Parent constructor
9899 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9900
9901 // Events
9902 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9903
9904 // Initialization
9905 this.$element
9906 .addClass( 'oo-ui-radioSelectInputWidget' )
9907 .append( this.radioSelectWidget.$element );
9908 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9909 };
9910
9911 /* Setup */
9912
9913 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9914
9915 /* Static Methods */
9916
9917 /**
9918 * @inheritdoc
9919 */
9920 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9921 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9922 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9923 return state;
9924 };
9925
9926 /**
9927 * @inheritdoc
9928 */
9929 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9930 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9931 // Cannot reuse the `<input type=radio>` set
9932 delete config.$input;
9933 return config;
9934 };
9935
9936 /* Methods */
9937
9938 /**
9939 * @inheritdoc
9940 * @protected
9941 */
9942 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9943 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9944 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9945 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9946 };
9947
9948 /**
9949 * Handles menu select events.
9950 *
9951 * @private
9952 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9953 */
9954 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9955 this.setValue( item.getData() );
9956 };
9957
9958 /**
9959 * @inheritdoc
9960 */
9961 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9962 var selected;
9963 value = this.cleanUpValue( value );
9964 // Only allow setting values that are actually present in the dropdown
9965 selected = this.radioSelectWidget.findItemFromData( value ) ||
9966 this.radioSelectWidget.findFirstSelectableItem();
9967 this.radioSelectWidget.selectItem( selected );
9968 value = selected ? selected.getData() : '';
9969 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9970 return this;
9971 };
9972
9973 /**
9974 * @inheritdoc
9975 */
9976 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9977 this.radioSelectWidget.setDisabled( state );
9978 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9979 return this;
9980 };
9981
9982 /**
9983 * Set the options available for this input.
9984 *
9985 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9986 * @chainable
9987 * @return {OO.ui.Widget} The widget, for chaining
9988 */
9989 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9990 var value = this.getValue();
9991
9992 this.setOptionsData( options );
9993
9994 // Re-set the value to update the visible interface (RadioSelectWidget).
9995 // In case the previous value is no longer an available option, select the first valid one.
9996 this.setValue( value );
9997
9998 return this;
9999 };
10000
10001 /**
10002 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10003 *
10004 * This method may be called before the parent constructor, so various properties may not be
10005 * intialized yet.
10006 *
10007 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10008 * @private
10009 */
10010 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10011 var widget = this;
10012
10013 this.radioSelectWidget
10014 .clearItems()
10015 .addItems( options.map( function ( opt ) {
10016 var optValue = widget.cleanUpValue( opt.data );
10017 return new OO.ui.RadioOptionWidget( {
10018 data: optValue,
10019 label: opt.label !== undefined ? opt.label : optValue
10020 } );
10021 } ) );
10022 };
10023
10024 /**
10025 * @inheritdoc
10026 */
10027 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10028 this.radioSelectWidget.focus();
10029 return this;
10030 };
10031
10032 /**
10033 * @inheritdoc
10034 */
10035 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10036 this.radioSelectWidget.blur();
10037 return this;
10038 };
10039
10040 /**
10041 * CheckboxMultiselectInputWidget is a
10042 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10043 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10044 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10045 * more information about input widgets.
10046 *
10047 * @example
10048 * // Example: A CheckboxMultiselectInputWidget with three options
10049 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10050 * options: [
10051 * { data: 'a', label: 'First' },
10052 * { data: 'b', label: 'Second'},
10053 * { data: 'c', label: 'Third' }
10054 * ]
10055 * } );
10056 * $( 'body' ).append( multiselectInput.$element );
10057 *
10058 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10059 *
10060 * @class
10061 * @extends OO.ui.InputWidget
10062 *
10063 * @constructor
10064 * @param {Object} [config] Configuration options
10065 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
10066 */
10067 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10068 // Configuration initialization
10069 config = config || {};
10070
10071 // Properties (must be done before parent constructor which calls #setDisabled)
10072 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10073 // Must be set before the #setOptionsData call below
10074 this.inputName = config.name;
10075 // Set up the options before parent constructor, which uses them to validate config.value.
10076 // Use this instead of setOptions() because this.$input is not set up yet
10077 this.setOptionsData( config.options || [] );
10078
10079 // Parent constructor
10080 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10081
10082 // Events
10083 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
10084
10085 // Initialization
10086 this.$element
10087 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10088 .append( this.checkboxMultiselectWidget.$element );
10089 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10090 this.$input.detach();
10091 };
10092
10093 /* Setup */
10094
10095 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10096
10097 /* Static Methods */
10098
10099 /**
10100 * @inheritdoc
10101 */
10102 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10103 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
10104 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10105 .toArray().map( function ( el ) { return el.value; } );
10106 return state;
10107 };
10108
10109 /**
10110 * @inheritdoc
10111 */
10112 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10113 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10114 // Cannot reuse the `<input type=checkbox>` set
10115 delete config.$input;
10116 return config;
10117 };
10118
10119 /* Methods */
10120
10121 /**
10122 * @inheritdoc
10123 * @protected
10124 */
10125 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10126 // Actually unused
10127 return $( '<unused>' );
10128 };
10129
10130 /**
10131 * Handles CheckboxMultiselectWidget select events.
10132 *
10133 * @private
10134 */
10135 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10136 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10137 };
10138
10139 /**
10140 * @inheritdoc
10141 */
10142 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10143 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10144 .toArray().map( function ( el ) { return el.value; } );
10145 if ( this.value !== value ) {
10146 this.setValue( value );
10147 }
10148 return this.value;
10149 };
10150
10151 /**
10152 * @inheritdoc
10153 */
10154 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10155 value = this.cleanUpValue( value );
10156 this.checkboxMultiselectWidget.selectItemsByData( value );
10157 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10158 if ( this.optionsDirty ) {
10159 // We reached this from the constructor or from #setOptions.
10160 // We have to update the <select> element.
10161 this.updateOptionsInterface();
10162 }
10163 return this;
10164 };
10165
10166 /**
10167 * Clean up incoming value.
10168 *
10169 * @param {string[]} value Original value
10170 * @return {string[]} Cleaned up value
10171 */
10172 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10173 var i, singleValue,
10174 cleanValue = [];
10175 if ( !Array.isArray( value ) ) {
10176 return cleanValue;
10177 }
10178 for ( i = 0; i < value.length; i++ ) {
10179 singleValue =
10180 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
10181 // Remove options that we don't have here
10182 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10183 continue;
10184 }
10185 cleanValue.push( singleValue );
10186 }
10187 return cleanValue;
10188 };
10189
10190 /**
10191 * @inheritdoc
10192 */
10193 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10194 this.checkboxMultiselectWidget.setDisabled( state );
10195 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10196 return this;
10197 };
10198
10199 /**
10200 * Set the options available for this input.
10201 *
10202 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
10203 * @chainable
10204 * @return {OO.ui.Widget} The widget, for chaining
10205 */
10206 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10207 var value = this.getValue();
10208
10209 this.setOptionsData( options );
10210
10211 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10212 // This will also get rid of any stale options that we just removed.
10213 this.setValue( value );
10214
10215 return this;
10216 };
10217
10218 /**
10219 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10220 *
10221 * This method may be called before the parent constructor, so various properties may not be
10222 * intialized yet.
10223 *
10224 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10225 * @private
10226 */
10227 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10228 var widget = this;
10229
10230 this.optionsDirty = true;
10231
10232 this.checkboxMultiselectWidget
10233 .clearItems()
10234 .addItems( options.map( function ( opt ) {
10235 var optValue, item, optDisabled;
10236 optValue =
10237 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
10238 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10239 item = new OO.ui.CheckboxMultioptionWidget( {
10240 data: optValue,
10241 label: opt.label !== undefined ? opt.label : optValue,
10242 disabled: optDisabled
10243 } );
10244 // Set the 'name' and 'value' for form submission
10245 item.checkbox.$input.attr( 'name', widget.inputName );
10246 item.checkbox.setValue( optValue );
10247 return item;
10248 } ) );
10249 };
10250
10251 /**
10252 * Update the user-visible interface to match the internal list of options and value.
10253 *
10254 * This method must only be called after the parent constructor.
10255 *
10256 * @private
10257 */
10258 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10259 var defaultValue = this.defaultValue;
10260
10261 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10262 // Remember original selection state. This property can be later used to check whether
10263 // the selection state of the input has been changed since it was created.
10264 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10265 item.checkbox.defaultSelected = isDefault;
10266 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10267 } );
10268
10269 this.optionsDirty = false;
10270 };
10271
10272 /**
10273 * @inheritdoc
10274 */
10275 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10276 this.checkboxMultiselectWidget.focus();
10277 return this;
10278 };
10279
10280 /**
10281 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10282 * size of the field as well as its presentation. In addition, these widgets can be configured
10283 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10284 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10285 * which modifies incoming values rather than validating them.
10286 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10287 *
10288 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10289 *
10290 * @example
10291 * // Example of a text input widget
10292 * var textInput = new OO.ui.TextInputWidget( {
10293 * value: 'Text input'
10294 * } )
10295 * $( 'body' ).append( textInput.$element );
10296 *
10297 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10298 *
10299 * @class
10300 * @extends OO.ui.InputWidget
10301 * @mixins OO.ui.mixin.IconElement
10302 * @mixins OO.ui.mixin.IndicatorElement
10303 * @mixins OO.ui.mixin.PendingElement
10304 * @mixins OO.ui.mixin.LabelElement
10305 *
10306 * @constructor
10307 * @param {Object} [config] Configuration options
10308 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10309 * 'email', 'url' or 'number'.
10310 * @cfg {string} [placeholder] Placeholder text
10311 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10312 * instruct the browser to focus this widget.
10313 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10314 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10315 *
10316 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10317 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10318 * many emojis) count as 2 characters each.
10319 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10320 * the value or placeholder text: `'before'` or `'after'`
10321 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10322 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10323 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10324 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10325 * leaving it up to the browser).
10326 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10327 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10328 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10329 * value for it to be considered valid; when Function, a function receiving the value as parameter
10330 * that must return true, or promise resolving to true, for it to be considered valid.
10331 */
10332 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10333 // Configuration initialization
10334 config = $.extend( {
10335 type: 'text',
10336 labelPosition: 'after'
10337 }, config );
10338
10339 // Parent constructor
10340 OO.ui.TextInputWidget.parent.call( this, config );
10341
10342 // Mixin constructors
10343 OO.ui.mixin.IconElement.call( this, config );
10344 OO.ui.mixin.IndicatorElement.call( this, config );
10345 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10346 OO.ui.mixin.LabelElement.call( this, config );
10347
10348 // Properties
10349 this.type = this.getSaneType( config );
10350 this.readOnly = false;
10351 this.required = false;
10352 this.validate = null;
10353 this.styleHeight = null;
10354 this.scrollWidth = null;
10355
10356 this.setValidation( config.validate );
10357 this.setLabelPosition( config.labelPosition );
10358
10359 // Events
10360 this.$input.on( {
10361 keypress: this.onKeyPress.bind( this ),
10362 blur: this.onBlur.bind( this ),
10363 focus: this.onFocus.bind( this )
10364 } );
10365 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10366 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10367 this.on( 'labelChange', this.updatePosition.bind( this ) );
10368 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10369
10370 // Initialization
10371 this.$element
10372 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10373 .append( this.$icon, this.$indicator );
10374 this.setReadOnly( !!config.readOnly );
10375 this.setRequired( !!config.required );
10376 if ( config.placeholder !== undefined ) {
10377 this.$input.attr( 'placeholder', config.placeholder );
10378 }
10379 if ( config.maxLength !== undefined ) {
10380 this.$input.attr( 'maxlength', config.maxLength );
10381 }
10382 if ( config.autofocus ) {
10383 this.$input.attr( 'autofocus', 'autofocus' );
10384 }
10385 if ( config.autocomplete === false ) {
10386 this.$input.attr( 'autocomplete', 'off' );
10387 // Turning off autocompletion also disables "form caching" when the user navigates to a
10388 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10389 $( window ).on( {
10390 beforeunload: function () {
10391 this.$input.removeAttr( 'autocomplete' );
10392 }.bind( this ),
10393 pageshow: function () {
10394 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10395 // whole page... it shouldn't hurt, though.
10396 this.$input.attr( 'autocomplete', 'off' );
10397 }.bind( this )
10398 } );
10399 }
10400 if ( config.spellcheck !== undefined ) {
10401 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10402 }
10403 if ( this.label ) {
10404 this.isWaitingToBeAttached = true;
10405 this.installParentChangeDetector();
10406 }
10407 };
10408
10409 /* Setup */
10410
10411 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10412 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10413 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10414 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10415 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10416
10417 /* Static Properties */
10418
10419 OO.ui.TextInputWidget.static.validationPatterns = {
10420 'non-empty': /.+/,
10421 integer: /^\d+$/
10422 };
10423
10424 /* Events */
10425
10426 /**
10427 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10428 *
10429 * @event enter
10430 */
10431
10432 /* Methods */
10433
10434 /**
10435 * Handle icon mouse down events.
10436 *
10437 * @private
10438 * @param {jQuery.Event} e Mouse down event
10439 * @return {undefined/boolean} False to prevent default if event is handled
10440 */
10441 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10442 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10443 this.focus();
10444 return false;
10445 }
10446 };
10447
10448 /**
10449 * Handle indicator mouse down events.
10450 *
10451 * @private
10452 * @param {jQuery.Event} e Mouse down event
10453 * @return {undefined/boolean} False to prevent default if event is handled
10454 */
10455 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10456 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10457 this.focus();
10458 return false;
10459 }
10460 };
10461
10462 /**
10463 * Handle key press events.
10464 *
10465 * @private
10466 * @param {jQuery.Event} e Key press event
10467 * @fires enter If enter key is pressed
10468 */
10469 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10470 if ( e.which === OO.ui.Keys.ENTER ) {
10471 this.emit( 'enter', e );
10472 }
10473 };
10474
10475 /**
10476 * Handle blur events.
10477 *
10478 * @private
10479 * @param {jQuery.Event} e Blur event
10480 */
10481 OO.ui.TextInputWidget.prototype.onBlur = function () {
10482 this.setValidityFlag();
10483 };
10484
10485 /**
10486 * Handle focus events.
10487 *
10488 * @private
10489 * @param {jQuery.Event} e Focus event
10490 */
10491 OO.ui.TextInputWidget.prototype.onFocus = function () {
10492 if ( this.isWaitingToBeAttached ) {
10493 // If we've received focus, then we must be attached to the document, and if
10494 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10495 this.onElementAttach();
10496 }
10497 this.setValidityFlag( true );
10498 };
10499
10500 /**
10501 * Handle element attach events.
10502 *
10503 * @private
10504 * @param {jQuery.Event} e Element attach event
10505 */
10506 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10507 this.isWaitingToBeAttached = false;
10508 // Any previously calculated size is now probably invalid if we reattached elsewhere
10509 this.valCache = null;
10510 this.positionLabel();
10511 };
10512
10513 /**
10514 * Handle debounced change events.
10515 *
10516 * @param {string} value
10517 * @private
10518 */
10519 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10520 this.setValidityFlag();
10521 };
10522
10523 /**
10524 * Check if the input is {@link #readOnly read-only}.
10525 *
10526 * @return {boolean}
10527 */
10528 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10529 return this.readOnly;
10530 };
10531
10532 /**
10533 * Set the {@link #readOnly read-only} state of the input.
10534 *
10535 * @param {boolean} state Make input read-only
10536 * @chainable
10537 * @return {OO.ui.Widget} The widget, for chaining
10538 */
10539 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10540 this.readOnly = !!state;
10541 this.$input.prop( 'readOnly', this.readOnly );
10542 return this;
10543 };
10544
10545 /**
10546 * Check if the input is {@link #required required}.
10547 *
10548 * @return {boolean}
10549 */
10550 OO.ui.TextInputWidget.prototype.isRequired = function () {
10551 return this.required;
10552 };
10553
10554 /**
10555 * Set the {@link #required required} state of the input.
10556 *
10557 * @param {boolean} state Make input required
10558 * @chainable
10559 * @return {OO.ui.Widget} The widget, for chaining
10560 */
10561 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10562 this.required = !!state;
10563 if ( this.required ) {
10564 this.$input
10565 .prop( 'required', true )
10566 .attr( 'aria-required', 'true' );
10567 if ( this.getIndicator() === null ) {
10568 this.setIndicator( 'required' );
10569 }
10570 } else {
10571 this.$input
10572 .prop( 'required', false )
10573 .removeAttr( 'aria-required' );
10574 if ( this.getIndicator() === 'required' ) {
10575 this.setIndicator( null );
10576 }
10577 }
10578 return this;
10579 };
10580
10581 /**
10582 * Support function for making #onElementAttach work across browsers.
10583 *
10584 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10585 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10586 *
10587 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10588 * first time that the element gets attached to the documented.
10589 */
10590 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10591 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10592 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10593 widget = this;
10594
10595 if ( MutationObserver ) {
10596 // The new way. If only it wasn't so ugly.
10597
10598 if ( this.isElementAttached() ) {
10599 // Widget is attached already, do nothing. This breaks the functionality of this function when
10600 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10601 // would require observation of the whole document, which would hurt performance of other,
10602 // more important code.
10603 return;
10604 }
10605
10606 // Find topmost node in the tree
10607 topmostNode = this.$element[ 0 ];
10608 while ( topmostNode.parentNode ) {
10609 topmostNode = topmostNode.parentNode;
10610 }
10611
10612 // We have no way to detect the $element being attached somewhere without observing the entire
10613 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10614 // parent node of $element, and instead detect when $element is removed from it (and thus
10615 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10616 // doesn't get attached, we end up back here and create the parent.
10617
10618 mutationObserver = new MutationObserver( function ( mutations ) {
10619 var i, j, removedNodes;
10620 for ( i = 0; i < mutations.length; i++ ) {
10621 removedNodes = mutations[ i ].removedNodes;
10622 for ( j = 0; j < removedNodes.length; j++ ) {
10623 if ( removedNodes[ j ] === topmostNode ) {
10624 setTimeout( onRemove, 0 );
10625 return;
10626 }
10627 }
10628 }
10629 } );
10630
10631 onRemove = function () {
10632 // If the node was attached somewhere else, report it
10633 if ( widget.isElementAttached() ) {
10634 widget.onElementAttach();
10635 }
10636 mutationObserver.disconnect();
10637 widget.installParentChangeDetector();
10638 };
10639
10640 // Create a fake parent and observe it
10641 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10642 mutationObserver.observe( fakeParentNode, { childList: true } );
10643 } else {
10644 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10645 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10646 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10647 }
10648 };
10649
10650 /**
10651 * @inheritdoc
10652 * @protected
10653 */
10654 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10655 if ( this.getSaneType( config ) === 'number' ) {
10656 return $( '<input>' )
10657 .attr( 'step', 'any' )
10658 .attr( 'type', 'number' );
10659 } else {
10660 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10661 }
10662 };
10663
10664 /**
10665 * Get sanitized value for 'type' for given config.
10666 *
10667 * @param {Object} config Configuration options
10668 * @return {string|null}
10669 * @protected
10670 */
10671 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10672 var allowedTypes = [
10673 'text',
10674 'password',
10675 'email',
10676 'url',
10677 'number'
10678 ];
10679 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10680 };
10681
10682 /**
10683 * Focus the input and select a specified range within the text.
10684 *
10685 * @param {number} from Select from offset
10686 * @param {number} [to] Select to offset, defaults to from
10687 * @chainable
10688 * @return {OO.ui.Widget} The widget, for chaining
10689 */
10690 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10691 var isBackwards, start, end,
10692 input = this.$input[ 0 ];
10693
10694 to = to || from;
10695
10696 isBackwards = to < from;
10697 start = isBackwards ? to : from;
10698 end = isBackwards ? from : to;
10699
10700 this.focus();
10701
10702 try {
10703 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10704 } catch ( e ) {
10705 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10706 // Rather than expensively check if the input is attached every time, just check
10707 // if it was the cause of an error being thrown. If not, rethrow the error.
10708 if ( this.getElementDocument().body.contains( input ) ) {
10709 throw e;
10710 }
10711 }
10712 return this;
10713 };
10714
10715 /**
10716 * Get an object describing the current selection range in a directional manner
10717 *
10718 * @return {Object} Object containing 'from' and 'to' offsets
10719 */
10720 OO.ui.TextInputWidget.prototype.getRange = function () {
10721 var input = this.$input[ 0 ],
10722 start = input.selectionStart,
10723 end = input.selectionEnd,
10724 isBackwards = input.selectionDirection === 'backward';
10725
10726 return {
10727 from: isBackwards ? end : start,
10728 to: isBackwards ? start : end
10729 };
10730 };
10731
10732 /**
10733 * Get the length of the text input value.
10734 *
10735 * This could differ from the length of #getValue if the
10736 * value gets filtered
10737 *
10738 * @return {number} Input length
10739 */
10740 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10741 return this.$input[ 0 ].value.length;
10742 };
10743
10744 /**
10745 * Focus the input and select the entire text.
10746 *
10747 * @chainable
10748 * @return {OO.ui.Widget} The widget, for chaining
10749 */
10750 OO.ui.TextInputWidget.prototype.select = function () {
10751 return this.selectRange( 0, this.getInputLength() );
10752 };
10753
10754 /**
10755 * Focus the input and move the cursor to the start.
10756 *
10757 * @chainable
10758 * @return {OO.ui.Widget} The widget, for chaining
10759 */
10760 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10761 return this.selectRange( 0 );
10762 };
10763
10764 /**
10765 * Focus the input and move the cursor to the end.
10766 *
10767 * @chainable
10768 * @return {OO.ui.Widget} The widget, for chaining
10769 */
10770 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10771 return this.selectRange( this.getInputLength() );
10772 };
10773
10774 /**
10775 * Insert new content into the input.
10776 *
10777 * @param {string} content Content to be inserted
10778 * @chainable
10779 * @return {OO.ui.Widget} The widget, for chaining
10780 */
10781 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10782 var start, end,
10783 range = this.getRange(),
10784 value = this.getValue();
10785
10786 start = Math.min( range.from, range.to );
10787 end = Math.max( range.from, range.to );
10788
10789 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10790 this.selectRange( start + content.length );
10791 return this;
10792 };
10793
10794 /**
10795 * Insert new content either side of a selection.
10796 *
10797 * @param {string} pre Content to be inserted before the selection
10798 * @param {string} post Content to be inserted after the selection
10799 * @chainable
10800 * @return {OO.ui.Widget} The widget, for chaining
10801 */
10802 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10803 var start, end,
10804 range = this.getRange(),
10805 offset = pre.length;
10806
10807 start = Math.min( range.from, range.to );
10808 end = Math.max( range.from, range.to );
10809
10810 this.selectRange( start ).insertContent( pre );
10811 this.selectRange( offset + end ).insertContent( post );
10812
10813 this.selectRange( offset + start, offset + end );
10814 return this;
10815 };
10816
10817 /**
10818 * Set the validation pattern.
10819 *
10820 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10821 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10822 * value must contain only numbers).
10823 *
10824 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10825 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10826 */
10827 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10828 if ( validate instanceof RegExp || validate instanceof Function ) {
10829 this.validate = validate;
10830 } else {
10831 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10832 }
10833 };
10834
10835 /**
10836 * Sets the 'invalid' flag appropriately.
10837 *
10838 * @param {boolean} [isValid] Optionally override validation result
10839 */
10840 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10841 var widget = this,
10842 setFlag = function ( valid ) {
10843 if ( !valid ) {
10844 widget.$input.attr( 'aria-invalid', 'true' );
10845 } else {
10846 widget.$input.removeAttr( 'aria-invalid' );
10847 }
10848 widget.setFlags( { invalid: !valid } );
10849 };
10850
10851 if ( isValid !== undefined ) {
10852 setFlag( isValid );
10853 } else {
10854 this.getValidity().then( function () {
10855 setFlag( true );
10856 }, function () {
10857 setFlag( false );
10858 } );
10859 }
10860 };
10861
10862 /**
10863 * Get the validity of current value.
10864 *
10865 * This method returns a promise that resolves if the value is valid and rejects if
10866 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10867 *
10868 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10869 */
10870 OO.ui.TextInputWidget.prototype.getValidity = function () {
10871 var result;
10872
10873 function rejectOrResolve( valid ) {
10874 if ( valid ) {
10875 return $.Deferred().resolve().promise();
10876 } else {
10877 return $.Deferred().reject().promise();
10878 }
10879 }
10880
10881 // Check browser validity and reject if it is invalid
10882 if (
10883 this.$input[ 0 ].checkValidity !== undefined &&
10884 this.$input[ 0 ].checkValidity() === false
10885 ) {
10886 return rejectOrResolve( false );
10887 }
10888
10889 // Run our checks if the browser thinks the field is valid
10890 if ( this.validate instanceof Function ) {
10891 result = this.validate( this.getValue() );
10892 if ( result && typeof result.promise === 'function' ) {
10893 return result.promise().then( function ( valid ) {
10894 return rejectOrResolve( valid );
10895 } );
10896 } else {
10897 return rejectOrResolve( result );
10898 }
10899 } else {
10900 return rejectOrResolve( this.getValue().match( this.validate ) );
10901 }
10902 };
10903
10904 /**
10905 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10906 *
10907 * @param {string} labelPosition Label position, 'before' or 'after'
10908 * @chainable
10909 * @return {OO.ui.Widget} The widget, for chaining
10910 */
10911 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10912 this.labelPosition = labelPosition;
10913 if ( this.label ) {
10914 // If there is no label and we only change the position, #updatePosition is a no-op,
10915 // but it takes really a lot of work to do nothing.
10916 this.updatePosition();
10917 }
10918 return this;
10919 };
10920
10921 /**
10922 * Update the position of the inline label.
10923 *
10924 * This method is called by #setLabelPosition, and can also be called on its own if
10925 * something causes the label to be mispositioned.
10926 *
10927 * @chainable
10928 * @return {OO.ui.Widget} The widget, for chaining
10929 */
10930 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10931 var after = this.labelPosition === 'after';
10932
10933 this.$element
10934 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10935 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10936
10937 this.valCache = null;
10938 this.scrollWidth = null;
10939 this.positionLabel();
10940
10941 return this;
10942 };
10943
10944 /**
10945 * Position the label by setting the correct padding on the input.
10946 *
10947 * @private
10948 * @chainable
10949 * @return {OO.ui.Widget} The widget, for chaining
10950 */
10951 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10952 var after, rtl, property, newCss;
10953
10954 if ( this.isWaitingToBeAttached ) {
10955 // #onElementAttach will be called soon, which calls this method
10956 return this;
10957 }
10958
10959 newCss = {
10960 'padding-right': '',
10961 'padding-left': ''
10962 };
10963
10964 if ( this.label ) {
10965 this.$element.append( this.$label );
10966 } else {
10967 this.$label.detach();
10968 // Clear old values if present
10969 this.$input.css( newCss );
10970 return;
10971 }
10972
10973 after = this.labelPosition === 'after';
10974 rtl = this.$element.css( 'direction' ) === 'rtl';
10975 property = after === rtl ? 'padding-left' : 'padding-right';
10976
10977 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10978 // We have to clear the padding on the other side, in case the element direction changed
10979 this.$input.css( newCss );
10980
10981 return this;
10982 };
10983
10984 /**
10985 * @class
10986 * @extends OO.ui.TextInputWidget
10987 *
10988 * @constructor
10989 * @param {Object} [config] Configuration options
10990 */
10991 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10992 config = $.extend( {
10993 icon: 'search'
10994 }, config );
10995
10996 // Parent constructor
10997 OO.ui.SearchInputWidget.parent.call( this, config );
10998
10999 // Events
11000 this.connect( this, {
11001 change: 'onChange'
11002 } );
11003
11004 // Initialization
11005 this.updateSearchIndicator();
11006 this.connect( this, {
11007 disable: 'onDisable'
11008 } );
11009 };
11010
11011 /* Setup */
11012
11013 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11014
11015 /* Methods */
11016
11017 /**
11018 * @inheritdoc
11019 * @protected
11020 */
11021 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11022 return 'search';
11023 };
11024
11025 /**
11026 * @inheritdoc
11027 */
11028 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11029 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11030 // Clear the text field
11031 this.setValue( '' );
11032 this.focus();
11033 return false;
11034 }
11035 };
11036
11037 /**
11038 * Update the 'clear' indicator displayed on type: 'search' text
11039 * fields, hiding it when the field is already empty or when it's not
11040 * editable.
11041 */
11042 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11043 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11044 this.setIndicator( null );
11045 } else {
11046 this.setIndicator( 'clear' );
11047 }
11048 };
11049
11050 /**
11051 * Handle change events.
11052 *
11053 * @private
11054 */
11055 OO.ui.SearchInputWidget.prototype.onChange = function () {
11056 this.updateSearchIndicator();
11057 };
11058
11059 /**
11060 * Handle disable events.
11061 *
11062 * @param {boolean} disabled Element is disabled
11063 * @private
11064 */
11065 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11066 this.updateSearchIndicator();
11067 };
11068
11069 /**
11070 * @inheritdoc
11071 */
11072 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11073 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11074 this.updateSearchIndicator();
11075 return this;
11076 };
11077
11078 /**
11079 * @class
11080 * @extends OO.ui.TextInputWidget
11081 *
11082 * @constructor
11083 * @param {Object} [config] Configuration options
11084 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11085 * specifies minimum number of rows to display.
11086 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11087 * Use the #maxRows config to specify a maximum number of displayed rows.
11088 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11089 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11090 */
11091 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11092 config = $.extend( {
11093 type: 'text'
11094 }, config );
11095 // Parent constructor
11096 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11097
11098 // Properties
11099 this.autosize = !!config.autosize;
11100 this.minRows = config.rows !== undefined ? config.rows : '';
11101 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11102
11103 // Clone for resizing
11104 if ( this.autosize ) {
11105 this.$clone = this.$input
11106 .clone()
11107 .removeAttr( 'id' )
11108 .removeAttr( 'name' )
11109 .insertAfter( this.$input )
11110 .attr( 'aria-hidden', 'true' )
11111 .addClass( 'oo-ui-element-hidden' );
11112 }
11113
11114 // Events
11115 this.connect( this, {
11116 change: 'onChange'
11117 } );
11118
11119 // Initialization
11120 if ( config.rows ) {
11121 this.$input.attr( 'rows', config.rows );
11122 }
11123 if ( this.autosize ) {
11124 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11125 this.isWaitingToBeAttached = true;
11126 this.installParentChangeDetector();
11127 }
11128 };
11129
11130 /* Setup */
11131
11132 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11133
11134 /* Static Methods */
11135
11136 /**
11137 * @inheritdoc
11138 */
11139 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11140 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11141 state.scrollTop = config.$input.scrollTop();
11142 return state;
11143 };
11144
11145 /* Methods */
11146
11147 /**
11148 * @inheritdoc
11149 */
11150 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11151 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11152 this.adjustSize();
11153 };
11154
11155 /**
11156 * Handle change events.
11157 *
11158 * @private
11159 */
11160 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11161 this.adjustSize();
11162 };
11163
11164 /**
11165 * @inheritdoc
11166 */
11167 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11168 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11169 this.adjustSize();
11170 };
11171
11172 /**
11173 * @inheritdoc
11174 *
11175 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11176 */
11177 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11178 if (
11179 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11180 // Some platforms emit keycode 10 for ctrl+enter in a textarea
11181 e.which === 10
11182 ) {
11183 this.emit( 'enter', e );
11184 }
11185 };
11186
11187 /**
11188 * Automatically adjust the size of the text input.
11189 *
11190 * This only affects multiline inputs that are {@link #autosize autosized}.
11191 *
11192 * @chainable
11193 * @return {OO.ui.Widget} The widget, for chaining
11194 * @fires resize
11195 */
11196 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11197 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11198 idealHeight, newHeight, scrollWidth, property;
11199
11200 if ( this.$input.val() !== this.valCache ) {
11201 if ( this.autosize ) {
11202 this.$clone
11203 .val( this.$input.val() )
11204 .attr( 'rows', this.minRows )
11205 // Set inline height property to 0 to measure scroll height
11206 .css( 'height', 0 );
11207
11208 this.$clone.removeClass( 'oo-ui-element-hidden' );
11209
11210 this.valCache = this.$input.val();
11211
11212 scrollHeight = this.$clone[ 0 ].scrollHeight;
11213
11214 // Remove inline height property to measure natural heights
11215 this.$clone.css( 'height', '' );
11216 innerHeight = this.$clone.innerHeight();
11217 outerHeight = this.$clone.outerHeight();
11218
11219 // Measure max rows height
11220 this.$clone
11221 .attr( 'rows', this.maxRows )
11222 .css( 'height', 'auto' )
11223 .val( '' );
11224 maxInnerHeight = this.$clone.innerHeight();
11225
11226 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11227 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11228 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11229 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11230
11231 this.$clone.addClass( 'oo-ui-element-hidden' );
11232
11233 // Only apply inline height when expansion beyond natural height is needed
11234 // Use the difference between the inner and outer height as a buffer
11235 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11236 if ( newHeight !== this.styleHeight ) {
11237 this.$input.css( 'height', newHeight );
11238 this.styleHeight = newHeight;
11239 this.emit( 'resize' );
11240 }
11241 }
11242 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11243 if ( scrollWidth !== this.scrollWidth ) {
11244 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11245 // Reset
11246 this.$label.css( { right: '', left: '' } );
11247 this.$indicator.css( { right: '', left: '' } );
11248
11249 if ( scrollWidth ) {
11250 this.$indicator.css( property, scrollWidth );
11251 if ( this.labelPosition === 'after' ) {
11252 this.$label.css( property, scrollWidth );
11253 }
11254 }
11255
11256 this.scrollWidth = scrollWidth;
11257 this.positionLabel();
11258 }
11259 }
11260 return this;
11261 };
11262
11263 /**
11264 * @inheritdoc
11265 * @protected
11266 */
11267 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11268 return $( '<textarea>' );
11269 };
11270
11271 /**
11272 * Check if the input automatically adjusts its size.
11273 *
11274 * @return {boolean}
11275 */
11276 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11277 return !!this.autosize;
11278 };
11279
11280 /**
11281 * @inheritdoc
11282 */
11283 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11284 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11285 if ( state.scrollTop !== undefined ) {
11286 this.$input.scrollTop( state.scrollTop );
11287 }
11288 };
11289
11290 /**
11291 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11292 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11293 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11294 *
11295 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11296 * option, that option will appear to be selected.
11297 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11298 * input field.
11299 *
11300 * After the user chooses an option, its `data` will be used as a new value for the widget.
11301 * A `label` also can be specified for each option: if given, it will be shown instead of the
11302 * `data` in the dropdown menu.
11303 *
11304 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11305 *
11306 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11307 *
11308 * @example
11309 * // Example: A ComboBoxInputWidget.
11310 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11311 * value: 'Option 1',
11312 * options: [
11313 * { data: 'Option 1' },
11314 * { data: 'Option 2' },
11315 * { data: 'Option 3' }
11316 * ]
11317 * } );
11318 * $( 'body' ).append( comboBox.$element );
11319 *
11320 * @example
11321 * // Example: A ComboBoxInputWidget with additional option labels.
11322 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11323 * value: 'Option 1',
11324 * options: [
11325 * {
11326 * data: 'Option 1',
11327 * label: 'Option One'
11328 * },
11329 * {
11330 * data: 'Option 2',
11331 * label: 'Option Two'
11332 * },
11333 * {
11334 * data: 'Option 3',
11335 * label: 'Option Three'
11336 * }
11337 * ]
11338 * } );
11339 * $( 'body' ).append( comboBox.$element );
11340 *
11341 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11342 *
11343 * @class
11344 * @extends OO.ui.TextInputWidget
11345 *
11346 * @constructor
11347 * @param {Object} [config] Configuration options
11348 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11349 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11350 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11351 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11352 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11353 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11354 */
11355 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11356 // Configuration initialization
11357 config = $.extend( {
11358 autocomplete: false
11359 }, config );
11360
11361 // ComboBoxInputWidget shouldn't support `multiline`
11362 config.multiline = false;
11363
11364 // See InputWidget#reusePreInfuseDOM about `config.$input`
11365 if ( config.$input ) {
11366 config.$input.removeAttr( 'list' );
11367 }
11368
11369 // Parent constructor
11370 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11371
11372 // Properties
11373 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11374 this.dropdownButton = new OO.ui.ButtonWidget( {
11375 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11376 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11377 indicator: 'down',
11378 invisibleLabel: true,
11379 disabled: this.disabled
11380 } );
11381 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11382 {
11383 widget: this,
11384 input: this,
11385 $floatableContainer: this.$element,
11386 disabled: this.isDisabled()
11387 },
11388 config.menu
11389 ) );
11390
11391 // Events
11392 this.connect( this, {
11393 change: 'onInputChange',
11394 enter: 'onInputEnter'
11395 } );
11396 this.dropdownButton.connect( this, {
11397 click: 'onDropdownButtonClick'
11398 } );
11399 this.menu.connect( this, {
11400 choose: 'onMenuChoose',
11401 add: 'onMenuItemsChange',
11402 remove: 'onMenuItemsChange',
11403 toggle: 'onMenuToggle'
11404 } );
11405
11406 // Initialization
11407 this.$input.attr( {
11408 role: 'combobox',
11409 'aria-expanded': 'false',
11410 'aria-owns': this.menu.getElementId(),
11411 'aria-autocomplete': 'list'
11412 } );
11413 this.dropdownButton.$button.attr( {
11414 'aria-controls': this.menu.getElementId()
11415 } );
11416 // Do not override options set via config.menu.items
11417 if ( config.options !== undefined ) {
11418 this.setOptions( config.options );
11419 }
11420 this.$field = $( '<div>' )
11421 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11422 .append( this.$input, this.dropdownButton.$element );
11423 this.$element
11424 .addClass( 'oo-ui-comboBoxInputWidget' )
11425 .append( this.$field );
11426 this.$overlay.append( this.menu.$element );
11427 this.onMenuItemsChange();
11428 };
11429
11430 /* Setup */
11431
11432 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11433
11434 /* Methods */
11435
11436 /**
11437 * Get the combobox's menu.
11438 *
11439 * @return {OO.ui.MenuSelectWidget} Menu widget
11440 */
11441 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11442 return this.menu;
11443 };
11444
11445 /**
11446 * Get the combobox's text input widget.
11447 *
11448 * @return {OO.ui.TextInputWidget} Text input widget
11449 */
11450 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11451 return this;
11452 };
11453
11454 /**
11455 * Handle input change events.
11456 *
11457 * @private
11458 * @param {string} value New value
11459 */
11460 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11461 var match = this.menu.findItemFromData( value );
11462
11463 this.menu.selectItem( match );
11464 if ( this.menu.findHighlightedItem() ) {
11465 this.menu.highlightItem( match );
11466 }
11467
11468 if ( !this.isDisabled() ) {
11469 this.menu.toggle( true );
11470 }
11471 };
11472
11473 /**
11474 * Handle input enter events.
11475 *
11476 * @private
11477 */
11478 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11479 if ( !this.isDisabled() ) {
11480 this.menu.toggle( false );
11481 }
11482 };
11483
11484 /**
11485 * Handle button click events.
11486 *
11487 * @private
11488 */
11489 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11490 this.menu.toggle();
11491 this.focus();
11492 };
11493
11494 /**
11495 * Handle menu choose events.
11496 *
11497 * @private
11498 * @param {OO.ui.OptionWidget} item Chosen item
11499 */
11500 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11501 this.setValue( item.getData() );
11502 };
11503
11504 /**
11505 * Handle menu item change events.
11506 *
11507 * @private
11508 */
11509 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11510 var match = this.menu.findItemFromData( this.getValue() );
11511 this.menu.selectItem( match );
11512 if ( this.menu.findHighlightedItem() ) {
11513 this.menu.highlightItem( match );
11514 }
11515 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11516 };
11517
11518 /**
11519 * Handle menu toggle events.
11520 *
11521 * @private
11522 * @param {boolean} isVisible Open state of the menu
11523 */
11524 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11525 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11526 };
11527
11528 /**
11529 * @inheritdoc
11530 */
11531 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11532 // Parent method
11533 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11534
11535 if ( this.dropdownButton ) {
11536 this.dropdownButton.setDisabled( this.isDisabled() );
11537 }
11538 if ( this.menu ) {
11539 this.menu.setDisabled( this.isDisabled() );
11540 }
11541
11542 return this;
11543 };
11544
11545 /**
11546 * Set the options available for this input.
11547 *
11548 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11549 * @chainable
11550 * @return {OO.ui.Widget} The widget, for chaining
11551 */
11552 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11553 this.getMenu()
11554 .clearItems()
11555 .addItems( options.map( function ( opt ) {
11556 return new OO.ui.MenuOptionWidget( {
11557 data: opt.data,
11558 label: opt.label !== undefined ? opt.label : opt.data
11559 } );
11560 } ) );
11561
11562 return this;
11563 };
11564
11565 /**
11566 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11567 * which is a widget that is specified by reference before any optional configuration settings.
11568 *
11569 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11570 *
11571 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11572 * A left-alignment is used for forms with many fields.
11573 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11574 * A right-alignment is used for long but familiar forms which users tab through,
11575 * verifying the current field with a quick glance at the label.
11576 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11577 * that users fill out from top to bottom.
11578 * - **inline**: The label is placed after the field-widget and aligned to the left.
11579 * An inline-alignment is best used with checkboxes or radio buttons.
11580 *
11581 * Help text can either be:
11582 *
11583 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11584 * - shown as a subtle explanation below the label.
11585 *
11586 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`. If it
11587 * is long or not essential, leave `helpInline` to its default, `false`.
11588 *
11589 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11590 *
11591 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11592 *
11593 * @class
11594 * @extends OO.ui.Layout
11595 * @mixins OO.ui.mixin.LabelElement
11596 * @mixins OO.ui.mixin.TitledElement
11597 *
11598 * @constructor
11599 * @param {OO.ui.Widget} fieldWidget Field widget
11600 * @param {Object} [config] Configuration options
11601 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11602 * or 'inline'
11603 * @cfg {Array} [errors] Error messages about the widget, which will be
11604 * displayed below the widget.
11605 * The array may contain strings or OO.ui.HtmlSnippet instances.
11606 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11607 * below the widget.
11608 * The array may contain strings or OO.ui.HtmlSnippet instances.
11609 * These are more visible than `help` messages when `helpInline` is set, and so
11610 * might be good for transient messages.
11611 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11612 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11613 * corner of the rendered field; clicking it will display the text in a popup.
11614 * If `helpInline` is `true`, then a subtle description will be shown after the
11615 * label.
11616 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11617 * or shown when the "help" icon is clicked.
11618 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11619 * `help` is given.
11620 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11621 *
11622 * @throws {Error} An error is thrown if no widget is specified
11623 */
11624 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11625 // Allow passing positional parameters inside the config object
11626 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11627 config = fieldWidget;
11628 fieldWidget = config.fieldWidget;
11629 }
11630
11631 // Make sure we have required constructor arguments
11632 if ( fieldWidget === undefined ) {
11633 throw new Error( 'Widget not found' );
11634 }
11635
11636 // Configuration initialization
11637 config = $.extend( { align: 'left', helpInline: false }, config );
11638
11639 // Parent constructor
11640 OO.ui.FieldLayout.parent.call( this, config );
11641
11642 // Mixin constructors
11643 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11644 $label: $( '<label>' )
11645 } ) );
11646 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11647
11648 // Properties
11649 this.fieldWidget = fieldWidget;
11650 this.errors = [];
11651 this.notices = [];
11652 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11653 this.$messages = $( '<ul>' );
11654 this.$header = $( '<span>' );
11655 this.$body = $( '<div>' );
11656 this.align = null;
11657 this.helpInline = config.helpInline;
11658
11659 // Events
11660 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11661
11662 // Initialization
11663 this.$help = config.help ?
11664 this.createHelpElement( config.help, config.$overlay ) :
11665 $( [] );
11666 if ( this.fieldWidget.getInputId() ) {
11667 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11668 if ( this.helpInline ) {
11669 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11670 }
11671 } else {
11672 this.$label.on( 'click', function () {
11673 this.fieldWidget.simulateLabelClick();
11674 }.bind( this ) );
11675 if ( this.helpInline ) {
11676 this.$help.on( 'click', function () {
11677 this.fieldWidget.simulateLabelClick();
11678 }.bind( this ) );
11679 }
11680 }
11681 this.$element
11682 .addClass( 'oo-ui-fieldLayout' )
11683 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11684 .append( this.$body );
11685 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11686 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11687 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11688 this.$field
11689 .addClass( 'oo-ui-fieldLayout-field' )
11690 .append( this.fieldWidget.$element );
11691
11692 this.setErrors( config.errors || [] );
11693 this.setNotices( config.notices || [] );
11694 this.setAlignment( config.align );
11695 // Call this again to take into account the widget's accessKey
11696 this.updateTitle();
11697 };
11698
11699 /* Setup */
11700
11701 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11702 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11703 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11704
11705 /* Methods */
11706
11707 /**
11708 * Handle field disable events.
11709 *
11710 * @private
11711 * @param {boolean} value Field is disabled
11712 */
11713 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11714 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11715 };
11716
11717 /**
11718 * Get the widget contained by the field.
11719 *
11720 * @return {OO.ui.Widget} Field widget
11721 */
11722 OO.ui.FieldLayout.prototype.getField = function () {
11723 return this.fieldWidget;
11724 };
11725
11726 /**
11727 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11728 * #setAlignment). Return `false` if it can't or if this can't be determined.
11729 *
11730 * @return {boolean}
11731 */
11732 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11733 // This is very simplistic, but should be good enough.
11734 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11735 };
11736
11737 /**
11738 * @protected
11739 * @param {string} kind 'error' or 'notice'
11740 * @param {string|OO.ui.HtmlSnippet} text
11741 * @return {jQuery}
11742 */
11743 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11744 var $listItem, $icon, message;
11745 $listItem = $( '<li>' );
11746 if ( kind === 'error' ) {
11747 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11748 $listItem.attr( 'role', 'alert' );
11749 } else if ( kind === 'notice' ) {
11750 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11751 } else {
11752 $icon = '';
11753 }
11754 message = new OO.ui.LabelWidget( { label: text } );
11755 $listItem
11756 .append( $icon, message.$element )
11757 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11758 return $listItem;
11759 };
11760
11761 /**
11762 * Set the field alignment mode.
11763 *
11764 * @private
11765 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11766 * @chainable
11767 * @return {OO.ui.BookletLayout} The layout, for chaining
11768 */
11769 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11770 if ( value !== this.align ) {
11771 // Default to 'left'
11772 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11773 value = 'left';
11774 }
11775 // Validate
11776 if ( value === 'inline' && !this.isFieldInline() ) {
11777 value = 'top';
11778 }
11779 // Reorder elements
11780
11781 if ( this.helpInline ) {
11782 if ( value === 'top' ) {
11783 this.$header.append( this.$label );
11784 this.$body.append( this.$header, this.$field, this.$help );
11785 } else if ( value === 'inline' ) {
11786 this.$header.append( this.$label, this.$help );
11787 this.$body.append( this.$field, this.$header );
11788 } else {
11789 this.$header.append( this.$label, this.$help );
11790 this.$body.append( this.$header, this.$field );
11791 }
11792 } else {
11793 if ( value === 'top' ) {
11794 this.$header.append( this.$help, this.$label );
11795 this.$body.append( this.$header, this.$field );
11796 } else if ( value === 'inline' ) {
11797 this.$header.append( this.$help, this.$label );
11798 this.$body.append( this.$field, this.$header );
11799 } else {
11800 this.$header.append( this.$label );
11801 this.$body.append( this.$header, this.$help, this.$field );
11802 }
11803 }
11804 // Set classes. The following classes can be used here:
11805 // * oo-ui-fieldLayout-align-left
11806 // * oo-ui-fieldLayout-align-right
11807 // * oo-ui-fieldLayout-align-top
11808 // * oo-ui-fieldLayout-align-inline
11809 if ( this.align ) {
11810 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11811 }
11812 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11813 this.align = value;
11814 }
11815
11816 return this;
11817 };
11818
11819 /**
11820 * Set the list of error messages.
11821 *
11822 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11823 * The array may contain strings or OO.ui.HtmlSnippet instances.
11824 * @chainable
11825 * @return {OO.ui.BookletLayout} The layout, for chaining
11826 */
11827 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11828 this.errors = errors.slice();
11829 this.updateMessages();
11830 return this;
11831 };
11832
11833 /**
11834 * Set the list of notice messages.
11835 *
11836 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11837 * The array may contain strings or OO.ui.HtmlSnippet instances.
11838 * @chainable
11839 * @return {OO.ui.BookletLayout} The layout, for chaining
11840 */
11841 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11842 this.notices = notices.slice();
11843 this.updateMessages();
11844 return this;
11845 };
11846
11847 /**
11848 * Update the rendering of error and notice messages.
11849 *
11850 * @private
11851 */
11852 OO.ui.FieldLayout.prototype.updateMessages = function () {
11853 var i;
11854 this.$messages.empty();
11855
11856 if ( this.errors.length || this.notices.length ) {
11857 this.$body.after( this.$messages );
11858 } else {
11859 this.$messages.remove();
11860 return;
11861 }
11862
11863 for ( i = 0; i < this.notices.length; i++ ) {
11864 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11865 }
11866 for ( i = 0; i < this.errors.length; i++ ) {
11867 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11868 }
11869 };
11870
11871 /**
11872 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11873 * (This is a bit of a hack.)
11874 *
11875 * @protected
11876 * @param {string} title Tooltip label for 'title' attribute
11877 * @return {string}
11878 */
11879 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11880 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11881 return this.fieldWidget.formatTitleWithAccessKey( title );
11882 }
11883 return title;
11884 };
11885
11886 /**
11887 * Creates and returns the help element. Also sets the `aria-describedby`
11888 * attribute on the main element of the `fieldWidget`.
11889 *
11890 * @private
11891 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11892 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11893 * @return {jQuery} The element that should become `this.$help`.
11894 */
11895 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11896 var helpId, helpWidget;
11897
11898 if ( this.helpInline ) {
11899 helpWidget = new OO.ui.LabelWidget( {
11900 label: help,
11901 classes: [ 'oo-ui-inline-help' ]
11902 } );
11903
11904 helpId = helpWidget.getElementId();
11905 } else {
11906 helpWidget = new OO.ui.PopupButtonWidget( {
11907 $overlay: $overlay,
11908 popup: {
11909 padded: true
11910 },
11911 classes: [ 'oo-ui-fieldLayout-help' ],
11912 framed: false,
11913 icon: 'info',
11914 label: OO.ui.msg( 'ooui-field-help' ),
11915 invisibleLabel: true
11916 } );
11917 if ( help instanceof OO.ui.HtmlSnippet ) {
11918 helpWidget.getPopup().$body.html( help.toString() );
11919 } else {
11920 helpWidget.getPopup().$body.text( help );
11921 }
11922
11923 helpId = helpWidget.getPopup().getBodyId();
11924 }
11925
11926 // Set the 'aria-describedby' attribute on the fieldWidget
11927 // Preference given to an input or a button
11928 (
11929 this.fieldWidget.$input ||
11930 this.fieldWidget.$button ||
11931 this.fieldWidget.$element
11932 ).attr( 'aria-describedby', helpId );
11933
11934 return helpWidget.$element;
11935 };
11936
11937 /**
11938 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11939 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11940 * is required and is specified before any optional configuration settings.
11941 *
11942 * Labels can be aligned in one of four ways:
11943 *
11944 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11945 * A left-alignment is used for forms with many fields.
11946 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11947 * A right-alignment is used for long but familiar forms which users tab through,
11948 * verifying the current field with a quick glance at the label.
11949 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11950 * that users fill out from top to bottom.
11951 * - **inline**: The label is placed after the field-widget and aligned to the left.
11952 * An inline-alignment is best used with checkboxes or radio buttons.
11953 *
11954 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11955 * text is specified.
11956 *
11957 * @example
11958 * // Example of an ActionFieldLayout
11959 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11960 * new OO.ui.TextInputWidget( {
11961 * placeholder: 'Field widget'
11962 * } ),
11963 * new OO.ui.ButtonWidget( {
11964 * label: 'Button'
11965 * } ),
11966 * {
11967 * label: 'An ActionFieldLayout. This label is aligned top',
11968 * align: 'top',
11969 * help: 'This is help text'
11970 * }
11971 * );
11972 *
11973 * $( 'body' ).append( actionFieldLayout.$element );
11974 *
11975 * @class
11976 * @extends OO.ui.FieldLayout
11977 *
11978 * @constructor
11979 * @param {OO.ui.Widget} fieldWidget Field widget
11980 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11981 * @param {Object} config
11982 */
11983 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11984 // Allow passing positional parameters inside the config object
11985 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11986 config = fieldWidget;
11987 fieldWidget = config.fieldWidget;
11988 buttonWidget = config.buttonWidget;
11989 }
11990
11991 // Parent constructor
11992 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11993
11994 // Properties
11995 this.buttonWidget = buttonWidget;
11996 this.$button = $( '<span>' );
11997 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11998
11999 // Initialization
12000 this.$element
12001 .addClass( 'oo-ui-actionFieldLayout' );
12002 this.$button
12003 .addClass( 'oo-ui-actionFieldLayout-button' )
12004 .append( this.buttonWidget.$element );
12005 this.$input
12006 .addClass( 'oo-ui-actionFieldLayout-input' )
12007 .append( this.fieldWidget.$element );
12008 this.$field
12009 .append( this.$input, this.$button );
12010 };
12011
12012 /* Setup */
12013
12014 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12015
12016 /**
12017 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12018 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12019 * configured with a label as well. For more information and examples,
12020 * please see the [OOUI documentation on MediaWiki][1].
12021 *
12022 * @example
12023 * // Example of a fieldset layout
12024 * var input1 = new OO.ui.TextInputWidget( {
12025 * placeholder: 'A text input field'
12026 * } );
12027 *
12028 * var input2 = new OO.ui.TextInputWidget( {
12029 * placeholder: 'A text input field'
12030 * } );
12031 *
12032 * var fieldset = new OO.ui.FieldsetLayout( {
12033 * label: 'Example of a fieldset layout'
12034 * } );
12035 *
12036 * fieldset.addItems( [
12037 * new OO.ui.FieldLayout( input1, {
12038 * label: 'Field One'
12039 * } ),
12040 * new OO.ui.FieldLayout( input2, {
12041 * label: 'Field Two'
12042 * } )
12043 * ] );
12044 * $( 'body' ).append( fieldset.$element );
12045 *
12046 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12047 *
12048 * @class
12049 * @extends OO.ui.Layout
12050 * @mixins OO.ui.mixin.IconElement
12051 * @mixins OO.ui.mixin.LabelElement
12052 * @mixins OO.ui.mixin.GroupElement
12053 *
12054 * @constructor
12055 * @param {Object} [config] Configuration options
12056 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
12057 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
12058 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
12059 * For important messages, you are advised to use `notices`, as they are always shown.
12060 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12061 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12062 */
12063 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12064 // Configuration initialization
12065 config = config || {};
12066
12067 // Parent constructor
12068 OO.ui.FieldsetLayout.parent.call( this, config );
12069
12070 // Mixin constructors
12071 OO.ui.mixin.IconElement.call( this, config );
12072 OO.ui.mixin.LabelElement.call( this, config );
12073 OO.ui.mixin.GroupElement.call( this, config );
12074
12075 // Properties
12076 this.$header = $( '<legend>' );
12077 if ( config.help ) {
12078 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12079 $overlay: config.$overlay,
12080 popup: {
12081 padded: true
12082 },
12083 classes: [ 'oo-ui-fieldsetLayout-help' ],
12084 framed: false,
12085 icon: 'info',
12086 label: OO.ui.msg( 'ooui-field-help' ),
12087 invisibleLabel: true
12088 } );
12089 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12090 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12091 } else {
12092 this.popupButtonWidget.getPopup().$body.text( config.help );
12093 }
12094 this.$help = this.popupButtonWidget.$element;
12095 } else {
12096 this.$help = $( [] );
12097 }
12098
12099 // Initialization
12100 this.$header
12101 .addClass( 'oo-ui-fieldsetLayout-header' )
12102 .append( this.$icon, this.$label, this.$help );
12103 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12104 this.$element
12105 .addClass( 'oo-ui-fieldsetLayout' )
12106 .prepend( this.$header, this.$group );
12107 if ( Array.isArray( config.items ) ) {
12108 this.addItems( config.items );
12109 }
12110 };
12111
12112 /* Setup */
12113
12114 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12115 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12116 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12117 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12118
12119 /* Static Properties */
12120
12121 /**
12122 * @static
12123 * @inheritdoc
12124 */
12125 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12126
12127 /**
12128 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
12129 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
12130 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
12131 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12132 *
12133 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12134 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12135 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12136 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12137 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12138 * often have simplified APIs to match the capabilities of HTML forms.
12139 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12140 *
12141 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12142 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12143 *
12144 * @example
12145 * // Example of a form layout that wraps a fieldset layout
12146 * var input1 = new OO.ui.TextInputWidget( {
12147 * placeholder: 'Username'
12148 * } );
12149 * var input2 = new OO.ui.TextInputWidget( {
12150 * placeholder: 'Password',
12151 * type: 'password'
12152 * } );
12153 * var submit = new OO.ui.ButtonInputWidget( {
12154 * label: 'Submit'
12155 * } );
12156 *
12157 * var fieldset = new OO.ui.FieldsetLayout( {
12158 * label: 'A form layout'
12159 * } );
12160 * fieldset.addItems( [
12161 * new OO.ui.FieldLayout( input1, {
12162 * label: 'Username',
12163 * align: 'top'
12164 * } ),
12165 * new OO.ui.FieldLayout( input2, {
12166 * label: 'Password',
12167 * align: 'top'
12168 * } ),
12169 * new OO.ui.FieldLayout( submit )
12170 * ] );
12171 * var form = new OO.ui.FormLayout( {
12172 * items: [ fieldset ],
12173 * action: '/api/formhandler',
12174 * method: 'get'
12175 * } )
12176 * $( 'body' ).append( form.$element );
12177 *
12178 * @class
12179 * @extends OO.ui.Layout
12180 * @mixins OO.ui.mixin.GroupElement
12181 *
12182 * @constructor
12183 * @param {Object} [config] Configuration options
12184 * @cfg {string} [method] HTML form `method` attribute
12185 * @cfg {string} [action] HTML form `action` attribute
12186 * @cfg {string} [enctype] HTML form `enctype` attribute
12187 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12188 */
12189 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12190 var action;
12191
12192 // Configuration initialization
12193 config = config || {};
12194
12195 // Parent constructor
12196 OO.ui.FormLayout.parent.call( this, config );
12197
12198 // Mixin constructors
12199 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12200
12201 // Events
12202 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12203
12204 // Make sure the action is safe
12205 action = config.action;
12206 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12207 action = './' + action;
12208 }
12209
12210 // Initialization
12211 this.$element
12212 .addClass( 'oo-ui-formLayout' )
12213 .attr( {
12214 method: config.method,
12215 action: action,
12216 enctype: config.enctype
12217 } );
12218 if ( Array.isArray( config.items ) ) {
12219 this.addItems( config.items );
12220 }
12221 };
12222
12223 /* Setup */
12224
12225 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12226 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12227
12228 /* Events */
12229
12230 /**
12231 * A 'submit' event is emitted when the form is submitted.
12232 *
12233 * @event submit
12234 */
12235
12236 /* Static Properties */
12237
12238 /**
12239 * @static
12240 * @inheritdoc
12241 */
12242 OO.ui.FormLayout.static.tagName = 'form';
12243
12244 /* Methods */
12245
12246 /**
12247 * Handle form submit events.
12248 *
12249 * @private
12250 * @param {jQuery.Event} e Submit event
12251 * @fires submit
12252 * @return {OO.ui.FormLayout} The layout, for chaining
12253 */
12254 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12255 if ( this.emit( 'submit' ) ) {
12256 return false;
12257 }
12258 };
12259
12260 /**
12261 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12262 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12263 *
12264 * @example
12265 * // Example of a panel layout
12266 * var panel = new OO.ui.PanelLayout( {
12267 * expanded: false,
12268 * framed: true,
12269 * padded: true,
12270 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12271 * } );
12272 * $( 'body' ).append( panel.$element );
12273 *
12274 * @class
12275 * @extends OO.ui.Layout
12276 *
12277 * @constructor
12278 * @param {Object} [config] Configuration options
12279 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12280 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12281 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12282 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12283 */
12284 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12285 // Configuration initialization
12286 config = $.extend( {
12287 scrollable: false,
12288 padded: false,
12289 expanded: true,
12290 framed: false
12291 }, config );
12292
12293 // Parent constructor
12294 OO.ui.PanelLayout.parent.call( this, config );
12295
12296 // Initialization
12297 this.$element.addClass( 'oo-ui-panelLayout' );
12298 if ( config.scrollable ) {
12299 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12300 }
12301 if ( config.padded ) {
12302 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12303 }
12304 if ( config.expanded ) {
12305 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12306 }
12307 if ( config.framed ) {
12308 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12309 }
12310 };
12311
12312 /* Setup */
12313
12314 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12315
12316 /* Methods */
12317
12318 /**
12319 * Focus the panel layout
12320 *
12321 * The default implementation just focuses the first focusable element in the panel
12322 */
12323 OO.ui.PanelLayout.prototype.focus = function () {
12324 OO.ui.findFocusable( this.$element ).focus();
12325 };
12326
12327 /**
12328 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12329 * items), with small margins between them. Convenient when you need to put a number of block-level
12330 * widgets on a single line next to each other.
12331 *
12332 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12333 *
12334 * @example
12335 * // HorizontalLayout with a text input and a label
12336 * var layout = new OO.ui.HorizontalLayout( {
12337 * items: [
12338 * new OO.ui.LabelWidget( { label: 'Label' } ),
12339 * new OO.ui.TextInputWidget( { value: 'Text' } )
12340 * ]
12341 * } );
12342 * $( 'body' ).append( layout.$element );
12343 *
12344 * @class
12345 * @extends OO.ui.Layout
12346 * @mixins OO.ui.mixin.GroupElement
12347 *
12348 * @constructor
12349 * @param {Object} [config] Configuration options
12350 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12351 */
12352 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12353 // Configuration initialization
12354 config = config || {};
12355
12356 // Parent constructor
12357 OO.ui.HorizontalLayout.parent.call( this, config );
12358
12359 // Mixin constructors
12360 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12361
12362 // Initialization
12363 this.$element.addClass( 'oo-ui-horizontalLayout' );
12364 if ( Array.isArray( config.items ) ) {
12365 this.addItems( config.items );
12366 }
12367 };
12368
12369 /* Setup */
12370
12371 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12372 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12373
12374 /**
12375 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12376 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12377 * (to adjust the value in increments) to allow the user to enter a number.
12378 *
12379 * @example
12380 * // Example: A NumberInputWidget.
12381 * var numberInput = new OO.ui.NumberInputWidget( {
12382 * label: 'NumberInputWidget',
12383 * input: { value: 5 },
12384 * min: 1,
12385 * max: 10
12386 * } );
12387 * $( 'body' ).append( numberInput.$element );
12388 *
12389 * @class
12390 * @extends OO.ui.TextInputWidget
12391 *
12392 * @constructor
12393 * @param {Object} [config] Configuration options
12394 * @cfg {Object} [minusButton] Configuration options to pass to the
12395 * {@link OO.ui.ButtonWidget decrementing button widget}.
12396 * @cfg {Object} [plusButton] Configuration options to pass to the
12397 * {@link OO.ui.ButtonWidget incrementing button widget}.
12398 * @cfg {number} [min=-Infinity] Minimum allowed value
12399 * @cfg {number} [max=Infinity] Maximum allowed value
12400 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12401 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12402 * Defaults to `step` if specified, otherwise `1`.
12403 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12404 * Defaults to 10 times `buttonStep`.
12405 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12406 */
12407 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12408 var $field = $( '<div>' )
12409 .addClass( 'oo-ui-numberInputWidget-field' );
12410
12411 // Configuration initialization
12412 config = $.extend( {
12413 min: -Infinity,
12414 max: Infinity,
12415 showButtons: true
12416 }, config );
12417
12418 // For backward compatibility
12419 $.extend( config, config.input );
12420 this.input = this;
12421
12422 // Parent constructor
12423 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12424 type: 'number'
12425 } ) );
12426
12427 if ( config.showButtons ) {
12428 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12429 {
12430 disabled: this.isDisabled(),
12431 tabIndex: -1,
12432 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12433 icon: 'subtract'
12434 },
12435 config.minusButton
12436 ) );
12437 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12438 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12439 {
12440 disabled: this.isDisabled(),
12441 tabIndex: -1,
12442 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12443 icon: 'add'
12444 },
12445 config.plusButton
12446 ) );
12447 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12448 }
12449
12450 // Events
12451 this.$input.on( {
12452 keydown: this.onKeyDown.bind( this ),
12453 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12454 } );
12455 if ( config.showButtons ) {
12456 this.plusButton.connect( this, {
12457 click: [ 'onButtonClick', +1 ]
12458 } );
12459 this.minusButton.connect( this, {
12460 click: [ 'onButtonClick', -1 ]
12461 } );
12462 }
12463
12464 // Build the field
12465 $field.append( this.$input );
12466 if ( config.showButtons ) {
12467 $field
12468 .prepend( this.minusButton.$element )
12469 .append( this.plusButton.$element );
12470 }
12471
12472 // Initialization
12473 if ( config.allowInteger || config.isInteger ) {
12474 // Backward compatibility
12475 config.step = 1;
12476 }
12477 this.setRange( config.min, config.max );
12478 this.setStep( config.buttonStep, config.pageStep, config.step );
12479 // Set the validation method after we set step and range
12480 // so that it doesn't immediately call setValidityFlag
12481 this.setValidation( this.validateNumber.bind( this ) );
12482
12483 this.$element
12484 .addClass( 'oo-ui-numberInputWidget' )
12485 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12486 .append( $field );
12487 };
12488
12489 /* Setup */
12490
12491 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12492
12493 /* Methods */
12494
12495 // Backward compatibility
12496 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12497 this.setStep( flag ? 1 : null );
12498 };
12499 // Backward compatibility
12500 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12501
12502 // Backward compatibility
12503 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12504 return this.step === 1;
12505 };
12506 // Backward compatibility
12507 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12508
12509 /**
12510 * Set the range of allowed values
12511 *
12512 * @param {number} min Minimum allowed value
12513 * @param {number} max Maximum allowed value
12514 */
12515 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12516 if ( min > max ) {
12517 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12518 }
12519 this.min = min;
12520 this.max = max;
12521 this.$input.attr( 'min', this.min );
12522 this.$input.attr( 'max', this.max );
12523 this.setValidityFlag();
12524 };
12525
12526 /**
12527 * Get the current range
12528 *
12529 * @return {number[]} Minimum and maximum values
12530 */
12531 OO.ui.NumberInputWidget.prototype.getRange = function () {
12532 return [ this.min, this.max ];
12533 };
12534
12535 /**
12536 * Set the stepping deltas
12537 *
12538 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12539 * Defaults to `step` if specified, otherwise `1`.
12540 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12541 * Defaults to 10 times `buttonStep`.
12542 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12543 */
12544 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12545 if ( buttonStep === undefined ) {
12546 buttonStep = step || 1;
12547 }
12548 if ( pageStep === undefined ) {
12549 pageStep = 10 * buttonStep;
12550 }
12551 if ( step !== null && step <= 0 ) {
12552 throw new Error( 'Step value, if given, must be positive' );
12553 }
12554 if ( buttonStep <= 0 ) {
12555 throw new Error( 'Button step value must be positive' );
12556 }
12557 if ( pageStep <= 0 ) {
12558 throw new Error( 'Page step value must be positive' );
12559 }
12560 this.step = step;
12561 this.buttonStep = buttonStep;
12562 this.pageStep = pageStep;
12563 this.$input.attr( 'step', this.step || 'any' );
12564 this.setValidityFlag();
12565 };
12566
12567 /**
12568 * @inheritdoc
12569 */
12570 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12571 if ( value === '' ) {
12572 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12573 // so here we make sure an 'empty' value is actually displayed as such.
12574 this.$input.val( '' );
12575 }
12576 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12577 };
12578
12579 /**
12580 * Get the current stepping values
12581 *
12582 * @return {number[]} Button step, page step, and validity step
12583 */
12584 OO.ui.NumberInputWidget.prototype.getStep = function () {
12585 return [ this.buttonStep, this.pageStep, this.step ];
12586 };
12587
12588 /**
12589 * Get the current value of the widget as a number
12590 *
12591 * @return {number} May be NaN, or an invalid number
12592 */
12593 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12594 return +this.getValue();
12595 };
12596
12597 /**
12598 * Adjust the value of the widget
12599 *
12600 * @param {number} delta Adjustment amount
12601 */
12602 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12603 var n, v = this.getNumericValue();
12604
12605 delta = +delta;
12606 if ( isNaN( delta ) || !isFinite( delta ) ) {
12607 throw new Error( 'Delta must be a finite number' );
12608 }
12609
12610 if ( isNaN( v ) ) {
12611 n = 0;
12612 } else {
12613 n = v + delta;
12614 n = Math.max( Math.min( n, this.max ), this.min );
12615 if ( this.step ) {
12616 n = Math.round( n / this.step ) * this.step;
12617 }
12618 }
12619
12620 if ( n !== v ) {
12621 this.setValue( n );
12622 }
12623 };
12624 /**
12625 * Validate input
12626 *
12627 * @private
12628 * @param {string} value Field value
12629 * @return {boolean}
12630 */
12631 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12632 var n = +value;
12633 if ( value === '' ) {
12634 return !this.isRequired();
12635 }
12636
12637 if ( isNaN( n ) || !isFinite( n ) ) {
12638 return false;
12639 }
12640
12641 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12642 return false;
12643 }
12644
12645 if ( n < this.min || n > this.max ) {
12646 return false;
12647 }
12648
12649 return true;
12650 };
12651
12652 /**
12653 * Handle mouse click events.
12654 *
12655 * @private
12656 * @param {number} dir +1 or -1
12657 */
12658 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12659 this.adjustValue( dir * this.buttonStep );
12660 };
12661
12662 /**
12663 * Handle mouse wheel events.
12664 *
12665 * @private
12666 * @param {jQuery.Event} event
12667 * @return {undefined/boolean} False to prevent default if event is handled
12668 */
12669 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12670 var delta = 0;
12671
12672 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12673 // Standard 'wheel' event
12674 if ( event.originalEvent.deltaMode !== undefined ) {
12675 this.sawWheelEvent = true;
12676 }
12677 if ( event.originalEvent.deltaY ) {
12678 delta = -event.originalEvent.deltaY;
12679 } else if ( event.originalEvent.deltaX ) {
12680 delta = event.originalEvent.deltaX;
12681 }
12682
12683 // Non-standard events
12684 if ( !this.sawWheelEvent ) {
12685 if ( event.originalEvent.wheelDeltaX ) {
12686 delta = -event.originalEvent.wheelDeltaX;
12687 } else if ( event.originalEvent.wheelDeltaY ) {
12688 delta = event.originalEvent.wheelDeltaY;
12689 } else if ( event.originalEvent.wheelDelta ) {
12690 delta = event.originalEvent.wheelDelta;
12691 } else if ( event.originalEvent.detail ) {
12692 delta = -event.originalEvent.detail;
12693 }
12694 }
12695
12696 if ( delta ) {
12697 delta = delta < 0 ? -1 : 1;
12698 this.adjustValue( delta * this.buttonStep );
12699 }
12700
12701 return false;
12702 }
12703 };
12704
12705 /**
12706 * Handle key down events.
12707 *
12708 * @private
12709 * @param {jQuery.Event} e Key down event
12710 * @return {undefined/boolean} False to prevent default if event is handled
12711 */
12712 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12713 if ( !this.isDisabled() ) {
12714 switch ( e.which ) {
12715 case OO.ui.Keys.UP:
12716 this.adjustValue( this.buttonStep );
12717 return false;
12718 case OO.ui.Keys.DOWN:
12719 this.adjustValue( -this.buttonStep );
12720 return false;
12721 case OO.ui.Keys.PAGEUP:
12722 this.adjustValue( this.pageStep );
12723 return false;
12724 case OO.ui.Keys.PAGEDOWN:
12725 this.adjustValue( -this.pageStep );
12726 return false;
12727 }
12728 }
12729 };
12730
12731 /**
12732 * @inheritdoc
12733 */
12734 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12735 // Parent method
12736 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12737
12738 if ( this.minusButton ) {
12739 this.minusButton.setDisabled( this.isDisabled() );
12740 }
12741 if ( this.plusButton ) {
12742 this.plusButton.setDisabled( this.isDisabled() );
12743 }
12744
12745 return this;
12746 };
12747
12748 }( OO ) );
12749
12750 //# sourceMappingURL=oojs-ui-core.js.map.json