Merge "Chinese Conversion Table Update 2018-4"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.30.1
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2019 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2019-01-10T07:00:09Z
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 * $( document.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 * $( document.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 $( document.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 // eslint-disable-next-line jquery/no-animate
1343 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1344 $container.queue( function ( next ) {
1345 deferred.resolve();
1346 next();
1347 } );
1348 } else {
1349 deferred.resolve();
1350 }
1351 return deferred.promise();
1352 };
1353
1354 /**
1355 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1356 * and reserve space for them, because it probably doesn't.
1357 *
1358 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1359 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1360 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1361 * and then reattach (or show) them back.
1362 *
1363 * @static
1364 * @param {HTMLElement} el Element to reconsider the scrollbars on
1365 */
1366 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1367 var i, len, scrollLeft, scrollTop, nodes = [];
1368 // Save scroll position
1369 scrollLeft = el.scrollLeft;
1370 scrollTop = el.scrollTop;
1371 // Detach all children
1372 while ( el.firstChild ) {
1373 nodes.push( el.firstChild );
1374 el.removeChild( el.firstChild );
1375 }
1376 // Force reflow
1377 // eslint-disable-next-line no-void
1378 void el.offsetHeight;
1379 // Reattach all children
1380 for ( i = 0, len = nodes.length; i < len; i++ ) {
1381 el.appendChild( nodes[ i ] );
1382 }
1383 // Restore scroll position (no-op if scrollbars disappeared)
1384 el.scrollLeft = scrollLeft;
1385 el.scrollTop = scrollTop;
1386 };
1387
1388 /* Methods */
1389
1390 /**
1391 * Toggle visibility of an element.
1392 *
1393 * @param {boolean} [show] Make element visible, omit to toggle visibility
1394 * @fires visible
1395 * @chainable
1396 * @return {OO.ui.Element} The element, for chaining
1397 */
1398 OO.ui.Element.prototype.toggle = function ( show ) {
1399 show = show === undefined ? !this.visible : !!show;
1400
1401 if ( show !== this.isVisible() ) {
1402 this.visible = show;
1403 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1404 this.emit( 'toggle', show );
1405 }
1406
1407 return this;
1408 };
1409
1410 /**
1411 * Check if element is visible.
1412 *
1413 * @return {boolean} element is visible
1414 */
1415 OO.ui.Element.prototype.isVisible = function () {
1416 return this.visible;
1417 };
1418
1419 /**
1420 * Get element data.
1421 *
1422 * @return {Mixed} Element data
1423 */
1424 OO.ui.Element.prototype.getData = function () {
1425 return this.data;
1426 };
1427
1428 /**
1429 * Set element data.
1430 *
1431 * @param {Mixed} data Element data
1432 * @chainable
1433 * @return {OO.ui.Element} The element, for chaining
1434 */
1435 OO.ui.Element.prototype.setData = function ( data ) {
1436 this.data = data;
1437 return this;
1438 };
1439
1440 /**
1441 * Set the element has an 'id' attribute.
1442 *
1443 * @param {string} id
1444 * @chainable
1445 * @return {OO.ui.Element} The element, for chaining
1446 */
1447 OO.ui.Element.prototype.setElementId = function ( id ) {
1448 this.elementId = id;
1449 this.$element.attr( 'id', id );
1450 return this;
1451 };
1452
1453 /**
1454 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1455 * and return its value.
1456 *
1457 * @return {string}
1458 */
1459 OO.ui.Element.prototype.getElementId = function () {
1460 if ( this.elementId === null ) {
1461 this.setElementId( OO.ui.generateElementId() );
1462 }
1463 return this.elementId;
1464 };
1465
1466 /**
1467 * Check if element supports one or more methods.
1468 *
1469 * @param {string|string[]} methods Method or list of methods to check
1470 * @return {boolean} All methods are supported
1471 */
1472 OO.ui.Element.prototype.supports = function ( methods ) {
1473 var i, len,
1474 support = 0;
1475
1476 methods = Array.isArray( methods ) ? methods : [ methods ];
1477 for ( i = 0, len = methods.length; i < len; i++ ) {
1478 if ( typeof this[ methods[ i ] ] === 'function' ) {
1479 support++;
1480 }
1481 }
1482
1483 return methods.length === support;
1484 };
1485
1486 /**
1487 * Update the theme-provided classes.
1488 *
1489 * @localdoc This is called in element mixins and widget classes any time state changes.
1490 * Updating is debounced, minimizing overhead of changing multiple attributes and
1491 * guaranteeing that theme updates do not occur within an element's constructor
1492 */
1493 OO.ui.Element.prototype.updateThemeClasses = function () {
1494 OO.ui.theme.queueUpdateElementClasses( this );
1495 };
1496
1497 /**
1498 * Get the HTML tag name.
1499 *
1500 * Override this method to base the result on instance information.
1501 *
1502 * @return {string} HTML tag name
1503 */
1504 OO.ui.Element.prototype.getTagName = function () {
1505 return this.constructor.static.tagName;
1506 };
1507
1508 /**
1509 * Check if the element is attached to the DOM
1510 *
1511 * @return {boolean} The element is attached to the DOM
1512 */
1513 OO.ui.Element.prototype.isElementAttached = function () {
1514 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1515 };
1516
1517 /**
1518 * Get the DOM document.
1519 *
1520 * @return {HTMLDocument} Document object
1521 */
1522 OO.ui.Element.prototype.getElementDocument = function () {
1523 // Don't cache this in other ways either because subclasses could can change this.$element
1524 return OO.ui.Element.static.getDocument( this.$element );
1525 };
1526
1527 /**
1528 * Get the DOM window.
1529 *
1530 * @return {Window} Window object
1531 */
1532 OO.ui.Element.prototype.getElementWindow = function () {
1533 return OO.ui.Element.static.getWindow( this.$element );
1534 };
1535
1536 /**
1537 * Get closest scrollable container.
1538 *
1539 * @return {HTMLElement} Closest scrollable container
1540 */
1541 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1542 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1543 };
1544
1545 /**
1546 * Get group element is in.
1547 *
1548 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1549 */
1550 OO.ui.Element.prototype.getElementGroup = function () {
1551 return this.elementGroup;
1552 };
1553
1554 /**
1555 * Set group element is in.
1556 *
1557 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1558 * @chainable
1559 * @return {OO.ui.Element} The element, for chaining
1560 */
1561 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1562 this.elementGroup = group;
1563 return this;
1564 };
1565
1566 /**
1567 * Scroll element into view.
1568 *
1569 * @param {Object} [config] Configuration options
1570 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1571 */
1572 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1573 if (
1574 !this.isElementAttached() ||
1575 !this.isVisible() ||
1576 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1577 ) {
1578 return $.Deferred().resolve();
1579 }
1580 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1581 };
1582
1583 /**
1584 * Restore the pre-infusion dynamic state for this widget.
1585 *
1586 * This method is called after #$element has been inserted into DOM. The parameter is the return
1587 * value of #gatherPreInfuseState.
1588 *
1589 * @protected
1590 * @param {Object} state
1591 */
1592 OO.ui.Element.prototype.restorePreInfuseState = function () {
1593 };
1594
1595 /**
1596 * Wraps an HTML snippet for use with configuration values which default
1597 * to strings. This bypasses the default html-escaping done to string
1598 * values.
1599 *
1600 * @class
1601 *
1602 * @constructor
1603 * @param {string} [content] HTML content
1604 */
1605 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1606 // Properties
1607 this.content = content;
1608 };
1609
1610 /* Setup */
1611
1612 OO.initClass( OO.ui.HtmlSnippet );
1613
1614 /* Methods */
1615
1616 /**
1617 * Render into HTML.
1618 *
1619 * @return {string} Unchanged HTML snippet.
1620 */
1621 OO.ui.HtmlSnippet.prototype.toString = function () {
1622 return this.content;
1623 };
1624
1625 /**
1626 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1627 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1628 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1629 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1630 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1631 *
1632 * @abstract
1633 * @class
1634 * @extends OO.ui.Element
1635 * @mixins OO.EventEmitter
1636 *
1637 * @constructor
1638 * @param {Object} [config] Configuration options
1639 */
1640 OO.ui.Layout = function OoUiLayout( config ) {
1641 // Configuration initialization
1642 config = config || {};
1643
1644 // Parent constructor
1645 OO.ui.Layout.parent.call( this, config );
1646
1647 // Mixin constructors
1648 OO.EventEmitter.call( this );
1649
1650 // Initialization
1651 this.$element.addClass( 'oo-ui-layout' );
1652 };
1653
1654 /* Setup */
1655
1656 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1657 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1658
1659 /* Methods */
1660
1661 /**
1662 * Reset scroll offsets
1663 *
1664 * @chainable
1665 * @return {OO.ui.Layout} The layout, for chaining
1666 */
1667 OO.ui.Layout.prototype.resetScroll = function () {
1668 this.$element[ 0 ].scrollTop = 0;
1669 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1670
1671 return this;
1672 };
1673
1674 /**
1675 * Widgets are compositions of one or more OOUI elements that users can both view
1676 * and interact with. All widgets can be configured and modified via a standard API,
1677 * and their state can change dynamically according to a model.
1678 *
1679 * @abstract
1680 * @class
1681 * @extends OO.ui.Element
1682 * @mixins OO.EventEmitter
1683 *
1684 * @constructor
1685 * @param {Object} [config] Configuration options
1686 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1687 * appearance reflects this state.
1688 */
1689 OO.ui.Widget = function OoUiWidget( config ) {
1690 // Initialize config
1691 config = $.extend( { disabled: false }, config );
1692
1693 // Parent constructor
1694 OO.ui.Widget.parent.call( this, config );
1695
1696 // Mixin constructors
1697 OO.EventEmitter.call( this );
1698
1699 // Properties
1700 this.disabled = null;
1701 this.wasDisabled = null;
1702
1703 // Initialization
1704 this.$element.addClass( 'oo-ui-widget' );
1705 this.setDisabled( !!config.disabled );
1706 };
1707
1708 /* Setup */
1709
1710 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1711 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1712
1713 /* Events */
1714
1715 /**
1716 * @event disable
1717 *
1718 * A 'disable' event is emitted when the disabled state of the widget changes
1719 * (i.e. on disable **and** enable).
1720 *
1721 * @param {boolean} disabled Widget is disabled
1722 */
1723
1724 /**
1725 * @event toggle
1726 *
1727 * A 'toggle' event is emitted when the visibility of the widget changes.
1728 *
1729 * @param {boolean} visible Widget is visible
1730 */
1731
1732 /* Methods */
1733
1734 /**
1735 * Check if the widget is disabled.
1736 *
1737 * @return {boolean} Widget is disabled
1738 */
1739 OO.ui.Widget.prototype.isDisabled = function () {
1740 return this.disabled;
1741 };
1742
1743 /**
1744 * Set the 'disabled' state of the widget.
1745 *
1746 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1747 *
1748 * @param {boolean} disabled Disable widget
1749 * @chainable
1750 * @return {OO.ui.Widget} The widget, for chaining
1751 */
1752 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1753 var isDisabled;
1754
1755 this.disabled = !!disabled;
1756 isDisabled = this.isDisabled();
1757 if ( isDisabled !== this.wasDisabled ) {
1758 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1759 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1760 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1761 this.emit( 'disable', isDisabled );
1762 this.updateThemeClasses();
1763 }
1764 this.wasDisabled = isDisabled;
1765
1766 return this;
1767 };
1768
1769 /**
1770 * Update the disabled state, in case of changes in parent widget.
1771 *
1772 * @chainable
1773 * @return {OO.ui.Widget} The widget, for chaining
1774 */
1775 OO.ui.Widget.prototype.updateDisabled = function () {
1776 this.setDisabled( this.disabled );
1777 return this;
1778 };
1779
1780 /**
1781 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1782 * value.
1783 *
1784 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1785 * instead.
1786 *
1787 * @return {string|null} The ID of the labelable element
1788 */
1789 OO.ui.Widget.prototype.getInputId = function () {
1790 return null;
1791 };
1792
1793 /**
1794 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1795 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1796 * override this method to provide intuitive, accessible behavior.
1797 *
1798 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1799 * Individual widgets may override it too.
1800 *
1801 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1802 * directly.
1803 */
1804 OO.ui.Widget.prototype.simulateLabelClick = function () {
1805 };
1806
1807 /**
1808 * Theme logic.
1809 *
1810 * @abstract
1811 * @class
1812 *
1813 * @constructor
1814 */
1815 OO.ui.Theme = function OoUiTheme() {
1816 this.elementClassesQueue = [];
1817 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1818 };
1819
1820 /* Setup */
1821
1822 OO.initClass( OO.ui.Theme );
1823
1824 /* Methods */
1825
1826 /**
1827 * Get a list of classes to be applied to a widget.
1828 *
1829 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1830 * otherwise state transitions will not work properly.
1831 *
1832 * @param {OO.ui.Element} element Element for which to get classes
1833 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1834 */
1835 OO.ui.Theme.prototype.getElementClasses = function () {
1836 return { on: [], off: [] };
1837 };
1838
1839 /**
1840 * Update CSS classes provided by the theme.
1841 *
1842 * For elements with theme logic hooks, this should be called any time there's a state change.
1843 *
1844 * @param {OO.ui.Element} element Element for which to update classes
1845 */
1846 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1847 var $elements = $( [] ),
1848 classes = this.getElementClasses( element );
1849
1850 if ( element.$icon ) {
1851 $elements = $elements.add( element.$icon );
1852 }
1853 if ( element.$indicator ) {
1854 $elements = $elements.add( element.$indicator );
1855 }
1856
1857 $elements
1858 .removeClass( classes.off )
1859 .addClass( classes.on );
1860 };
1861
1862 /**
1863 * @private
1864 */
1865 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1866 var i;
1867 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1868 this.updateElementClasses( this.elementClassesQueue[ i ] );
1869 }
1870 // Clear the queue
1871 this.elementClassesQueue = [];
1872 };
1873
1874 /**
1875 * Queue #updateElementClasses to be called for this element.
1876 *
1877 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1878 * to make them synchronous.
1879 *
1880 * @param {OO.ui.Element} element Element for which to update classes
1881 */
1882 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1883 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1884 // the most common case (this method is often called repeatedly for the same element).
1885 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1886 return;
1887 }
1888 this.elementClassesQueue.push( element );
1889 this.debouncedUpdateQueuedElementClasses();
1890 };
1891
1892 /**
1893 * Get the transition duration in milliseconds for dialogs opening/closing
1894 *
1895 * The dialog should be fully rendered this many milliseconds after the
1896 * ready process has executed.
1897 *
1898 * @return {number} Transition duration in milliseconds
1899 */
1900 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1901 return 0;
1902 };
1903
1904 /**
1905 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1906 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1907 * order in which users will navigate through the focusable elements via the “tab” key.
1908 *
1909 * @example
1910 * // TabIndexedElement is mixed into the ButtonWidget class
1911 * // to provide a tabIndex property.
1912 * var button1 = new OO.ui.ButtonWidget( {
1913 * label: 'fourth',
1914 * tabIndex: 4
1915 * } ),
1916 * button2 = new OO.ui.ButtonWidget( {
1917 * label: 'second',
1918 * tabIndex: 2
1919 * } ),
1920 * button3 = new OO.ui.ButtonWidget( {
1921 * label: 'third',
1922 * tabIndex: 3
1923 * } ),
1924 * button4 = new OO.ui.ButtonWidget( {
1925 * label: 'first',
1926 * tabIndex: 1
1927 * } );
1928 * $( document.body ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1929 *
1930 * @abstract
1931 * @class
1932 *
1933 * @constructor
1934 * @param {Object} [config] Configuration options
1935 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1936 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1937 * functionality will be applied to it instead.
1938 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1939 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1940 * to remove the element from the tab-navigation flow.
1941 */
1942 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1943 // Configuration initialization
1944 config = $.extend( { tabIndex: 0 }, config );
1945
1946 // Properties
1947 this.$tabIndexed = null;
1948 this.tabIndex = null;
1949
1950 // Events
1951 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1952
1953 // Initialization
1954 this.setTabIndex( config.tabIndex );
1955 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1956 };
1957
1958 /* Setup */
1959
1960 OO.initClass( OO.ui.mixin.TabIndexedElement );
1961
1962 /* Methods */
1963
1964 /**
1965 * Set the element that should use the tabindex functionality.
1966 *
1967 * This method is used to retarget a tabindex mixin so that its functionality applies
1968 * to the specified element. If an element is currently using the functionality, the mixin’s
1969 * effect on that element is removed before the new element is set up.
1970 *
1971 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1972 * @chainable
1973 * @return {OO.ui.Element} The element, for chaining
1974 */
1975 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1976 var tabIndex = this.tabIndex;
1977 // Remove attributes from old $tabIndexed
1978 this.setTabIndex( null );
1979 // Force update of new $tabIndexed
1980 this.$tabIndexed = $tabIndexed;
1981 this.tabIndex = tabIndex;
1982 return this.updateTabIndex();
1983 };
1984
1985 /**
1986 * Set the value of the tabindex.
1987 *
1988 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1989 * @chainable
1990 * @return {OO.ui.Element} The element, for chaining
1991 */
1992 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1993 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1994
1995 if ( this.tabIndex !== tabIndex ) {
1996 this.tabIndex = tabIndex;
1997 this.updateTabIndex();
1998 }
1999
2000 return this;
2001 };
2002
2003 /**
2004 * Update the `tabindex` attribute, in case of changes to tab index or
2005 * disabled state.
2006 *
2007 * @private
2008 * @chainable
2009 * @return {OO.ui.Element} The element, for chaining
2010 */
2011 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2012 if ( this.$tabIndexed ) {
2013 if ( this.tabIndex !== null ) {
2014 // Do not index over disabled elements
2015 this.$tabIndexed.attr( {
2016 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2017 // Support: ChromeVox and NVDA
2018 // These do not seem to inherit aria-disabled from parent elements
2019 'aria-disabled': this.isDisabled().toString()
2020 } );
2021 } else {
2022 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2023 }
2024 }
2025 return this;
2026 };
2027
2028 /**
2029 * Handle disable events.
2030 *
2031 * @private
2032 * @param {boolean} disabled Element is disabled
2033 */
2034 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2035 this.updateTabIndex();
2036 };
2037
2038 /**
2039 * Get the value of the tabindex.
2040 *
2041 * @return {number|null} Tabindex value
2042 */
2043 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2044 return this.tabIndex;
2045 };
2046
2047 /**
2048 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2049 *
2050 * If the element already has an ID then that is returned, otherwise unique ID is
2051 * generated, set on the element, and returned.
2052 *
2053 * @return {string|null} The ID of the focusable element
2054 */
2055 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2056 var id;
2057
2058 if ( !this.$tabIndexed ) {
2059 return null;
2060 }
2061 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2062 return null;
2063 }
2064
2065 id = this.$tabIndexed.attr( 'id' );
2066 if ( id === undefined ) {
2067 id = OO.ui.generateElementId();
2068 this.$tabIndexed.attr( 'id', id );
2069 }
2070
2071 return id;
2072 };
2073
2074 /**
2075 * Whether the node is 'labelable' according to the HTML spec
2076 * (i.e., whether it can be interacted with through a `<label for="…">`).
2077 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2078 *
2079 * @private
2080 * @param {jQuery} $node
2081 * @return {boolean}
2082 */
2083 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2084 var
2085 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2086 tagName = $node.prop( 'tagName' ).toLowerCase();
2087
2088 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2089 return true;
2090 }
2091 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2092 return true;
2093 }
2094 return false;
2095 };
2096
2097 /**
2098 * Focus this element.
2099 *
2100 * @chainable
2101 * @return {OO.ui.Element} The element, for chaining
2102 */
2103 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2104 if ( !this.isDisabled() ) {
2105 this.$tabIndexed.focus();
2106 }
2107 return this;
2108 };
2109
2110 /**
2111 * Blur this element.
2112 *
2113 * @chainable
2114 * @return {OO.ui.Element} The element, for chaining
2115 */
2116 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2117 this.$tabIndexed.blur();
2118 return this;
2119 };
2120
2121 /**
2122 * @inheritdoc OO.ui.Widget
2123 */
2124 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2125 this.focus();
2126 };
2127
2128 /**
2129 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2130 * interface element that can be configured with access keys for keyboard interaction.
2131 * See the [OOUI documentation on MediaWiki] [1] for examples.
2132 *
2133 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2134 *
2135 * @abstract
2136 * @class
2137 *
2138 * @constructor
2139 * @param {Object} [config] Configuration options
2140 * @cfg {jQuery} [$button] The button element created by the class.
2141 * If this configuration is omitted, the button element will use a generated `<a>`.
2142 * @cfg {boolean} [framed=true] Render the button with a frame
2143 */
2144 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2145 // Configuration initialization
2146 config = config || {};
2147
2148 // Properties
2149 this.$button = null;
2150 this.framed = null;
2151 this.active = config.active !== undefined && config.active;
2152 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2153 this.onMouseDownHandler = this.onMouseDown.bind( this );
2154 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2155 this.onKeyDownHandler = this.onKeyDown.bind( this );
2156 this.onClickHandler = this.onClick.bind( this );
2157 this.onKeyPressHandler = this.onKeyPress.bind( this );
2158
2159 // Initialization
2160 this.$element.addClass( 'oo-ui-buttonElement' );
2161 this.toggleFramed( config.framed === undefined || config.framed );
2162 this.setButtonElement( config.$button || $( '<a>' ) );
2163 };
2164
2165 /* Setup */
2166
2167 OO.initClass( OO.ui.mixin.ButtonElement );
2168
2169 /* Static Properties */
2170
2171 /**
2172 * Cancel mouse down events.
2173 *
2174 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2175 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2176 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2177 * parent widget.
2178 *
2179 * @static
2180 * @inheritable
2181 * @property {boolean}
2182 */
2183 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2184
2185 /* Events */
2186
2187 /**
2188 * A 'click' event is emitted when the button element is clicked.
2189 *
2190 * @event click
2191 */
2192
2193 /* Methods */
2194
2195 /**
2196 * Set the button element.
2197 *
2198 * This method is used to retarget a button mixin so that its functionality applies to
2199 * the specified button element instead of the one created by the class. If a button element
2200 * is already set, the method will remove the mixin’s effect on that element.
2201 *
2202 * @param {jQuery} $button Element to use as button
2203 */
2204 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2205 if ( this.$button ) {
2206 this.$button
2207 .removeClass( 'oo-ui-buttonElement-button' )
2208 .removeAttr( 'role accesskey' )
2209 .off( {
2210 mousedown: this.onMouseDownHandler,
2211 keydown: this.onKeyDownHandler,
2212 click: this.onClickHandler,
2213 keypress: this.onKeyPressHandler
2214 } );
2215 }
2216
2217 this.$button = $button
2218 .addClass( 'oo-ui-buttonElement-button' )
2219 .on( {
2220 mousedown: this.onMouseDownHandler,
2221 keydown: this.onKeyDownHandler,
2222 click: this.onClickHandler,
2223 keypress: this.onKeyPressHandler
2224 } );
2225
2226 // Add `role="button"` on `<a>` elements, where it's needed
2227 // `toUpperCase()` is added for XHTML documents
2228 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2229 this.$button.attr( 'role', 'button' );
2230 }
2231 };
2232
2233 /**
2234 * Handles mouse down events.
2235 *
2236 * @protected
2237 * @param {jQuery.Event} e Mouse down event
2238 * @return {undefined/boolean} False to prevent default if event is handled
2239 */
2240 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2241 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2242 return;
2243 }
2244 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2245 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2246 // reliably remove the pressed class
2247 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2248 // Prevent change of focus unless specifically configured otherwise
2249 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2250 return false;
2251 }
2252 };
2253
2254 /**
2255 * Handles document mouse up events.
2256 *
2257 * @protected
2258 * @param {MouseEvent} e Mouse up event
2259 */
2260 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2261 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2262 return;
2263 }
2264 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2265 // Stop listening for mouseup, since we only needed this once
2266 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2267 };
2268
2269 // Deprecated alias since 0.28.3
2270 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function () {
2271 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
2272 this.onDocumentMouseUp.apply( this, arguments );
2273 };
2274
2275 /**
2276 * Handles mouse click events.
2277 *
2278 * @protected
2279 * @param {jQuery.Event} e Mouse click event
2280 * @fires click
2281 * @return {undefined/boolean} False to prevent default if event is handled
2282 */
2283 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2284 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2285 if ( this.emit( 'click' ) ) {
2286 return false;
2287 }
2288 }
2289 };
2290
2291 /**
2292 * Handles key down events.
2293 *
2294 * @protected
2295 * @param {jQuery.Event} e Key down event
2296 */
2297 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2298 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2299 return;
2300 }
2301 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2302 // Run the keyup handler no matter where the key is when the button is let go, so we can
2303 // reliably remove the pressed class
2304 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2305 };
2306
2307 /**
2308 * Handles document key up events.
2309 *
2310 * @protected
2311 * @param {KeyboardEvent} e Key up event
2312 */
2313 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2314 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2315 return;
2316 }
2317 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2318 // Stop listening for keyup, since we only needed this once
2319 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2320 };
2321
2322 // Deprecated alias since 0.28.3
2323 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function () {
2324 OO.ui.warnDeprecation( 'onKeyUp is deprecated, use onDocumentKeyUp instead' );
2325 this.onDocumentKeyUp.apply( this, arguments );
2326 };
2327
2328 /**
2329 * Handles key press events.
2330 *
2331 * @protected
2332 * @param {jQuery.Event} e Key press event
2333 * @fires click
2334 * @return {undefined/boolean} False to prevent default if event is handled
2335 */
2336 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2337 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2338 if ( this.emit( 'click' ) ) {
2339 return false;
2340 }
2341 }
2342 };
2343
2344 /**
2345 * Check if button has a frame.
2346 *
2347 * @return {boolean} Button is framed
2348 */
2349 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2350 return this.framed;
2351 };
2352
2353 /**
2354 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2355 *
2356 * @param {boolean} [framed] Make button framed, omit to toggle
2357 * @chainable
2358 * @return {OO.ui.Element} The element, for chaining
2359 */
2360 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2361 framed = framed === undefined ? !this.framed : !!framed;
2362 if ( framed !== this.framed ) {
2363 this.framed = framed;
2364 this.$element
2365 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2366 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2367 this.updateThemeClasses();
2368 }
2369
2370 return this;
2371 };
2372
2373 /**
2374 * Set the button's active state.
2375 *
2376 * The active state can be set on:
2377 *
2378 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2379 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2380 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2381 *
2382 * @protected
2383 * @param {boolean} value Make button active
2384 * @chainable
2385 * @return {OO.ui.Element} The element, for chaining
2386 */
2387 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2388 this.active = !!value;
2389 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2390 this.updateThemeClasses();
2391 return this;
2392 };
2393
2394 /**
2395 * Check if the button is active
2396 *
2397 * @protected
2398 * @return {boolean} The button is active
2399 */
2400 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2401 return this.active;
2402 };
2403
2404 /**
2405 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2406 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2407 * items from the group is done through the interface the class provides.
2408 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2409 *
2410 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2411 *
2412 * @abstract
2413 * @mixins OO.EmitterList
2414 * @class
2415 *
2416 * @constructor
2417 * @param {Object} [config] Configuration options
2418 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2419 * is omitted, the group element will use a generated `<div>`.
2420 */
2421 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2422 // Configuration initialization
2423 config = config || {};
2424
2425 // Mixin constructors
2426 OO.EmitterList.call( this, config );
2427
2428 // Properties
2429 this.$group = null;
2430
2431 // Initialization
2432 this.setGroupElement( config.$group || $( '<div>' ) );
2433 };
2434
2435 /* Setup */
2436
2437 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2438
2439 /* Events */
2440
2441 /**
2442 * @event change
2443 *
2444 * A change event is emitted when the set of selected items changes.
2445 *
2446 * @param {OO.ui.Element[]} items Items currently in the group
2447 */
2448
2449 /* Methods */
2450
2451 /**
2452 * Set the group element.
2453 *
2454 * If an element is already set, items will be moved to the new element.
2455 *
2456 * @param {jQuery} $group Element to use as group
2457 */
2458 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2459 var i, len;
2460
2461 this.$group = $group;
2462 for ( i = 0, len = this.items.length; i < len; i++ ) {
2463 this.$group.append( this.items[ i ].$element );
2464 }
2465 };
2466
2467 /**
2468 * Find an item by its data.
2469 *
2470 * Only the first item with matching data will be returned. To return all matching items,
2471 * use the #findItemsFromData method.
2472 *
2473 * @param {Object} data Item data to search for
2474 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2475 */
2476 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2477 var i, len, item,
2478 hash = OO.getHash( data );
2479
2480 for ( i = 0, len = this.items.length; i < len; i++ ) {
2481 item = this.items[ i ];
2482 if ( hash === OO.getHash( item.getData() ) ) {
2483 return item;
2484 }
2485 }
2486
2487 return null;
2488 };
2489
2490 /**
2491 * Find items by their data.
2492 *
2493 * All items with matching data will be returned. To return only the first match, use the #findItemFromData method instead.
2494 *
2495 * @param {Object} data Item data to search for
2496 * @return {OO.ui.Element[]} Items with equivalent data
2497 */
2498 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2499 var i, len, item,
2500 hash = OO.getHash( data ),
2501 items = [];
2502
2503 for ( i = 0, len = this.items.length; i < len; i++ ) {
2504 item = this.items[ i ];
2505 if ( hash === OO.getHash( item.getData() ) ) {
2506 items.push( item );
2507 }
2508 }
2509
2510 return items;
2511 };
2512
2513 /**
2514 * Add items to the group.
2515 *
2516 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2517 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2518 *
2519 * @param {OO.ui.Element[]} items An array of items to add to the group
2520 * @param {number} [index] Index of the insertion point
2521 * @chainable
2522 * @return {OO.ui.Element} The element, for chaining
2523 */
2524 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2525 // Mixin method
2526 OO.EmitterList.prototype.addItems.call( this, items, index );
2527
2528 this.emit( 'change', this.getItems() );
2529 return this;
2530 };
2531
2532 /**
2533 * @inheritdoc
2534 */
2535 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2536 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2537 this.insertItemElements( items, newIndex );
2538
2539 // Mixin method
2540 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2541
2542 return newIndex;
2543 };
2544
2545 /**
2546 * @inheritdoc
2547 */
2548 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2549 item.setElementGroup( this );
2550 this.insertItemElements( item, index );
2551
2552 // Mixin method
2553 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2554
2555 return index;
2556 };
2557
2558 /**
2559 * Insert elements into the group
2560 *
2561 * @private
2562 * @param {OO.ui.Element} itemWidget Item to insert
2563 * @param {number} index Insertion index
2564 */
2565 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2566 if ( index === undefined || index < 0 || index >= this.items.length ) {
2567 this.$group.append( itemWidget.$element );
2568 } else if ( index === 0 ) {
2569 this.$group.prepend( itemWidget.$element );
2570 } else {
2571 this.items[ index ].$element.before( itemWidget.$element );
2572 }
2573 };
2574
2575 /**
2576 * Remove the specified items from a group.
2577 *
2578 * Removed items are detached (not removed) from the DOM so that they may be reused.
2579 * To remove all items from a group, you may wish to use the #clearItems method instead.
2580 *
2581 * @param {OO.ui.Element[]} items An array of items to remove
2582 * @chainable
2583 * @return {OO.ui.Element} The element, for chaining
2584 */
2585 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2586 var i, len, item, index;
2587
2588 // Remove specific items elements
2589 for ( i = 0, len = items.length; i < len; i++ ) {
2590 item = items[ i ];
2591 index = this.items.indexOf( item );
2592 if ( index !== -1 ) {
2593 item.setElementGroup( null );
2594 item.$element.detach();
2595 }
2596 }
2597
2598 // Mixin method
2599 OO.EmitterList.prototype.removeItems.call( this, items );
2600
2601 this.emit( 'change', this.getItems() );
2602 return this;
2603 };
2604
2605 /**
2606 * Clear all items from the group.
2607 *
2608 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2609 * To remove only a subset of items from a group, use the #removeItems method.
2610 *
2611 * @chainable
2612 * @return {OO.ui.Element} The element, for chaining
2613 */
2614 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2615 var i, len;
2616
2617 // Remove all item elements
2618 for ( i = 0, len = this.items.length; i < len; i++ ) {
2619 this.items[ i ].setElementGroup( null );
2620 this.items[ i ].$element.detach();
2621 }
2622
2623 // Mixin method
2624 OO.EmitterList.prototype.clearItems.call( this );
2625
2626 this.emit( 'change', this.getItems() );
2627 return this;
2628 };
2629
2630 /**
2631 * LabelElement is often mixed into other classes to generate a label, which
2632 * helps identify the function of an interface element.
2633 * See the [OOUI documentation on MediaWiki] [1] for more information.
2634 *
2635 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2636 *
2637 * @abstract
2638 * @class
2639 *
2640 * @constructor
2641 * @param {Object} [config] Configuration options
2642 * @cfg {jQuery} [$label] The label element created by the class. If this
2643 * configuration is omitted, the label element will use a generated `<span>`.
2644 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2645 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2646 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2647 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2648 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still accessible
2649 * to screen-readers).
2650 */
2651 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2652 // Configuration initialization
2653 config = config || {};
2654
2655 // Properties
2656 this.$label = null;
2657 this.label = null;
2658 this.invisibleLabel = null;
2659
2660 // Initialization
2661 this.setLabel( config.label || this.constructor.static.label );
2662 this.setLabelElement( config.$label || $( '<span>' ) );
2663 this.setInvisibleLabel( config.invisibleLabel );
2664 };
2665
2666 /* Setup */
2667
2668 OO.initClass( OO.ui.mixin.LabelElement );
2669
2670 /* Events */
2671
2672 /**
2673 * @event labelChange
2674 * @param {string} value
2675 */
2676
2677 /* Static Properties */
2678
2679 /**
2680 * The label text. The label can be specified as a plaintext string, a function that will
2681 * produce a string in the future, or `null` for no label. The static value will
2682 * be overridden if a label is specified with the #label config option.
2683 *
2684 * @static
2685 * @inheritable
2686 * @property {string|Function|null}
2687 */
2688 OO.ui.mixin.LabelElement.static.label = null;
2689
2690 /* Static methods */
2691
2692 /**
2693 * Highlight the first occurrence of the query in the given text
2694 *
2695 * @param {string} text Text
2696 * @param {string} query Query to find
2697 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2698 * @return {jQuery} Text with the first match of the query
2699 * sub-string wrapped in highlighted span
2700 */
2701 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2702 var i, tLen, qLen,
2703 offset = -1,
2704 $result = $( '<span>' );
2705
2706 if ( compare ) {
2707 tLen = text.length;
2708 qLen = query.length;
2709 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2710 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2711 offset = i;
2712 }
2713 }
2714 } else {
2715 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2716 }
2717
2718 if ( !query.length || offset === -1 ) {
2719 $result.text( text );
2720 } else {
2721 $result.append(
2722 document.createTextNode( text.slice( 0, offset ) ),
2723 $( '<span>' )
2724 .addClass( 'oo-ui-labelElement-label-highlight' )
2725 .text( text.slice( offset, offset + query.length ) ),
2726 document.createTextNode( text.slice( offset + query.length ) )
2727 );
2728 }
2729 return $result.contents();
2730 };
2731
2732 /* Methods */
2733
2734 /**
2735 * Set the label element.
2736 *
2737 * If an element is already set, it will be cleaned up before setting up the new element.
2738 *
2739 * @param {jQuery} $label Element to use as label
2740 */
2741 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2742 if ( this.$label ) {
2743 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2744 }
2745
2746 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2747 this.setLabelContent( this.label );
2748 };
2749
2750 /**
2751 * Set the label.
2752 *
2753 * An empty string will result in the label being hidden. A string containing only whitespace will
2754 * be converted to a single `&nbsp;`.
2755 *
2756 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
2757 * text; or null for no label
2758 * @chainable
2759 * @return {OO.ui.Element} The element, for chaining
2760 */
2761 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2762 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2763 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2764
2765 if ( this.label !== label ) {
2766 if ( this.$label ) {
2767 this.setLabelContent( label );
2768 }
2769 this.label = label;
2770 this.emit( 'labelChange' );
2771 }
2772
2773 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2774
2775 return this;
2776 };
2777
2778 /**
2779 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2780 *
2781 * @param {boolean} invisibleLabel
2782 * @chainable
2783 * @return {OO.ui.Element} The element, for chaining
2784 */
2785 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2786 invisibleLabel = !!invisibleLabel;
2787
2788 if ( this.invisibleLabel !== invisibleLabel ) {
2789 this.invisibleLabel = invisibleLabel;
2790 this.emit( 'labelChange' );
2791 }
2792
2793 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2794 // Pretend that there is no label, a lot of CSS has been written with this assumption
2795 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2796
2797 return this;
2798 };
2799
2800 /**
2801 * Set the label as plain text with a highlighted query
2802 *
2803 * @param {string} text Text label to set
2804 * @param {string} query Substring of text to highlight
2805 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2806 * @chainable
2807 * @return {OO.ui.Element} The element, for chaining
2808 */
2809 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2810 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2811 };
2812
2813 /**
2814 * Get the label.
2815 *
2816 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2817 * text; or null for no label
2818 */
2819 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2820 return this.label;
2821 };
2822
2823 /**
2824 * Set the content of the label.
2825 *
2826 * Do not call this method until after the label element has been set by #setLabelElement.
2827 *
2828 * @private
2829 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2830 * text; or null for no label
2831 */
2832 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2833 if ( typeof label === 'string' ) {
2834 if ( label.match( /^\s*$/ ) ) {
2835 // Convert whitespace only string to a single non-breaking space
2836 this.$label.html( '&nbsp;' );
2837 } else {
2838 this.$label.text( label );
2839 }
2840 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2841 this.$label.html( label.toString() );
2842 } else if ( label instanceof $ ) {
2843 this.$label.empty().append( label );
2844 } else {
2845 this.$label.empty();
2846 }
2847 };
2848
2849 /**
2850 * IconElement is often mixed into other classes to generate an icon.
2851 * Icons are graphics, about the size of normal text. They are used to aid the user
2852 * in locating a control or to convey information in a space-efficient way. See the
2853 * [OOUI documentation on MediaWiki] [1] for a list of icons
2854 * included in the library.
2855 *
2856 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2857 *
2858 * @abstract
2859 * @class
2860 *
2861 * @constructor
2862 * @param {Object} [config] Configuration options
2863 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2864 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2865 * the icon element be set to an existing icon instead of the one generated by this class, set a
2866 * value using a jQuery selection. For example:
2867 *
2868 * // Use a <div> tag instead of a <span>
2869 * $icon: $( '<div>' )
2870 * // Use an existing icon element instead of the one generated by the class
2871 * $icon: this.$element
2872 * // Use an icon element from a child widget
2873 * $icon: this.childwidget.$element
2874 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2875 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2876 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2877 * by the user's language.
2878 *
2879 * Example of an i18n map:
2880 *
2881 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2882 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2883 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2884 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2885 * text. The icon title is displayed when users move the mouse over the icon.
2886 */
2887 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2888 // Configuration initialization
2889 config = config || {};
2890
2891 // Properties
2892 this.$icon = null;
2893 this.icon = null;
2894 this.iconTitle = null;
2895
2896 // `iconTitle`s are deprecated since 0.30.0
2897 if ( config.iconTitle !== undefined ) {
2898 OO.ui.warnDeprecation( 'IconElement: Widgets with iconTitle set are deprecated, use title instead. See T76638 for details.' );
2899 }
2900
2901 // Initialization
2902 this.setIcon( config.icon || this.constructor.static.icon );
2903 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2904 this.setIconElement( config.$icon || $( '<span>' ) );
2905 };
2906
2907 /* Setup */
2908
2909 OO.initClass( OO.ui.mixin.IconElement );
2910
2911 /* Static Properties */
2912
2913 /**
2914 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2915 * for i18n purposes and contains a `default` icon name and additional names keyed by
2916 * language code. The `default` name is used when no icon is keyed by the user's language.
2917 *
2918 * Example of an i18n map:
2919 *
2920 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2921 *
2922 * Note: the static property will be overridden if the #icon configuration is used.
2923 *
2924 * @static
2925 * @inheritable
2926 * @property {Object|string}
2927 */
2928 OO.ui.mixin.IconElement.static.icon = null;
2929
2930 /**
2931 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2932 * function that returns title text, or `null` for no title.
2933 *
2934 * The static property will be overridden if the #iconTitle configuration is used.
2935 *
2936 * @static
2937 * @inheritable
2938 * @property {string|Function|null}
2939 */
2940 OO.ui.mixin.IconElement.static.iconTitle = null;
2941
2942 /* Methods */
2943
2944 /**
2945 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2946 * applies to the specified icon element instead of the one created by the class. If an icon
2947 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2948 * and mixin methods will no longer affect the element.
2949 *
2950 * @param {jQuery} $icon Element to use as icon
2951 */
2952 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2953 if ( this.$icon ) {
2954 this.$icon
2955 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2956 .removeAttr( 'title' );
2957 }
2958
2959 this.$icon = $icon
2960 .addClass( 'oo-ui-iconElement-icon' )
2961 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2962 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2963 if ( this.iconTitle !== null ) {
2964 this.$icon.attr( 'title', this.iconTitle );
2965 }
2966
2967 this.updateThemeClasses();
2968 };
2969
2970 /**
2971 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2972 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2973 * for an example.
2974 *
2975 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2976 * by language code, or `null` to remove the icon.
2977 * @chainable
2978 * @return {OO.ui.Element} The element, for chaining
2979 */
2980 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2981 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2982 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2983
2984 if ( this.icon !== icon ) {
2985 if ( this.$icon ) {
2986 if ( this.icon !== null ) {
2987 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2988 }
2989 if ( icon !== null ) {
2990 this.$icon.addClass( 'oo-ui-icon-' + icon );
2991 }
2992 }
2993 this.icon = icon;
2994 }
2995
2996 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2997 if ( this.$icon ) {
2998 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
2999 }
3000 this.updateThemeClasses();
3001
3002 return this;
3003 };
3004
3005 /**
3006 * Set the icon title. Use `null` to remove the title.
3007 *
3008 * @param {string|Function|null} iconTitle A text string used as the icon title,
3009 * a function that returns title text, or `null` for no title.
3010 * @chainable
3011 * @return {OO.ui.Element} The element, for chaining
3012 * @deprecated
3013 */
3014 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
3015 iconTitle =
3016 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
3017 OO.ui.resolveMsg( iconTitle ) : null;
3018
3019 if ( this.iconTitle !== iconTitle ) {
3020 this.iconTitle = iconTitle;
3021 if ( this.$icon ) {
3022 if ( this.iconTitle !== null ) {
3023 this.$icon.attr( 'title', iconTitle );
3024 } else {
3025 this.$icon.removeAttr( 'title' );
3026 }
3027 }
3028 }
3029
3030 // `setIconTitle is deprecated since 0.30.0
3031 if ( iconTitle !== null ) {
3032 // Avoid a warning when this is called from the constructor with no iconTitle set
3033 OO.ui.warnDeprecation( 'IconElement: setIconTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3034 }
3035
3036 return this;
3037 };
3038
3039 /**
3040 * Get the symbolic name of the icon.
3041 *
3042 * @return {string} Icon name
3043 */
3044 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3045 return this.icon;
3046 };
3047
3048 /**
3049 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3050 *
3051 * @return {string} Icon title text
3052 */
3053 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3054 return this.iconTitle;
3055 };
3056
3057 /**
3058 * IndicatorElement is often mixed into other classes to generate an indicator.
3059 * Indicators are small graphics that are generally used in two ways:
3060 *
3061 * - To draw attention to the status of an item. For example, an indicator might be
3062 * used to show that an item in a list has errors that need to be resolved.
3063 * - To clarify the function of a control that acts in an exceptional way (a button
3064 * that opens a menu instead of performing an action directly, for example).
3065 *
3066 * For a list of indicators included in the library, please see the
3067 * [OOUI documentation on MediaWiki] [1].
3068 *
3069 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3070 *
3071 * @abstract
3072 * @class
3073 *
3074 * @constructor
3075 * @param {Object} [config] Configuration options
3076 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3077 * configuration is omitted, the indicator element will use a generated `<span>`.
3078 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3079 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3080 * in the library.
3081 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3082 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
3083 * or a function that returns title text. The indicator title is displayed when users move
3084 * the mouse over the indicator.
3085 */
3086 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3087 // Configuration initialization
3088 config = config || {};
3089
3090 // Properties
3091 this.$indicator = null;
3092 this.indicator = null;
3093 this.indicatorTitle = null;
3094
3095 // `indicatorTitle`s are deprecated since 0.30.0
3096 if ( config.indicatorTitle !== undefined ) {
3097 OO.ui.warnDeprecation( 'IndicatorElement: Widgets with indicatorTitle set are deprecated, use title instead. See T76638 for details.' );
3098 }
3099
3100 // Initialization
3101 this.setIndicator( config.indicator || this.constructor.static.indicator );
3102 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
3103 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3104 };
3105
3106 /* Setup */
3107
3108 OO.initClass( OO.ui.mixin.IndicatorElement );
3109
3110 /* Static Properties */
3111
3112 /**
3113 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3114 * The static property will be overridden if the #indicator configuration is used.
3115 *
3116 * @static
3117 * @inheritable
3118 * @property {string|null}
3119 */
3120 OO.ui.mixin.IndicatorElement.static.indicator = null;
3121
3122 /**
3123 * A text string used as the indicator title, a function that returns title text, or `null`
3124 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
3125 *
3126 * @static
3127 * @inheritable
3128 * @property {string|Function|null}
3129 */
3130 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3131
3132 /* Methods */
3133
3134 /**
3135 * Set the indicator element.
3136 *
3137 * If an element is already set, it will be cleaned up before setting up the new element.
3138 *
3139 * @param {jQuery} $indicator Element to use as indicator
3140 */
3141 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3142 if ( this.$indicator ) {
3143 this.$indicator
3144 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3145 .removeAttr( 'title' );
3146 }
3147
3148 this.$indicator = $indicator
3149 .addClass( 'oo-ui-indicatorElement-indicator' )
3150 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3151 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3152 if ( this.indicatorTitle !== null ) {
3153 this.$indicator.attr( 'title', this.indicatorTitle );
3154 }
3155
3156 this.updateThemeClasses();
3157 };
3158
3159 /**
3160 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
3161 *
3162 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3163 * @chainable
3164 * @return {OO.ui.Element} The element, for chaining
3165 */
3166 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3167 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3168
3169 if ( this.indicator !== indicator ) {
3170 if ( this.$indicator ) {
3171 if ( this.indicator !== null ) {
3172 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3173 }
3174 if ( indicator !== null ) {
3175 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3176 }
3177 }
3178 this.indicator = indicator;
3179 }
3180
3181 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3182 if ( this.$indicator ) {
3183 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3184 }
3185 this.updateThemeClasses();
3186
3187 return this;
3188 };
3189
3190 /**
3191 * Set the indicator title.
3192 *
3193 * The title is displayed when a user moves the mouse over the indicator.
3194 *
3195 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
3196 * `null` for no indicator title
3197 * @chainable
3198 * @return {OO.ui.Element} The element, for chaining
3199 * @deprecated
3200 */
3201 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
3202 indicatorTitle =
3203 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
3204 OO.ui.resolveMsg( indicatorTitle ) : null;
3205
3206 if ( this.indicatorTitle !== indicatorTitle ) {
3207 this.indicatorTitle = indicatorTitle;
3208 if ( this.$indicator ) {
3209 if ( this.indicatorTitle !== null ) {
3210 this.$indicator.attr( 'title', indicatorTitle );
3211 } else {
3212 this.$indicator.removeAttr( 'title' );
3213 }
3214 }
3215 }
3216
3217 // `setIndicatorTitle is deprecated since 0.30.0
3218 if ( indicatorTitle !== null ) {
3219 // Avoid a warning when this is called from the constructor with no indicatorTitle set
3220 OO.ui.warnDeprecation( 'IndicatorElement: setIndicatorTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3221 }
3222
3223 return this;
3224 };
3225
3226 /**
3227 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3228 *
3229 * @return {string} Symbolic name of indicator
3230 */
3231 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3232 return this.indicator;
3233 };
3234
3235 /**
3236 * Get the indicator title.
3237 *
3238 * The title is displayed when a user moves the mouse over the indicator.
3239 *
3240 * @return {string} Indicator title text
3241 */
3242 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3243 return this.indicatorTitle;
3244 };
3245
3246 /**
3247 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3248 * additional functionality to an element created by another class. The class provides
3249 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3250 * which are used to customize the look and feel of a widget to better describe its
3251 * importance and functionality.
3252 *
3253 * The library currently contains the following styling flags for general use:
3254 *
3255 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3256 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3257 *
3258 * The flags affect the appearance of the buttons:
3259 *
3260 * @example
3261 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3262 * var button1 = new OO.ui.ButtonWidget( {
3263 * label: 'Progressive',
3264 * flags: 'progressive'
3265 * } ),
3266 * button2 = new OO.ui.ButtonWidget( {
3267 * label: 'Destructive',
3268 * flags: 'destructive'
3269 * } );
3270 * $( document.body ).append( button1.$element, button2.$element );
3271 *
3272 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3273 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3274 *
3275 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3276 *
3277 * @abstract
3278 * @class
3279 *
3280 * @constructor
3281 * @param {Object} [config] Configuration options
3282 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3283 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3284 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3285 * @cfg {jQuery} [$flagged] The flagged element. By default,
3286 * the flagged functionality is applied to the element created by the class ($element).
3287 * If a different element is specified, the flagged functionality will be applied to it instead.
3288 */
3289 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3290 // Configuration initialization
3291 config = config || {};
3292
3293 // Properties
3294 this.flags = {};
3295 this.$flagged = null;
3296
3297 // Initialization
3298 this.setFlags( config.flags );
3299 this.setFlaggedElement( config.$flagged || this.$element );
3300 };
3301
3302 /* Events */
3303
3304 /**
3305 * @event flag
3306 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3307 * parameter contains the name of each modified flag and indicates whether it was
3308 * added or removed.
3309 *
3310 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3311 * that the flag was added, `false` that the flag was removed.
3312 */
3313
3314 /* Methods */
3315
3316 /**
3317 * Set the flagged element.
3318 *
3319 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3320 * If an element is already set, the method will remove the mixin’s effect on that element.
3321 *
3322 * @param {jQuery} $flagged Element that should be flagged
3323 */
3324 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3325 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3326 return 'oo-ui-flaggedElement-' + flag;
3327 } );
3328
3329 if ( this.$flagged ) {
3330 this.$flagged.removeClass( classNames );
3331 }
3332
3333 this.$flagged = $flagged.addClass( classNames );
3334 };
3335
3336 /**
3337 * Check if the specified flag is set.
3338 *
3339 * @param {string} flag Name of flag
3340 * @return {boolean} The flag is set
3341 */
3342 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3343 // This may be called before the constructor, thus before this.flags is set
3344 return this.flags && ( flag in this.flags );
3345 };
3346
3347 /**
3348 * Get the names of all flags set.
3349 *
3350 * @return {string[]} Flag names
3351 */
3352 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3353 // This may be called before the constructor, thus before this.flags is set
3354 return Object.keys( this.flags || {} );
3355 };
3356
3357 /**
3358 * Clear all flags.
3359 *
3360 * @chainable
3361 * @return {OO.ui.Element} The element, for chaining
3362 * @fires flag
3363 */
3364 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3365 var flag, className,
3366 changes = {},
3367 remove = [],
3368 classPrefix = 'oo-ui-flaggedElement-';
3369
3370 for ( flag in this.flags ) {
3371 className = classPrefix + flag;
3372 changes[ flag ] = false;
3373 delete this.flags[ flag ];
3374 remove.push( className );
3375 }
3376
3377 if ( this.$flagged ) {
3378 this.$flagged.removeClass( remove );
3379 }
3380
3381 this.updateThemeClasses();
3382 this.emit( 'flag', changes );
3383
3384 return this;
3385 };
3386
3387 /**
3388 * Add one or more flags.
3389 *
3390 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3391 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3392 * be added (`true`) or removed (`false`).
3393 * @chainable
3394 * @return {OO.ui.Element} The element, for chaining
3395 * @fires flag
3396 */
3397 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3398 var i, len, flag, className,
3399 changes = {},
3400 add = [],
3401 remove = [],
3402 classPrefix = 'oo-ui-flaggedElement-';
3403
3404 if ( typeof flags === 'string' ) {
3405 className = classPrefix + flags;
3406 // Set
3407 if ( !this.flags[ flags ] ) {
3408 this.flags[ flags ] = true;
3409 add.push( className );
3410 }
3411 } else if ( Array.isArray( flags ) ) {
3412 for ( i = 0, len = flags.length; i < len; i++ ) {
3413 flag = flags[ i ];
3414 className = classPrefix + flag;
3415 // Set
3416 if ( !this.flags[ flag ] ) {
3417 changes[ flag ] = true;
3418 this.flags[ flag ] = true;
3419 add.push( className );
3420 }
3421 }
3422 } else if ( OO.isPlainObject( flags ) ) {
3423 for ( flag in flags ) {
3424 className = classPrefix + flag;
3425 if ( flags[ flag ] ) {
3426 // Set
3427 if ( !this.flags[ flag ] ) {
3428 changes[ flag ] = true;
3429 this.flags[ flag ] = true;
3430 add.push( className );
3431 }
3432 } else {
3433 // Remove
3434 if ( this.flags[ flag ] ) {
3435 changes[ flag ] = false;
3436 delete this.flags[ flag ];
3437 remove.push( className );
3438 }
3439 }
3440 }
3441 }
3442
3443 if ( this.$flagged ) {
3444 this.$flagged
3445 .addClass( add )
3446 .removeClass( remove );
3447 }
3448
3449 this.updateThemeClasses();
3450 this.emit( 'flag', changes );
3451
3452 return this;
3453 };
3454
3455 /**
3456 * TitledElement is mixed into other classes to provide a `title` attribute.
3457 * Titles are rendered by the browser and are made visible when the user moves
3458 * the mouse over the element. Titles are not visible on touch devices.
3459 *
3460 * @example
3461 * // TitledElement provides a `title` attribute to the
3462 * // ButtonWidget class.
3463 * var button = new OO.ui.ButtonWidget( {
3464 * label: 'Button with Title',
3465 * title: 'I am a button'
3466 * } );
3467 * $( document.body ).append( button.$element );
3468 *
3469 * @abstract
3470 * @class
3471 *
3472 * @constructor
3473 * @param {Object} [config] Configuration options
3474 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3475 * If this config is omitted, the title functionality is applied to $element, the
3476 * element created by the class.
3477 * @cfg {string|Function} [title] The title text or a function that returns text. If
3478 * this config is omitted, the value of the {@link #static-title static title} property is used.
3479 */
3480 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3481 // Configuration initialization
3482 config = config || {};
3483
3484 // Properties
3485 this.$titled = null;
3486 this.title = null;
3487
3488 // Initialization
3489 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3490 this.setTitledElement( config.$titled || this.$element );
3491 };
3492
3493 /* Setup */
3494
3495 OO.initClass( OO.ui.mixin.TitledElement );
3496
3497 /* Static Properties */
3498
3499 /**
3500 * The title text, a function that returns text, or `null` for no title. The value of the static property
3501 * is overridden if the #title config option is used.
3502 *
3503 * @static
3504 * @inheritable
3505 * @property {string|Function|null}
3506 */
3507 OO.ui.mixin.TitledElement.static.title = null;
3508
3509 /* Methods */
3510
3511 /**
3512 * Set the titled element.
3513 *
3514 * This method is used to retarget a TitledElement mixin so that its functionality applies to the specified element.
3515 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3516 *
3517 * @param {jQuery} $titled Element that should use the 'titled' functionality
3518 */
3519 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3520 if ( this.$titled ) {
3521 this.$titled.removeAttr( 'title' );
3522 }
3523
3524 this.$titled = $titled;
3525 if ( this.title ) {
3526 this.updateTitle();
3527 }
3528 };
3529
3530 /**
3531 * Set title.
3532 *
3533 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3534 * @chainable
3535 * @return {OO.ui.Element} The element, for chaining
3536 */
3537 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3538 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3539 title = ( typeof title === 'string' && title.length ) ? title : null;
3540
3541 if ( this.title !== title ) {
3542 this.title = title;
3543 this.updateTitle();
3544 }
3545
3546 return this;
3547 };
3548
3549 /**
3550 * Update the title attribute, in case of changes to title or accessKey.
3551 *
3552 * @protected
3553 * @chainable
3554 * @return {OO.ui.Element} The element, for chaining
3555 */
3556 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3557 var title = this.getTitle();
3558 if ( this.$titled ) {
3559 if ( title !== null ) {
3560 // Only if this is an AccessKeyedElement
3561 if ( this.formatTitleWithAccessKey ) {
3562 title = this.formatTitleWithAccessKey( title );
3563 }
3564 this.$titled.attr( 'title', title );
3565 } else {
3566 this.$titled.removeAttr( 'title' );
3567 }
3568 }
3569 return this;
3570 };
3571
3572 /**
3573 * Get title.
3574 *
3575 * @return {string} Title string
3576 */
3577 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3578 return this.title;
3579 };
3580
3581 /**
3582 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3583 * Accesskeys allow an user to go to a specific element by using
3584 * a shortcut combination of a browser specific keys + the key
3585 * set to the field.
3586 *
3587 * @example
3588 * // AccessKeyedElement provides an `accesskey` attribute to the
3589 * // ButtonWidget class.
3590 * var button = new OO.ui.ButtonWidget( {
3591 * label: 'Button with Accesskey',
3592 * accessKey: 'k'
3593 * } );
3594 * $( document.body ).append( button.$element );
3595 *
3596 * @abstract
3597 * @class
3598 *
3599 * @constructor
3600 * @param {Object} [config] Configuration options
3601 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3602 * If this config is omitted, the accesskey functionality is applied to $element, the
3603 * element created by the class.
3604 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3605 * this config is omitted, no accesskey will be added.
3606 */
3607 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3608 // Configuration initialization
3609 config = config || {};
3610
3611 // Properties
3612 this.$accessKeyed = null;
3613 this.accessKey = null;
3614
3615 // Initialization
3616 this.setAccessKey( config.accessKey || null );
3617 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3618
3619 // If this is also a TitledElement and it initialized before we did, we may have
3620 // to update the title with the access key
3621 if ( this.updateTitle ) {
3622 this.updateTitle();
3623 }
3624 };
3625
3626 /* Setup */
3627
3628 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3629
3630 /* Static Properties */
3631
3632 /**
3633 * The access key, a function that returns a key, or `null` for no accesskey.
3634 *
3635 * @static
3636 * @inheritable
3637 * @property {string|Function|null}
3638 */
3639 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3640
3641 /* Methods */
3642
3643 /**
3644 * Set the accesskeyed element.
3645 *
3646 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3647 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3648 *
3649 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyed' functionality
3650 */
3651 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3652 if ( this.$accessKeyed ) {
3653 this.$accessKeyed.removeAttr( 'accesskey' );
3654 }
3655
3656 this.$accessKeyed = $accessKeyed;
3657 if ( this.accessKey ) {
3658 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3659 }
3660 };
3661
3662 /**
3663 * Set accesskey.
3664 *
3665 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3666 * @chainable
3667 * @return {OO.ui.Element} The element, for chaining
3668 */
3669 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3670 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3671
3672 if ( this.accessKey !== accessKey ) {
3673 if ( this.$accessKeyed ) {
3674 if ( accessKey !== null ) {
3675 this.$accessKeyed.attr( 'accesskey', accessKey );
3676 } else {
3677 this.$accessKeyed.removeAttr( 'accesskey' );
3678 }
3679 }
3680 this.accessKey = accessKey;
3681
3682 // Only if this is a TitledElement
3683 if ( this.updateTitle ) {
3684 this.updateTitle();
3685 }
3686 }
3687
3688 return this;
3689 };
3690
3691 /**
3692 * Get accesskey.
3693 *
3694 * @return {string} accessKey string
3695 */
3696 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3697 return this.accessKey;
3698 };
3699
3700 /**
3701 * Add information about the access key to the element's tooltip label.
3702 * (This is only public for hacky usage in FieldLayout.)
3703 *
3704 * @param {string} title Tooltip label for `title` attribute
3705 * @return {string}
3706 */
3707 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3708 var accessKey;
3709
3710 if ( !this.$accessKeyed ) {
3711 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3712 return title;
3713 }
3714 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3715 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3716 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3717 } else {
3718 accessKey = this.getAccessKey();
3719 }
3720 if ( accessKey ) {
3721 title += ' [' + accessKey + ']';
3722 }
3723 return title;
3724 };
3725
3726 /**
3727 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3728 * feels, and functionality can be customized via the class’s configuration options
3729 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3730 * and examples.
3731 *
3732 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3733 *
3734 * @example
3735 * // A button widget.
3736 * var button = new OO.ui.ButtonWidget( {
3737 * label: 'Button with Icon',
3738 * icon: 'trash',
3739 * title: 'Remove'
3740 * } );
3741 * $( document.body ).append( button.$element );
3742 *
3743 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3744 *
3745 * @class
3746 * @extends OO.ui.Widget
3747 * @mixins OO.ui.mixin.ButtonElement
3748 * @mixins OO.ui.mixin.IconElement
3749 * @mixins OO.ui.mixin.IndicatorElement
3750 * @mixins OO.ui.mixin.LabelElement
3751 * @mixins OO.ui.mixin.TitledElement
3752 * @mixins OO.ui.mixin.FlaggedElement
3753 * @mixins OO.ui.mixin.TabIndexedElement
3754 * @mixins OO.ui.mixin.AccessKeyedElement
3755 *
3756 * @constructor
3757 * @param {Object} [config] Configuration options
3758 * @cfg {boolean} [active=false] Whether button should be shown as active
3759 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3760 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3761 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3762 */
3763 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3764 // Configuration initialization
3765 config = config || {};
3766
3767 // Parent constructor
3768 OO.ui.ButtonWidget.parent.call( this, config );
3769
3770 // Mixin constructors
3771 OO.ui.mixin.ButtonElement.call( this, config );
3772 OO.ui.mixin.IconElement.call( this, config );
3773 OO.ui.mixin.IndicatorElement.call( this, config );
3774 OO.ui.mixin.LabelElement.call( this, config );
3775 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3776 OO.ui.mixin.FlaggedElement.call( this, config );
3777 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3778 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3779
3780 // Properties
3781 this.href = null;
3782 this.target = null;
3783 this.noFollow = false;
3784
3785 // Events
3786 this.connect( this, { disable: 'onDisable' } );
3787
3788 // Initialization
3789 this.$button.append( this.$icon, this.$label, this.$indicator );
3790 this.$element
3791 .addClass( 'oo-ui-buttonWidget' )
3792 .append( this.$button );
3793 this.setActive( config.active );
3794 this.setHref( config.href );
3795 this.setTarget( config.target );
3796 this.setNoFollow( config.noFollow );
3797 };
3798
3799 /* Setup */
3800
3801 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3802 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3803 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3804 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3805 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3806 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3807 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3808 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3809 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3810
3811 /* Static Properties */
3812
3813 /**
3814 * @static
3815 * @inheritdoc
3816 */
3817 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3818
3819 /**
3820 * @static
3821 * @inheritdoc
3822 */
3823 OO.ui.ButtonWidget.static.tagName = 'span';
3824
3825 /* Methods */
3826
3827 /**
3828 * Get hyperlink location.
3829 *
3830 * @return {string} Hyperlink location
3831 */
3832 OO.ui.ButtonWidget.prototype.getHref = function () {
3833 return this.href;
3834 };
3835
3836 /**
3837 * Get hyperlink target.
3838 *
3839 * @return {string} Hyperlink target
3840 */
3841 OO.ui.ButtonWidget.prototype.getTarget = function () {
3842 return this.target;
3843 };
3844
3845 /**
3846 * Get search engine traversal hint.
3847 *
3848 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3849 */
3850 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3851 return this.noFollow;
3852 };
3853
3854 /**
3855 * Set hyperlink location.
3856 *
3857 * @param {string|null} href Hyperlink location, null to remove
3858 * @chainable
3859 * @return {OO.ui.Widget} The widget, for chaining
3860 */
3861 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3862 href = typeof href === 'string' ? href : null;
3863 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3864 href = './' + href;
3865 }
3866
3867 if ( href !== this.href ) {
3868 this.href = href;
3869 this.updateHref();
3870 }
3871
3872 return this;
3873 };
3874
3875 /**
3876 * Update the `href` attribute, in case of changes to href or
3877 * disabled state.
3878 *
3879 * @private
3880 * @chainable
3881 * @return {OO.ui.Widget} The widget, for chaining
3882 */
3883 OO.ui.ButtonWidget.prototype.updateHref = function () {
3884 if ( this.href !== null && !this.isDisabled() ) {
3885 this.$button.attr( 'href', this.href );
3886 } else {
3887 this.$button.removeAttr( 'href' );
3888 }
3889
3890 return this;
3891 };
3892
3893 /**
3894 * Handle disable events.
3895 *
3896 * @private
3897 * @param {boolean} disabled Element is disabled
3898 */
3899 OO.ui.ButtonWidget.prototype.onDisable = function () {
3900 this.updateHref();
3901 };
3902
3903 /**
3904 * Set hyperlink target.
3905 *
3906 * @param {string|null} target Hyperlink target, null to remove
3907 * @return {OO.ui.Widget} The widget, for chaining
3908 */
3909 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3910 target = typeof target === 'string' ? target : null;
3911
3912 if ( target !== this.target ) {
3913 this.target = target;
3914 if ( target !== null ) {
3915 this.$button.attr( 'target', target );
3916 } else {
3917 this.$button.removeAttr( 'target' );
3918 }
3919 }
3920
3921 return this;
3922 };
3923
3924 /**
3925 * Set search engine traversal hint.
3926 *
3927 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3928 * @return {OO.ui.Widget} The widget, for chaining
3929 */
3930 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3931 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3932
3933 if ( noFollow !== this.noFollow ) {
3934 this.noFollow = noFollow;
3935 if ( noFollow ) {
3936 this.$button.attr( 'rel', 'nofollow' );
3937 } else {
3938 this.$button.removeAttr( 'rel' );
3939 }
3940 }
3941
3942 return this;
3943 };
3944
3945 // Override method visibility hints from ButtonElement
3946 /**
3947 * @method setActive
3948 * @inheritdoc
3949 */
3950 /**
3951 * @method isActive
3952 * @inheritdoc
3953 */
3954
3955 /**
3956 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3957 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3958 * removed, and cleared from the group.
3959 *
3960 * @example
3961 * // A ButtonGroupWidget with two buttons.
3962 * var button1 = new OO.ui.PopupButtonWidget( {
3963 * label: 'Select a category',
3964 * icon: 'menu',
3965 * popup: {
3966 * $content: $( '<p>List of categories…</p>' ),
3967 * padded: true,
3968 * align: 'left'
3969 * }
3970 * } ),
3971 * button2 = new OO.ui.ButtonWidget( {
3972 * label: 'Add item'
3973 * } ),
3974 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3975 * items: [ button1, button2 ]
3976 * } );
3977 * $( document.body ).append( buttonGroup.$element );
3978 *
3979 * @class
3980 * @extends OO.ui.Widget
3981 * @mixins OO.ui.mixin.GroupElement
3982 * @mixins OO.ui.mixin.TitledElement
3983 *
3984 * @constructor
3985 * @param {Object} [config] Configuration options
3986 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3987 */
3988 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3989 // Configuration initialization
3990 config = config || {};
3991
3992 // Parent constructor
3993 OO.ui.ButtonGroupWidget.parent.call( this, config );
3994
3995 // Mixin constructors
3996 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3997 OO.ui.mixin.TitledElement.call( this, config );
3998
3999 // Initialization
4000 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
4001 if ( Array.isArray( config.items ) ) {
4002 this.addItems( config.items );
4003 }
4004 };
4005
4006 /* Setup */
4007
4008 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
4009 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
4010 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
4011
4012 /* Static Properties */
4013
4014 /**
4015 * @static
4016 * @inheritdoc
4017 */
4018 OO.ui.ButtonGroupWidget.static.tagName = 'span';
4019
4020 /* Methods */
4021
4022 /**
4023 * Focus the widget
4024 *
4025 * @chainable
4026 * @return {OO.ui.Widget} The widget, for chaining
4027 */
4028 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4029 if ( !this.isDisabled() ) {
4030 if ( this.items[ 0 ] ) {
4031 this.items[ 0 ].focus();
4032 }
4033 }
4034 return this;
4035 };
4036
4037 /**
4038 * @inheritdoc
4039 */
4040 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4041 this.focus();
4042 };
4043
4044 /**
4045 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
4046 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4047 * for a list of icons included in the library.
4048 *
4049 * @example
4050 * // An IconWidget with a label via LabelWidget.
4051 * var myIcon = new OO.ui.IconWidget( {
4052 * icon: 'help',
4053 * title: 'Help'
4054 * } ),
4055 * // Create a label.
4056 * iconLabel = new OO.ui.LabelWidget( {
4057 * label: 'Help'
4058 * } );
4059 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4060 *
4061 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4062 *
4063 * @class
4064 * @extends OO.ui.Widget
4065 * @mixins OO.ui.mixin.IconElement
4066 * @mixins OO.ui.mixin.TitledElement
4067 * @mixins OO.ui.mixin.LabelElement
4068 * @mixins OO.ui.mixin.FlaggedElement
4069 *
4070 * @constructor
4071 * @param {Object} [config] Configuration options
4072 */
4073 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4074 // Configuration initialization
4075 config = config || {};
4076
4077 // Parent constructor
4078 OO.ui.IconWidget.parent.call( this, config );
4079
4080 // Mixin constructors
4081 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
4082 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4083 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4084 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
4085
4086 // Initialization
4087 this.$element.addClass( 'oo-ui-iconWidget' );
4088 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4089 // nested in other widgets, because this widget used to not mix in LabelElement.
4090 this.$element.removeClass( 'oo-ui-labelElement-label' );
4091 };
4092
4093 /* Setup */
4094
4095 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4096 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4097 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4098 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4099 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4100
4101 /* Static Properties */
4102
4103 /**
4104 * @static
4105 * @inheritdoc
4106 */
4107 OO.ui.IconWidget.static.tagName = 'span';
4108
4109 /**
4110 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4111 * attention to the status of an item or to clarify the function within a control. For a list of
4112 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4113 *
4114 * @example
4115 * // An indicator widget.
4116 * var indicator1 = new OO.ui.IndicatorWidget( {
4117 * indicator: 'required'
4118 * } ),
4119 * // Create a fieldset layout to add a label.
4120 * fieldset = new OO.ui.FieldsetLayout();
4121 * fieldset.addItems( [
4122 * new OO.ui.FieldLayout( indicator1, {
4123 * label: 'A required indicator:'
4124 * } )
4125 * ] );
4126 * $( document.body ).append( fieldset.$element );
4127 *
4128 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4129 *
4130 * @class
4131 * @extends OO.ui.Widget
4132 * @mixins OO.ui.mixin.IndicatorElement
4133 * @mixins OO.ui.mixin.TitledElement
4134 * @mixins OO.ui.mixin.LabelElement
4135 *
4136 * @constructor
4137 * @param {Object} [config] Configuration options
4138 */
4139 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4140 // Configuration initialization
4141 config = config || {};
4142
4143 // Parent constructor
4144 OO.ui.IndicatorWidget.parent.call( this, config );
4145
4146 // Mixin constructors
4147 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4148 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4149 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4150
4151 // Initialization
4152 this.$element.addClass( 'oo-ui-indicatorWidget' );
4153 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4154 // nested in other widgets, because this widget used to not mix in LabelElement.
4155 this.$element.removeClass( 'oo-ui-labelElement-label' );
4156 };
4157
4158 /* Setup */
4159
4160 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4161 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4162 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4163 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4164
4165 /* Static Properties */
4166
4167 /**
4168 * @static
4169 * @inheritdoc
4170 */
4171 OO.ui.IndicatorWidget.static.tagName = 'span';
4172
4173 /**
4174 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4175 * be configured with a `label` option that is set to a string, a label node, or a function:
4176 *
4177 * - String: a plaintext string
4178 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4179 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4180 * - Function: a function that will produce a string in the future. Functions are used
4181 * in cases where the value of the label is not currently defined.
4182 *
4183 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4184 * will come into focus when the label is clicked.
4185 *
4186 * @example
4187 * // Two LabelWidgets.
4188 * var label1 = new OO.ui.LabelWidget( {
4189 * label: 'plaintext label'
4190 * } ),
4191 * label2 = new OO.ui.LabelWidget( {
4192 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4193 * } ),
4194 * // Create a fieldset layout with fields for each example.
4195 * fieldset = new OO.ui.FieldsetLayout();
4196 * fieldset.addItems( [
4197 * new OO.ui.FieldLayout( label1 ),
4198 * new OO.ui.FieldLayout( label2 )
4199 * ] );
4200 * $( document.body ).append( fieldset.$element );
4201 *
4202 * @class
4203 * @extends OO.ui.Widget
4204 * @mixins OO.ui.mixin.LabelElement
4205 * @mixins OO.ui.mixin.TitledElement
4206 *
4207 * @constructor
4208 * @param {Object} [config] Configuration options
4209 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4210 * Clicking the label will focus the specified input field.
4211 */
4212 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4213 // Configuration initialization
4214 config = config || {};
4215
4216 // Parent constructor
4217 OO.ui.LabelWidget.parent.call( this, config );
4218
4219 // Mixin constructors
4220 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4221 OO.ui.mixin.TitledElement.call( this, config );
4222
4223 // Properties
4224 this.input = config.input;
4225
4226 // Initialization
4227 if ( this.input ) {
4228 if ( this.input.getInputId() ) {
4229 this.$element.attr( 'for', this.input.getInputId() );
4230 } else {
4231 this.$label.on( 'click', function () {
4232 this.input.simulateLabelClick();
4233 }.bind( this ) );
4234 }
4235 }
4236 this.$element.addClass( 'oo-ui-labelWidget' );
4237 };
4238
4239 /* Setup */
4240
4241 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4242 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4243 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4244
4245 /* Static Properties */
4246
4247 /**
4248 * @static
4249 * @inheritdoc
4250 */
4251 OO.ui.LabelWidget.static.tagName = 'label';
4252
4253 /**
4254 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4255 * and that they should wait before proceeding. The pending state is visually represented with a pending
4256 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4257 * field of a {@link OO.ui.TextInputWidget text input widget}.
4258 *
4259 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4260 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4261 * in process dialogs.
4262 *
4263 * @example
4264 * function MessageDialog( config ) {
4265 * MessageDialog.parent.call( this, config );
4266 * }
4267 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4268 *
4269 * MessageDialog.static.name = 'myMessageDialog';
4270 * MessageDialog.static.actions = [
4271 * { action: 'save', label: 'Done', flags: 'primary' },
4272 * { label: 'Cancel', flags: 'safe' }
4273 * ];
4274 *
4275 * MessageDialog.prototype.initialize = function () {
4276 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4277 * this.content = new OO.ui.PanelLayout( { padded: true } );
4278 * 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>' );
4279 * this.$body.append( this.content.$element );
4280 * };
4281 * MessageDialog.prototype.getBodyHeight = function () {
4282 * return 100;
4283 * }
4284 * MessageDialog.prototype.getActionProcess = function ( action ) {
4285 * var dialog = this;
4286 * if ( action === 'save' ) {
4287 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4288 * return new OO.ui.Process()
4289 * .next( 1000 )
4290 * .next( function () {
4291 * dialog.getActions().get({actions: 'save'})[0].popPending();
4292 * } );
4293 * }
4294 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4295 * };
4296 *
4297 * var windowManager = new OO.ui.WindowManager();
4298 * $( document.body ).append( windowManager.$element );
4299 *
4300 * var dialog = new MessageDialog();
4301 * windowManager.addWindows( [ dialog ] );
4302 * windowManager.openWindow( dialog );
4303 *
4304 * @abstract
4305 * @class
4306 *
4307 * @constructor
4308 * @param {Object} [config] Configuration options
4309 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4310 */
4311 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4312 // Configuration initialization
4313 config = config || {};
4314
4315 // Properties
4316 this.pending = 0;
4317 this.$pending = null;
4318
4319 // Initialisation
4320 this.setPendingElement( config.$pending || this.$element );
4321 };
4322
4323 /* Setup */
4324
4325 OO.initClass( OO.ui.mixin.PendingElement );
4326
4327 /* Methods */
4328
4329 /**
4330 * Set the pending element (and clean up any existing one).
4331 *
4332 * @param {jQuery} $pending The element to set to pending.
4333 */
4334 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4335 if ( this.$pending ) {
4336 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4337 }
4338
4339 this.$pending = $pending;
4340 if ( this.pending > 0 ) {
4341 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4342 }
4343 };
4344
4345 /**
4346 * Check if an element is pending.
4347 *
4348 * @return {boolean} Element is pending
4349 */
4350 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4351 return !!this.pending;
4352 };
4353
4354 /**
4355 * Increase the pending counter. The pending state will remain active until the counter is zero
4356 * (i.e., the number of calls to #pushPending and #popPending is the same).
4357 *
4358 * @chainable
4359 * @return {OO.ui.Element} The element, for chaining
4360 */
4361 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4362 if ( this.pending === 0 ) {
4363 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4364 this.updateThemeClasses();
4365 }
4366 this.pending++;
4367
4368 return this;
4369 };
4370
4371 /**
4372 * Decrease the pending counter. The pending state will remain active until the counter is zero
4373 * (i.e., the number of calls to #pushPending and #popPending is the same).
4374 *
4375 * @chainable
4376 * @return {OO.ui.Element} The element, for chaining
4377 */
4378 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4379 if ( this.pending === 1 ) {
4380 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4381 this.updateThemeClasses();
4382 }
4383 this.pending = Math.max( 0, this.pending - 1 );
4384
4385 return this;
4386 };
4387
4388 /**
4389 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4390 * in the document (for example, in an OO.ui.Window's $overlay).
4391 *
4392 * The elements's position is automatically calculated and maintained when window is resized or the
4393 * page is scrolled. If you reposition the container manually, you have to call #position to make
4394 * sure the element is still placed correctly.
4395 *
4396 * As positioning is only possible when both the element and the container are attached to the DOM
4397 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4398 * the #toggle method to display a floating popup, for example.
4399 *
4400 * @abstract
4401 * @class
4402 *
4403 * @constructor
4404 * @param {Object} [config] Configuration options
4405 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4406 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4407 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4408 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4409 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4410 * 'top': Align the top edge with $floatableContainer's top edge
4411 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4412 * 'center': Vertically align the center with $floatableContainer's center
4413 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4414 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4415 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4416 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4417 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4418 * 'center': Horizontally align the center with $floatableContainer's center
4419 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4420 * is out of view
4421 */
4422 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4423 // Configuration initialization
4424 config = config || {};
4425
4426 // Properties
4427 this.$floatable = null;
4428 this.$floatableContainer = null;
4429 this.$floatableWindow = null;
4430 this.$floatableClosestScrollable = null;
4431 this.floatableOutOfView = false;
4432 this.onFloatableScrollHandler = this.position.bind( this );
4433 this.onFloatableWindowResizeHandler = this.position.bind( this );
4434
4435 // Initialization
4436 this.setFloatableContainer( config.$floatableContainer );
4437 this.setFloatableElement( config.$floatable || this.$element );
4438 this.setVerticalPosition( config.verticalPosition || 'below' );
4439 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4440 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4441 };
4442
4443 /* Methods */
4444
4445 /**
4446 * Set floatable element.
4447 *
4448 * If an element is already set, it will be cleaned up before setting up the new element.
4449 *
4450 * @param {jQuery} $floatable Element to make floatable
4451 */
4452 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4453 if ( this.$floatable ) {
4454 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4455 this.$floatable.css( { left: '', top: '' } );
4456 }
4457
4458 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4459 this.position();
4460 };
4461
4462 /**
4463 * Set floatable container.
4464 *
4465 * The element will be positioned relative to the specified container.
4466 *
4467 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4468 */
4469 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4470 this.$floatableContainer = $floatableContainer;
4471 if ( this.$floatable ) {
4472 this.position();
4473 }
4474 };
4475
4476 /**
4477 * Change how the element is positioned vertically.
4478 *
4479 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4480 */
4481 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4482 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4483 throw new Error( 'Invalid value for vertical position: ' + position );
4484 }
4485 if ( this.verticalPosition !== position ) {
4486 this.verticalPosition = position;
4487 if ( this.$floatable ) {
4488 this.position();
4489 }
4490 }
4491 };
4492
4493 /**
4494 * Change how the element is positioned horizontally.
4495 *
4496 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4497 */
4498 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4499 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4500 throw new Error( 'Invalid value for horizontal position: ' + position );
4501 }
4502 if ( this.horizontalPosition !== position ) {
4503 this.horizontalPosition = position;
4504 if ( this.$floatable ) {
4505 this.position();
4506 }
4507 }
4508 };
4509
4510 /**
4511 * Toggle positioning.
4512 *
4513 * Do not turn positioning on until after the element is attached to the DOM and visible.
4514 *
4515 * @param {boolean} [positioning] Enable positioning, omit to toggle
4516 * @chainable
4517 * @return {OO.ui.Element} The element, for chaining
4518 */
4519 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4520 var closestScrollableOfContainer;
4521
4522 if ( !this.$floatable || !this.$floatableContainer ) {
4523 return this;
4524 }
4525
4526 positioning = positioning === undefined ? !this.positioning : !!positioning;
4527
4528 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4529 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4530 this.warnedUnattached = true;
4531 }
4532
4533 if ( this.positioning !== positioning ) {
4534 this.positioning = positioning;
4535
4536 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4537 // If the scrollable is the root, we have to listen to scroll events
4538 // on the window because of browser inconsistencies.
4539 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4540 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4541 }
4542
4543 if ( positioning ) {
4544 this.$floatableWindow = $( this.getElementWindow() );
4545 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4546
4547 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4548 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4549
4550 // Initial position after visible
4551 this.position();
4552 } else {
4553 if ( this.$floatableWindow ) {
4554 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4555 this.$floatableWindow = null;
4556 }
4557
4558 if ( this.$floatableClosestScrollable ) {
4559 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4560 this.$floatableClosestScrollable = null;
4561 }
4562
4563 this.$floatable.css( { left: '', right: '', top: '' } );
4564 }
4565 }
4566
4567 return this;
4568 };
4569
4570 /**
4571 * Check whether the bottom edge of the given element is within the viewport of the given container.
4572 *
4573 * @private
4574 * @param {jQuery} $element
4575 * @param {jQuery} $container
4576 * @return {boolean}
4577 */
4578 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4579 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4580 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4581 direction = $element.css( 'direction' );
4582
4583 elemRect = $element[ 0 ].getBoundingClientRect();
4584 if ( $container[ 0 ] === window ) {
4585 viewportSpacing = OO.ui.getViewportSpacing();
4586 contRect = {
4587 top: 0,
4588 left: 0,
4589 right: document.documentElement.clientWidth,
4590 bottom: document.documentElement.clientHeight
4591 };
4592 contRect.top += viewportSpacing.top;
4593 contRect.left += viewportSpacing.left;
4594 contRect.right -= viewportSpacing.right;
4595 contRect.bottom -= viewportSpacing.bottom;
4596 } else {
4597 contRect = $container[ 0 ].getBoundingClientRect();
4598 }
4599
4600 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4601 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4602 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4603 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4604 if ( direction === 'rtl' ) {
4605 startEdgeInBounds = rightEdgeInBounds;
4606 endEdgeInBounds = leftEdgeInBounds;
4607 } else {
4608 startEdgeInBounds = leftEdgeInBounds;
4609 endEdgeInBounds = rightEdgeInBounds;
4610 }
4611
4612 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4613 return false;
4614 }
4615 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4616 return false;
4617 }
4618 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4619 return false;
4620 }
4621 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4622 return false;
4623 }
4624
4625 // The other positioning values are all about being inside the container,
4626 // so in those cases all we care about is that any part of the container is visible.
4627 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4628 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4629 };
4630
4631 /**
4632 * Check if the floatable is hidden to the user because it was offscreen.
4633 *
4634 * @return {boolean} Floatable is out of view
4635 */
4636 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4637 return this.floatableOutOfView;
4638 };
4639
4640 /**
4641 * Position the floatable below its container.
4642 *
4643 * This should only be done when both of them are attached to the DOM and visible.
4644 *
4645 * @chainable
4646 * @return {OO.ui.Element} The element, for chaining
4647 */
4648 OO.ui.mixin.FloatableElement.prototype.position = function () {
4649 if ( !this.positioning ) {
4650 return this;
4651 }
4652
4653 if ( !(
4654 // To continue, some things need to be true:
4655 // The element must actually be in the DOM
4656 this.isElementAttached() && (
4657 // The closest scrollable is the current window
4658 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4659 // OR is an element in the element's DOM
4660 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4661 )
4662 ) ) {
4663 // Abort early if important parts of the widget are no longer attached to the DOM
4664 return this;
4665 }
4666
4667 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4668 if ( this.floatableOutOfView ) {
4669 this.$floatable.addClass( 'oo-ui-element-hidden' );
4670 return this;
4671 } else {
4672 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4673 }
4674
4675 this.$floatable.css( this.computePosition() );
4676
4677 // We updated the position, so re-evaluate the clipping state.
4678 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4679 // will not notice the need to update itself.)
4680 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4681 // it not listen to the right events in the right places?
4682 if ( this.clip ) {
4683 this.clip();
4684 }
4685
4686 return this;
4687 };
4688
4689 /**
4690 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4691 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4692 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4693 *
4694 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4695 */
4696 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4697 var isBody, scrollableX, scrollableY, containerPos,
4698 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4699 newPos = { top: '', left: '', bottom: '', right: '' },
4700 direction = this.$floatableContainer.css( 'direction' ),
4701 $offsetParent = this.$floatable.offsetParent();
4702
4703 if ( $offsetParent.is( 'html' ) ) {
4704 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4705 // <html> element, but they do work on the <body>
4706 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4707 }
4708 isBody = $offsetParent.is( 'body' );
4709 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4710 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4711
4712 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4713 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4714 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4715 // or if it isn't scrollable
4716 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4717 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4718
4719 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4720 // if the <body> has a margin
4721 containerPos = isBody ?
4722 this.$floatableContainer.offset() :
4723 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4724 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4725 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4726 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4727 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4728
4729 if ( this.verticalPosition === 'below' ) {
4730 newPos.top = containerPos.bottom;
4731 } else if ( this.verticalPosition === 'above' ) {
4732 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4733 } else if ( this.verticalPosition === 'top' ) {
4734 newPos.top = containerPos.top;
4735 } else if ( this.verticalPosition === 'bottom' ) {
4736 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4737 } else if ( this.verticalPosition === 'center' ) {
4738 newPos.top = containerPos.top +
4739 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4740 }
4741
4742 if ( this.horizontalPosition === 'before' ) {
4743 newPos.end = containerPos.start;
4744 } else if ( this.horizontalPosition === 'after' ) {
4745 newPos.start = containerPos.end;
4746 } else if ( this.horizontalPosition === 'start' ) {
4747 newPos.start = containerPos.start;
4748 } else if ( this.horizontalPosition === 'end' ) {
4749 newPos.end = containerPos.end;
4750 } else if ( this.horizontalPosition === 'center' ) {
4751 newPos.left = containerPos.left +
4752 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4753 }
4754
4755 if ( newPos.start !== undefined ) {
4756 if ( direction === 'rtl' ) {
4757 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4758 } else {
4759 newPos.left = newPos.start;
4760 }
4761 delete newPos.start;
4762 }
4763 if ( newPos.end !== undefined ) {
4764 if ( direction === 'rtl' ) {
4765 newPos.left = newPos.end;
4766 } else {
4767 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4768 }
4769 delete newPos.end;
4770 }
4771
4772 // Account for scroll position
4773 if ( newPos.top !== '' ) {
4774 newPos.top += scrollTop;
4775 }
4776 if ( newPos.bottom !== '' ) {
4777 newPos.bottom -= scrollTop;
4778 }
4779 if ( newPos.left !== '' ) {
4780 newPos.left += scrollLeft;
4781 }
4782 if ( newPos.right !== '' ) {
4783 newPos.right -= scrollLeft;
4784 }
4785
4786 // Account for scrollbar gutter
4787 if ( newPos.bottom !== '' ) {
4788 newPos.bottom -= horizScrollbarHeight;
4789 }
4790 if ( direction === 'rtl' ) {
4791 if ( newPos.left !== '' ) {
4792 newPos.left -= vertScrollbarWidth;
4793 }
4794 } else {
4795 if ( newPos.right !== '' ) {
4796 newPos.right -= vertScrollbarWidth;
4797 }
4798 }
4799
4800 return newPos;
4801 };
4802
4803 /**
4804 * Element that can be automatically clipped to visible boundaries.
4805 *
4806 * Whenever the element's natural height changes, you have to call
4807 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4808 * clipping correctly.
4809 *
4810 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4811 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4812 * then #$clippable will be given a fixed reduced height and/or width and will be made
4813 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4814 * but you can build a static footer by setting #$clippableContainer to an element that contains
4815 * #$clippable and the footer.
4816 *
4817 * @abstract
4818 * @class
4819 *
4820 * @constructor
4821 * @param {Object} [config] Configuration options
4822 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4823 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4824 * omit to use #$clippable
4825 */
4826 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4827 // Configuration initialization
4828 config = config || {};
4829
4830 // Properties
4831 this.$clippable = null;
4832 this.$clippableContainer = null;
4833 this.clipping = false;
4834 this.clippedHorizontally = false;
4835 this.clippedVertically = false;
4836 this.$clippableScrollableContainer = null;
4837 this.$clippableScroller = null;
4838 this.$clippableWindow = null;
4839 this.idealWidth = null;
4840 this.idealHeight = null;
4841 this.onClippableScrollHandler = this.clip.bind( this );
4842 this.onClippableWindowResizeHandler = this.clip.bind( this );
4843
4844 // Initialization
4845 if ( config.$clippableContainer ) {
4846 this.setClippableContainer( config.$clippableContainer );
4847 }
4848 this.setClippableElement( config.$clippable || this.$element );
4849 };
4850
4851 /* Methods */
4852
4853 /**
4854 * Set clippable element.
4855 *
4856 * If an element is already set, it will be cleaned up before setting up the new element.
4857 *
4858 * @param {jQuery} $clippable Element to make clippable
4859 */
4860 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4861 if ( this.$clippable ) {
4862 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4863 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4864 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4865 }
4866
4867 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4868 this.clip();
4869 };
4870
4871 /**
4872 * Set clippable container.
4873 *
4874 * This is the container that will be measured when deciding whether to clip. When clipping,
4875 * #$clippable will be resized in order to keep the clippable container fully visible.
4876 *
4877 * If the clippable container is unset, #$clippable will be used.
4878 *
4879 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4880 */
4881 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4882 this.$clippableContainer = $clippableContainer;
4883 if ( this.$clippable ) {
4884 this.clip();
4885 }
4886 };
4887
4888 /**
4889 * Toggle clipping.
4890 *
4891 * Do not turn clipping on until after the element is attached to the DOM and visible.
4892 *
4893 * @param {boolean} [clipping] Enable clipping, omit to toggle
4894 * @chainable
4895 * @return {OO.ui.Element} The element, for chaining
4896 */
4897 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4898 clipping = clipping === undefined ? !this.clipping : !!clipping;
4899
4900 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4901 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4902 this.warnedUnattached = true;
4903 }
4904
4905 if ( this.clipping !== clipping ) {
4906 this.clipping = clipping;
4907 if ( clipping ) {
4908 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4909 // If the clippable container is the root, we have to listen to scroll events and check
4910 // jQuery.scrollTop on the window because of browser inconsistencies
4911 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4912 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4913 this.$clippableScrollableContainer;
4914 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4915 this.$clippableWindow = $( this.getElementWindow() )
4916 .on( 'resize', this.onClippableWindowResizeHandler );
4917 // Initial clip after visible
4918 this.clip();
4919 } else {
4920 this.$clippable.css( {
4921 width: '',
4922 height: '',
4923 maxWidth: '',
4924 maxHeight: '',
4925 overflowX: '',
4926 overflowY: ''
4927 } );
4928 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4929
4930 this.$clippableScrollableContainer = null;
4931 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4932 this.$clippableScroller = null;
4933 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4934 this.$clippableWindow = null;
4935 }
4936 }
4937
4938 return this;
4939 };
4940
4941 /**
4942 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4943 *
4944 * @return {boolean} Element will be clipped to the visible area
4945 */
4946 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4947 return this.clipping;
4948 };
4949
4950 /**
4951 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4952 *
4953 * @return {boolean} Part of the element is being clipped
4954 */
4955 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4956 return this.clippedHorizontally || this.clippedVertically;
4957 };
4958
4959 /**
4960 * Check if the right of the element is being clipped by the nearest scrollable container.
4961 *
4962 * @return {boolean} Part of the element is being clipped
4963 */
4964 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4965 return this.clippedHorizontally;
4966 };
4967
4968 /**
4969 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4970 *
4971 * @return {boolean} Part of the element is being clipped
4972 */
4973 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4974 return this.clippedVertically;
4975 };
4976
4977 /**
4978 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4979 *
4980 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4981 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4982 */
4983 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4984 this.idealWidth = width;
4985 this.idealHeight = height;
4986
4987 if ( !this.clipping ) {
4988 // Update dimensions
4989 this.$clippable.css( { width: width, height: height } );
4990 }
4991 // While clipping, idealWidth and idealHeight are not considered
4992 };
4993
4994 /**
4995 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4996 * ClippableElement will clip the opposite side when reducing element's width.
4997 *
4998 * Classes that mix in ClippableElement should override this to return 'right' if their
4999 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5000 * If your class also mixes in FloatableElement, this is handled automatically.
5001 *
5002 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5003 * always in pixels, even if they were unset or set to 'auto'.)
5004 *
5005 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5006 *
5007 * @return {string} 'left' or 'right'
5008 */
5009 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5010 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5011 return 'right';
5012 }
5013 return 'left';
5014 };
5015
5016 /**
5017 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5018 * ClippableElement will clip the opposite side when reducing element's width.
5019 *
5020 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5021 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5022 * If your class also mixes in FloatableElement, this is handled automatically.
5023 *
5024 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5025 * always in pixels, even if they were unset or set to 'auto'.)
5026 *
5027 * When in doubt, 'top' is a sane fallback.
5028 *
5029 * @return {string} 'top' or 'bottom'
5030 */
5031 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5032 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5033 return 'bottom';
5034 }
5035 return 'top';
5036 };
5037
5038 /**
5039 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5040 * when the element's natural height changes.
5041 *
5042 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5043 * overlapped by, the visible area of the nearest scrollable container.
5044 *
5045 * Because calling clip() when the natural height changes isn't always possible, we also set
5046 * max-height when the element isn't being clipped. This means that if the element tries to grow
5047 * beyond the edge, something reasonable will happen before clip() is called.
5048 *
5049 * @chainable
5050 * @return {OO.ui.Element} The element, for chaining
5051 */
5052 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5053 var extraHeight, extraWidth, viewportSpacing,
5054 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5055 naturalWidth, naturalHeight, clipWidth, clipHeight,
5056 $item, itemRect, $viewport, viewportRect, availableRect,
5057 direction, vertScrollbarWidth, horizScrollbarHeight,
5058 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5059 // by one or two pixels. (And also so that we have space to display drop shadows.)
5060 // Chosen by fair dice roll.
5061 buffer = 7;
5062
5063 if ( !this.clipping ) {
5064 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
5065 return this;
5066 }
5067
5068 function rectIntersection( a, b ) {
5069 var out = {};
5070 out.top = Math.max( a.top, b.top );
5071 out.left = Math.max( a.left, b.left );
5072 out.bottom = Math.min( a.bottom, b.bottom );
5073 out.right = Math.min( a.right, b.right );
5074 return out;
5075 }
5076
5077 viewportSpacing = OO.ui.getViewportSpacing();
5078
5079 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5080 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5081 // Dimensions of the browser window, rather than the element!
5082 viewportRect = {
5083 top: 0,
5084 left: 0,
5085 right: document.documentElement.clientWidth,
5086 bottom: document.documentElement.clientHeight
5087 };
5088 viewportRect.top += viewportSpacing.top;
5089 viewportRect.left += viewportSpacing.left;
5090 viewportRect.right -= viewportSpacing.right;
5091 viewportRect.bottom -= viewportSpacing.bottom;
5092 } else {
5093 $viewport = this.$clippableScrollableContainer;
5094 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5095 // Convert into a plain object
5096 viewportRect = $.extend( {}, viewportRect );
5097 }
5098
5099 // Account for scrollbar gutter
5100 direction = $viewport.css( 'direction' );
5101 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5102 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5103 viewportRect.bottom -= horizScrollbarHeight;
5104 if ( direction === 'rtl' ) {
5105 viewportRect.left += vertScrollbarWidth;
5106 } else {
5107 viewportRect.right -= vertScrollbarWidth;
5108 }
5109
5110 // Add arbitrary tolerance
5111 viewportRect.top += buffer;
5112 viewportRect.left += buffer;
5113 viewportRect.right -= buffer;
5114 viewportRect.bottom -= buffer;
5115
5116 $item = this.$clippableContainer || this.$clippable;
5117
5118 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5119 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5120
5121 itemRect = $item[ 0 ].getBoundingClientRect();
5122 // Convert into a plain object
5123 itemRect = $.extend( {}, itemRect );
5124
5125 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5126 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5127 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5128 itemRect.left = viewportRect.left;
5129 } else {
5130 itemRect.right = viewportRect.right;
5131 }
5132 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5133 itemRect.top = viewportRect.top;
5134 } else {
5135 itemRect.bottom = viewportRect.bottom;
5136 }
5137
5138 availableRect = rectIntersection( viewportRect, itemRect );
5139
5140 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5141 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5142 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5143 desiredWidth = Math.min( desiredWidth,
5144 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5145 desiredHeight = Math.min( desiredHeight,
5146 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5147 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5148 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5149 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5150 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5151 clipWidth = allotedWidth < naturalWidth;
5152 clipHeight = allotedHeight < naturalHeight;
5153
5154 if ( clipWidth ) {
5155 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5156 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5157 this.$clippable.css( 'overflowX', 'scroll' );
5158 // eslint-disable-next-line no-void
5159 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5160 this.$clippable.css( {
5161 width: Math.max( 0, allotedWidth ),
5162 maxWidth: ''
5163 } );
5164 } else {
5165 this.$clippable.css( {
5166 overflowX: '',
5167 width: this.idealWidth || '',
5168 maxWidth: Math.max( 0, allotedWidth )
5169 } );
5170 }
5171 if ( clipHeight ) {
5172 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5173 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5174 this.$clippable.css( 'overflowY', 'scroll' );
5175 // eslint-disable-next-line no-void
5176 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5177 this.$clippable.css( {
5178 height: Math.max( 0, allotedHeight ),
5179 maxHeight: ''
5180 } );
5181 } else {
5182 this.$clippable.css( {
5183 overflowY: '',
5184 height: this.idealHeight || '',
5185 maxHeight: Math.max( 0, allotedHeight )
5186 } );
5187 }
5188
5189 // If we stopped clipping in at least one of the dimensions
5190 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5191 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5192 }
5193
5194 this.clippedHorizontally = clipWidth;
5195 this.clippedVertically = clipHeight;
5196
5197 return this;
5198 };
5199
5200 /**
5201 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5202 * By default, each popup has an anchor that points toward its origin.
5203 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5204 *
5205 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5206 *
5207 * @example
5208 * // A PopupWidget.
5209 * var popup = new OO.ui.PopupWidget( {
5210 * $content: $( '<p>Hi there!</p>' ),
5211 * padded: true,
5212 * width: 300
5213 * } );
5214 *
5215 * $( document.body ).append( popup.$element );
5216 * // To display the popup, toggle the visibility to 'true'.
5217 * popup.toggle( true );
5218 *
5219 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5220 *
5221 * @class
5222 * @extends OO.ui.Widget
5223 * @mixins OO.ui.mixin.LabelElement
5224 * @mixins OO.ui.mixin.ClippableElement
5225 * @mixins OO.ui.mixin.FloatableElement
5226 *
5227 * @constructor
5228 * @param {Object} [config] Configuration options
5229 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5230 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5231 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5232 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5233 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5234 * of $floatableContainer
5235 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5236 * of $floatableContainer
5237 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5238 * endwards (right/left) to the vertical center of $floatableContainer
5239 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5240 * startwards (left/right) to the vertical center of $floatableContainer
5241 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5242 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5243 * as possible while still keeping the anchor within the popup;
5244 * if position is before/after, move the popup as far downwards as possible.
5245 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5246 * as possible while still keeping the anchor within the popup;
5247 * if position in before/after, move the popup as far upwards as possible.
5248 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5249 * of the popup with the center of $floatableContainer.
5250 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5251 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5252 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5253 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5254 * desired direction to display the popup without clipping
5255 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5256 * See the [OOUI docs on MediaWiki][3] for an example.
5257 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5258 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5259 * @cfg {jQuery} [$content] Content to append to the popup's body
5260 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5261 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5262 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5263 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5264 * for an example.
5265 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5266 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5267 * button.
5268 * @cfg {boolean} [padded=false] Add padding to the popup's body
5269 */
5270 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5271 // Configuration initialization
5272 config = config || {};
5273
5274 // Parent constructor
5275 OO.ui.PopupWidget.parent.call( this, config );
5276
5277 // Properties (must be set before ClippableElement constructor call)
5278 this.$body = $( '<div>' );
5279 this.$popup = $( '<div>' );
5280
5281 // Mixin constructors
5282 OO.ui.mixin.LabelElement.call( this, config );
5283 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5284 $clippable: this.$body,
5285 $clippableContainer: this.$popup
5286 } ) );
5287 OO.ui.mixin.FloatableElement.call( this, config );
5288
5289 // Properties
5290 this.$anchor = $( '<div>' );
5291 // If undefined, will be computed lazily in computePosition()
5292 this.$container = config.$container;
5293 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5294 this.autoClose = !!config.autoClose;
5295 this.transitionTimeout = null;
5296 this.anchored = false;
5297 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5298 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5299
5300 // Initialization
5301 this.setSize( config.width, config.height );
5302 this.toggleAnchor( config.anchor === undefined || config.anchor );
5303 this.setAlignment( config.align || 'center' );
5304 this.setPosition( config.position || 'below' );
5305 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5306 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5307 this.$body.addClass( 'oo-ui-popupWidget-body' );
5308 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5309 this.$popup
5310 .addClass( 'oo-ui-popupWidget-popup' )
5311 .append( this.$body );
5312 this.$element
5313 .addClass( 'oo-ui-popupWidget' )
5314 .append( this.$popup, this.$anchor );
5315 // Move content, which was added to #$element by OO.ui.Widget, to the body
5316 // FIXME This is gross, we should use '$body' or something for the config
5317 if ( config.$content instanceof $ ) {
5318 this.$body.append( config.$content );
5319 }
5320
5321 if ( config.padded ) {
5322 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5323 }
5324
5325 if ( config.head ) {
5326 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5327 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5328 this.$head = $( '<div>' )
5329 .addClass( 'oo-ui-popupWidget-head' )
5330 .append( this.$label, this.closeButton.$element );
5331 this.$popup.prepend( this.$head );
5332 }
5333
5334 if ( config.$footer ) {
5335 this.$footer = $( '<div>' )
5336 .addClass( 'oo-ui-popupWidget-footer' )
5337 .append( config.$footer );
5338 this.$popup.append( this.$footer );
5339 }
5340
5341 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5342 // that reference properties not initialized at that time of parent class construction
5343 // TODO: Find a better way to handle post-constructor setup
5344 this.visible = false;
5345 this.$element.addClass( 'oo-ui-element-hidden' );
5346 };
5347
5348 /* Setup */
5349
5350 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5351 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5352 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5353 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5354
5355 /* Events */
5356
5357 /**
5358 * @event ready
5359 *
5360 * The popup is ready: it is visible and has been positioned and clipped.
5361 */
5362
5363 /* Methods */
5364
5365 /**
5366 * Handles document mouse down events.
5367 *
5368 * @private
5369 * @param {MouseEvent} e Mouse down event
5370 */
5371 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5372 if (
5373 this.isVisible() &&
5374 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5375 ) {
5376 this.toggle( false );
5377 }
5378 };
5379
5380 // Deprecated alias since 0.28.3
5381 OO.ui.PopupWidget.prototype.onMouseDown = function () {
5382 OO.ui.warnDeprecation( 'onMouseDown is deprecated, use onDocumentMouseDown instead' );
5383 this.onDocumentMouseDown.apply( this, arguments );
5384 };
5385
5386 /**
5387 * Bind document mouse down listener.
5388 *
5389 * @private
5390 */
5391 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5392 // Capture clicks outside popup
5393 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5394 // We add 'click' event because iOS safari needs to respond to this event.
5395 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5396 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5397 // of occasionally not emitting 'click' properly, that event seems to be the standard
5398 // that it should be emitting, so we add it to this and will operate the event handler
5399 // on whichever of these events was triggered first
5400 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5401 };
5402
5403 // Deprecated alias since 0.28.3
5404 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5405 OO.ui.warnDeprecation( 'bindMouseDownListener is deprecated, use bindDocumentMouseDownListener instead' );
5406 this.bindDocumentMouseDownListener.apply( this, arguments );
5407 };
5408
5409 /**
5410 * Handles close button click events.
5411 *
5412 * @private
5413 */
5414 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5415 if ( this.isVisible() ) {
5416 this.toggle( false );
5417 }
5418 };
5419
5420 /**
5421 * Unbind document mouse down listener.
5422 *
5423 * @private
5424 */
5425 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5426 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5427 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5428 };
5429
5430 // Deprecated alias since 0.28.3
5431 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5432 OO.ui.warnDeprecation( 'unbindMouseDownListener is deprecated, use unbindDocumentMouseDownListener instead' );
5433 this.unbindDocumentMouseDownListener.apply( this, arguments );
5434 };
5435
5436 /**
5437 * Handles document key down events.
5438 *
5439 * @private
5440 * @param {KeyboardEvent} e Key down event
5441 */
5442 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5443 if (
5444 e.which === OO.ui.Keys.ESCAPE &&
5445 this.isVisible()
5446 ) {
5447 this.toggle( false );
5448 e.preventDefault();
5449 e.stopPropagation();
5450 }
5451 };
5452
5453 /**
5454 * Bind document key down listener.
5455 *
5456 * @private
5457 */
5458 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5459 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5460 };
5461
5462 // Deprecated alias since 0.28.3
5463 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5464 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
5465 this.bindDocumentKeyDownListener.apply( this, arguments );
5466 };
5467
5468 /**
5469 * Unbind document key down listener.
5470 *
5471 * @private
5472 */
5473 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5474 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5475 };
5476
5477 // Deprecated alias since 0.28.3
5478 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5479 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
5480 this.unbindDocumentKeyDownListener.apply( this, arguments );
5481 };
5482
5483 /**
5484 * Show, hide, or toggle the visibility of the anchor.
5485 *
5486 * @param {boolean} [show] Show anchor, omit to toggle
5487 */
5488 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5489 show = show === undefined ? !this.anchored : !!show;
5490
5491 if ( this.anchored !== show ) {
5492 if ( show ) {
5493 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5494 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5495 } else {
5496 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5497 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5498 }
5499 this.anchored = show;
5500 }
5501 };
5502
5503 /**
5504 * Change which edge the anchor appears on.
5505 *
5506 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5507 */
5508 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5509 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5510 throw new Error( 'Invalid value for edge: ' + edge );
5511 }
5512 if ( this.anchorEdge !== null ) {
5513 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5514 }
5515 this.anchorEdge = edge;
5516 if ( this.anchored ) {
5517 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5518 }
5519 };
5520
5521 /**
5522 * Check if the anchor is visible.
5523 *
5524 * @return {boolean} Anchor is visible
5525 */
5526 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5527 return this.anchored;
5528 };
5529
5530 /**
5531 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5532 * `.toggle( true )` after its #$element is attached to the DOM.
5533 *
5534 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5535 * it in the right place and with the right dimensions only work correctly while it is attached.
5536 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5537 * strictly enforced, so currently it only generates a warning in the browser console.
5538 *
5539 * @fires ready
5540 * @inheritdoc
5541 */
5542 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5543 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5544 show = show === undefined ? !this.isVisible() : !!show;
5545
5546 change = show !== this.isVisible();
5547
5548 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5549 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5550 this.warnedUnattached = true;
5551 }
5552 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5553 // Fall back to the parent node if the floatableContainer is not set
5554 this.setFloatableContainer( this.$element.parent() );
5555 }
5556
5557 if ( change && show && this.autoFlip ) {
5558 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5559 // (e.g. if the user scrolled).
5560 this.isAutoFlipped = false;
5561 }
5562
5563 // Parent method
5564 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5565
5566 if ( change ) {
5567 this.togglePositioning( show && !!this.$floatableContainer );
5568
5569 if ( show ) {
5570 if ( this.autoClose ) {
5571 this.bindDocumentMouseDownListener();
5572 this.bindDocumentKeyDownListener();
5573 }
5574 this.updateDimensions();
5575 this.toggleClipping( true );
5576
5577 if ( this.autoFlip ) {
5578 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5579 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5580 // If opening the popup in the normal direction causes it to be clipped, open
5581 // in the opposite one instead
5582 normalHeight = this.$element.height();
5583 this.isAutoFlipped = !this.isAutoFlipped;
5584 this.position();
5585 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5586 // If that also causes it to be clipped, open in whichever direction
5587 // we have more space
5588 oppositeHeight = this.$element.height();
5589 if ( oppositeHeight < normalHeight ) {
5590 this.isAutoFlipped = !this.isAutoFlipped;
5591 this.position();
5592 }
5593 }
5594 }
5595 }
5596 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5597 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5598 // If opening the popup in the normal direction causes it to be clipped, open
5599 // in the opposite one instead
5600 normalWidth = this.$element.width();
5601 this.isAutoFlipped = !this.isAutoFlipped;
5602 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5603 // which causes positioning to be off. Toggle clipping back and fort to work around.
5604 this.toggleClipping( false );
5605 this.position();
5606 this.toggleClipping( true );
5607 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5608 // If that also causes it to be clipped, open in whichever direction
5609 // we have more space
5610 oppositeWidth = this.$element.width();
5611 if ( oppositeWidth < normalWidth ) {
5612 this.isAutoFlipped = !this.isAutoFlipped;
5613 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5614 // which causes positioning to be off. Toggle clipping back and fort to work around.
5615 this.toggleClipping( false );
5616 this.position();
5617 this.toggleClipping( true );
5618 }
5619 }
5620 }
5621 }
5622 }
5623
5624 this.emit( 'ready' );
5625 } else {
5626 this.toggleClipping( false );
5627 if ( this.autoClose ) {
5628 this.unbindDocumentMouseDownListener();
5629 this.unbindDocumentKeyDownListener();
5630 }
5631 }
5632 }
5633
5634 return this;
5635 };
5636
5637 /**
5638 * Set the size of the popup.
5639 *
5640 * Changing the size may also change the popup's position depending on the alignment.
5641 *
5642 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5643 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5644 * @param {boolean} [transition=false] Use a smooth transition
5645 * @chainable
5646 */
5647 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5648 this.width = width !== undefined ? width : 320;
5649 this.height = height !== undefined ? height : null;
5650 if ( this.isVisible() ) {
5651 this.updateDimensions( transition );
5652 }
5653 };
5654
5655 /**
5656 * Update the size and position.
5657 *
5658 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5659 * be called automatically.
5660 *
5661 * @param {boolean} [transition=false] Use a smooth transition
5662 * @chainable
5663 */
5664 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5665 var widget = this;
5666
5667 // Prevent transition from being interrupted
5668 clearTimeout( this.transitionTimeout );
5669 if ( transition ) {
5670 // Enable transition
5671 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5672 }
5673
5674 this.position();
5675
5676 if ( transition ) {
5677 // Prevent transitioning after transition is complete
5678 this.transitionTimeout = setTimeout( function () {
5679 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5680 }, 200 );
5681 } else {
5682 // Prevent transitioning immediately
5683 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5684 }
5685 };
5686
5687 /**
5688 * @inheritdoc
5689 */
5690 OO.ui.PopupWidget.prototype.computePosition = function () {
5691 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5692 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5693 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5694 popupPos = {},
5695 anchorCss = { left: '', right: '', top: '', bottom: '' },
5696 popupPositionOppositeMap = {
5697 above: 'below',
5698 below: 'above',
5699 before: 'after',
5700 after: 'before'
5701 },
5702 alignMap = {
5703 ltr: {
5704 'force-left': 'backwards',
5705 'force-right': 'forwards'
5706 },
5707 rtl: {
5708 'force-left': 'forwards',
5709 'force-right': 'backwards'
5710 }
5711 },
5712 anchorEdgeMap = {
5713 above: 'bottom',
5714 below: 'top',
5715 before: 'end',
5716 after: 'start'
5717 },
5718 hPosMap = {
5719 forwards: 'start',
5720 center: 'center',
5721 backwards: this.anchored ? 'before' : 'end'
5722 },
5723 vPosMap = {
5724 forwards: 'top',
5725 center: 'center',
5726 backwards: 'bottom'
5727 };
5728
5729 if ( !this.$container ) {
5730 // Lazy-initialize $container if not specified in constructor
5731 this.$container = $( this.getClosestScrollableElementContainer() );
5732 }
5733 direction = this.$container.css( 'direction' );
5734
5735 // Set height and width before we do anything else, since it might cause our measurements
5736 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5737 this.$popup.css( {
5738 width: this.width !== null ? this.width : 'auto',
5739 height: this.height !== null ? this.height : 'auto'
5740 } );
5741
5742 align = alignMap[ direction ][ this.align ] || this.align;
5743 popupPosition = this.popupPosition;
5744 if ( this.isAutoFlipped ) {
5745 popupPosition = popupPositionOppositeMap[ popupPosition ];
5746 }
5747
5748 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5749 vertical = popupPosition === 'before' || popupPosition === 'after';
5750 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5751 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5752 near = vertical ? 'top' : 'left';
5753 far = vertical ? 'bottom' : 'right';
5754 sizeProp = vertical ? 'Height' : 'Width';
5755 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5756
5757 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5758 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5759 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5760
5761 // Parent method
5762 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5763 // Find out which property FloatableElement used for positioning, and adjust that value
5764 positionProp = vertical ?
5765 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5766 ( parentPosition.left !== '' ? 'left' : 'right' );
5767
5768 // Figure out where the near and far edges of the popup and $floatableContainer are
5769 floatablePos = this.$floatableContainer.offset();
5770 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5771 // Measure where the offsetParent is and compute our position based on that and parentPosition
5772 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5773 { top: 0, left: 0 } :
5774 this.$element.offsetParent().offset();
5775
5776 if ( positionProp === near ) {
5777 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5778 popupPos[ far ] = popupPos[ near ] + popupSize;
5779 } else {
5780 popupPos[ far ] = offsetParentPos[ near ] +
5781 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5782 popupPos[ near ] = popupPos[ far ] - popupSize;
5783 }
5784
5785 if ( this.anchored ) {
5786 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5787 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5788 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5789
5790 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5791 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5792 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5793 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5794 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5795 // Not enough space for the anchor on the start side; pull the popup startwards
5796 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5797 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5798 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5799 // Not enough space for the anchor on the end side; pull the popup endwards
5800 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5801 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5802 } else {
5803 positionAdjustment = 0;
5804 }
5805 } else {
5806 positionAdjustment = 0;
5807 }
5808
5809 // Check if the popup will go beyond the edge of this.$container
5810 containerPos = this.$container[ 0 ] === document.documentElement ?
5811 { top: 0, left: 0 } :
5812 this.$container.offset();
5813 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5814 if ( this.$container[ 0 ] === document.documentElement ) {
5815 viewportSpacing = OO.ui.getViewportSpacing();
5816 containerPos[ near ] += viewportSpacing[ near ];
5817 containerPos[ far ] -= viewportSpacing[ far ];
5818 }
5819 // Take into account how much the popup will move because of the adjustments we're going to make
5820 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5821 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5822 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5823 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5824 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5825 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5826 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5827 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5828 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5829 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5830 }
5831
5832 if ( this.anchored ) {
5833 // Adjust anchorOffset for positionAdjustment
5834 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5835
5836 // Position the anchor
5837 anchorCss[ start ] = anchorOffset;
5838 this.$anchor.css( anchorCss );
5839 }
5840
5841 // Move the popup if needed
5842 parentPosition[ positionProp ] += positionAdjustment;
5843
5844 return parentPosition;
5845 };
5846
5847 /**
5848 * Set popup alignment
5849 *
5850 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5851 * `backwards` or `forwards`.
5852 */
5853 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5854 // Validate alignment
5855 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5856 this.align = align;
5857 } else {
5858 this.align = 'center';
5859 }
5860 this.position();
5861 };
5862
5863 /**
5864 * Get popup alignment
5865 *
5866 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5867 * `backwards` or `forwards`.
5868 */
5869 OO.ui.PopupWidget.prototype.getAlignment = function () {
5870 return this.align;
5871 };
5872
5873 /**
5874 * Change the positioning of the popup.
5875 *
5876 * @param {string} position 'above', 'below', 'before' or 'after'
5877 */
5878 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5879 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5880 position = 'below';
5881 }
5882 this.popupPosition = position;
5883 this.position();
5884 };
5885
5886 /**
5887 * Get popup positioning.
5888 *
5889 * @return {string} 'above', 'below', 'before' or 'after'
5890 */
5891 OO.ui.PopupWidget.prototype.getPosition = function () {
5892 return this.popupPosition;
5893 };
5894
5895 /**
5896 * Set popup auto-flipping.
5897 *
5898 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5899 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5900 * desired direction to display the popup without clipping
5901 */
5902 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5903 autoFlip = !!autoFlip;
5904
5905 if ( this.autoFlip !== autoFlip ) {
5906 this.autoFlip = autoFlip;
5907 }
5908 };
5909
5910 /**
5911 * Set which elements will not close the popup when clicked.
5912 *
5913 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5914 *
5915 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5916 */
5917 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5918 this.$autoCloseIgnore = $autoCloseIgnore;
5919 };
5920
5921 /**
5922 * Get an ID of the body element, this can be used as the
5923 * `aria-describedby` attribute for an input field.
5924 *
5925 * @return {string} The ID of the body element
5926 */
5927 OO.ui.PopupWidget.prototype.getBodyId = function () {
5928 var id = this.$body.attr( 'id' );
5929 if ( id === undefined ) {
5930 id = OO.ui.generateElementId();
5931 this.$body.attr( 'id', id );
5932 }
5933 return id;
5934 };
5935
5936 /**
5937 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5938 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5939 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5940 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5941 *
5942 * @abstract
5943 * @class
5944 *
5945 * @constructor
5946 * @param {Object} [config] Configuration options
5947 * @cfg {Object} [popup] Configuration to pass to popup
5948 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5949 */
5950 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5951 // Configuration initialization
5952 config = config || {};
5953
5954 // Properties
5955 this.popup = new OO.ui.PopupWidget( $.extend(
5956 {
5957 autoClose: true,
5958 $floatableContainer: this.$element
5959 },
5960 config.popup,
5961 {
5962 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5963 }
5964 ) );
5965 };
5966
5967 /* Methods */
5968
5969 /**
5970 * Get popup.
5971 *
5972 * @return {OO.ui.PopupWidget} Popup widget
5973 */
5974 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5975 return this.popup;
5976 };
5977
5978 /**
5979 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5980 * which is used to display additional information or options.
5981 *
5982 * @example
5983 * // A PopupButtonWidget.
5984 * var popupButton = new OO.ui.PopupButtonWidget( {
5985 * label: 'Popup button with options',
5986 * icon: 'menu',
5987 * popup: {
5988 * $content: $( '<p>Additional options here.</p>' ),
5989 * padded: true,
5990 * align: 'force-left'
5991 * }
5992 * } );
5993 * // Append the button to the DOM.
5994 * $( document.body ).append( popupButton.$element );
5995 *
5996 * @class
5997 * @extends OO.ui.ButtonWidget
5998 * @mixins OO.ui.mixin.PopupElement
5999 *
6000 * @constructor
6001 * @param {Object} [config] Configuration options
6002 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
6003 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
6004 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
6005 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6006 */
6007 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6008 // Configuration initialization
6009 config = config || {};
6010
6011 // Parent constructor
6012 OO.ui.PopupButtonWidget.parent.call( this, config );
6013
6014 // Mixin constructors
6015 OO.ui.mixin.PopupElement.call( this, config );
6016
6017 // Properties
6018 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6019
6020 // Events
6021 this.connect( this, { click: 'onAction' } );
6022
6023 // Initialization
6024 this.$element
6025 .addClass( 'oo-ui-popupButtonWidget' );
6026 this.popup.$element
6027 .addClass( 'oo-ui-popupButtonWidget-popup' )
6028 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6029 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6030 this.$overlay.append( this.popup.$element );
6031 };
6032
6033 /* Setup */
6034
6035 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6036 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6037
6038 /* Methods */
6039
6040 /**
6041 * Handle the button action being triggered.
6042 *
6043 * @private
6044 */
6045 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6046 this.popup.toggle();
6047 };
6048
6049 /**
6050 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6051 *
6052 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6053 *
6054 * @private
6055 * @abstract
6056 * @class
6057 * @mixins OO.ui.mixin.GroupElement
6058 *
6059 * @constructor
6060 * @param {Object} [config] Configuration options
6061 */
6062 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6063 // Mixin constructors
6064 OO.ui.mixin.GroupElement.call( this, config );
6065 };
6066
6067 /* Setup */
6068
6069 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6070
6071 /* Methods */
6072
6073 /**
6074 * Set the disabled state of the widget.
6075 *
6076 * This will also update the disabled state of child widgets.
6077 *
6078 * @param {boolean} disabled Disable widget
6079 * @chainable
6080 * @return {OO.ui.Widget} The widget, for chaining
6081 */
6082 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6083 var i, len;
6084
6085 // Parent method
6086 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6087 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6088
6089 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6090 if ( this.items ) {
6091 for ( i = 0, len = this.items.length; i < len; i++ ) {
6092 this.items[ i ].updateDisabled();
6093 }
6094 }
6095
6096 return this;
6097 };
6098
6099 /**
6100 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6101 *
6102 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
6103 * allows bidirectional communication.
6104 *
6105 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6106 *
6107 * @private
6108 * @abstract
6109 * @class
6110 *
6111 * @constructor
6112 */
6113 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6114 //
6115 };
6116
6117 /* Methods */
6118
6119 /**
6120 * Check if widget is disabled.
6121 *
6122 * Checks parent if present, making disabled state inheritable.
6123 *
6124 * @return {boolean} Widget is disabled
6125 */
6126 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6127 return this.disabled ||
6128 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6129 };
6130
6131 /**
6132 * Set group element is in.
6133 *
6134 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6135 * @chainable
6136 * @return {OO.ui.Widget} The widget, for chaining
6137 */
6138 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6139 // Parent method
6140 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6141 OO.ui.Element.prototype.setElementGroup.call( this, group );
6142
6143 // Initialize item disabled states
6144 this.updateDisabled();
6145
6146 return this;
6147 };
6148
6149 /**
6150 * OptionWidgets are special elements that can be selected and configured with data. The
6151 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6152 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6153 * and examples, please see the [OOUI documentation on MediaWiki][1].
6154 *
6155 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6156 *
6157 * @class
6158 * @extends OO.ui.Widget
6159 * @mixins OO.ui.mixin.ItemWidget
6160 * @mixins OO.ui.mixin.LabelElement
6161 * @mixins OO.ui.mixin.FlaggedElement
6162 * @mixins OO.ui.mixin.AccessKeyedElement
6163 * @mixins OO.ui.mixin.TitledElement
6164 *
6165 * @constructor
6166 * @param {Object} [config] Configuration options
6167 */
6168 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6169 // Configuration initialization
6170 config = config || {};
6171
6172 // Parent constructor
6173 OO.ui.OptionWidget.parent.call( this, config );
6174
6175 // Mixin constructors
6176 OO.ui.mixin.ItemWidget.call( this );
6177 OO.ui.mixin.LabelElement.call( this, config );
6178 OO.ui.mixin.FlaggedElement.call( this, config );
6179 OO.ui.mixin.AccessKeyedElement.call( this, config );
6180 OO.ui.mixin.TitledElement.call( this, config );
6181
6182 // Properties
6183 this.selected = false;
6184 this.highlighted = false;
6185 this.pressed = false;
6186
6187 // Initialization
6188 this.$element
6189 .data( 'oo-ui-optionWidget', this )
6190 // Allow programmatic focussing (and by accesskey), but not tabbing
6191 .attr( 'tabindex', '-1' )
6192 .attr( 'role', 'option' )
6193 .attr( 'aria-selected', 'false' )
6194 .addClass( 'oo-ui-optionWidget' )
6195 .append( this.$label );
6196 };
6197
6198 /* Setup */
6199
6200 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6201 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6202 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6203 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6204 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6205 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6206
6207 /* Static Properties */
6208
6209 /**
6210 * Whether this option can be selected. See #setSelected.
6211 *
6212 * @static
6213 * @inheritable
6214 * @property {boolean}
6215 */
6216 OO.ui.OptionWidget.static.selectable = true;
6217
6218 /**
6219 * Whether this option can be highlighted. See #setHighlighted.
6220 *
6221 * @static
6222 * @inheritable
6223 * @property {boolean}
6224 */
6225 OO.ui.OptionWidget.static.highlightable = true;
6226
6227 /**
6228 * Whether this option can be pressed. See #setPressed.
6229 *
6230 * @static
6231 * @inheritable
6232 * @property {boolean}
6233 */
6234 OO.ui.OptionWidget.static.pressable = true;
6235
6236 /**
6237 * Whether this option will be scrolled into view when it is selected.
6238 *
6239 * @static
6240 * @inheritable
6241 * @property {boolean}
6242 */
6243 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6244
6245 /* Methods */
6246
6247 /**
6248 * Check if the option can be selected.
6249 *
6250 * @return {boolean} Item is selectable
6251 */
6252 OO.ui.OptionWidget.prototype.isSelectable = function () {
6253 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6254 };
6255
6256 /**
6257 * Check if the option can be highlighted. A highlight indicates that the option
6258 * may be selected when a user presses enter or clicks. Disabled items cannot
6259 * be highlighted.
6260 *
6261 * @return {boolean} Item is highlightable
6262 */
6263 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6264 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6265 };
6266
6267 /**
6268 * Check if the option can be pressed. The pressed state occurs when a user mouses
6269 * down on an item, but has not yet let go of the mouse.
6270 *
6271 * @return {boolean} Item is pressable
6272 */
6273 OO.ui.OptionWidget.prototype.isPressable = function () {
6274 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6275 };
6276
6277 /**
6278 * Check if the option is selected.
6279 *
6280 * @return {boolean} Item is selected
6281 */
6282 OO.ui.OptionWidget.prototype.isSelected = function () {
6283 return this.selected;
6284 };
6285
6286 /**
6287 * Check if the option is highlighted. A highlight indicates that the
6288 * item may be selected when a user presses enter or clicks.
6289 *
6290 * @return {boolean} Item is highlighted
6291 */
6292 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6293 return this.highlighted;
6294 };
6295
6296 /**
6297 * Check if the option is pressed. The pressed state occurs when a user mouses
6298 * down on an item, but has not yet let go of the mouse. The item may appear
6299 * selected, but it will not be selected until the user releases the mouse.
6300 *
6301 * @return {boolean} Item is pressed
6302 */
6303 OO.ui.OptionWidget.prototype.isPressed = function () {
6304 return this.pressed;
6305 };
6306
6307 /**
6308 * Set the option’s selected state. In general, all modifications to the selection
6309 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6310 * method instead of this method.
6311 *
6312 * @param {boolean} [state=false] Select option
6313 * @chainable
6314 * @return {OO.ui.Widget} The widget, for chaining
6315 */
6316 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6317 if ( this.constructor.static.selectable ) {
6318 this.selected = !!state;
6319 this.$element
6320 .toggleClass( 'oo-ui-optionWidget-selected', state )
6321 .attr( 'aria-selected', state.toString() );
6322 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6323 this.scrollElementIntoView();
6324 }
6325 this.updateThemeClasses();
6326 }
6327 return this;
6328 };
6329
6330 /**
6331 * Set the option’s highlighted state. In general, all programmatic
6332 * modifications to the highlight should be handled by the
6333 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6334 * method instead of this method.
6335 *
6336 * @param {boolean} [state=false] Highlight option
6337 * @chainable
6338 * @return {OO.ui.Widget} The widget, for chaining
6339 */
6340 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6341 if ( this.constructor.static.highlightable ) {
6342 this.highlighted = !!state;
6343 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6344 this.updateThemeClasses();
6345 }
6346 return this;
6347 };
6348
6349 /**
6350 * Set the option’s pressed state. In general, all
6351 * programmatic modifications to the pressed state should be handled by the
6352 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6353 * method instead of this method.
6354 *
6355 * @param {boolean} [state=false] Press option
6356 * @chainable
6357 * @return {OO.ui.Widget} The widget, for chaining
6358 */
6359 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6360 if ( this.constructor.static.pressable ) {
6361 this.pressed = !!state;
6362 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6363 this.updateThemeClasses();
6364 }
6365 return this;
6366 };
6367
6368 /**
6369 * Get text to match search strings against.
6370 *
6371 * The default implementation returns the label text, but subclasses
6372 * can override this to provide more complex behavior.
6373 *
6374 * @return {string|boolean} String to match search string against
6375 */
6376 OO.ui.OptionWidget.prototype.getMatchText = function () {
6377 var label = this.getLabel();
6378 return typeof label === 'string' ? label : this.$label.text();
6379 };
6380
6381 /**
6382 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6383 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6384 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6385 * menu selects}.
6386 *
6387 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6388 * information, please see the [OOUI documentation on MediaWiki][1].
6389 *
6390 * @example
6391 * // A select widget with three options.
6392 * var select = new OO.ui.SelectWidget( {
6393 * items: [
6394 * new OO.ui.OptionWidget( {
6395 * data: 'a',
6396 * label: 'Option One',
6397 * } ),
6398 * new OO.ui.OptionWidget( {
6399 * data: 'b',
6400 * label: 'Option Two',
6401 * } ),
6402 * new OO.ui.OptionWidget( {
6403 * data: 'c',
6404 * label: 'Option Three',
6405 * } )
6406 * ]
6407 * } );
6408 * $( document.body ).append( select.$element );
6409 *
6410 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6411 *
6412 * @abstract
6413 * @class
6414 * @extends OO.ui.Widget
6415 * @mixins OO.ui.mixin.GroupWidget
6416 *
6417 * @constructor
6418 * @param {Object} [config] Configuration options
6419 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6420 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6421 * the [OOUI documentation on MediaWiki] [2] for examples.
6422 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6423 */
6424 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6425 // Configuration initialization
6426 config = config || {};
6427
6428 // Parent constructor
6429 OO.ui.SelectWidget.parent.call( this, config );
6430
6431 // Mixin constructors
6432 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6433
6434 // Properties
6435 this.pressed = false;
6436 this.selecting = null;
6437 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6438 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6439 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6440 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6441 this.keyPressBuffer = '';
6442 this.keyPressBufferTimer = null;
6443 this.blockMouseOverEvents = 0;
6444
6445 // Events
6446 this.connect( this, {
6447 toggle: 'onToggle'
6448 } );
6449 this.$element.on( {
6450 focusin: this.onFocus.bind( this ),
6451 mousedown: this.onMouseDown.bind( this ),
6452 mouseover: this.onMouseOver.bind( this ),
6453 mouseleave: this.onMouseLeave.bind( this )
6454 } );
6455
6456 // Initialization
6457 this.$element
6458 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6459 .attr( 'role', 'listbox' );
6460 this.setFocusOwner( this.$element );
6461 if ( Array.isArray( config.items ) ) {
6462 this.addItems( config.items );
6463 }
6464 };
6465
6466 /* Setup */
6467
6468 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6469 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6470
6471 /* Events */
6472
6473 /**
6474 * @event highlight
6475 *
6476 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6477 *
6478 * @param {OO.ui.OptionWidget|null} item Highlighted item
6479 */
6480
6481 /**
6482 * @event press
6483 *
6484 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6485 * pressed state of an option.
6486 *
6487 * @param {OO.ui.OptionWidget|null} item Pressed item
6488 */
6489
6490 /**
6491 * @event select
6492 *
6493 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6494 *
6495 * @param {OO.ui.OptionWidget|null} item Selected item
6496 */
6497
6498 /**
6499 * @event choose
6500 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6501 * @param {OO.ui.OptionWidget} item Chosen item
6502 */
6503
6504 /**
6505 * @event add
6506 *
6507 * An `add` event is emitted when options are added to the select with the #addItems method.
6508 *
6509 * @param {OO.ui.OptionWidget[]} items Added items
6510 * @param {number} index Index of insertion point
6511 */
6512
6513 /**
6514 * @event remove
6515 *
6516 * A `remove` event is emitted when options are removed from the select with the #clearItems
6517 * or #removeItems methods.
6518 *
6519 * @param {OO.ui.OptionWidget[]} items Removed items
6520 */
6521
6522 /* Methods */
6523
6524 /**
6525 * Handle focus events
6526 *
6527 * @private
6528 * @param {jQuery.Event} event
6529 */
6530 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6531 var item;
6532 if ( event.target === this.$element[ 0 ] ) {
6533 // This widget was focussed, e.g. by the user tabbing to it.
6534 // The styles for focus state depend on one of the items being selected.
6535 if ( !this.findSelectedItem() ) {
6536 item = this.findFirstSelectableItem();
6537 }
6538 } else {
6539 if ( event.target.tabIndex === -1 ) {
6540 // One of the options got focussed (and the event bubbled up here).
6541 // They can't be tabbed to, but they can be activated using accesskeys.
6542 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6543 item = this.findTargetItem( event );
6544 } else {
6545 // There is something actually user-focusable in one of the labels of the options, and the
6546 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6547 return;
6548 }
6549 }
6550
6551 if ( item ) {
6552 if ( item.constructor.static.highlightable ) {
6553 this.highlightItem( item );
6554 } else {
6555 this.selectItem( item );
6556 }
6557 }
6558
6559 if ( event.target !== this.$element[ 0 ] ) {
6560 this.$focusOwner.focus();
6561 }
6562 };
6563
6564 /**
6565 * Handle mouse down events.
6566 *
6567 * @private
6568 * @param {jQuery.Event} e Mouse down event
6569 * @return {undefined/boolean} False to prevent default if event is handled
6570 */
6571 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6572 var item;
6573
6574 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6575 this.togglePressed( true );
6576 item = this.findTargetItem( e );
6577 if ( item && item.isSelectable() ) {
6578 this.pressItem( item );
6579 this.selecting = item;
6580 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6581 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6582 }
6583 }
6584 return false;
6585 };
6586
6587 /**
6588 * Handle document mouse up events.
6589 *
6590 * @private
6591 * @param {MouseEvent} e Mouse up event
6592 * @return {undefined/boolean} False to prevent default if event is handled
6593 */
6594 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6595 var item;
6596
6597 this.togglePressed( false );
6598 if ( !this.selecting ) {
6599 item = this.findTargetItem( e );
6600 if ( item && item.isSelectable() ) {
6601 this.selecting = item;
6602 }
6603 }
6604 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6605 this.pressItem( null );
6606 this.chooseItem( this.selecting );
6607 this.selecting = null;
6608 }
6609
6610 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6611 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6612
6613 return false;
6614 };
6615
6616 // Deprecated alias since 0.28.3
6617 OO.ui.SelectWidget.prototype.onMouseUp = function () {
6618 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
6619 this.onDocumentMouseUp.apply( this, arguments );
6620 };
6621
6622 /**
6623 * Handle document mouse move events.
6624 *
6625 * @private
6626 * @param {MouseEvent} e Mouse move event
6627 */
6628 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6629 var item;
6630
6631 if ( !this.isDisabled() && this.pressed ) {
6632 item = this.findTargetItem( e );
6633 if ( item && item !== this.selecting && item.isSelectable() ) {
6634 this.pressItem( item );
6635 this.selecting = item;
6636 }
6637 }
6638 };
6639
6640 // Deprecated alias since 0.28.3
6641 OO.ui.SelectWidget.prototype.onMouseMove = function () {
6642 OO.ui.warnDeprecation( 'onMouseMove is deprecated, use onDocumentMouseMove instead' );
6643 this.onDocumentMouseMove.apply( this, arguments );
6644 };
6645
6646 /**
6647 * Handle mouse over events.
6648 *
6649 * @private
6650 * @param {jQuery.Event} e Mouse over event
6651 * @return {undefined/boolean} False to prevent default if event is handled
6652 */
6653 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6654 var item;
6655 if ( this.blockMouseOverEvents ) {
6656 return;
6657 }
6658 if ( !this.isDisabled() ) {
6659 item = this.findTargetItem( e );
6660 this.highlightItem( item && item.isHighlightable() ? item : null );
6661 }
6662 return false;
6663 };
6664
6665 /**
6666 * Handle mouse leave events.
6667 *
6668 * @private
6669 * @param {jQuery.Event} e Mouse over event
6670 * @return {undefined/boolean} False to prevent default if event is handled
6671 */
6672 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6673 if ( !this.isDisabled() ) {
6674 this.highlightItem( null );
6675 }
6676 return false;
6677 };
6678
6679 /**
6680 * Handle document key down events.
6681 *
6682 * @protected
6683 * @param {KeyboardEvent} e Key down event
6684 */
6685 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6686 var nextItem,
6687 handled = false,
6688 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6689
6690 if ( !this.isDisabled() && this.isVisible() ) {
6691 switch ( e.keyCode ) {
6692 case OO.ui.Keys.ENTER:
6693 if ( currentItem && currentItem.constructor.static.highlightable ) {
6694 // Was only highlighted, now let's select it. No-op if already selected.
6695 this.chooseItem( currentItem );
6696 handled = true;
6697 }
6698 break;
6699 case OO.ui.Keys.UP:
6700 case OO.ui.Keys.LEFT:
6701 this.clearKeyPressBuffer();
6702 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6703 handled = true;
6704 break;
6705 case OO.ui.Keys.DOWN:
6706 case OO.ui.Keys.RIGHT:
6707 this.clearKeyPressBuffer();
6708 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6709 handled = true;
6710 break;
6711 case OO.ui.Keys.ESCAPE:
6712 case OO.ui.Keys.TAB:
6713 if ( currentItem && currentItem.constructor.static.highlightable ) {
6714 currentItem.setHighlighted( false );
6715 }
6716 this.unbindDocumentKeyDownListener();
6717 this.unbindDocumentKeyPressListener();
6718 // Don't prevent tabbing away / defocusing
6719 handled = false;
6720 break;
6721 }
6722
6723 if ( nextItem ) {
6724 if ( nextItem.constructor.static.highlightable ) {
6725 this.highlightItem( nextItem );
6726 } else {
6727 this.chooseItem( nextItem );
6728 }
6729 this.scrollItemIntoView( nextItem );
6730 }
6731
6732 if ( handled ) {
6733 e.preventDefault();
6734 e.stopPropagation();
6735 }
6736 }
6737 };
6738
6739 // Deprecated alias since 0.28.3
6740 OO.ui.SelectWidget.prototype.onKeyDown = function () {
6741 OO.ui.warnDeprecation( 'onKeyDown is deprecated, use onDocumentKeyDown instead' );
6742 this.onDocumentKeyDown.apply( this, arguments );
6743 };
6744
6745 /**
6746 * Bind document key down listener.
6747 *
6748 * @protected
6749 */
6750 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6751 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6752 };
6753
6754 // Deprecated alias since 0.28.3
6755 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6756 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
6757 this.bindDocumentKeyDownListener.apply( this, arguments );
6758 };
6759
6760 /**
6761 * Unbind document key down listener.
6762 *
6763 * @protected
6764 */
6765 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6766 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6767 };
6768
6769 // Deprecated alias since 0.28.3
6770 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6771 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
6772 this.unbindDocumentKeyDownListener.apply( this, arguments );
6773 };
6774
6775 /**
6776 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6777 *
6778 * @param {OO.ui.OptionWidget} item Item to scroll into view
6779 */
6780 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6781 var widget = this;
6782 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6783 // and around 100-150 ms after it is finished.
6784 this.blockMouseOverEvents++;
6785 item.scrollElementIntoView().done( function () {
6786 setTimeout( function () {
6787 widget.blockMouseOverEvents--;
6788 }, 200 );
6789 } );
6790 };
6791
6792 /**
6793 * Clear the key-press buffer
6794 *
6795 * @protected
6796 */
6797 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6798 if ( this.keyPressBufferTimer ) {
6799 clearTimeout( this.keyPressBufferTimer );
6800 this.keyPressBufferTimer = null;
6801 }
6802 this.keyPressBuffer = '';
6803 };
6804
6805 /**
6806 * Handle key press events.
6807 *
6808 * @protected
6809 * @param {KeyboardEvent} e Key press event
6810 * @return {undefined/boolean} False to prevent default if event is handled
6811 */
6812 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6813 var c, filter, item;
6814
6815 if ( !e.charCode ) {
6816 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6817 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6818 return false;
6819 }
6820 return;
6821 }
6822 // eslint-disable-next-line no-restricted-properties
6823 if ( String.fromCodePoint ) {
6824 // eslint-disable-next-line no-restricted-properties
6825 c = String.fromCodePoint( e.charCode );
6826 } else {
6827 c = String.fromCharCode( e.charCode );
6828 }
6829
6830 if ( this.keyPressBufferTimer ) {
6831 clearTimeout( this.keyPressBufferTimer );
6832 }
6833 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6834
6835 item = this.findHighlightedItem() || this.findSelectedItem();
6836
6837 if ( this.keyPressBuffer === c ) {
6838 // Common (if weird) special case: typing "xxxx" will cycle through all
6839 // the items beginning with "x".
6840 if ( item ) {
6841 item = this.findRelativeSelectableItem( item, 1 );
6842 }
6843 } else {
6844 this.keyPressBuffer += c;
6845 }
6846
6847 filter = this.getItemMatcher( this.keyPressBuffer, false );
6848 if ( !item || !filter( item ) ) {
6849 item = this.findRelativeSelectableItem( item, 1, filter );
6850 }
6851 if ( item ) {
6852 if ( this.isVisible() && item.constructor.static.highlightable ) {
6853 this.highlightItem( item );
6854 } else {
6855 this.chooseItem( item );
6856 }
6857 this.scrollItemIntoView( item );
6858 }
6859
6860 e.preventDefault();
6861 e.stopPropagation();
6862 };
6863
6864 // Deprecated alias since 0.28.3
6865 OO.ui.SelectWidget.prototype.onKeyPress = function () {
6866 OO.ui.warnDeprecation( 'onKeyPress is deprecated, use onDocumentKeyPress instead' );
6867 this.onDocumentKeyPress.apply( this, arguments );
6868 };
6869
6870 /**
6871 * Get a matcher for the specific string
6872 *
6873 * @protected
6874 * @param {string} s String to match against items
6875 * @param {boolean} [exact=false] Only accept exact matches
6876 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6877 */
6878 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6879 var re;
6880
6881 // eslint-disable-next-line no-restricted-properties
6882 if ( s.normalize ) {
6883 // eslint-disable-next-line no-restricted-properties
6884 s = s.normalize();
6885 }
6886 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6887 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6888 if ( exact ) {
6889 re += '\\s*$';
6890 }
6891 re = new RegExp( re, 'i' );
6892 return function ( item ) {
6893 var matchText = item.getMatchText();
6894 // eslint-disable-next-line no-restricted-properties
6895 if ( matchText.normalize ) {
6896 // eslint-disable-next-line no-restricted-properties
6897 matchText = matchText.normalize();
6898 }
6899 return re.test( matchText );
6900 };
6901 };
6902
6903 /**
6904 * Bind document key press listener.
6905 *
6906 * @protected
6907 */
6908 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6909 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6910 };
6911
6912 // Deprecated alias since 0.28.3
6913 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6914 OO.ui.warnDeprecation( 'bindKeyPressListener is deprecated, use bindDocumentKeyPressListener instead' );
6915 this.bindDocumentKeyPressListener.apply( this, arguments );
6916 };
6917
6918 /**
6919 * Unbind document key down listener.
6920 *
6921 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6922 * implementation.
6923 *
6924 * @protected
6925 */
6926 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6927 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6928 this.clearKeyPressBuffer();
6929 };
6930
6931 // Deprecated alias since 0.28.3
6932 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6933 OO.ui.warnDeprecation( 'unbindKeyPressListener is deprecated, use unbindDocumentKeyPressListener instead' );
6934 this.unbindDocumentKeyPressListener.apply( this, arguments );
6935 };
6936
6937 /**
6938 * Visibility change handler
6939 *
6940 * @protected
6941 * @param {boolean} visible
6942 */
6943 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6944 if ( !visible ) {
6945 this.clearKeyPressBuffer();
6946 }
6947 };
6948
6949 /**
6950 * Get the closest item to a jQuery.Event.
6951 *
6952 * @private
6953 * @param {jQuery.Event} e
6954 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6955 */
6956 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6957 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6958 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6959 return null;
6960 }
6961 return $option.data( 'oo-ui-optionWidget' ) || null;
6962 };
6963
6964 /**
6965 * Find selected item.
6966 *
6967 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6968 */
6969 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6970 var i, len;
6971
6972 for ( i = 0, len = this.items.length; i < len; i++ ) {
6973 if ( this.items[ i ].isSelected() ) {
6974 return this.items[ i ];
6975 }
6976 }
6977 return null;
6978 };
6979
6980 /**
6981 * Find highlighted item.
6982 *
6983 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6984 */
6985 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6986 var i, len;
6987
6988 for ( i = 0, len = this.items.length; i < len; i++ ) {
6989 if ( this.items[ i ].isHighlighted() ) {
6990 return this.items[ i ];
6991 }
6992 }
6993 return null;
6994 };
6995
6996 /**
6997 * Toggle pressed state.
6998 *
6999 * Press is a state that occurs when a user mouses down on an item, but
7000 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7001 * until the user releases the mouse.
7002 *
7003 * @param {boolean} pressed An option is being pressed
7004 */
7005 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7006 if ( pressed === undefined ) {
7007 pressed = !this.pressed;
7008 }
7009 if ( pressed !== this.pressed ) {
7010 this.$element
7011 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7012 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
7013 this.pressed = pressed;
7014 }
7015 };
7016
7017 /**
7018 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7019 * and any existing highlight will be removed. The highlight is mutually exclusive.
7020 *
7021 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7022 * @fires highlight
7023 * @chainable
7024 * @return {OO.ui.Widget} The widget, for chaining
7025 */
7026 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7027 var i, len, highlighted,
7028 changed = false;
7029
7030 for ( i = 0, len = this.items.length; i < len; i++ ) {
7031 highlighted = this.items[ i ] === item;
7032 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7033 this.items[ i ].setHighlighted( highlighted );
7034 changed = true;
7035 }
7036 }
7037 if ( changed ) {
7038 if ( item ) {
7039 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7040 } else {
7041 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7042 }
7043 this.emit( 'highlight', item );
7044 }
7045
7046 return this;
7047 };
7048
7049 /**
7050 * Fetch an item by its label.
7051 *
7052 * @param {string} label Label of the item to select.
7053 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7054 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7055 */
7056 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7057 var i, item, found,
7058 len = this.items.length,
7059 filter = this.getItemMatcher( label, true );
7060
7061 for ( i = 0; i < len; i++ ) {
7062 item = this.items[ i ];
7063 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7064 return item;
7065 }
7066 }
7067
7068 if ( prefix ) {
7069 found = null;
7070 filter = this.getItemMatcher( label, false );
7071 for ( i = 0; i < len; i++ ) {
7072 item = this.items[ i ];
7073 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7074 if ( found ) {
7075 return null;
7076 }
7077 found = item;
7078 }
7079 }
7080 if ( found ) {
7081 return found;
7082 }
7083 }
7084
7085 return null;
7086 };
7087
7088 /**
7089 * Programmatically select an option by its label. If the item does not exist,
7090 * all options will be deselected.
7091 *
7092 * @param {string} [label] Label of the item to select.
7093 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7094 * @fires select
7095 * @chainable
7096 * @return {OO.ui.Widget} The widget, for chaining
7097 */
7098 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7099 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7100 if ( label === undefined || !itemFromLabel ) {
7101 return this.selectItem();
7102 }
7103 return this.selectItem( itemFromLabel );
7104 };
7105
7106 /**
7107 * Programmatically select an option by its data. If the `data` parameter is omitted,
7108 * or if the item does not exist, all options will be deselected.
7109 *
7110 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7111 * @fires select
7112 * @chainable
7113 * @return {OO.ui.Widget} The widget, for chaining
7114 */
7115 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7116 var itemFromData = this.findItemFromData( data );
7117 if ( data === undefined || !itemFromData ) {
7118 return this.selectItem();
7119 }
7120 return this.selectItem( itemFromData );
7121 };
7122
7123 /**
7124 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7125 * all options will be deselected.
7126 *
7127 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7128 * @fires select
7129 * @chainable
7130 * @return {OO.ui.Widget} The widget, for chaining
7131 */
7132 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7133 var i, len, selected,
7134 changed = false;
7135
7136 for ( i = 0, len = this.items.length; i < len; i++ ) {
7137 selected = this.items[ i ] === item;
7138 if ( this.items[ i ].isSelected() !== selected ) {
7139 this.items[ i ].setSelected( selected );
7140 changed = true;
7141 }
7142 }
7143 if ( changed ) {
7144 if ( item && !item.constructor.static.highlightable ) {
7145 if ( item ) {
7146 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7147 } else {
7148 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7149 }
7150 }
7151 this.emit( 'select', item );
7152 }
7153
7154 return this;
7155 };
7156
7157 /**
7158 * Press an item.
7159 *
7160 * Press is a state that occurs when a user mouses down on an item, but has not
7161 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7162 * releases the mouse.
7163 *
7164 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7165 * @fires press
7166 * @chainable
7167 * @return {OO.ui.Widget} The widget, for chaining
7168 */
7169 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7170 var i, len, pressed,
7171 changed = false;
7172
7173 for ( i = 0, len = this.items.length; i < len; i++ ) {
7174 pressed = this.items[ i ] === item;
7175 if ( this.items[ i ].isPressed() !== pressed ) {
7176 this.items[ i ].setPressed( pressed );
7177 changed = true;
7178 }
7179 }
7180 if ( changed ) {
7181 this.emit( 'press', item );
7182 }
7183
7184 return this;
7185 };
7186
7187 /**
7188 * Choose an item.
7189 *
7190 * Note that ‘choose’ should never be modified programmatically. A user can choose
7191 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7192 * use the #selectItem method.
7193 *
7194 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7195 * when users choose an item with the keyboard or mouse.
7196 *
7197 * @param {OO.ui.OptionWidget} item Item to choose
7198 * @fires choose
7199 * @chainable
7200 * @return {OO.ui.Widget} The widget, for chaining
7201 */
7202 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7203 if ( item ) {
7204 this.selectItem( item );
7205 this.emit( 'choose', item );
7206 }
7207
7208 return this;
7209 };
7210
7211 /**
7212 * Find an option by its position relative to the specified item (or to the start of the option array,
7213 * if item is `null`). The direction in which to search through the option array is specified with a
7214 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
7215 * `null` if there are no options in the array.
7216 *
7217 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
7218 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7219 * @param {Function} [filter] Only consider items for which this function returns
7220 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7221 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7222 */
7223 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7224 var currentIndex, nextIndex, i,
7225 increase = direction > 0 ? 1 : -1,
7226 len = this.items.length;
7227
7228 if ( item instanceof OO.ui.OptionWidget ) {
7229 currentIndex = this.items.indexOf( item );
7230 nextIndex = ( currentIndex + increase + len ) % len;
7231 } else {
7232 // If no item is selected and moving forward, start at the beginning.
7233 // If moving backward, start at the end.
7234 nextIndex = direction > 0 ? 0 : len - 1;
7235 }
7236
7237 for ( i = 0; i < len; i++ ) {
7238 item = this.items[ nextIndex ];
7239 if (
7240 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7241 ( !filter || filter( item ) )
7242 ) {
7243 return item;
7244 }
7245 nextIndex = ( nextIndex + increase + len ) % len;
7246 }
7247 return null;
7248 };
7249
7250 /**
7251 * Find the next selectable item or `null` if there are no selectable items.
7252 * Disabled options and menu-section markers and breaks are not selectable.
7253 *
7254 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7255 */
7256 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7257 return this.findRelativeSelectableItem( null, 1 );
7258 };
7259
7260 /**
7261 * Add an array of options to the select. Optionally, an index number can be used to
7262 * specify an insertion point.
7263 *
7264 * @param {OO.ui.OptionWidget[]} items Items to add
7265 * @param {number} [index] Index to insert items after
7266 * @fires add
7267 * @chainable
7268 * @return {OO.ui.Widget} The widget, for chaining
7269 */
7270 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7271 // Mixin method
7272 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7273
7274 // Always provide an index, even if it was omitted
7275 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7276
7277 return this;
7278 };
7279
7280 /**
7281 * Remove the specified array of options from the select. Options will be detached
7282 * from the DOM, not removed, so they can be reused later. To remove all options from
7283 * the select, you may wish to use the #clearItems method instead.
7284 *
7285 * @param {OO.ui.OptionWidget[]} items Items to remove
7286 * @fires remove
7287 * @chainable
7288 * @return {OO.ui.Widget} The widget, for chaining
7289 */
7290 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7291 var i, len, item;
7292
7293 // Deselect items being removed
7294 for ( i = 0, len = items.length; i < len; i++ ) {
7295 item = items[ i ];
7296 if ( item.isSelected() ) {
7297 this.selectItem( null );
7298 }
7299 }
7300
7301 // Mixin method
7302 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7303
7304 this.emit( 'remove', items );
7305
7306 return this;
7307 };
7308
7309 /**
7310 * Clear all options from the select. Options will be detached from the DOM, not removed,
7311 * so that they can be reused later. To remove a subset of options from the select, use
7312 * the #removeItems method.
7313 *
7314 * @fires remove
7315 * @chainable
7316 * @return {OO.ui.Widget} The widget, for chaining
7317 */
7318 OO.ui.SelectWidget.prototype.clearItems = function () {
7319 var items = this.items.slice();
7320
7321 // Mixin method
7322 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7323
7324 // Clear selection
7325 this.selectItem( null );
7326
7327 this.emit( 'remove', items );
7328
7329 return this;
7330 };
7331
7332 /**
7333 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7334 *
7335 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7336 *
7337 * @protected
7338 * @param {jQuery} $focusOwner
7339 */
7340 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7341 this.$focusOwner = $focusOwner;
7342 };
7343
7344 /**
7345 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7346 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7347 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7348 * options. For more information about options and selects, please see the
7349 * [OOUI documentation on MediaWiki][1].
7350 *
7351 * @example
7352 * // Decorated options in a select widget.
7353 * var select = new OO.ui.SelectWidget( {
7354 * items: [
7355 * new OO.ui.DecoratedOptionWidget( {
7356 * data: 'a',
7357 * label: 'Option with icon',
7358 * icon: 'help'
7359 * } ),
7360 * new OO.ui.DecoratedOptionWidget( {
7361 * data: 'b',
7362 * label: 'Option with indicator',
7363 * indicator: 'next'
7364 * } )
7365 * ]
7366 * } );
7367 * $( document.body ).append( select.$element );
7368 *
7369 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7370 *
7371 * @class
7372 * @extends OO.ui.OptionWidget
7373 * @mixins OO.ui.mixin.IconElement
7374 * @mixins OO.ui.mixin.IndicatorElement
7375 *
7376 * @constructor
7377 * @param {Object} [config] Configuration options
7378 */
7379 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7380 // Parent constructor
7381 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7382
7383 // Mixin constructors
7384 OO.ui.mixin.IconElement.call( this, config );
7385 OO.ui.mixin.IndicatorElement.call( this, config );
7386
7387 // Initialization
7388 this.$element
7389 .addClass( 'oo-ui-decoratedOptionWidget' )
7390 .prepend( this.$icon )
7391 .append( this.$indicator );
7392 };
7393
7394 /* Setup */
7395
7396 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7397 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7398 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7399
7400 /**
7401 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7402 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7403 * the [OOUI documentation on MediaWiki] [1] for more information.
7404 *
7405 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7406 *
7407 * @class
7408 * @extends OO.ui.DecoratedOptionWidget
7409 *
7410 * @constructor
7411 * @param {Object} [config] Configuration options
7412 */
7413 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7414 // Parent constructor
7415 OO.ui.MenuOptionWidget.parent.call( this, config );
7416
7417 // Properties
7418 this.checkIcon = new OO.ui.IconWidget( {
7419 icon: 'check',
7420 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7421 } );
7422
7423 // Initialization
7424 this.$element
7425 .prepend( this.checkIcon.$element )
7426 .addClass( 'oo-ui-menuOptionWidget' );
7427 };
7428
7429 /* Setup */
7430
7431 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7432
7433 /* Static Properties */
7434
7435 /**
7436 * @static
7437 * @inheritdoc
7438 */
7439 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7440
7441 /**
7442 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7443 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7444 *
7445 * @example
7446 * var dropdown = new OO.ui.DropdownWidget( {
7447 * menu: {
7448 * items: [
7449 * new OO.ui.MenuSectionOptionWidget( {
7450 * label: 'Dogs'
7451 * } ),
7452 * new OO.ui.MenuOptionWidget( {
7453 * data: 'corgi',
7454 * label: 'Welsh Corgi'
7455 * } ),
7456 * new OO.ui.MenuOptionWidget( {
7457 * data: 'poodle',
7458 * label: 'Standard Poodle'
7459 * } ),
7460 * new OO.ui.MenuSectionOptionWidget( {
7461 * label: 'Cats'
7462 * } ),
7463 * new OO.ui.MenuOptionWidget( {
7464 * data: 'lion',
7465 * label: 'Lion'
7466 * } )
7467 * ]
7468 * }
7469 * } );
7470 * $( document.body ).append( dropdown.$element );
7471 *
7472 * @class
7473 * @extends OO.ui.DecoratedOptionWidget
7474 *
7475 * @constructor
7476 * @param {Object} [config] Configuration options
7477 */
7478 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7479 // Parent constructor
7480 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7481
7482 // Initialization
7483 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7484 .removeAttr( 'role aria-selected' );
7485 };
7486
7487 /* Setup */
7488
7489 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7490
7491 /* Static Properties */
7492
7493 /**
7494 * @static
7495 * @inheritdoc
7496 */
7497 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7498
7499 /**
7500 * @static
7501 * @inheritdoc
7502 */
7503 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7504
7505 /**
7506 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7507 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7508 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7509 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7510 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7511 * and customized to be opened, closed, and displayed as needed.
7512 *
7513 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7514 * mouse outside the menu.
7515 *
7516 * Menus also have support for keyboard interaction:
7517 *
7518 * - Enter/Return key: choose and select a menu option
7519 * - Up-arrow key: highlight the previous menu option
7520 * - Down-arrow key: highlight the next menu option
7521 * - Esc key: hide the menu
7522 *
7523 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7524 *
7525 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7526 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7527 *
7528 * @class
7529 * @extends OO.ui.SelectWidget
7530 * @mixins OO.ui.mixin.ClippableElement
7531 * @mixins OO.ui.mixin.FloatableElement
7532 *
7533 * @constructor
7534 * @param {Object} [config] Configuration options
7535 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7536 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7537 * and {@link OO.ui.mixin.LookupElement LookupElement}
7538 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7539 * the text the user types. This config is used by {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7540 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7541 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7542 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7543 * that button, unless the button (or its parent widget) is passed in here.
7544 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7545 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7546 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7547 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7548 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7549 * @cfg {number} [width] Width of the menu
7550 */
7551 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7552 // Configuration initialization
7553 config = config || {};
7554
7555 // Parent constructor
7556 OO.ui.MenuSelectWidget.parent.call( this, config );
7557
7558 // Mixin constructors
7559 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7560 OO.ui.mixin.FloatableElement.call( this, config );
7561
7562 // Initial vertical positions other than 'center' will result in
7563 // the menu being flipped if there is not enough space in the container.
7564 // Store the original position so we know what to reset to.
7565 this.originalVerticalPosition = this.verticalPosition;
7566
7567 // Properties
7568 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7569 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7570 this.filterFromInput = !!config.filterFromInput;
7571 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7572 this.$widget = config.widget ? config.widget.$element : null;
7573 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7574 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7575 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7576 this.highlightOnFilter = !!config.highlightOnFilter;
7577 this.width = config.width;
7578
7579 // Initialization
7580 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7581 if ( config.widget ) {
7582 this.setFocusOwner( config.widget.$tabIndexed );
7583 }
7584
7585 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7586 // that reference properties not initialized at that time of parent class construction
7587 // TODO: Find a better way to handle post-constructor setup
7588 this.visible = false;
7589 this.$element.addClass( 'oo-ui-element-hidden' );
7590 this.$focusOwner.attr( 'aria-expanded', 'false' );
7591 };
7592
7593 /* Setup */
7594
7595 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7596 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7597 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7598
7599 /* Events */
7600
7601 /**
7602 * @event ready
7603 *
7604 * The menu is ready: it is visible and has been positioned and clipped.
7605 */
7606
7607 /* Static properties */
7608
7609 /**
7610 * Positions to flip to if there isn't room in the container for the
7611 * menu in a specific direction.
7612 *
7613 * @property {Object.<string,string>}
7614 */
7615 OO.ui.MenuSelectWidget.static.flippedPositions = {
7616 below: 'above',
7617 above: 'below',
7618 top: 'bottom',
7619 bottom: 'top'
7620 };
7621
7622 /* Methods */
7623
7624 /**
7625 * Handles document mouse down events.
7626 *
7627 * @protected
7628 * @param {MouseEvent} e Mouse down event
7629 */
7630 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7631 if (
7632 this.isVisible() &&
7633 !OO.ui.contains(
7634 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7635 e.target,
7636 true
7637 )
7638 ) {
7639 this.toggle( false );
7640 }
7641 };
7642
7643 /**
7644 * @inheritdoc
7645 */
7646 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7647 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7648
7649 if ( !this.isDisabled() && this.isVisible() ) {
7650 switch ( e.keyCode ) {
7651 case OO.ui.Keys.LEFT:
7652 case OO.ui.Keys.RIGHT:
7653 // Do nothing if a text field is associated, arrow keys will be handled natively
7654 if ( !this.$input ) {
7655 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7656 }
7657 break;
7658 case OO.ui.Keys.ESCAPE:
7659 case OO.ui.Keys.TAB:
7660 if ( currentItem ) {
7661 currentItem.setHighlighted( false );
7662 }
7663 this.toggle( false );
7664 // Don't prevent tabbing away, prevent defocusing
7665 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7666 e.preventDefault();
7667 e.stopPropagation();
7668 }
7669 break;
7670 default:
7671 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7672 return;
7673 }
7674 }
7675 };
7676
7677 /**
7678 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7679 * or after items were added/removed (always).
7680 *
7681 * @protected
7682 */
7683 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7684 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7685 anyVisible = false,
7686 len = this.items.length,
7687 showAll = !this.isVisible(),
7688 exactMatch = false;
7689
7690 if ( this.$input && this.filterFromInput ) {
7691 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7692 exactFilter = this.getItemMatcher( this.$input.val(), true );
7693 // Hide non-matching options, and also hide section headers if all options
7694 // in their section are hidden.
7695 for ( i = 0; i < len; i++ ) {
7696 item = this.items[ i ];
7697 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7698 if ( section ) {
7699 // If the previous section was empty, hide its header
7700 section.toggle( showAll || !sectionEmpty );
7701 }
7702 section = item;
7703 sectionEmpty = true;
7704 } else if ( item instanceof OO.ui.OptionWidget ) {
7705 visible = showAll || filter( item );
7706 exactMatch = exactMatch || exactFilter( item );
7707 anyVisible = anyVisible || visible;
7708 sectionEmpty = sectionEmpty && !visible;
7709 item.toggle( visible );
7710 }
7711 }
7712 // Process the final section
7713 if ( section ) {
7714 section.toggle( showAll || !sectionEmpty );
7715 }
7716
7717 if ( anyVisible && this.items.length && !exactMatch ) {
7718 this.scrollItemIntoView( this.items[ 0 ] );
7719 }
7720
7721 if ( !anyVisible ) {
7722 this.highlightItem( null );
7723 }
7724
7725 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7726
7727 if ( this.highlightOnFilter ) {
7728 // Highlight the first item on the list
7729 item = null;
7730 items = this.getItems();
7731 for ( i = 0; i < items.length; i++ ) {
7732 if ( items[ i ].isVisible() ) {
7733 item = items[ i ];
7734 break;
7735 }
7736 }
7737 this.highlightItem( item );
7738 }
7739
7740 }
7741
7742 // Reevaluate clipping
7743 this.clip();
7744 };
7745
7746 /**
7747 * @inheritdoc
7748 */
7749 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7750 if ( this.$input ) {
7751 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7752 } else {
7753 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7754 }
7755 };
7756
7757 /**
7758 * @inheritdoc
7759 */
7760 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7761 if ( this.$input ) {
7762 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7763 } else {
7764 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7765 }
7766 };
7767
7768 /**
7769 * @inheritdoc
7770 */
7771 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7772 if ( this.$input ) {
7773 if ( this.filterFromInput ) {
7774 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7775 this.updateItemVisibility();
7776 }
7777 } else {
7778 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7779 }
7780 };
7781
7782 /**
7783 * @inheritdoc
7784 */
7785 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7786 if ( this.$input ) {
7787 if ( this.filterFromInput ) {
7788 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7789 this.updateItemVisibility();
7790 }
7791 } else {
7792 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7793 }
7794 };
7795
7796 /**
7797 * Choose an item.
7798 *
7799 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7800 *
7801 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7802 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7803 *
7804 * @param {OO.ui.OptionWidget} item Item to choose
7805 * @chainable
7806 * @return {OO.ui.Widget} The widget, for chaining
7807 */
7808 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7809 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7810 if ( this.hideOnChoose ) {
7811 this.toggle( false );
7812 }
7813 return this;
7814 };
7815
7816 /**
7817 * @inheritdoc
7818 */
7819 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7820 // Parent method
7821 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7822
7823 this.updateItemVisibility();
7824
7825 return this;
7826 };
7827
7828 /**
7829 * @inheritdoc
7830 */
7831 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7832 // Parent method
7833 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7834
7835 this.updateItemVisibility();
7836
7837 return this;
7838 };
7839
7840 /**
7841 * @inheritdoc
7842 */
7843 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7844 // Parent method
7845 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7846
7847 this.updateItemVisibility();
7848
7849 return this;
7850 };
7851
7852 /**
7853 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7854 * `.toggle( true )` after its #$element is attached to the DOM.
7855 *
7856 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7857 * it in the right place and with the right dimensions only work correctly while it is attached.
7858 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7859 * strictly enforced, so currently it only generates a warning in the browser console.
7860 *
7861 * @fires ready
7862 * @inheritdoc
7863 */
7864 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7865 var change, originalHeight, flippedHeight;
7866
7867 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7868 change = visible !== this.isVisible();
7869
7870 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7871 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7872 this.warnedUnattached = true;
7873 }
7874
7875 if ( change && visible ) {
7876 // Reset position before showing the popup again. It's possible we no longer need to flip
7877 // (e.g. if the user scrolled).
7878 this.setVerticalPosition( this.originalVerticalPosition );
7879 }
7880
7881 // Parent method
7882 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7883
7884 if ( change ) {
7885 if ( visible ) {
7886
7887 if ( this.width ) {
7888 this.setIdealSize( this.width );
7889 } else if ( this.$floatableContainer ) {
7890 this.$clippable.css( 'width', 'auto' );
7891 this.setIdealSize(
7892 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7893 // Dropdown is smaller than handle so expand to width
7894 this.$floatableContainer[ 0 ].offsetWidth :
7895 // Dropdown is larger than handle so auto size
7896 'auto'
7897 );
7898 this.$clippable.css( 'width', '' );
7899 }
7900
7901 this.togglePositioning( !!this.$floatableContainer );
7902 this.toggleClipping( true );
7903
7904 this.bindDocumentKeyDownListener();
7905 this.bindDocumentKeyPressListener();
7906
7907 if (
7908 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7909 this.originalVerticalPosition !== 'center'
7910 ) {
7911 // If opening the menu in one direction causes it to be clipped, flip it
7912 originalHeight = this.$element.height();
7913 this.setVerticalPosition(
7914 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7915 );
7916 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7917 // If flipping also causes it to be clipped, open in whichever direction
7918 // we have more space
7919 flippedHeight = this.$element.height();
7920 if ( originalHeight > flippedHeight ) {
7921 this.setVerticalPosition( this.originalVerticalPosition );
7922 }
7923 }
7924 }
7925 // Note that we do not flip the menu's opening direction if the clipping changes
7926 // later (e.g. after the user scrolls), that seems like it would be annoying
7927
7928 this.$focusOwner.attr( 'aria-expanded', 'true' );
7929
7930 if ( this.findSelectedItem() ) {
7931 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7932 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7933 }
7934
7935 // Auto-hide
7936 if ( this.autoHide ) {
7937 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7938 }
7939
7940 this.emit( 'ready' );
7941 } else {
7942 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7943 this.unbindDocumentKeyDownListener();
7944 this.unbindDocumentKeyPressListener();
7945 this.$focusOwner.attr( 'aria-expanded', 'false' );
7946 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7947 this.togglePositioning( false );
7948 this.toggleClipping( false );
7949 }
7950 }
7951
7952 return this;
7953 };
7954
7955 /**
7956 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7957 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7958 * users can interact with it.
7959 *
7960 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7961 * OO.ui.DropdownInputWidget instead.
7962 *
7963 * @example
7964 * // A DropdownWidget with a menu that contains three options.
7965 * var dropDown = new OO.ui.DropdownWidget( {
7966 * label: 'Dropdown menu: Select a menu option',
7967 * menu: {
7968 * items: [
7969 * new OO.ui.MenuOptionWidget( {
7970 * data: 'a',
7971 * label: 'First'
7972 * } ),
7973 * new OO.ui.MenuOptionWidget( {
7974 * data: 'b',
7975 * label: 'Second'
7976 * } ),
7977 * new OO.ui.MenuOptionWidget( {
7978 * data: 'c',
7979 * label: 'Third'
7980 * } )
7981 * ]
7982 * }
7983 * } );
7984 *
7985 * $( document.body ).append( dropDown.$element );
7986 *
7987 * dropDown.getMenu().selectItemByData( 'b' );
7988 *
7989 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
7990 *
7991 * For more information, please see the [OOUI documentation on MediaWiki] [1].
7992 *
7993 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7994 *
7995 * @class
7996 * @extends OO.ui.Widget
7997 * @mixins OO.ui.mixin.IconElement
7998 * @mixins OO.ui.mixin.IndicatorElement
7999 * @mixins OO.ui.mixin.LabelElement
8000 * @mixins OO.ui.mixin.TitledElement
8001 * @mixins OO.ui.mixin.TabIndexedElement
8002 *
8003 * @constructor
8004 * @param {Object} [config] Configuration options
8005 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
8006 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
8007 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
8008 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
8009 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8010 */
8011 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8012 // Configuration initialization
8013 config = $.extend( { indicator: 'down' }, config );
8014
8015 // Parent constructor
8016 OO.ui.DropdownWidget.parent.call( this, config );
8017
8018 // Properties (must be set before TabIndexedElement constructor call)
8019 this.$handle = $( '<button>' );
8020 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8021
8022 // Mixin constructors
8023 OO.ui.mixin.IconElement.call( this, config );
8024 OO.ui.mixin.IndicatorElement.call( this, config );
8025 OO.ui.mixin.LabelElement.call( this, config );
8026 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
8027 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
8028
8029 // Properties
8030 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8031 widget: this,
8032 $floatableContainer: this.$element
8033 }, config.menu ) );
8034
8035 // Events
8036 this.$handle.on( {
8037 click: this.onClick.bind( this ),
8038 keydown: this.onKeyDown.bind( this ),
8039 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
8040 keypress: this.menu.onDocumentKeyPressHandler,
8041 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8042 } );
8043 this.menu.connect( this, {
8044 select: 'onMenuSelect',
8045 toggle: 'onMenuToggle'
8046 } );
8047
8048 // Initialization
8049 this.$handle
8050 .addClass( 'oo-ui-dropdownWidget-handle' )
8051 .attr( {
8052 type: 'button',
8053 'aria-owns': this.menu.getElementId(),
8054 'aria-haspopup': 'listbox'
8055 } )
8056 .append( this.$icon, this.$label, this.$indicator );
8057 this.$element
8058 .addClass( 'oo-ui-dropdownWidget' )
8059 .append( this.$handle );
8060 this.$overlay.append( this.menu.$element );
8061 };
8062
8063 /* Setup */
8064
8065 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8066 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8067 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8068 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8069 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8070 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8071
8072 /* Methods */
8073
8074 /**
8075 * Get the menu.
8076 *
8077 * @return {OO.ui.MenuSelectWidget} Menu of widget
8078 */
8079 OO.ui.DropdownWidget.prototype.getMenu = function () {
8080 return this.menu;
8081 };
8082
8083 /**
8084 * Handles menu select events.
8085 *
8086 * @private
8087 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8088 */
8089 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8090 var selectedLabel;
8091
8092 if ( !item ) {
8093 this.setLabel( null );
8094 return;
8095 }
8096
8097 selectedLabel = item.getLabel();
8098
8099 // If the label is a DOM element, clone it, because setLabel will append() it
8100 if ( selectedLabel instanceof $ ) {
8101 selectedLabel = selectedLabel.clone();
8102 }
8103
8104 this.setLabel( selectedLabel );
8105 };
8106
8107 /**
8108 * Handle menu toggle events.
8109 *
8110 * @private
8111 * @param {boolean} isVisible Open state of the menu
8112 */
8113 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8114 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8115 };
8116
8117 /**
8118 * Handle mouse click events.
8119 *
8120 * @private
8121 * @param {jQuery.Event} e Mouse click event
8122 * @return {undefined/boolean} False to prevent default if event is handled
8123 */
8124 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8125 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8126 this.menu.toggle();
8127 }
8128 return false;
8129 };
8130
8131 /**
8132 * Handle key down events.
8133 *
8134 * @private
8135 * @param {jQuery.Event} e Key down event
8136 * @return {undefined/boolean} False to prevent default if event is handled
8137 */
8138 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8139 if (
8140 !this.isDisabled() &&
8141 (
8142 e.which === OO.ui.Keys.ENTER ||
8143 (
8144 e.which === OO.ui.Keys.SPACE &&
8145 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8146 // Space only closes the menu is the user is not typing to search.
8147 this.menu.keyPressBuffer === ''
8148 ) ||
8149 (
8150 !this.menu.isVisible() &&
8151 (
8152 e.which === OO.ui.Keys.UP ||
8153 e.which === OO.ui.Keys.DOWN
8154 )
8155 )
8156 )
8157 ) {
8158 this.menu.toggle();
8159 return false;
8160 }
8161 };
8162
8163 /**
8164 * RadioOptionWidget is an option widget that looks like a radio button.
8165 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8166 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8167 *
8168 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8169 *
8170 * @class
8171 * @extends OO.ui.OptionWidget
8172 *
8173 * @constructor
8174 * @param {Object} [config] Configuration options
8175 */
8176 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8177 // Configuration initialization
8178 config = config || {};
8179
8180 // Properties (must be done before parent constructor which calls #setDisabled)
8181 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8182
8183 // Parent constructor
8184 OO.ui.RadioOptionWidget.parent.call( this, config );
8185
8186 // Initialization
8187 // Remove implicit role, we're handling it ourselves
8188 this.radio.$input.attr( 'role', 'presentation' );
8189 this.$element
8190 .addClass( 'oo-ui-radioOptionWidget' )
8191 .attr( 'role', 'radio' )
8192 .attr( 'aria-checked', 'false' )
8193 .removeAttr( 'aria-selected' )
8194 .prepend( this.radio.$element );
8195 };
8196
8197 /* Setup */
8198
8199 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8200
8201 /* Static Properties */
8202
8203 /**
8204 * @static
8205 * @inheritdoc
8206 */
8207 OO.ui.RadioOptionWidget.static.highlightable = false;
8208
8209 /**
8210 * @static
8211 * @inheritdoc
8212 */
8213 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8214
8215 /**
8216 * @static
8217 * @inheritdoc
8218 */
8219 OO.ui.RadioOptionWidget.static.pressable = false;
8220
8221 /**
8222 * @static
8223 * @inheritdoc
8224 */
8225 OO.ui.RadioOptionWidget.static.tagName = 'label';
8226
8227 /* Methods */
8228
8229 /**
8230 * @inheritdoc
8231 */
8232 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8233 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8234
8235 this.radio.setSelected( state );
8236 this.$element
8237 .attr( 'aria-checked', state.toString() )
8238 .removeAttr( 'aria-selected' );
8239
8240 return this;
8241 };
8242
8243 /**
8244 * @inheritdoc
8245 */
8246 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8247 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8248
8249 this.radio.setDisabled( this.isDisabled() );
8250
8251 return this;
8252 };
8253
8254 /**
8255 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8256 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8257 * an interface for adding, removing and selecting options.
8258 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8259 *
8260 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8261 * OO.ui.RadioSelectInputWidget instead.
8262 *
8263 * @example
8264 * // A RadioSelectWidget with RadioOptions.
8265 * var option1 = new OO.ui.RadioOptionWidget( {
8266 * data: 'a',
8267 * label: 'Selected radio option'
8268 * } ),
8269 * option2 = new OO.ui.RadioOptionWidget( {
8270 * data: 'b',
8271 * label: 'Unselected radio option'
8272 * } );
8273 * radioSelect = new OO.ui.RadioSelectWidget( {
8274 * items: [ option1, option2 ]
8275 * } );
8276 *
8277 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8278 * radioSelect.selectItem( option1 );
8279 *
8280 * $( document.body ).append( radioSelect.$element );
8281 *
8282 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8283
8284 *
8285 * @class
8286 * @extends OO.ui.SelectWidget
8287 * @mixins OO.ui.mixin.TabIndexedElement
8288 *
8289 * @constructor
8290 * @param {Object} [config] Configuration options
8291 */
8292 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8293 // Parent constructor
8294 OO.ui.RadioSelectWidget.parent.call( this, config );
8295
8296 // Mixin constructors
8297 OO.ui.mixin.TabIndexedElement.call( this, config );
8298
8299 // Events
8300 this.$element.on( {
8301 focus: this.bindDocumentKeyDownListener.bind( this ),
8302 blur: this.unbindDocumentKeyDownListener.bind( this )
8303 } );
8304
8305 // Initialization
8306 this.$element
8307 .addClass( 'oo-ui-radioSelectWidget' )
8308 .attr( 'role', 'radiogroup' );
8309 };
8310
8311 /* Setup */
8312
8313 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8314 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8315
8316 /**
8317 * MultioptionWidgets are special elements that can be selected and configured with data. The
8318 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8319 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8320 * and examples, please see the [OOUI documentation on MediaWiki][1].
8321 *
8322 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8323 *
8324 * @class
8325 * @extends OO.ui.Widget
8326 * @mixins OO.ui.mixin.ItemWidget
8327 * @mixins OO.ui.mixin.LabelElement
8328 * @mixins OO.ui.mixin.TitledElement
8329 *
8330 * @constructor
8331 * @param {Object} [config] Configuration options
8332 * @cfg {boolean} [selected=false] Whether the option is initially selected
8333 */
8334 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8335 // Configuration initialization
8336 config = config || {};
8337
8338 // Parent constructor
8339 OO.ui.MultioptionWidget.parent.call( this, config );
8340
8341 // Mixin constructors
8342 OO.ui.mixin.ItemWidget.call( this );
8343 OO.ui.mixin.LabelElement.call( this, config );
8344 OO.ui.mixin.TitledElement.call( this, config );
8345
8346 // Properties
8347 this.selected = null;
8348
8349 // Initialization
8350 this.$element
8351 .addClass( 'oo-ui-multioptionWidget' )
8352 .append( this.$label );
8353 this.setSelected( config.selected );
8354 };
8355
8356 /* Setup */
8357
8358 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8359 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8360 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8361 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8362
8363 /* Events */
8364
8365 /**
8366 * @event change
8367 *
8368 * A change event is emitted when the selected state of the option changes.
8369 *
8370 * @param {boolean} selected Whether the option is now selected
8371 */
8372
8373 /* Methods */
8374
8375 /**
8376 * Check if the option is selected.
8377 *
8378 * @return {boolean} Item is selected
8379 */
8380 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8381 return this.selected;
8382 };
8383
8384 /**
8385 * Set the option’s selected state. In general, all modifications to the selection
8386 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8387 * method instead of this method.
8388 *
8389 * @param {boolean} [state=false] Select option
8390 * @chainable
8391 * @return {OO.ui.Widget} The widget, for chaining
8392 */
8393 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8394 state = !!state;
8395 if ( this.selected !== state ) {
8396 this.selected = state;
8397 this.emit( 'change', state );
8398 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8399 }
8400 return this;
8401 };
8402
8403 /**
8404 * MultiselectWidget allows selecting multiple options from a list.
8405 *
8406 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8407 *
8408 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8409 *
8410 * @class
8411 * @abstract
8412 * @extends OO.ui.Widget
8413 * @mixins OO.ui.mixin.GroupWidget
8414 * @mixins OO.ui.mixin.TitledElement
8415 *
8416 * @constructor
8417 * @param {Object} [config] Configuration options
8418 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8419 */
8420 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8421 // Parent constructor
8422 OO.ui.MultiselectWidget.parent.call( this, config );
8423
8424 // Configuration initialization
8425 config = config || {};
8426
8427 // Mixin constructors
8428 OO.ui.mixin.GroupWidget.call( this, config );
8429 OO.ui.mixin.TitledElement.call( this, config );
8430
8431 // Events
8432 this.aggregate( { change: 'select' } );
8433 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8434 // by GroupElement only when items are added/removed
8435 this.connect( this, { select: [ 'emit', 'change' ] } );
8436
8437 // Initialization
8438 if ( config.items ) {
8439 this.addItems( config.items );
8440 }
8441 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8442 this.$element.addClass( 'oo-ui-multiselectWidget' )
8443 .append( this.$group );
8444 };
8445
8446 /* Setup */
8447
8448 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8449 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8450 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8451
8452 /* Events */
8453
8454 /**
8455 * @event change
8456 *
8457 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8458 */
8459
8460 /**
8461 * @event select
8462 *
8463 * A select event is emitted when an item is selected or deselected.
8464 */
8465
8466 /* Methods */
8467
8468 /**
8469 * Find options that are selected.
8470 *
8471 * @return {OO.ui.MultioptionWidget[]} Selected options
8472 */
8473 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8474 return this.items.filter( function ( item ) {
8475 return item.isSelected();
8476 } );
8477 };
8478
8479 /**
8480 * Find the data of options that are selected.
8481 *
8482 * @return {Object[]|string[]} Values of selected options
8483 */
8484 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8485 return this.findSelectedItems().map( function ( item ) {
8486 return item.data;
8487 } );
8488 };
8489
8490 /**
8491 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8492 *
8493 * @param {OO.ui.MultioptionWidget[]} items Items to select
8494 * @chainable
8495 * @return {OO.ui.Widget} The widget, for chaining
8496 */
8497 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8498 this.items.forEach( function ( item ) {
8499 var selected = items.indexOf( item ) !== -1;
8500 item.setSelected( selected );
8501 } );
8502 return this;
8503 };
8504
8505 /**
8506 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8507 *
8508 * @param {Object[]|string[]} datas Values of items to select
8509 * @chainable
8510 * @return {OO.ui.Widget} The widget, for chaining
8511 */
8512 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8513 var items,
8514 widget = this;
8515 items = datas.map( function ( data ) {
8516 return widget.findItemFromData( data );
8517 } );
8518 this.selectItems( items );
8519 return this;
8520 };
8521
8522 /**
8523 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8524 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8525 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8526 *
8527 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8528 *
8529 * @class
8530 * @extends OO.ui.MultioptionWidget
8531 *
8532 * @constructor
8533 * @param {Object} [config] Configuration options
8534 */
8535 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8536 // Configuration initialization
8537 config = config || {};
8538
8539 // Properties (must be done before parent constructor which calls #setDisabled)
8540 this.checkbox = new OO.ui.CheckboxInputWidget();
8541
8542 // Parent constructor
8543 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8544
8545 // Events
8546 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8547 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8548
8549 // Initialization
8550 this.$element
8551 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8552 .prepend( this.checkbox.$element );
8553 };
8554
8555 /* Setup */
8556
8557 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8558
8559 /* Static Properties */
8560
8561 /**
8562 * @static
8563 * @inheritdoc
8564 */
8565 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8566
8567 /* Methods */
8568
8569 /**
8570 * Handle checkbox selected state change.
8571 *
8572 * @private
8573 */
8574 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8575 this.setSelected( this.checkbox.isSelected() );
8576 };
8577
8578 /**
8579 * @inheritdoc
8580 */
8581 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8582 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8583 this.checkbox.setSelected( state );
8584 return this;
8585 };
8586
8587 /**
8588 * @inheritdoc
8589 */
8590 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8591 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8592 this.checkbox.setDisabled( this.isDisabled() );
8593 return this;
8594 };
8595
8596 /**
8597 * Focus the widget.
8598 */
8599 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8600 this.checkbox.focus();
8601 };
8602
8603 /**
8604 * Handle key down events.
8605 *
8606 * @protected
8607 * @param {jQuery.Event} e
8608 */
8609 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8610 var
8611 element = this.getElementGroup(),
8612 nextItem;
8613
8614 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8615 nextItem = element.getRelativeFocusableItem( this, -1 );
8616 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8617 nextItem = element.getRelativeFocusableItem( this, 1 );
8618 }
8619
8620 if ( nextItem ) {
8621 e.preventDefault();
8622 nextItem.focus();
8623 }
8624 };
8625
8626 /**
8627 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8628 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8629 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8630 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8631 *
8632 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8633 * OO.ui.CheckboxMultiselectInputWidget instead.
8634 *
8635 * @example
8636 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8637 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8638 * data: 'a',
8639 * selected: true,
8640 * label: 'Selected checkbox'
8641 * } ),
8642 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8643 * data: 'b',
8644 * label: 'Unselected checkbox'
8645 * } ),
8646 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8647 * items: [ option1, option2 ]
8648 * } );
8649 * $( document.body ).append( multiselect.$element );
8650 *
8651 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8652 *
8653 * @class
8654 * @extends OO.ui.MultiselectWidget
8655 *
8656 * @constructor
8657 * @param {Object} [config] Configuration options
8658 */
8659 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8660 // Parent constructor
8661 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8662
8663 // Properties
8664 this.$lastClicked = null;
8665
8666 // Events
8667 this.$group.on( 'click', this.onClick.bind( this ) );
8668
8669 // Initialization
8670 this.$element
8671 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8672 };
8673
8674 /* Setup */
8675
8676 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8677
8678 /* Methods */
8679
8680 /**
8681 * Get an option by its position relative to the specified item (or to the start of the option array,
8682 * if item is `null`). The direction in which to search through the option array is specified with a
8683 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8684 * `null` if there are no options in the array.
8685 *
8686 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8687 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8688 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8689 */
8690 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8691 var currentIndex, nextIndex, i,
8692 increase = direction > 0 ? 1 : -1,
8693 len = this.items.length;
8694
8695 if ( item ) {
8696 currentIndex = this.items.indexOf( item );
8697 nextIndex = ( currentIndex + increase + len ) % len;
8698 } else {
8699 // If no item is selected and moving forward, start at the beginning.
8700 // If moving backward, start at the end.
8701 nextIndex = direction > 0 ? 0 : len - 1;
8702 }
8703
8704 for ( i = 0; i < len; i++ ) {
8705 item = this.items[ nextIndex ];
8706 if ( item && !item.isDisabled() ) {
8707 return item;
8708 }
8709 nextIndex = ( nextIndex + increase + len ) % len;
8710 }
8711 return null;
8712 };
8713
8714 /**
8715 * Handle click events on checkboxes.
8716 *
8717 * @param {jQuery.Event} e
8718 */
8719 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8720 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8721 $lastClicked = this.$lastClicked,
8722 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8723 .not( '.oo-ui-widget-disabled' );
8724
8725 // Allow selecting multiple options at once by Shift-clicking them
8726 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8727 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8728 lastClickedIndex = $options.index( $lastClicked );
8729 nowClickedIndex = $options.index( $nowClicked );
8730 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8731 // browser. In either case we don't need custom handling.
8732 if ( nowClickedIndex !== lastClickedIndex ) {
8733 items = this.items;
8734 wasSelected = items[ nowClickedIndex ].isSelected();
8735 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8736
8737 // This depends on the DOM order of the items and the order of the .items array being the same.
8738 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8739 if ( !items[ i ].isDisabled() ) {
8740 items[ i ].setSelected( !wasSelected );
8741 }
8742 }
8743 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8744 // handling first, then set our value. The order in which events happen is different for
8745 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8746 // non-click actions that change the checkboxes.
8747 e.preventDefault();
8748 setTimeout( function () {
8749 if ( !items[ nowClickedIndex ].isDisabled() ) {
8750 items[ nowClickedIndex ].setSelected( !wasSelected );
8751 }
8752 } );
8753 }
8754 }
8755
8756 if ( $nowClicked.length ) {
8757 this.$lastClicked = $nowClicked;
8758 }
8759 };
8760
8761 /**
8762 * Focus the widget
8763 *
8764 * @chainable
8765 * @return {OO.ui.Widget} The widget, for chaining
8766 */
8767 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8768 var item;
8769 if ( !this.isDisabled() ) {
8770 item = this.getRelativeFocusableItem( null, 1 );
8771 if ( item ) {
8772 item.focus();
8773 }
8774 }
8775 return this;
8776 };
8777
8778 /**
8779 * @inheritdoc
8780 */
8781 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8782 this.focus();
8783 };
8784
8785 /**
8786 * Progress bars visually display the status of an operation, such as a download,
8787 * and can be either determinate or indeterminate:
8788 *
8789 * - **determinate** process bars show the percent of an operation that is complete.
8790 *
8791 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8792 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8793 * not use percentages.
8794 *
8795 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8796 *
8797 * @example
8798 * // Examples of determinate and indeterminate progress bars.
8799 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8800 * progress: 33
8801 * } );
8802 * var progressBar2 = new OO.ui.ProgressBarWidget();
8803 *
8804 * // Create a FieldsetLayout to layout progress bars.
8805 * var fieldset = new OO.ui.FieldsetLayout;
8806 * fieldset.addItems( [
8807 * new OO.ui.FieldLayout( progressBar1, {
8808 * label: 'Determinate',
8809 * align: 'top'
8810 * } ),
8811 * new OO.ui.FieldLayout( progressBar2, {
8812 * label: 'Indeterminate',
8813 * align: 'top'
8814 * } )
8815 * ] );
8816 * $( document.body ).append( fieldset.$element );
8817 *
8818 * @class
8819 * @extends OO.ui.Widget
8820 *
8821 * @constructor
8822 * @param {Object} [config] Configuration options
8823 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8824 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8825 * By default, the progress bar is indeterminate.
8826 */
8827 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8828 // Configuration initialization
8829 config = config || {};
8830
8831 // Parent constructor
8832 OO.ui.ProgressBarWidget.parent.call( this, config );
8833
8834 // Properties
8835 this.$bar = $( '<div>' );
8836 this.progress = null;
8837
8838 // Initialization
8839 this.setProgress( config.progress !== undefined ? config.progress : false );
8840 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8841 this.$element
8842 .attr( {
8843 role: 'progressbar',
8844 'aria-valuemin': 0,
8845 'aria-valuemax': 100
8846 } )
8847 .addClass( 'oo-ui-progressBarWidget' )
8848 .append( this.$bar );
8849 };
8850
8851 /* Setup */
8852
8853 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8854
8855 /* Static Properties */
8856
8857 /**
8858 * @static
8859 * @inheritdoc
8860 */
8861 OO.ui.ProgressBarWidget.static.tagName = 'div';
8862
8863 /* Methods */
8864
8865 /**
8866 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8867 *
8868 * @return {number|boolean} Progress percent
8869 */
8870 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8871 return this.progress;
8872 };
8873
8874 /**
8875 * Set the percent of the process completed or `false` for an indeterminate process.
8876 *
8877 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8878 */
8879 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8880 this.progress = progress;
8881
8882 if ( progress !== false ) {
8883 this.$bar.css( 'width', this.progress + '%' );
8884 this.$element.attr( 'aria-valuenow', this.progress );
8885 } else {
8886 this.$bar.css( 'width', '' );
8887 this.$element.removeAttr( 'aria-valuenow' );
8888 }
8889 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8890 };
8891
8892 /**
8893 * InputWidget is the base class for all input widgets, which
8894 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8895 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8896 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8897 *
8898 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8899 *
8900 * @abstract
8901 * @class
8902 * @extends OO.ui.Widget
8903 * @mixins OO.ui.mixin.FlaggedElement
8904 * @mixins OO.ui.mixin.TabIndexedElement
8905 * @mixins OO.ui.mixin.TitledElement
8906 * @mixins OO.ui.mixin.AccessKeyedElement
8907 *
8908 * @constructor
8909 * @param {Object} [config] Configuration options
8910 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8911 * @cfg {string} [value=''] The value of the input.
8912 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8913 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8914 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8915 * before it is accepted.
8916 */
8917 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8918 // Configuration initialization
8919 config = config || {};
8920
8921 // Parent constructor
8922 OO.ui.InputWidget.parent.call( this, config );
8923
8924 // Properties
8925 // See #reusePreInfuseDOM about config.$input
8926 this.$input = config.$input || this.getInputElement( config );
8927 this.value = '';
8928 this.inputFilter = config.inputFilter;
8929
8930 // Mixin constructors
8931 OO.ui.mixin.FlaggedElement.call( this, config );
8932 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8933 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8934 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8935
8936 // Events
8937 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8938
8939 // Initialization
8940 this.$input
8941 .addClass( 'oo-ui-inputWidget-input' )
8942 .attr( 'name', config.name )
8943 .prop( 'disabled', this.isDisabled() );
8944 this.$element
8945 .addClass( 'oo-ui-inputWidget' )
8946 .append( this.$input );
8947 this.setValue( config.value );
8948 if ( config.dir ) {
8949 this.setDir( config.dir );
8950 }
8951 if ( config.inputId !== undefined ) {
8952 this.setInputId( config.inputId );
8953 }
8954 };
8955
8956 /* Setup */
8957
8958 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8959 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8960 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8961 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8962 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8963
8964 /* Static Methods */
8965
8966 /**
8967 * @inheritdoc
8968 */
8969 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8970 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8971 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8972 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8973 return config;
8974 };
8975
8976 /**
8977 * @inheritdoc
8978 */
8979 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8980 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8981 if ( config.$input && config.$input.length ) {
8982 state.value = config.$input.val();
8983 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8984 state.focus = config.$input.is( ':focus' );
8985 }
8986 return state;
8987 };
8988
8989 /* Events */
8990
8991 /**
8992 * @event change
8993 *
8994 * A change event is emitted when the value of the input changes.
8995 *
8996 * @param {string} value
8997 */
8998
8999 /* Methods */
9000
9001 /**
9002 * Get input element.
9003 *
9004 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9005 * different circumstances. The element must have a `value` property (like form elements).
9006 *
9007 * @protected
9008 * @param {Object} config Configuration options
9009 * @return {jQuery} Input element
9010 */
9011 OO.ui.InputWidget.prototype.getInputElement = function () {
9012 return $( '<input>' );
9013 };
9014
9015 /**
9016 * Handle potentially value-changing events.
9017 *
9018 * @private
9019 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9020 */
9021 OO.ui.InputWidget.prototype.onEdit = function () {
9022 var widget = this;
9023 if ( !this.isDisabled() ) {
9024 // Allow the stack to clear so the value will be updated
9025 setTimeout( function () {
9026 widget.setValue( widget.$input.val() );
9027 } );
9028 }
9029 };
9030
9031 /**
9032 * Get the value of the input.
9033 *
9034 * @return {string} Input value
9035 */
9036 OO.ui.InputWidget.prototype.getValue = function () {
9037 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9038 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9039 var value = this.$input.val();
9040 if ( this.value !== value ) {
9041 this.setValue( value );
9042 }
9043 return this.value;
9044 };
9045
9046 /**
9047 * Set the directionality of the input.
9048 *
9049 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9050 * @chainable
9051 * @return {OO.ui.Widget} The widget, for chaining
9052 */
9053 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9054 this.$input.prop( 'dir', dir );
9055 return this;
9056 };
9057
9058 /**
9059 * Set the value of the input.
9060 *
9061 * @param {string} value New value
9062 * @fires change
9063 * @chainable
9064 * @return {OO.ui.Widget} The widget, for chaining
9065 */
9066 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9067 value = this.cleanUpValue( value );
9068 // Update the DOM if it has changed. Note that with cleanUpValue, it
9069 // is possible for the DOM value to change without this.value changing.
9070 if ( this.$input.val() !== value ) {
9071 this.$input.val( value );
9072 }
9073 if ( this.value !== value ) {
9074 this.value = value;
9075 this.emit( 'change', this.value );
9076 }
9077 // The first time that the value is set (probably while constructing the widget),
9078 // remember it in defaultValue. This property can be later used to check whether
9079 // the value of the input has been changed since it was created.
9080 if ( this.defaultValue === undefined ) {
9081 this.defaultValue = this.value;
9082 this.$input[ 0 ].defaultValue = this.defaultValue;
9083 }
9084 return this;
9085 };
9086
9087 /**
9088 * Clean up incoming value.
9089 *
9090 * Ensures value is a string, and converts undefined and null to empty string.
9091 *
9092 * @private
9093 * @param {string} value Original value
9094 * @return {string} Cleaned up value
9095 */
9096 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9097 if ( value === undefined || value === null ) {
9098 return '';
9099 } else if ( this.inputFilter ) {
9100 return this.inputFilter( String( value ) );
9101 } else {
9102 return String( value );
9103 }
9104 };
9105
9106 /**
9107 * @inheritdoc
9108 */
9109 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9110 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9111 if ( this.$input ) {
9112 this.$input.prop( 'disabled', this.isDisabled() );
9113 }
9114 return this;
9115 };
9116
9117 /**
9118 * Set the 'id' attribute of the `<input>` element.
9119 *
9120 * @param {string} id
9121 * @chainable
9122 * @return {OO.ui.Widget} The widget, for chaining
9123 */
9124 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9125 this.$input.attr( 'id', id );
9126 return this;
9127 };
9128
9129 /**
9130 * @inheritdoc
9131 */
9132 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9133 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9134 if ( state.value !== undefined && state.value !== this.getValue() ) {
9135 this.setValue( state.value );
9136 }
9137 if ( state.focus ) {
9138 this.focus();
9139 }
9140 };
9141
9142 /**
9143 * Data widget intended for creating `<input type="hidden">` inputs.
9144 *
9145 * @class
9146 * @extends OO.ui.Widget
9147 *
9148 * @constructor
9149 * @param {Object} [config] Configuration options
9150 * @cfg {string} [value=''] The value of the input.
9151 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9152 */
9153 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9154 // Configuration initialization
9155 config = $.extend( { value: '', name: '' }, config );
9156
9157 // Parent constructor
9158 OO.ui.HiddenInputWidget.parent.call( this, config );
9159
9160 // Initialization
9161 this.$element.attr( {
9162 type: 'hidden',
9163 value: config.value,
9164 name: config.name
9165 } );
9166 this.$element.removeAttr( 'aria-disabled' );
9167 };
9168
9169 /* Setup */
9170
9171 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9172
9173 /* Static Properties */
9174
9175 /**
9176 * @static
9177 * @inheritdoc
9178 */
9179 OO.ui.HiddenInputWidget.static.tagName = 'input';
9180
9181 /**
9182 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9183 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9184 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9185 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9186 * [OOUI documentation on MediaWiki] [1] for more information.
9187 *
9188 * @example
9189 * // A ButtonInputWidget rendered as an HTML button, the default.
9190 * var button = new OO.ui.ButtonInputWidget( {
9191 * label: 'Input button',
9192 * icon: 'check',
9193 * value: 'check'
9194 * } );
9195 * $( document.body ).append( button.$element );
9196 *
9197 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9198 *
9199 * @class
9200 * @extends OO.ui.InputWidget
9201 * @mixins OO.ui.mixin.ButtonElement
9202 * @mixins OO.ui.mixin.IconElement
9203 * @mixins OO.ui.mixin.IndicatorElement
9204 * @mixins OO.ui.mixin.LabelElement
9205 *
9206 * @constructor
9207 * @param {Object} [config] Configuration options
9208 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
9209 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9210 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
9211 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
9212 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9213 */
9214 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9215 // Configuration initialization
9216 config = $.extend( { type: 'button', useInputTag: false }, config );
9217
9218 // See InputWidget#reusePreInfuseDOM about config.$input
9219 if ( config.$input ) {
9220 config.$input.empty();
9221 }
9222
9223 // Properties (must be set before parent constructor, which calls #setValue)
9224 this.useInputTag = config.useInputTag;
9225
9226 // Parent constructor
9227 OO.ui.ButtonInputWidget.parent.call( this, config );
9228
9229 // Mixin constructors
9230 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
9231 OO.ui.mixin.IconElement.call( this, config );
9232 OO.ui.mixin.IndicatorElement.call( this, config );
9233 OO.ui.mixin.LabelElement.call( this, config );
9234
9235 // Initialization
9236 if ( !config.useInputTag ) {
9237 this.$input.append( this.$icon, this.$label, this.$indicator );
9238 }
9239 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9240 };
9241
9242 /* Setup */
9243
9244 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9245 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9246 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9247 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9248 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9249
9250 /* Static Properties */
9251
9252 /**
9253 * @static
9254 * @inheritdoc
9255 */
9256 OO.ui.ButtonInputWidget.static.tagName = 'span';
9257
9258 /* Methods */
9259
9260 /**
9261 * @inheritdoc
9262 * @protected
9263 */
9264 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9265 var type;
9266 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9267 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9268 };
9269
9270 /**
9271 * Set label value.
9272 *
9273 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9274 *
9275 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9276 * text, or `null` for no label
9277 * @chainable
9278 * @return {OO.ui.Widget} The widget, for chaining
9279 */
9280 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9281 if ( typeof label === 'function' ) {
9282 label = OO.ui.resolveMsg( label );
9283 }
9284
9285 if ( this.useInputTag ) {
9286 // Discard non-plaintext labels
9287 if ( typeof label !== 'string' ) {
9288 label = '';
9289 }
9290
9291 this.$input.val( label );
9292 }
9293
9294 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9295 };
9296
9297 /**
9298 * Set the value of the input.
9299 *
9300 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9301 * they do not support {@link #value values}.
9302 *
9303 * @param {string} value New value
9304 * @chainable
9305 * @return {OO.ui.Widget} The widget, for chaining
9306 */
9307 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9308 if ( !this.useInputTag ) {
9309 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9310 }
9311 return this;
9312 };
9313
9314 /**
9315 * @inheritdoc
9316 */
9317 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9318 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9319 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9320 return null;
9321 };
9322
9323 /**
9324 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9325 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9326 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9327 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9328 *
9329 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9330 *
9331 * @example
9332 * // An example of selected, unselected, and disabled checkbox inputs.
9333 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9334 * value: 'a',
9335 * selected: true
9336 * } ),
9337 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9338 * value: 'b'
9339 * } ),
9340 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9341 * value:'c',
9342 * disabled: true
9343 * } ),
9344 * // Create a fieldset layout with fields for each checkbox.
9345 * fieldset = new OO.ui.FieldsetLayout( {
9346 * label: 'Checkboxes'
9347 * } );
9348 * fieldset.addItems( [
9349 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9350 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9351 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9352 * ] );
9353 * $( document.body ).append( fieldset.$element );
9354 *
9355 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9356 *
9357 * @class
9358 * @extends OO.ui.InputWidget
9359 *
9360 * @constructor
9361 * @param {Object} [config] Configuration options
9362 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9363 */
9364 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9365 // Configuration initialization
9366 config = config || {};
9367
9368 // Parent constructor
9369 OO.ui.CheckboxInputWidget.parent.call( this, config );
9370
9371 // Properties
9372 this.checkIcon = new OO.ui.IconWidget( {
9373 icon: 'check',
9374 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9375 } );
9376
9377 // Initialization
9378 this.$element
9379 .addClass( 'oo-ui-checkboxInputWidget' )
9380 // Required for pretty styling in WikimediaUI theme
9381 .append( this.checkIcon.$element );
9382 this.setSelected( config.selected !== undefined ? config.selected : false );
9383 };
9384
9385 /* Setup */
9386
9387 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9388
9389 /* Static Properties */
9390
9391 /**
9392 * @static
9393 * @inheritdoc
9394 */
9395 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9396
9397 /* Static Methods */
9398
9399 /**
9400 * @inheritdoc
9401 */
9402 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9403 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9404 state.checked = config.$input.prop( 'checked' );
9405 return state;
9406 };
9407
9408 /* Methods */
9409
9410 /**
9411 * @inheritdoc
9412 * @protected
9413 */
9414 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9415 return $( '<input>' ).attr( 'type', 'checkbox' );
9416 };
9417
9418 /**
9419 * @inheritdoc
9420 */
9421 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9422 var widget = this;
9423 if ( !this.isDisabled() ) {
9424 // Allow the stack to clear so the value will be updated
9425 setTimeout( function () {
9426 widget.setSelected( widget.$input.prop( 'checked' ) );
9427 } );
9428 }
9429 };
9430
9431 /**
9432 * Set selection state of this checkbox.
9433 *
9434 * @param {boolean} state `true` for selected
9435 * @chainable
9436 * @return {OO.ui.Widget} The widget, for chaining
9437 */
9438 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9439 state = !!state;
9440 if ( this.selected !== state ) {
9441 this.selected = state;
9442 this.$input.prop( 'checked', this.selected );
9443 this.emit( 'change', this.selected );
9444 }
9445 // The first time that the selection state is set (probably while constructing the widget),
9446 // remember it in defaultSelected. This property can be later used to check whether
9447 // the selection state of the input has been changed since it was created.
9448 if ( this.defaultSelected === undefined ) {
9449 this.defaultSelected = this.selected;
9450 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9451 }
9452 return this;
9453 };
9454
9455 /**
9456 * Check if this checkbox is selected.
9457 *
9458 * @return {boolean} Checkbox is selected
9459 */
9460 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9461 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9462 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9463 var selected = this.$input.prop( 'checked' );
9464 if ( this.selected !== selected ) {
9465 this.setSelected( selected );
9466 }
9467 return this.selected;
9468 };
9469
9470 /**
9471 * @inheritdoc
9472 */
9473 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9474 if ( !this.isDisabled() ) {
9475 this.$input.click();
9476 }
9477 this.focus();
9478 };
9479
9480 /**
9481 * @inheritdoc
9482 */
9483 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9484 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9485 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9486 this.setSelected( state.checked );
9487 }
9488 };
9489
9490 /**
9491 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9492 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9493 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9494 * more information about input widgets.
9495 *
9496 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9497 * are no options. If no `value` configuration option is provided, the first option is selected.
9498 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9499 *
9500 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9501 *
9502 * @example
9503 * // A DropdownInputWidget with three options.
9504 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9505 * options: [
9506 * { data: 'a', label: 'First' },
9507 * { data: 'b', label: 'Second'},
9508 * { data: 'c', label: 'Third' }
9509 * ]
9510 * } );
9511 * $( document.body ).append( dropdownInput.$element );
9512 *
9513 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9514 *
9515 * @class
9516 * @extends OO.ui.InputWidget
9517 *
9518 * @constructor
9519 * @param {Object} [config] Configuration options
9520 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9521 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9522 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9523 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9524 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9525 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9526 */
9527 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9528 // Configuration initialization
9529 config = config || {};
9530
9531 // Properties (must be done before parent constructor which calls #setDisabled)
9532 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9533 {
9534 $overlay: config.$overlay
9535 },
9536 config.dropdown
9537 ) );
9538 // Set up the options before parent constructor, which uses them to validate config.value.
9539 // Use this instead of setOptions() because this.$input is not set up yet.
9540 this.setOptionsData( config.options || [] );
9541
9542 // Parent constructor
9543 OO.ui.DropdownInputWidget.parent.call( this, config );
9544
9545 // Events
9546 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9547
9548 // Initialization
9549 this.$element
9550 .addClass( 'oo-ui-dropdownInputWidget' )
9551 .append( this.dropdownWidget.$element );
9552 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9553 this.setTitledElement( this.dropdownWidget.$handle );
9554 };
9555
9556 /* Setup */
9557
9558 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9559
9560 /* Methods */
9561
9562 /**
9563 * @inheritdoc
9564 * @protected
9565 */
9566 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9567 return $( '<select>' );
9568 };
9569
9570 /**
9571 * Handles menu select events.
9572 *
9573 * @private
9574 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9575 */
9576 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9577 this.setValue( item ? item.getData() : '' );
9578 };
9579
9580 /**
9581 * @inheritdoc
9582 */
9583 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9584 var selected;
9585 value = this.cleanUpValue( value );
9586 // Only allow setting values that are actually present in the dropdown
9587 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9588 this.dropdownWidget.getMenu().findFirstSelectableItem();
9589 this.dropdownWidget.getMenu().selectItem( selected );
9590 value = selected ? selected.getData() : '';
9591 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9592 if ( this.optionsDirty ) {
9593 // We reached this from the constructor or from #setOptions.
9594 // We have to update the <select> element.
9595 this.updateOptionsInterface();
9596 }
9597 return this;
9598 };
9599
9600 /**
9601 * @inheritdoc
9602 */
9603 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9604 this.dropdownWidget.setDisabled( state );
9605 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9606 return this;
9607 };
9608
9609 /**
9610 * Set the options available for this input.
9611 *
9612 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9613 * @chainable
9614 * @return {OO.ui.Widget} The widget, for chaining
9615 */
9616 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9617 var value = this.getValue();
9618
9619 this.setOptionsData( options );
9620
9621 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9622 // In case the previous value is no longer an available option, select the first valid one.
9623 this.setValue( value );
9624
9625 return this;
9626 };
9627
9628 /**
9629 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9630 *
9631 * This method may be called before the parent constructor, so various properties may not be
9632 * intialized yet.
9633 *
9634 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9635 * @private
9636 */
9637 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9638 var
9639 optionWidgets,
9640 widget = this;
9641
9642 this.optionsDirty = true;
9643
9644 optionWidgets = options.map( function ( opt ) {
9645 var optValue;
9646
9647 if ( opt.optgroup !== undefined ) {
9648 return widget.createMenuSectionOptionWidget( opt.optgroup );
9649 }
9650
9651 optValue = widget.cleanUpValue( opt.data );
9652 return widget.createMenuOptionWidget(
9653 optValue,
9654 opt.label !== undefined ? opt.label : optValue
9655 );
9656
9657 } );
9658
9659 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9660 };
9661
9662 /**
9663 * Create a menu option widget.
9664 *
9665 * @protected
9666 * @param {string} data Item data
9667 * @param {string} label Item label
9668 * @return {OO.ui.MenuOptionWidget} Option widget
9669 */
9670 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9671 return new OO.ui.MenuOptionWidget( {
9672 data: data,
9673 label: label
9674 } );
9675 };
9676
9677 /**
9678 * Create a menu section option widget.
9679 *
9680 * @protected
9681 * @param {string} label Section item label
9682 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9683 */
9684 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9685 return new OO.ui.MenuSectionOptionWidget( {
9686 label: label
9687 } );
9688 };
9689
9690 /**
9691 * Update the user-visible interface to match the internal list of options and value.
9692 *
9693 * This method must only be called after the parent constructor.
9694 *
9695 * @private
9696 */
9697 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9698 var
9699 $optionsContainer = this.$input,
9700 defaultValue = this.defaultValue,
9701 widget = this;
9702
9703 this.$input.empty();
9704
9705 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9706 var $optionNode;
9707
9708 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9709 $optionNode = $( '<option>' )
9710 .attr( 'value', optionWidget.getData() )
9711 .text( optionWidget.getLabel() );
9712
9713 // Remember original selection state. This property can be later used to check whether
9714 // the selection state of the input has been changed since it was created.
9715 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9716
9717 $optionsContainer.append( $optionNode );
9718 } else {
9719 $optionNode = $( '<optgroup>' )
9720 .attr( 'label', optionWidget.getLabel() );
9721 widget.$input.append( $optionNode );
9722 $optionsContainer = $optionNode;
9723 }
9724 } );
9725
9726 this.optionsDirty = false;
9727 };
9728
9729 /**
9730 * @inheritdoc
9731 */
9732 OO.ui.DropdownInputWidget.prototype.focus = function () {
9733 this.dropdownWidget.focus();
9734 return this;
9735 };
9736
9737 /**
9738 * @inheritdoc
9739 */
9740 OO.ui.DropdownInputWidget.prototype.blur = function () {
9741 this.dropdownWidget.blur();
9742 return this;
9743 };
9744
9745 /**
9746 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9747 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9748 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9749 * please see the [OOUI documentation on MediaWiki][1].
9750 *
9751 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9752 *
9753 * @example
9754 * // An example of selected, unselected, and disabled radio inputs
9755 * var radio1 = new OO.ui.RadioInputWidget( {
9756 * value: 'a',
9757 * selected: true
9758 * } );
9759 * var radio2 = new OO.ui.RadioInputWidget( {
9760 * value: 'b'
9761 * } );
9762 * var radio3 = new OO.ui.RadioInputWidget( {
9763 * value: 'c',
9764 * disabled: true
9765 * } );
9766 * // Create a fieldset layout with fields for each radio button.
9767 * var fieldset = new OO.ui.FieldsetLayout( {
9768 * label: 'Radio inputs'
9769 * } );
9770 * fieldset.addItems( [
9771 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9772 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9773 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9774 * ] );
9775 * $( document.body ).append( fieldset.$element );
9776 *
9777 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9778 *
9779 * @class
9780 * @extends OO.ui.InputWidget
9781 *
9782 * @constructor
9783 * @param {Object} [config] Configuration options
9784 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9785 */
9786 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9787 // Configuration initialization
9788 config = config || {};
9789
9790 // Parent constructor
9791 OO.ui.RadioInputWidget.parent.call( this, config );
9792
9793 // Initialization
9794 this.$element
9795 .addClass( 'oo-ui-radioInputWidget' )
9796 // Required for pretty styling in WikimediaUI theme
9797 .append( $( '<span>' ) );
9798 this.setSelected( config.selected !== undefined ? config.selected : false );
9799 };
9800
9801 /* Setup */
9802
9803 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9804
9805 /* Static Properties */
9806
9807 /**
9808 * @static
9809 * @inheritdoc
9810 */
9811 OO.ui.RadioInputWidget.static.tagName = 'span';
9812
9813 /* Static Methods */
9814
9815 /**
9816 * @inheritdoc
9817 */
9818 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9819 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9820 state.checked = config.$input.prop( 'checked' );
9821 return state;
9822 };
9823
9824 /* Methods */
9825
9826 /**
9827 * @inheritdoc
9828 * @protected
9829 */
9830 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9831 return $( '<input>' ).attr( 'type', 'radio' );
9832 };
9833
9834 /**
9835 * @inheritdoc
9836 */
9837 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9838 // RadioInputWidget doesn't track its state.
9839 };
9840
9841 /**
9842 * Set selection state of this radio button.
9843 *
9844 * @param {boolean} state `true` for selected
9845 * @chainable
9846 * @return {OO.ui.Widget} The widget, for chaining
9847 */
9848 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9849 // RadioInputWidget doesn't track its state.
9850 this.$input.prop( 'checked', state );
9851 // The first time that the selection state is set (probably while constructing the widget),
9852 // remember it in defaultSelected. This property can be later used to check whether
9853 // the selection state of the input has been changed since it was created.
9854 if ( this.defaultSelected === undefined ) {
9855 this.defaultSelected = state;
9856 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9857 }
9858 return this;
9859 };
9860
9861 /**
9862 * Check if this radio button is selected.
9863 *
9864 * @return {boolean} Radio is selected
9865 */
9866 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9867 return this.$input.prop( 'checked' );
9868 };
9869
9870 /**
9871 * @inheritdoc
9872 */
9873 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9874 if ( !this.isDisabled() ) {
9875 this.$input.click();
9876 }
9877 this.focus();
9878 };
9879
9880 /**
9881 * @inheritdoc
9882 */
9883 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9884 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9885 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9886 this.setSelected( state.checked );
9887 }
9888 };
9889
9890 /**
9891 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9892 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9893 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9894 * more information about input widgets.
9895 *
9896 * This and OO.ui.DropdownInputWidget support the same configuration options.
9897 *
9898 * @example
9899 * // A RadioSelectInputWidget with three options
9900 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9901 * options: [
9902 * { data: 'a', label: 'First' },
9903 * { data: 'b', label: 'Second'},
9904 * { data: 'c', label: 'Third' }
9905 * ]
9906 * } );
9907 * $( document.body ).append( radioSelectInput.$element );
9908 *
9909 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9910 *
9911 * @class
9912 * @extends OO.ui.InputWidget
9913 *
9914 * @constructor
9915 * @param {Object} [config] Configuration options
9916 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9917 */
9918 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9919 // Configuration initialization
9920 config = config || {};
9921
9922 // Properties (must be done before parent constructor which calls #setDisabled)
9923 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9924 // Set up the options before parent constructor, which uses them to validate config.value.
9925 // Use this instead of setOptions() because this.$input is not set up yet
9926 this.setOptionsData( config.options || [] );
9927
9928 // Parent constructor
9929 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9930
9931 // Events
9932 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9933
9934 // Initialization
9935 this.$element
9936 .addClass( 'oo-ui-radioSelectInputWidget' )
9937 .append( this.radioSelectWidget.$element );
9938 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9939 };
9940
9941 /* Setup */
9942
9943 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9944
9945 /* Static Methods */
9946
9947 /**
9948 * @inheritdoc
9949 */
9950 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9951 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9952 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9953 return state;
9954 };
9955
9956 /**
9957 * @inheritdoc
9958 */
9959 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9960 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9961 // Cannot reuse the `<input type=radio>` set
9962 delete config.$input;
9963 return config;
9964 };
9965
9966 /* Methods */
9967
9968 /**
9969 * @inheritdoc
9970 * @protected
9971 */
9972 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9973 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9974 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9975 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9976 };
9977
9978 /**
9979 * Handles menu select events.
9980 *
9981 * @private
9982 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9983 */
9984 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9985 this.setValue( item.getData() );
9986 };
9987
9988 /**
9989 * @inheritdoc
9990 */
9991 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9992 var selected;
9993 value = this.cleanUpValue( value );
9994 // Only allow setting values that are actually present in the dropdown
9995 selected = this.radioSelectWidget.findItemFromData( value ) ||
9996 this.radioSelectWidget.findFirstSelectableItem();
9997 this.radioSelectWidget.selectItem( selected );
9998 value = selected ? selected.getData() : '';
9999 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10000 return this;
10001 };
10002
10003 /**
10004 * @inheritdoc
10005 */
10006 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10007 this.radioSelectWidget.setDisabled( state );
10008 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10009 return this;
10010 };
10011
10012 /**
10013 * Set the options available for this input.
10014 *
10015 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10016 * @chainable
10017 * @return {OO.ui.Widget} The widget, for chaining
10018 */
10019 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10020 var value = this.getValue();
10021
10022 this.setOptionsData( options );
10023
10024 // Re-set the value to update the visible interface (RadioSelectWidget).
10025 // In case the previous value is no longer an available option, select the first valid one.
10026 this.setValue( value );
10027
10028 return this;
10029 };
10030
10031 /**
10032 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10033 *
10034 * This method may be called before the parent constructor, so various properties may not be
10035 * intialized yet.
10036 *
10037 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10038 * @private
10039 */
10040 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10041 var widget = this;
10042
10043 this.radioSelectWidget
10044 .clearItems()
10045 .addItems( options.map( function ( opt ) {
10046 var optValue = widget.cleanUpValue( opt.data );
10047 return new OO.ui.RadioOptionWidget( {
10048 data: optValue,
10049 label: opt.label !== undefined ? opt.label : optValue
10050 } );
10051 } ) );
10052 };
10053
10054 /**
10055 * @inheritdoc
10056 */
10057 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10058 this.radioSelectWidget.focus();
10059 return this;
10060 };
10061
10062 /**
10063 * @inheritdoc
10064 */
10065 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10066 this.radioSelectWidget.blur();
10067 return this;
10068 };
10069
10070 /**
10071 * CheckboxMultiselectInputWidget is a
10072 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10073 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10074 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10075 * more information about input widgets.
10076 *
10077 * @example
10078 * // A CheckboxMultiselectInputWidget with three options.
10079 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10080 * options: [
10081 * { data: 'a', label: 'First' },
10082 * { data: 'b', label: 'Second' },
10083 * { data: 'c', label: 'Third' }
10084 * ]
10085 * } );
10086 * $( document.body ).append( multiselectInput.$element );
10087 *
10088 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10089 *
10090 * @class
10091 * @extends OO.ui.InputWidget
10092 *
10093 * @constructor
10094 * @param {Object} [config] Configuration options
10095 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
10096 */
10097 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10098 // Configuration initialization
10099 config = config || {};
10100
10101 // Properties (must be done before parent constructor which calls #setDisabled)
10102 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10103 // Must be set before the #setOptionsData call below
10104 this.inputName = config.name;
10105 // Set up the options before parent constructor, which uses them to validate config.value.
10106 // Use this instead of setOptions() because this.$input is not set up yet
10107 this.setOptionsData( config.options || [] );
10108
10109 // Parent constructor
10110 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10111
10112 // Events
10113 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
10114
10115 // Initialization
10116 this.$element
10117 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10118 .append( this.checkboxMultiselectWidget.$element );
10119 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10120 this.$input.detach();
10121 };
10122
10123 /* Setup */
10124
10125 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10126
10127 /* Static Methods */
10128
10129 /**
10130 * @inheritdoc
10131 */
10132 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10133 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
10134 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10135 .toArray().map( function ( el ) { return el.value; } );
10136 return state;
10137 };
10138
10139 /**
10140 * @inheritdoc
10141 */
10142 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10143 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10144 // Cannot reuse the `<input type=checkbox>` set
10145 delete config.$input;
10146 return config;
10147 };
10148
10149 /* Methods */
10150
10151 /**
10152 * @inheritdoc
10153 * @protected
10154 */
10155 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10156 // Actually unused
10157 return $( '<unused>' );
10158 };
10159
10160 /**
10161 * Handles CheckboxMultiselectWidget select events.
10162 *
10163 * @private
10164 */
10165 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10166 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10167 };
10168
10169 /**
10170 * @inheritdoc
10171 */
10172 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10173 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10174 .toArray().map( function ( el ) { return el.value; } );
10175 if ( this.value !== value ) {
10176 this.setValue( value );
10177 }
10178 return this.value;
10179 };
10180
10181 /**
10182 * @inheritdoc
10183 */
10184 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10185 value = this.cleanUpValue( value );
10186 this.checkboxMultiselectWidget.selectItemsByData( value );
10187 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10188 if ( this.optionsDirty ) {
10189 // We reached this from the constructor or from #setOptions.
10190 // We have to update the <select> element.
10191 this.updateOptionsInterface();
10192 }
10193 return this;
10194 };
10195
10196 /**
10197 * Clean up incoming value.
10198 *
10199 * @param {string[]} value Original value
10200 * @return {string[]} Cleaned up value
10201 */
10202 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10203 var i, singleValue,
10204 cleanValue = [];
10205 if ( !Array.isArray( value ) ) {
10206 return cleanValue;
10207 }
10208 for ( i = 0; i < value.length; i++ ) {
10209 singleValue =
10210 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
10211 // Remove options that we don't have here
10212 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10213 continue;
10214 }
10215 cleanValue.push( singleValue );
10216 }
10217 return cleanValue;
10218 };
10219
10220 /**
10221 * @inheritdoc
10222 */
10223 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10224 this.checkboxMultiselectWidget.setDisabled( state );
10225 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10226 return this;
10227 };
10228
10229 /**
10230 * Set the options available for this input.
10231 *
10232 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
10233 * @chainable
10234 * @return {OO.ui.Widget} The widget, for chaining
10235 */
10236 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10237 var value = this.getValue();
10238
10239 this.setOptionsData( options );
10240
10241 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10242 // This will also get rid of any stale options that we just removed.
10243 this.setValue( value );
10244
10245 return this;
10246 };
10247
10248 /**
10249 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10250 *
10251 * This method may be called before the parent constructor, so various properties may not be
10252 * intialized yet.
10253 *
10254 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10255 * @private
10256 */
10257 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10258 var widget = this;
10259
10260 this.optionsDirty = true;
10261
10262 this.checkboxMultiselectWidget
10263 .clearItems()
10264 .addItems( options.map( function ( opt ) {
10265 var optValue, item, optDisabled;
10266 optValue =
10267 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
10268 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10269 item = new OO.ui.CheckboxMultioptionWidget( {
10270 data: optValue,
10271 label: opt.label !== undefined ? opt.label : optValue,
10272 disabled: optDisabled
10273 } );
10274 // Set the 'name' and 'value' for form submission
10275 item.checkbox.$input.attr( 'name', widget.inputName );
10276 item.checkbox.setValue( optValue );
10277 return item;
10278 } ) );
10279 };
10280
10281 /**
10282 * Update the user-visible interface to match the internal list of options and value.
10283 *
10284 * This method must only be called after the parent constructor.
10285 *
10286 * @private
10287 */
10288 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10289 var defaultValue = this.defaultValue;
10290
10291 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10292 // Remember original selection state. This property can be later used to check whether
10293 // the selection state of the input has been changed since it was created.
10294 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10295 item.checkbox.defaultSelected = isDefault;
10296 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10297 } );
10298
10299 this.optionsDirty = false;
10300 };
10301
10302 /**
10303 * @inheritdoc
10304 */
10305 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10306 this.checkboxMultiselectWidget.focus();
10307 return this;
10308 };
10309
10310 /**
10311 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10312 * size of the field as well as its presentation. In addition, these widgets can be configured
10313 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10314 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10315 * which modifies incoming values rather than validating them.
10316 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10317 *
10318 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10319 *
10320 * @example
10321 * // A TextInputWidget.
10322 * var textInput = new OO.ui.TextInputWidget( {
10323 * value: 'Text input'
10324 * } )
10325 * $( document.body ).append( textInput.$element );
10326 *
10327 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10328 *
10329 * @class
10330 * @extends OO.ui.InputWidget
10331 * @mixins OO.ui.mixin.IconElement
10332 * @mixins OO.ui.mixin.IndicatorElement
10333 * @mixins OO.ui.mixin.PendingElement
10334 * @mixins OO.ui.mixin.LabelElement
10335 *
10336 * @constructor
10337 * @param {Object} [config] Configuration options
10338 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10339 * 'email', 'url' or 'number'.
10340 * @cfg {string} [placeholder] Placeholder text
10341 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10342 * instruct the browser to focus this widget.
10343 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10344 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10345 *
10346 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10347 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10348 * many emojis) count as 2 characters each.
10349 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10350 * the value or placeholder text: `'before'` or `'after'`
10351 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10352 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10353 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10354 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10355 * leaving it up to the browser).
10356 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10357 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10358 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10359 * value for it to be considered valid; when Function, a function receiving the value as parameter
10360 * that must return true, or promise resolving to true, for it to be considered valid.
10361 */
10362 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10363 // Configuration initialization
10364 config = $.extend( {
10365 type: 'text',
10366 labelPosition: 'after'
10367 }, config );
10368
10369 // Parent constructor
10370 OO.ui.TextInputWidget.parent.call( this, config );
10371
10372 // Mixin constructors
10373 OO.ui.mixin.IconElement.call( this, config );
10374 OO.ui.mixin.IndicatorElement.call( this, config );
10375 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10376 OO.ui.mixin.LabelElement.call( this, config );
10377
10378 // Properties
10379 this.type = this.getSaneType( config );
10380 this.readOnly = false;
10381 this.required = false;
10382 this.validate = null;
10383 this.scrollWidth = null;
10384
10385 this.setValidation( config.validate );
10386 this.setLabelPosition( config.labelPosition );
10387
10388 // Events
10389 this.$input.on( {
10390 keypress: this.onKeyPress.bind( this ),
10391 blur: this.onBlur.bind( this ),
10392 focus: this.onFocus.bind( this )
10393 } );
10394 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10395 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10396 this.on( 'labelChange', this.updatePosition.bind( this ) );
10397 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10398
10399 // Initialization
10400 this.$element
10401 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10402 .append( this.$icon, this.$indicator );
10403 this.setReadOnly( !!config.readOnly );
10404 this.setRequired( !!config.required );
10405 if ( config.placeholder !== undefined ) {
10406 this.$input.attr( 'placeholder', config.placeholder );
10407 }
10408 if ( config.maxLength !== undefined ) {
10409 this.$input.attr( 'maxlength', config.maxLength );
10410 }
10411 if ( config.autofocus ) {
10412 this.$input.attr( 'autofocus', 'autofocus' );
10413 }
10414 if ( config.autocomplete === false ) {
10415 this.$input.attr( 'autocomplete', 'off' );
10416 // Turning off autocompletion also disables "form caching" when the user navigates to a
10417 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10418 $( window ).on( {
10419 beforeunload: function () {
10420 this.$input.removeAttr( 'autocomplete' );
10421 }.bind( this ),
10422 pageshow: function () {
10423 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10424 // whole page... it shouldn't hurt, though.
10425 this.$input.attr( 'autocomplete', 'off' );
10426 }.bind( this )
10427 } );
10428 }
10429 if ( config.spellcheck !== undefined ) {
10430 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10431 }
10432 if ( this.label ) {
10433 this.isWaitingToBeAttached = true;
10434 this.installParentChangeDetector();
10435 }
10436 };
10437
10438 /* Setup */
10439
10440 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10441 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10442 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10443 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10444 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10445
10446 /* Static Properties */
10447
10448 OO.ui.TextInputWidget.static.validationPatterns = {
10449 'non-empty': /.+/,
10450 integer: /^\d+$/
10451 };
10452
10453 /* Events */
10454
10455 /**
10456 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10457 *
10458 * @event enter
10459 */
10460
10461 /* Methods */
10462
10463 /**
10464 * Handle icon mouse down events.
10465 *
10466 * @private
10467 * @param {jQuery.Event} e Mouse down event
10468 * @return {undefined/boolean} False to prevent default if event is handled
10469 */
10470 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10471 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10472 this.focus();
10473 return false;
10474 }
10475 };
10476
10477 /**
10478 * Handle indicator mouse down events.
10479 *
10480 * @private
10481 * @param {jQuery.Event} e Mouse down event
10482 * @return {undefined/boolean} False to prevent default if event is handled
10483 */
10484 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10485 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10486 this.focus();
10487 return false;
10488 }
10489 };
10490
10491 /**
10492 * Handle key press events.
10493 *
10494 * @private
10495 * @param {jQuery.Event} e Key press event
10496 * @fires enter If enter key is pressed
10497 */
10498 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10499 if ( e.which === OO.ui.Keys.ENTER ) {
10500 this.emit( 'enter', e );
10501 }
10502 };
10503
10504 /**
10505 * Handle blur events.
10506 *
10507 * @private
10508 * @param {jQuery.Event} e Blur event
10509 */
10510 OO.ui.TextInputWidget.prototype.onBlur = function () {
10511 this.setValidityFlag();
10512 };
10513
10514 /**
10515 * Handle focus events.
10516 *
10517 * @private
10518 * @param {jQuery.Event} e Focus event
10519 */
10520 OO.ui.TextInputWidget.prototype.onFocus = function () {
10521 if ( this.isWaitingToBeAttached ) {
10522 // If we've received focus, then we must be attached to the document, and if
10523 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10524 this.onElementAttach();
10525 }
10526 this.setValidityFlag( true );
10527 };
10528
10529 /**
10530 * Handle element attach events.
10531 *
10532 * @private
10533 * @param {jQuery.Event} e Element attach event
10534 */
10535 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10536 this.isWaitingToBeAttached = false;
10537 // Any previously calculated size is now probably invalid if we reattached elsewhere
10538 this.valCache = null;
10539 this.positionLabel();
10540 };
10541
10542 /**
10543 * Handle debounced change events.
10544 *
10545 * @param {string} value
10546 * @private
10547 */
10548 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10549 this.setValidityFlag();
10550 };
10551
10552 /**
10553 * Check if the input is {@link #readOnly read-only}.
10554 *
10555 * @return {boolean}
10556 */
10557 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10558 return this.readOnly;
10559 };
10560
10561 /**
10562 * Set the {@link #readOnly read-only} state of the input.
10563 *
10564 * @param {boolean} state Make input read-only
10565 * @chainable
10566 * @return {OO.ui.Widget} The widget, for chaining
10567 */
10568 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10569 this.readOnly = !!state;
10570 this.$input.prop( 'readOnly', this.readOnly );
10571 return this;
10572 };
10573
10574 /**
10575 * Check if the input is {@link #required required}.
10576 *
10577 * @return {boolean}
10578 */
10579 OO.ui.TextInputWidget.prototype.isRequired = function () {
10580 return this.required;
10581 };
10582
10583 /**
10584 * Set the {@link #required required} state of the input.
10585 *
10586 * @param {boolean} state Make input required
10587 * @chainable
10588 * @return {OO.ui.Widget} The widget, for chaining
10589 */
10590 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10591 this.required = !!state;
10592 if ( this.required ) {
10593 this.$input
10594 .prop( 'required', true )
10595 .attr( 'aria-required', 'true' );
10596 if ( this.getIndicator() === null ) {
10597 this.setIndicator( 'required' );
10598 }
10599 } else {
10600 this.$input
10601 .prop( 'required', false )
10602 .removeAttr( 'aria-required' );
10603 if ( this.getIndicator() === 'required' ) {
10604 this.setIndicator( null );
10605 }
10606 }
10607 return this;
10608 };
10609
10610 /**
10611 * Support function for making #onElementAttach work across browsers.
10612 *
10613 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10614 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10615 *
10616 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10617 * first time that the element gets attached to the documented.
10618 */
10619 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10620 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10621 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10622 widget = this;
10623
10624 if ( MutationObserver ) {
10625 // The new way. If only it wasn't so ugly.
10626
10627 if ( this.isElementAttached() ) {
10628 // Widget is attached already, do nothing. This breaks the functionality of this function when
10629 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10630 // would require observation of the whole document, which would hurt performance of other,
10631 // more important code.
10632 return;
10633 }
10634
10635 // Find topmost node in the tree
10636 topmostNode = this.$element[ 0 ];
10637 while ( topmostNode.parentNode ) {
10638 topmostNode = topmostNode.parentNode;
10639 }
10640
10641 // We have no way to detect the $element being attached somewhere without observing the entire
10642 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10643 // parent node of $element, and instead detect when $element is removed from it (and thus
10644 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10645 // doesn't get attached, we end up back here and create the parent.
10646
10647 mutationObserver = new MutationObserver( function ( mutations ) {
10648 var i, j, removedNodes;
10649 for ( i = 0; i < mutations.length; i++ ) {
10650 removedNodes = mutations[ i ].removedNodes;
10651 for ( j = 0; j < removedNodes.length; j++ ) {
10652 if ( removedNodes[ j ] === topmostNode ) {
10653 setTimeout( onRemove, 0 );
10654 return;
10655 }
10656 }
10657 }
10658 } );
10659
10660 onRemove = function () {
10661 // If the node was attached somewhere else, report it
10662 if ( widget.isElementAttached() ) {
10663 widget.onElementAttach();
10664 }
10665 mutationObserver.disconnect();
10666 widget.installParentChangeDetector();
10667 };
10668
10669 // Create a fake parent and observe it
10670 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10671 mutationObserver.observe( fakeParentNode, { childList: true } );
10672 } else {
10673 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10674 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10675 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10676 }
10677 };
10678
10679 /**
10680 * @inheritdoc
10681 * @protected
10682 */
10683 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10684 if ( this.getSaneType( config ) === 'number' ) {
10685 return $( '<input>' )
10686 .attr( 'step', 'any' )
10687 .attr( 'type', 'number' );
10688 } else {
10689 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10690 }
10691 };
10692
10693 /**
10694 * Get sanitized value for 'type' for given config.
10695 *
10696 * @param {Object} config Configuration options
10697 * @return {string|null}
10698 * @protected
10699 */
10700 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10701 var allowedTypes = [
10702 'text',
10703 'password',
10704 'email',
10705 'url',
10706 'number'
10707 ];
10708 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10709 };
10710
10711 /**
10712 * Focus the input and select a specified range within the text.
10713 *
10714 * @param {number} from Select from offset
10715 * @param {number} [to] Select to offset, defaults to from
10716 * @chainable
10717 * @return {OO.ui.Widget} The widget, for chaining
10718 */
10719 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10720 var isBackwards, start, end,
10721 input = this.$input[ 0 ];
10722
10723 to = to || from;
10724
10725 isBackwards = to < from;
10726 start = isBackwards ? to : from;
10727 end = isBackwards ? from : to;
10728
10729 this.focus();
10730
10731 try {
10732 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10733 } catch ( e ) {
10734 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10735 // Rather than expensively check if the input is attached every time, just check
10736 // if it was the cause of an error being thrown. If not, rethrow the error.
10737 if ( this.getElementDocument().body.contains( input ) ) {
10738 throw e;
10739 }
10740 }
10741 return this;
10742 };
10743
10744 /**
10745 * Get an object describing the current selection range in a directional manner
10746 *
10747 * @return {Object} Object containing 'from' and 'to' offsets
10748 */
10749 OO.ui.TextInputWidget.prototype.getRange = function () {
10750 var input = this.$input[ 0 ],
10751 start = input.selectionStart,
10752 end = input.selectionEnd,
10753 isBackwards = input.selectionDirection === 'backward';
10754
10755 return {
10756 from: isBackwards ? end : start,
10757 to: isBackwards ? start : end
10758 };
10759 };
10760
10761 /**
10762 * Get the length of the text input value.
10763 *
10764 * This could differ from the length of #getValue if the
10765 * value gets filtered
10766 *
10767 * @return {number} Input length
10768 */
10769 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10770 return this.$input[ 0 ].value.length;
10771 };
10772
10773 /**
10774 * Focus the input and select the entire text.
10775 *
10776 * @chainable
10777 * @return {OO.ui.Widget} The widget, for chaining
10778 */
10779 OO.ui.TextInputWidget.prototype.select = function () {
10780 return this.selectRange( 0, this.getInputLength() );
10781 };
10782
10783 /**
10784 * Focus the input and move the cursor to the start.
10785 *
10786 * @chainable
10787 * @return {OO.ui.Widget} The widget, for chaining
10788 */
10789 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10790 return this.selectRange( 0 );
10791 };
10792
10793 /**
10794 * Focus the input and move the cursor to the end.
10795 *
10796 * @chainable
10797 * @return {OO.ui.Widget} The widget, for chaining
10798 */
10799 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10800 return this.selectRange( this.getInputLength() );
10801 };
10802
10803 /**
10804 * Insert new content into the input.
10805 *
10806 * @param {string} content Content to be inserted
10807 * @chainable
10808 * @return {OO.ui.Widget} The widget, for chaining
10809 */
10810 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10811 var start, end,
10812 range = this.getRange(),
10813 value = this.getValue();
10814
10815 start = Math.min( range.from, range.to );
10816 end = Math.max( range.from, range.to );
10817
10818 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10819 this.selectRange( start + content.length );
10820 return this;
10821 };
10822
10823 /**
10824 * Insert new content either side of a selection.
10825 *
10826 * @param {string} pre Content to be inserted before the selection
10827 * @param {string} post Content to be inserted after the selection
10828 * @chainable
10829 * @return {OO.ui.Widget} The widget, for chaining
10830 */
10831 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10832 var start, end,
10833 range = this.getRange(),
10834 offset = pre.length;
10835
10836 start = Math.min( range.from, range.to );
10837 end = Math.max( range.from, range.to );
10838
10839 this.selectRange( start ).insertContent( pre );
10840 this.selectRange( offset + end ).insertContent( post );
10841
10842 this.selectRange( offset + start, offset + end );
10843 return this;
10844 };
10845
10846 /**
10847 * Set the validation pattern.
10848 *
10849 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10850 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10851 * value must contain only numbers).
10852 *
10853 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10854 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10855 */
10856 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10857 if ( validate instanceof RegExp || validate instanceof Function ) {
10858 this.validate = validate;
10859 } else {
10860 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10861 }
10862 };
10863
10864 /**
10865 * Sets the 'invalid' flag appropriately.
10866 *
10867 * @param {boolean} [isValid] Optionally override validation result
10868 */
10869 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10870 var widget = this,
10871 setFlag = function ( valid ) {
10872 if ( !valid ) {
10873 widget.$input.attr( 'aria-invalid', 'true' );
10874 } else {
10875 widget.$input.removeAttr( 'aria-invalid' );
10876 }
10877 widget.setFlags( { invalid: !valid } );
10878 };
10879
10880 if ( isValid !== undefined ) {
10881 setFlag( isValid );
10882 } else {
10883 this.getValidity().then( function () {
10884 setFlag( true );
10885 }, function () {
10886 setFlag( false );
10887 } );
10888 }
10889 };
10890
10891 /**
10892 * Get the validity of current value.
10893 *
10894 * This method returns a promise that resolves if the value is valid and rejects if
10895 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10896 *
10897 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10898 */
10899 OO.ui.TextInputWidget.prototype.getValidity = function () {
10900 var result;
10901
10902 function rejectOrResolve( valid ) {
10903 if ( valid ) {
10904 return $.Deferred().resolve().promise();
10905 } else {
10906 return $.Deferred().reject().promise();
10907 }
10908 }
10909
10910 // Check browser validity and reject if it is invalid
10911 if (
10912 this.$input[ 0 ].checkValidity !== undefined &&
10913 this.$input[ 0 ].checkValidity() === false
10914 ) {
10915 return rejectOrResolve( false );
10916 }
10917
10918 // Run our checks if the browser thinks the field is valid
10919 if ( this.validate instanceof Function ) {
10920 result = this.validate( this.getValue() );
10921 if ( result && typeof result.promise === 'function' ) {
10922 return result.promise().then( function ( valid ) {
10923 return rejectOrResolve( valid );
10924 } );
10925 } else {
10926 return rejectOrResolve( result );
10927 }
10928 } else {
10929 return rejectOrResolve( this.getValue().match( this.validate ) );
10930 }
10931 };
10932
10933 /**
10934 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10935 *
10936 * @param {string} labelPosition Label position, 'before' or 'after'
10937 * @chainable
10938 * @return {OO.ui.Widget} The widget, for chaining
10939 */
10940 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10941 this.labelPosition = labelPosition;
10942 if ( this.label ) {
10943 // If there is no label and we only change the position, #updatePosition is a no-op,
10944 // but it takes really a lot of work to do nothing.
10945 this.updatePosition();
10946 }
10947 return this;
10948 };
10949
10950 /**
10951 * Update the position of the inline label.
10952 *
10953 * This method is called by #setLabelPosition, and can also be called on its own if
10954 * something causes the label to be mispositioned.
10955 *
10956 * @chainable
10957 * @return {OO.ui.Widget} The widget, for chaining
10958 */
10959 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10960 var after = this.labelPosition === 'after';
10961
10962 this.$element
10963 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10964 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10965
10966 this.valCache = null;
10967 this.scrollWidth = null;
10968 this.positionLabel();
10969
10970 return this;
10971 };
10972
10973 /**
10974 * Position the label by setting the correct padding on the input.
10975 *
10976 * @private
10977 * @chainable
10978 * @return {OO.ui.Widget} The widget, for chaining
10979 */
10980 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10981 var after, rtl, property, newCss;
10982
10983 if ( this.isWaitingToBeAttached ) {
10984 // #onElementAttach will be called soon, which calls this method
10985 return this;
10986 }
10987
10988 newCss = {
10989 'padding-right': '',
10990 'padding-left': ''
10991 };
10992
10993 if ( this.label ) {
10994 this.$element.append( this.$label );
10995 } else {
10996 this.$label.detach();
10997 // Clear old values if present
10998 this.$input.css( newCss );
10999 return;
11000 }
11001
11002 after = this.labelPosition === 'after';
11003 rtl = this.$element.css( 'direction' ) === 'rtl';
11004 property = after === rtl ? 'padding-left' : 'padding-right';
11005
11006 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11007 // We have to clear the padding on the other side, in case the element direction changed
11008 this.$input.css( newCss );
11009
11010 return this;
11011 };
11012
11013 /**
11014 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11015 * {@link OO.ui.mixin.IconElement search icon} by default.
11016 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11017 *
11018 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11019 *
11020 * @class
11021 * @extends OO.ui.TextInputWidget
11022 *
11023 * @constructor
11024 * @param {Object} [config] Configuration options
11025 */
11026 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11027 config = $.extend( {
11028 icon: 'search'
11029 }, config );
11030
11031 // Parent constructor
11032 OO.ui.SearchInputWidget.parent.call( this, config );
11033
11034 // Events
11035 this.connect( this, {
11036 change: 'onChange'
11037 } );
11038
11039 // Initialization
11040 this.updateSearchIndicator();
11041 this.connect( this, {
11042 disable: 'onDisable'
11043 } );
11044 };
11045
11046 /* Setup */
11047
11048 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11049
11050 /* Methods */
11051
11052 /**
11053 * @inheritdoc
11054 * @protected
11055 */
11056 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11057 return 'search';
11058 };
11059
11060 /**
11061 * @inheritdoc
11062 */
11063 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11064 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11065 // Clear the text field
11066 this.setValue( '' );
11067 this.focus();
11068 return false;
11069 }
11070 };
11071
11072 /**
11073 * Update the 'clear' indicator displayed on type: 'search' text
11074 * fields, hiding it when the field is already empty or when it's not
11075 * editable.
11076 */
11077 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11078 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11079 this.setIndicator( null );
11080 } else {
11081 this.setIndicator( 'clear' );
11082 }
11083 };
11084
11085 /**
11086 * Handle change events.
11087 *
11088 * @private
11089 */
11090 OO.ui.SearchInputWidget.prototype.onChange = function () {
11091 this.updateSearchIndicator();
11092 };
11093
11094 /**
11095 * Handle disable events.
11096 *
11097 * @param {boolean} disabled Element is disabled
11098 * @private
11099 */
11100 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11101 this.updateSearchIndicator();
11102 };
11103
11104 /**
11105 * @inheritdoc
11106 */
11107 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11108 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11109 this.updateSearchIndicator();
11110 return this;
11111 };
11112
11113 /**
11114 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11115 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11116 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11117 * {@link OO.ui.mixin.IndicatorElement indicators}.
11118 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11119 *
11120 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11121 *
11122 * @example
11123 * // A MultilineTextInputWidget.
11124 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11125 * value: 'Text input on multiple lines'
11126 * } )
11127 * $( 'body' ).append( multilineTextInput.$element );
11128 *
11129 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11130 *
11131 * @class
11132 * @extends OO.ui.TextInputWidget
11133 *
11134 * @constructor
11135 * @param {Object} [config] Configuration options
11136 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11137 * specifies minimum number of rows to display.
11138 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11139 * Use the #maxRows config to specify a maximum number of displayed rows.
11140 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11141 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11142 */
11143 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11144 config = $.extend( {
11145 type: 'text'
11146 }, config );
11147 // Parent constructor
11148 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11149
11150 // Properties
11151 this.autosize = !!config.autosize;
11152 this.styleHeight = null;
11153 this.minRows = config.rows !== undefined ? config.rows : '';
11154 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11155
11156 // Clone for resizing
11157 if ( this.autosize ) {
11158 this.$clone = this.$input
11159 .clone()
11160 .removeAttr( 'id' )
11161 .removeAttr( 'name' )
11162 .insertAfter( this.$input )
11163 .attr( 'aria-hidden', 'true' )
11164 .addClass( 'oo-ui-element-hidden' );
11165 }
11166
11167 // Events
11168 this.connect( this, {
11169 change: 'onChange'
11170 } );
11171
11172 // Initialization
11173 if ( config.rows ) {
11174 this.$input.attr( 'rows', config.rows );
11175 }
11176 if ( this.autosize ) {
11177 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11178 this.isWaitingToBeAttached = true;
11179 this.installParentChangeDetector();
11180 }
11181 };
11182
11183 /* Setup */
11184
11185 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11186
11187 /* Static Methods */
11188
11189 /**
11190 * @inheritdoc
11191 */
11192 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11193 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11194 state.scrollTop = config.$input.scrollTop();
11195 return state;
11196 };
11197
11198 /* Methods */
11199
11200 /**
11201 * @inheritdoc
11202 */
11203 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11204 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11205 this.adjustSize();
11206 };
11207
11208 /**
11209 * Handle change events.
11210 *
11211 * @private
11212 */
11213 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11214 this.adjustSize();
11215 };
11216
11217 /**
11218 * @inheritdoc
11219 */
11220 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11221 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11222 this.adjustSize();
11223 };
11224
11225 /**
11226 * @inheritdoc
11227 *
11228 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11229 */
11230 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11231 if (
11232 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11233 // Some platforms emit keycode 10 for ctrl+enter in a textarea
11234 e.which === 10
11235 ) {
11236 this.emit( 'enter', e );
11237 }
11238 };
11239
11240 /**
11241 * Automatically adjust the size of the text input.
11242 *
11243 * This only affects multiline inputs that are {@link #autosize autosized}.
11244 *
11245 * @chainable
11246 * @return {OO.ui.Widget} The widget, for chaining
11247 * @fires resize
11248 */
11249 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11250 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11251 idealHeight, newHeight, scrollWidth, property;
11252
11253 if ( this.$input.val() !== this.valCache ) {
11254 if ( this.autosize ) {
11255 this.$clone
11256 .val( this.$input.val() )
11257 .attr( 'rows', this.minRows )
11258 // Set inline height property to 0 to measure scroll height
11259 .css( 'height', 0 );
11260
11261 this.$clone.removeClass( 'oo-ui-element-hidden' );
11262
11263 this.valCache = this.$input.val();
11264
11265 scrollHeight = this.$clone[ 0 ].scrollHeight;
11266
11267 // Remove inline height property to measure natural heights
11268 this.$clone.css( 'height', '' );
11269 innerHeight = this.$clone.innerHeight();
11270 outerHeight = this.$clone.outerHeight();
11271
11272 // Measure max rows height
11273 this.$clone
11274 .attr( 'rows', this.maxRows )
11275 .css( 'height', 'auto' )
11276 .val( '' );
11277 maxInnerHeight = this.$clone.innerHeight();
11278
11279 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11280 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11281 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11282 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11283
11284 this.$clone.addClass( 'oo-ui-element-hidden' );
11285
11286 // Only apply inline height when expansion beyond natural height is needed
11287 // Use the difference between the inner and outer height as a buffer
11288 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11289 if ( newHeight !== this.styleHeight ) {
11290 this.$input.css( 'height', newHeight );
11291 this.styleHeight = newHeight;
11292 this.emit( 'resize' );
11293 }
11294 }
11295 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11296 if ( scrollWidth !== this.scrollWidth ) {
11297 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11298 // Reset
11299 this.$label.css( { right: '', left: '' } );
11300 this.$indicator.css( { right: '', left: '' } );
11301
11302 if ( scrollWidth ) {
11303 this.$indicator.css( property, scrollWidth );
11304 if ( this.labelPosition === 'after' ) {
11305 this.$label.css( property, scrollWidth );
11306 }
11307 }
11308
11309 this.scrollWidth = scrollWidth;
11310 this.positionLabel();
11311 }
11312 }
11313 return this;
11314 };
11315
11316 /**
11317 * @inheritdoc
11318 * @protected
11319 */
11320 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11321 return $( '<textarea>' );
11322 };
11323
11324 /**
11325 * Check if the input automatically adjusts its size.
11326 *
11327 * @return {boolean}
11328 */
11329 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11330 return !!this.autosize;
11331 };
11332
11333 /**
11334 * @inheritdoc
11335 */
11336 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11337 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11338 if ( state.scrollTop !== undefined ) {
11339 this.$input.scrollTop( state.scrollTop );
11340 }
11341 };
11342
11343 /**
11344 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11345 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11346 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11347 *
11348 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11349 * option, that option will appear to be selected.
11350 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11351 * input field.
11352 *
11353 * After the user chooses an option, its `data` will be used as a new value for the widget.
11354 * A `label` also can be specified for each option: if given, it will be shown instead of the
11355 * `data` in the dropdown menu.
11356 *
11357 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11358 *
11359 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11360 *
11361 * @example
11362 * // A ComboBoxInputWidget.
11363 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11364 * value: 'Option 1',
11365 * options: [
11366 * { data: 'Option 1' },
11367 * { data: 'Option 2' },
11368 * { data: 'Option 3' }
11369 * ]
11370 * } );
11371 * $( document.body ).append( comboBox.$element );
11372 *
11373 * @example
11374 * // Example: A ComboBoxInputWidget with additional option labels.
11375 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11376 * value: 'Option 1',
11377 * options: [
11378 * {
11379 * data: 'Option 1',
11380 * label: 'Option One'
11381 * },
11382 * {
11383 * data: 'Option 2',
11384 * label: 'Option Two'
11385 * },
11386 * {
11387 * data: 'Option 3',
11388 * label: 'Option Three'
11389 * }
11390 * ]
11391 * } );
11392 * $( document.body ).append( comboBox.$element );
11393 *
11394 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11395 *
11396 * @class
11397 * @extends OO.ui.TextInputWidget
11398 *
11399 * @constructor
11400 * @param {Object} [config] Configuration options
11401 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11402 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11403 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11404 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11405 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11406 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11407 */
11408 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11409 // Configuration initialization
11410 config = $.extend( {
11411 autocomplete: false
11412 }, config );
11413
11414 // ComboBoxInputWidget shouldn't support `multiline`
11415 config.multiline = false;
11416
11417 // See InputWidget#reusePreInfuseDOM about `config.$input`
11418 if ( config.$input ) {
11419 config.$input.removeAttr( 'list' );
11420 }
11421
11422 // Parent constructor
11423 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11424
11425 // Properties
11426 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11427 this.dropdownButton = new OO.ui.ButtonWidget( {
11428 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11429 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11430 indicator: 'down',
11431 invisibleLabel: true,
11432 disabled: this.disabled
11433 } );
11434 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11435 {
11436 widget: this,
11437 input: this,
11438 $floatableContainer: this.$element,
11439 disabled: this.isDisabled()
11440 },
11441 config.menu
11442 ) );
11443
11444 // Events
11445 this.connect( this, {
11446 change: 'onInputChange',
11447 enter: 'onInputEnter'
11448 } );
11449 this.dropdownButton.connect( this, {
11450 click: 'onDropdownButtonClick'
11451 } );
11452 this.menu.connect( this, {
11453 choose: 'onMenuChoose',
11454 add: 'onMenuItemsChange',
11455 remove: 'onMenuItemsChange',
11456 toggle: 'onMenuToggle'
11457 } );
11458
11459 // Initialization
11460 this.$input.attr( {
11461 role: 'combobox',
11462 'aria-owns': this.menu.getElementId(),
11463 'aria-autocomplete': 'list'
11464 } );
11465 this.dropdownButton.$button.attr( {
11466 'aria-controls': this.menu.getElementId()
11467 } );
11468 // Do not override options set via config.menu.items
11469 if ( config.options !== undefined ) {
11470 this.setOptions( config.options );
11471 }
11472 this.$field = $( '<div>' )
11473 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11474 .append( this.$input, this.dropdownButton.$element );
11475 this.$element
11476 .addClass( 'oo-ui-comboBoxInputWidget' )
11477 .append( this.$field );
11478 this.$overlay.append( this.menu.$element );
11479 this.onMenuItemsChange();
11480 };
11481
11482 /* Setup */
11483
11484 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11485
11486 /* Methods */
11487
11488 /**
11489 * Get the combobox's menu.
11490 *
11491 * @return {OO.ui.MenuSelectWidget} Menu widget
11492 */
11493 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11494 return this.menu;
11495 };
11496
11497 /**
11498 * Get the combobox's text input widget.
11499 *
11500 * @return {OO.ui.TextInputWidget} Text input widget
11501 */
11502 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11503 return this;
11504 };
11505
11506 /**
11507 * Handle input change events.
11508 *
11509 * @private
11510 * @param {string} value New value
11511 */
11512 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11513 var match = this.menu.findItemFromData( value );
11514
11515 this.menu.selectItem( match );
11516 if ( this.menu.findHighlightedItem() ) {
11517 this.menu.highlightItem( match );
11518 }
11519
11520 if ( !this.isDisabled() ) {
11521 this.menu.toggle( true );
11522 }
11523 };
11524
11525 /**
11526 * Handle input enter events.
11527 *
11528 * @private
11529 */
11530 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11531 if ( !this.isDisabled() ) {
11532 this.menu.toggle( false );
11533 }
11534 };
11535
11536 /**
11537 * Handle button click events.
11538 *
11539 * @private
11540 */
11541 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11542 this.menu.toggle();
11543 this.focus();
11544 };
11545
11546 /**
11547 * Handle menu choose events.
11548 *
11549 * @private
11550 * @param {OO.ui.OptionWidget} item Chosen item
11551 */
11552 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11553 this.setValue( item.getData() );
11554 };
11555
11556 /**
11557 * Handle menu item change events.
11558 *
11559 * @private
11560 */
11561 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11562 var match = this.menu.findItemFromData( this.getValue() );
11563 this.menu.selectItem( match );
11564 if ( this.menu.findHighlightedItem() ) {
11565 this.menu.highlightItem( match );
11566 }
11567 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11568 };
11569
11570 /**
11571 * Handle menu toggle events.
11572 *
11573 * @private
11574 * @param {boolean} isVisible Open state of the menu
11575 */
11576 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11577 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11578 };
11579
11580 /**
11581 * @inheritdoc
11582 */
11583 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11584 // Parent method
11585 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11586
11587 if ( this.dropdownButton ) {
11588 this.dropdownButton.setDisabled( this.isDisabled() );
11589 }
11590 if ( this.menu ) {
11591 this.menu.setDisabled( this.isDisabled() );
11592 }
11593
11594 return this;
11595 };
11596
11597 /**
11598 * Set the options available for this input.
11599 *
11600 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11601 * @chainable
11602 * @return {OO.ui.Widget} The widget, for chaining
11603 */
11604 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11605 this.getMenu()
11606 .clearItems()
11607 .addItems( options.map( function ( opt ) {
11608 return new OO.ui.MenuOptionWidget( {
11609 data: opt.data,
11610 label: opt.label !== undefined ? opt.label : opt.data
11611 } );
11612 } ) );
11613
11614 return this;
11615 };
11616
11617 /**
11618 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11619 * which is a widget that is specified by reference before any optional configuration settings.
11620 *
11621 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11622 *
11623 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11624 * A left-alignment is used for forms with many fields.
11625 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11626 * A right-alignment is used for long but familiar forms which users tab through,
11627 * verifying the current field with a quick glance at the label.
11628 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11629 * that users fill out from top to bottom.
11630 * - **inline**: The label is placed after the field-widget and aligned to the left.
11631 * An inline-alignment is best used with checkboxes or radio buttons.
11632 *
11633 * Help text can either be:
11634 *
11635 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11636 * - shown as a subtle explanation below the label.
11637 *
11638 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`. If it
11639 * is long or not essential, leave `helpInline` to its default, `false`.
11640 *
11641 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11642 *
11643 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11644 *
11645 * @class
11646 * @extends OO.ui.Layout
11647 * @mixins OO.ui.mixin.LabelElement
11648 * @mixins OO.ui.mixin.TitledElement
11649 *
11650 * @constructor
11651 * @param {OO.ui.Widget} fieldWidget Field widget
11652 * @param {Object} [config] Configuration options
11653 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11654 * or 'inline'
11655 * @cfg {Array} [errors] Error messages about the widget, which will be
11656 * displayed below the widget.
11657 * The array may contain strings or OO.ui.HtmlSnippet instances.
11658 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11659 * below the widget.
11660 * The array may contain strings or OO.ui.HtmlSnippet instances.
11661 * These are more visible than `help` messages when `helpInline` is set, and so
11662 * might be good for transient messages.
11663 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11664 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11665 * corner of the rendered field; clicking it will display the text in a popup.
11666 * If `helpInline` is `true`, then a subtle description will be shown after the
11667 * label.
11668 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11669 * or shown when the "help" icon is clicked.
11670 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11671 * `help` is given.
11672 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11673 *
11674 * @throws {Error} An error is thrown if no widget is specified
11675 */
11676 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11677 // Allow passing positional parameters inside the config object
11678 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11679 config = fieldWidget;
11680 fieldWidget = config.fieldWidget;
11681 }
11682
11683 // Make sure we have required constructor arguments
11684 if ( fieldWidget === undefined ) {
11685 throw new Error( 'Widget not found' );
11686 }
11687
11688 // Configuration initialization
11689 config = $.extend( { align: 'left', helpInline: false }, config );
11690
11691 // Parent constructor
11692 OO.ui.FieldLayout.parent.call( this, config );
11693
11694 // Mixin constructors
11695 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11696 $label: $( '<label>' )
11697 } ) );
11698 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11699
11700 // Properties
11701 this.fieldWidget = fieldWidget;
11702 this.errors = [];
11703 this.notices = [];
11704 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11705 this.$messages = $( '<ul>' );
11706 this.$header = $( '<span>' );
11707 this.$body = $( '<div>' );
11708 this.align = null;
11709 this.helpInline = config.helpInline;
11710
11711 // Events
11712 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11713
11714 // Initialization
11715 this.$help = config.help ?
11716 this.createHelpElement( config.help, config.$overlay ) :
11717 $( [] );
11718 if ( this.fieldWidget.getInputId() ) {
11719 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11720 if ( this.helpInline ) {
11721 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11722 }
11723 } else {
11724 this.$label.on( 'click', function () {
11725 this.fieldWidget.simulateLabelClick();
11726 }.bind( this ) );
11727 if ( this.helpInline ) {
11728 this.$help.on( 'click', function () {
11729 this.fieldWidget.simulateLabelClick();
11730 }.bind( this ) );
11731 }
11732 }
11733 this.$element
11734 .addClass( 'oo-ui-fieldLayout' )
11735 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11736 .append( this.$body );
11737 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11738 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11739 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11740 this.$field
11741 .addClass( 'oo-ui-fieldLayout-field' )
11742 .append( this.fieldWidget.$element );
11743
11744 this.setErrors( config.errors || [] );
11745 this.setNotices( config.notices || [] );
11746 this.setAlignment( config.align );
11747 // Call this again to take into account the widget's accessKey
11748 this.updateTitle();
11749 };
11750
11751 /* Setup */
11752
11753 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11754 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11755 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11756
11757 /* Methods */
11758
11759 /**
11760 * Handle field disable events.
11761 *
11762 * @private
11763 * @param {boolean} value Field is disabled
11764 */
11765 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11766 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11767 };
11768
11769 /**
11770 * Get the widget contained by the field.
11771 *
11772 * @return {OO.ui.Widget} Field widget
11773 */
11774 OO.ui.FieldLayout.prototype.getField = function () {
11775 return this.fieldWidget;
11776 };
11777
11778 /**
11779 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11780 * #setAlignment). Return `false` if it can't or if this can't be determined.
11781 *
11782 * @return {boolean}
11783 */
11784 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11785 // This is very simplistic, but should be good enough.
11786 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11787 };
11788
11789 /**
11790 * @protected
11791 * @param {string} kind 'error' or 'notice'
11792 * @param {string|OO.ui.HtmlSnippet} text
11793 * @return {jQuery}
11794 */
11795 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11796 var $listItem, $icon, message;
11797 $listItem = $( '<li>' );
11798 if ( kind === 'error' ) {
11799 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11800 $listItem.attr( 'role', 'alert' );
11801 } else if ( kind === 'notice' ) {
11802 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11803 } else {
11804 $icon = '';
11805 }
11806 message = new OO.ui.LabelWidget( { label: text } );
11807 $listItem
11808 .append( $icon, message.$element )
11809 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11810 return $listItem;
11811 };
11812
11813 /**
11814 * Set the field alignment mode.
11815 *
11816 * @private
11817 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11818 * @chainable
11819 * @return {OO.ui.BookletLayout} The layout, for chaining
11820 */
11821 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11822 if ( value !== this.align ) {
11823 // Default to 'left'
11824 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11825 value = 'left';
11826 }
11827 // Validate
11828 if ( value === 'inline' && !this.isFieldInline() ) {
11829 value = 'top';
11830 }
11831 // Reorder elements
11832
11833 if ( this.helpInline ) {
11834 if ( value === 'top' ) {
11835 this.$header.append( this.$label );
11836 this.$body.append( this.$header, this.$field, this.$help );
11837 } else if ( value === 'inline' ) {
11838 this.$header.append( this.$label, this.$help );
11839 this.$body.append( this.$field, this.$header );
11840 } else {
11841 this.$header.append( this.$label, this.$help );
11842 this.$body.append( this.$header, this.$field );
11843 }
11844 } else {
11845 if ( value === 'top' ) {
11846 this.$header.append( this.$help, this.$label );
11847 this.$body.append( this.$header, this.$field );
11848 } else if ( value === 'inline' ) {
11849 this.$header.append( this.$help, this.$label );
11850 this.$body.append( this.$field, this.$header );
11851 } else {
11852 this.$header.append( this.$label );
11853 this.$body.append( this.$header, this.$help, this.$field );
11854 }
11855 }
11856 // Set classes. The following classes can be used here:
11857 // * oo-ui-fieldLayout-align-left
11858 // * oo-ui-fieldLayout-align-right
11859 // * oo-ui-fieldLayout-align-top
11860 // * oo-ui-fieldLayout-align-inline
11861 if ( this.align ) {
11862 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11863 }
11864 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11865 this.align = value;
11866 }
11867
11868 return this;
11869 };
11870
11871 /**
11872 * Set the list of error messages.
11873 *
11874 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11875 * The array may contain strings or OO.ui.HtmlSnippet instances.
11876 * @chainable
11877 * @return {OO.ui.BookletLayout} The layout, for chaining
11878 */
11879 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11880 this.errors = errors.slice();
11881 this.updateMessages();
11882 return this;
11883 };
11884
11885 /**
11886 * Set the list of notice messages.
11887 *
11888 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11889 * The array may contain strings or OO.ui.HtmlSnippet instances.
11890 * @chainable
11891 * @return {OO.ui.BookletLayout} The layout, for chaining
11892 */
11893 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11894 this.notices = notices.slice();
11895 this.updateMessages();
11896 return this;
11897 };
11898
11899 /**
11900 * Update the rendering of error and notice messages.
11901 *
11902 * @private
11903 */
11904 OO.ui.FieldLayout.prototype.updateMessages = function () {
11905 var i;
11906 this.$messages.empty();
11907
11908 if ( this.errors.length || this.notices.length ) {
11909 this.$body.after( this.$messages );
11910 } else {
11911 this.$messages.remove();
11912 return;
11913 }
11914
11915 for ( i = 0; i < this.notices.length; i++ ) {
11916 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11917 }
11918 for ( i = 0; i < this.errors.length; i++ ) {
11919 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11920 }
11921 };
11922
11923 /**
11924 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11925 * (This is a bit of a hack.)
11926 *
11927 * @protected
11928 * @param {string} title Tooltip label for 'title' attribute
11929 * @return {string}
11930 */
11931 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11932 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11933 return this.fieldWidget.formatTitleWithAccessKey( title );
11934 }
11935 return title;
11936 };
11937
11938 /**
11939 * Creates and returns the help element. Also sets the `aria-describedby`
11940 * attribute on the main element of the `fieldWidget`.
11941 *
11942 * @private
11943 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11944 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11945 * @return {jQuery} The element that should become `this.$help`.
11946 */
11947 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11948 var helpId, helpWidget;
11949
11950 if ( this.helpInline ) {
11951 helpWidget = new OO.ui.LabelWidget( {
11952 label: help,
11953 classes: [ 'oo-ui-inline-help' ]
11954 } );
11955
11956 helpId = helpWidget.getElementId();
11957 } else {
11958 helpWidget = new OO.ui.PopupButtonWidget( {
11959 $overlay: $overlay,
11960 popup: {
11961 padded: true
11962 },
11963 classes: [ 'oo-ui-fieldLayout-help' ],
11964 framed: false,
11965 icon: 'info',
11966 label: OO.ui.msg( 'ooui-field-help' ),
11967 invisibleLabel: true
11968 } );
11969 if ( help instanceof OO.ui.HtmlSnippet ) {
11970 helpWidget.getPopup().$body.html( help.toString() );
11971 } else {
11972 helpWidget.getPopup().$body.text( help );
11973 }
11974
11975 helpId = helpWidget.getPopup().getBodyId();
11976 }
11977
11978 // Set the 'aria-describedby' attribute on the fieldWidget
11979 // Preference given to an input or a button
11980 (
11981 this.fieldWidget.$input ||
11982 this.fieldWidget.$button ||
11983 this.fieldWidget.$element
11984 ).attr( 'aria-describedby', helpId );
11985
11986 return helpWidget.$element;
11987 };
11988
11989 /**
11990 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11991 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11992 * is required and is specified before any optional configuration settings.
11993 *
11994 * Labels can be aligned in one of four ways:
11995 *
11996 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11997 * A left-alignment is used for forms with many fields.
11998 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11999 * A right-alignment is used for long but familiar forms which users tab through,
12000 * verifying the current field with a quick glance at the label.
12001 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12002 * that users fill out from top to bottom.
12003 * - **inline**: The label is placed after the field-widget and aligned to the left.
12004 * An inline-alignment is best used with checkboxes or radio buttons.
12005 *
12006 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
12007 * text is specified.
12008 *
12009 * @example
12010 * // Example of an ActionFieldLayout
12011 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12012 * new OO.ui.TextInputWidget( {
12013 * placeholder: 'Field widget'
12014 * } ),
12015 * new OO.ui.ButtonWidget( {
12016 * label: 'Button'
12017 * } ),
12018 * {
12019 * label: 'An ActionFieldLayout. This label is aligned top',
12020 * align: 'top',
12021 * help: 'This is help text'
12022 * }
12023 * );
12024 *
12025 * $( document.body ).append( actionFieldLayout.$element );
12026 *
12027 * @class
12028 * @extends OO.ui.FieldLayout
12029 *
12030 * @constructor
12031 * @param {OO.ui.Widget} fieldWidget Field widget
12032 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12033 * @param {Object} config
12034 */
12035 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12036 // Allow passing positional parameters inside the config object
12037 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12038 config = fieldWidget;
12039 fieldWidget = config.fieldWidget;
12040 buttonWidget = config.buttonWidget;
12041 }
12042
12043 // Parent constructor
12044 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12045
12046 // Properties
12047 this.buttonWidget = buttonWidget;
12048 this.$button = $( '<span>' );
12049 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12050
12051 // Initialization
12052 this.$element
12053 .addClass( 'oo-ui-actionFieldLayout' );
12054 this.$button
12055 .addClass( 'oo-ui-actionFieldLayout-button' )
12056 .append( this.buttonWidget.$element );
12057 this.$input
12058 .addClass( 'oo-ui-actionFieldLayout-input' )
12059 .append( this.fieldWidget.$element );
12060 this.$field
12061 .append( this.$input, this.$button );
12062 };
12063
12064 /* Setup */
12065
12066 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12067
12068 /**
12069 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12070 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12071 * configured with a label as well. For more information and examples,
12072 * please see the [OOUI documentation on MediaWiki][1].
12073 *
12074 * @example
12075 * // Example of a fieldset layout
12076 * var input1 = new OO.ui.TextInputWidget( {
12077 * placeholder: 'A text input field'
12078 * } );
12079 *
12080 * var input2 = new OO.ui.TextInputWidget( {
12081 * placeholder: 'A text input field'
12082 * } );
12083 *
12084 * var fieldset = new OO.ui.FieldsetLayout( {
12085 * label: 'Example of a fieldset layout'
12086 * } );
12087 *
12088 * fieldset.addItems( [
12089 * new OO.ui.FieldLayout( input1, {
12090 * label: 'Field One'
12091 * } ),
12092 * new OO.ui.FieldLayout( input2, {
12093 * label: 'Field Two'
12094 * } )
12095 * ] );
12096 * $( document.body ).append( fieldset.$element );
12097 *
12098 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12099 *
12100 * @class
12101 * @extends OO.ui.Layout
12102 * @mixins OO.ui.mixin.IconElement
12103 * @mixins OO.ui.mixin.LabelElement
12104 * @mixins OO.ui.mixin.GroupElement
12105 *
12106 * @constructor
12107 * @param {Object} [config] Configuration options
12108 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
12109 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
12110 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
12111 * For important messages, you are advised to use `notices`, as they are always shown.
12112 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12113 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12114 */
12115 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12116 // Configuration initialization
12117 config = config || {};
12118
12119 // Parent constructor
12120 OO.ui.FieldsetLayout.parent.call( this, config );
12121
12122 // Mixin constructors
12123 OO.ui.mixin.IconElement.call( this, config );
12124 OO.ui.mixin.LabelElement.call( this, config );
12125 OO.ui.mixin.GroupElement.call( this, config );
12126
12127 // Properties
12128 this.$header = $( '<legend>' );
12129 if ( config.help ) {
12130 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12131 $overlay: config.$overlay,
12132 popup: {
12133 padded: true
12134 },
12135 classes: [ 'oo-ui-fieldsetLayout-help' ],
12136 framed: false,
12137 icon: 'info',
12138 label: OO.ui.msg( 'ooui-field-help' ),
12139 invisibleLabel: true
12140 } );
12141 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12142 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12143 } else {
12144 this.popupButtonWidget.getPopup().$body.text( config.help );
12145 }
12146 this.$help = this.popupButtonWidget.$element;
12147 } else {
12148 this.$help = $( [] );
12149 }
12150
12151 // Initialization
12152 this.$header
12153 .addClass( 'oo-ui-fieldsetLayout-header' )
12154 .append( this.$icon, this.$label, this.$help );
12155 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12156 this.$element
12157 .addClass( 'oo-ui-fieldsetLayout' )
12158 .prepend( this.$header, this.$group );
12159 if ( Array.isArray( config.items ) ) {
12160 this.addItems( config.items );
12161 }
12162 };
12163
12164 /* Setup */
12165
12166 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12167 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12168 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12169 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12170
12171 /* Static Properties */
12172
12173 /**
12174 * @static
12175 * @inheritdoc
12176 */
12177 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12178
12179 /**
12180 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
12181 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
12182 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
12183 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12184 *
12185 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12186 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12187 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12188 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12189 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12190 * often have simplified APIs to match the capabilities of HTML forms.
12191 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12192 *
12193 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12194 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12195 *
12196 * @example
12197 * // Example of a form layout that wraps a fieldset layout
12198 * var input1 = new OO.ui.TextInputWidget( {
12199 * placeholder: 'Username'
12200 * } );
12201 * var input2 = new OO.ui.TextInputWidget( {
12202 * placeholder: 'Password',
12203 * type: 'password'
12204 * } );
12205 * var submit = new OO.ui.ButtonInputWidget( {
12206 * label: 'Submit'
12207 * } );
12208 *
12209 * var fieldset = new OO.ui.FieldsetLayout( {
12210 * label: 'A form layout'
12211 * } );
12212 * fieldset.addItems( [
12213 * new OO.ui.FieldLayout( input1, {
12214 * label: 'Username',
12215 * align: 'top'
12216 * } ),
12217 * new OO.ui.FieldLayout( input2, {
12218 * label: 'Password',
12219 * align: 'top'
12220 * } ),
12221 * new OO.ui.FieldLayout( submit )
12222 * ] );
12223 * var form = new OO.ui.FormLayout( {
12224 * items: [ fieldset ],
12225 * action: '/api/formhandler',
12226 * method: 'get'
12227 * } )
12228 * $( document.body ).append( form.$element );
12229 *
12230 * @class
12231 * @extends OO.ui.Layout
12232 * @mixins OO.ui.mixin.GroupElement
12233 *
12234 * @constructor
12235 * @param {Object} [config] Configuration options
12236 * @cfg {string} [method] HTML form `method` attribute
12237 * @cfg {string} [action] HTML form `action` attribute
12238 * @cfg {string} [enctype] HTML form `enctype` attribute
12239 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12240 */
12241 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12242 var action;
12243
12244 // Configuration initialization
12245 config = config || {};
12246
12247 // Parent constructor
12248 OO.ui.FormLayout.parent.call( this, config );
12249
12250 // Mixin constructors
12251 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12252
12253 // Events
12254 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12255
12256 // Make sure the action is safe
12257 action = config.action;
12258 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12259 action = './' + action;
12260 }
12261
12262 // Initialization
12263 this.$element
12264 .addClass( 'oo-ui-formLayout' )
12265 .attr( {
12266 method: config.method,
12267 action: action,
12268 enctype: config.enctype
12269 } );
12270 if ( Array.isArray( config.items ) ) {
12271 this.addItems( config.items );
12272 }
12273 };
12274
12275 /* Setup */
12276
12277 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12278 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12279
12280 /* Events */
12281
12282 /**
12283 * A 'submit' event is emitted when the form is submitted.
12284 *
12285 * @event submit
12286 */
12287
12288 /* Static Properties */
12289
12290 /**
12291 * @static
12292 * @inheritdoc
12293 */
12294 OO.ui.FormLayout.static.tagName = 'form';
12295
12296 /* Methods */
12297
12298 /**
12299 * Handle form submit events.
12300 *
12301 * @private
12302 * @param {jQuery.Event} e Submit event
12303 * @fires submit
12304 * @return {OO.ui.FormLayout} The layout, for chaining
12305 */
12306 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12307 if ( this.emit( 'submit' ) ) {
12308 return false;
12309 }
12310 };
12311
12312 /**
12313 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12314 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12315 *
12316 * @example
12317 * // Example of a panel layout
12318 * var panel = new OO.ui.PanelLayout( {
12319 * expanded: false,
12320 * framed: true,
12321 * padded: true,
12322 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12323 * } );
12324 * $( document.body ).append( panel.$element );
12325 *
12326 * @class
12327 * @extends OO.ui.Layout
12328 *
12329 * @constructor
12330 * @param {Object} [config] Configuration options
12331 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12332 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12333 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12334 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12335 */
12336 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12337 // Configuration initialization
12338 config = $.extend( {
12339 scrollable: false,
12340 padded: false,
12341 expanded: true,
12342 framed: false
12343 }, config );
12344
12345 // Parent constructor
12346 OO.ui.PanelLayout.parent.call( this, config );
12347
12348 // Initialization
12349 this.$element.addClass( 'oo-ui-panelLayout' );
12350 if ( config.scrollable ) {
12351 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12352 }
12353 if ( config.padded ) {
12354 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12355 }
12356 if ( config.expanded ) {
12357 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12358 }
12359 if ( config.framed ) {
12360 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12361 }
12362 };
12363
12364 /* Setup */
12365
12366 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12367
12368 /* Methods */
12369
12370 /**
12371 * Focus the panel layout
12372 *
12373 * The default implementation just focuses the first focusable element in the panel
12374 */
12375 OO.ui.PanelLayout.prototype.focus = function () {
12376 OO.ui.findFocusable( this.$element ).focus();
12377 };
12378
12379 /**
12380 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12381 * items), with small margins between them. Convenient when you need to put a number of block-level
12382 * widgets on a single line next to each other.
12383 *
12384 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12385 *
12386 * @example
12387 * // HorizontalLayout with a text input and a label
12388 * var layout = new OO.ui.HorizontalLayout( {
12389 * items: [
12390 * new OO.ui.LabelWidget( { label: 'Label' } ),
12391 * new OO.ui.TextInputWidget( { value: 'Text' } )
12392 * ]
12393 * } );
12394 * $( document.body ).append( layout.$element );
12395 *
12396 * @class
12397 * @extends OO.ui.Layout
12398 * @mixins OO.ui.mixin.GroupElement
12399 *
12400 * @constructor
12401 * @param {Object} [config] Configuration options
12402 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12403 */
12404 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12405 // Configuration initialization
12406 config = config || {};
12407
12408 // Parent constructor
12409 OO.ui.HorizontalLayout.parent.call( this, config );
12410
12411 // Mixin constructors
12412 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12413
12414 // Initialization
12415 this.$element.addClass( 'oo-ui-horizontalLayout' );
12416 if ( Array.isArray( config.items ) ) {
12417 this.addItems( config.items );
12418 }
12419 };
12420
12421 /* Setup */
12422
12423 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12424 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12425
12426 /**
12427 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12428 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12429 * (to adjust the value in increments) to allow the user to enter a number.
12430 *
12431 * @example
12432 * // A NumberInputWidget.
12433 * var numberInput = new OO.ui.NumberInputWidget( {
12434 * label: 'NumberInputWidget',
12435 * input: { value: 5 },
12436 * min: 1,
12437 * max: 10
12438 * } );
12439 * $( document.body ).append( numberInput.$element );
12440 *
12441 * @class
12442 * @extends OO.ui.TextInputWidget
12443 *
12444 * @constructor
12445 * @param {Object} [config] Configuration options
12446 * @cfg {Object} [minusButton] Configuration options to pass to the
12447 * {@link OO.ui.ButtonWidget decrementing button widget}.
12448 * @cfg {Object} [plusButton] Configuration options to pass to the
12449 * {@link OO.ui.ButtonWidget incrementing button widget}.
12450 * @cfg {number} [min=-Infinity] Minimum allowed value
12451 * @cfg {number} [max=Infinity] Maximum allowed value
12452 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12453 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12454 * Defaults to `step` if specified, otherwise `1`.
12455 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12456 * Defaults to 10 times `buttonStep`.
12457 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12458 */
12459 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12460 var $field = $( '<div>' )
12461 .addClass( 'oo-ui-numberInputWidget-field' );
12462
12463 // Configuration initialization
12464 config = $.extend( {
12465 min: -Infinity,
12466 max: Infinity,
12467 showButtons: true
12468 }, config );
12469
12470 // For backward compatibility
12471 $.extend( config, config.input );
12472 this.input = this;
12473
12474 // Parent constructor
12475 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12476 type: 'number'
12477 } ) );
12478
12479 if ( config.showButtons ) {
12480 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12481 {
12482 disabled: this.isDisabled(),
12483 tabIndex: -1,
12484 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12485 icon: 'subtract'
12486 },
12487 config.minusButton
12488 ) );
12489 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12490 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12491 {
12492 disabled: this.isDisabled(),
12493 tabIndex: -1,
12494 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12495 icon: 'add'
12496 },
12497 config.plusButton
12498 ) );
12499 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12500 }
12501
12502 // Events
12503 this.$input.on( {
12504 keydown: this.onKeyDown.bind( this ),
12505 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12506 } );
12507 if ( config.showButtons ) {
12508 this.plusButton.connect( this, {
12509 click: [ 'onButtonClick', +1 ]
12510 } );
12511 this.minusButton.connect( this, {
12512 click: [ 'onButtonClick', -1 ]
12513 } );
12514 }
12515
12516 // Build the field
12517 $field.append( this.$input );
12518 if ( config.showButtons ) {
12519 $field
12520 .prepend( this.minusButton.$element )
12521 .append( this.plusButton.$element );
12522 }
12523
12524 // Initialization
12525 if ( config.allowInteger || config.isInteger ) {
12526 // Backward compatibility
12527 config.step = 1;
12528 }
12529 this.setRange( config.min, config.max );
12530 this.setStep( config.buttonStep, config.pageStep, config.step );
12531 // Set the validation method after we set step and range
12532 // so that it doesn't immediately call setValidityFlag
12533 this.setValidation( this.validateNumber.bind( this ) );
12534
12535 this.$element
12536 .addClass( 'oo-ui-numberInputWidget' )
12537 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12538 .append( $field );
12539 };
12540
12541 /* Setup */
12542
12543 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12544
12545 /* Methods */
12546
12547 // Backward compatibility
12548 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12549 this.setStep( flag ? 1 : null );
12550 };
12551 // Backward compatibility
12552 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12553
12554 // Backward compatibility
12555 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12556 return this.step === 1;
12557 };
12558 // Backward compatibility
12559 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12560
12561 /**
12562 * Set the range of allowed values
12563 *
12564 * @param {number} min Minimum allowed value
12565 * @param {number} max Maximum allowed value
12566 */
12567 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12568 if ( min > max ) {
12569 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12570 }
12571 this.min = min;
12572 this.max = max;
12573 this.$input.attr( 'min', this.min );
12574 this.$input.attr( 'max', this.max );
12575 this.setValidityFlag();
12576 };
12577
12578 /**
12579 * Get the current range
12580 *
12581 * @return {number[]} Minimum and maximum values
12582 */
12583 OO.ui.NumberInputWidget.prototype.getRange = function () {
12584 return [ this.min, this.max ];
12585 };
12586
12587 /**
12588 * Set the stepping deltas
12589 *
12590 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12591 * Defaults to `step` if specified, otherwise `1`.
12592 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12593 * Defaults to 10 times `buttonStep`.
12594 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12595 */
12596 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12597 if ( buttonStep === undefined ) {
12598 buttonStep = step || 1;
12599 }
12600 if ( pageStep === undefined ) {
12601 pageStep = 10 * buttonStep;
12602 }
12603 if ( step !== null && step <= 0 ) {
12604 throw new Error( 'Step value, if given, must be positive' );
12605 }
12606 if ( buttonStep <= 0 ) {
12607 throw new Error( 'Button step value must be positive' );
12608 }
12609 if ( pageStep <= 0 ) {
12610 throw new Error( 'Page step value must be positive' );
12611 }
12612 this.step = step;
12613 this.buttonStep = buttonStep;
12614 this.pageStep = pageStep;
12615 this.$input.attr( 'step', this.step || 'any' );
12616 this.setValidityFlag();
12617 };
12618
12619 /**
12620 * @inheritdoc
12621 */
12622 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12623 if ( value === '' ) {
12624 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12625 // so here we make sure an 'empty' value is actually displayed as such.
12626 this.$input.val( '' );
12627 }
12628 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12629 };
12630
12631 /**
12632 * Get the current stepping values
12633 *
12634 * @return {number[]} Button step, page step, and validity step
12635 */
12636 OO.ui.NumberInputWidget.prototype.getStep = function () {
12637 return [ this.buttonStep, this.pageStep, this.step ];
12638 };
12639
12640 /**
12641 * Get the current value of the widget as a number
12642 *
12643 * @return {number} May be NaN, or an invalid number
12644 */
12645 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12646 return +this.getValue();
12647 };
12648
12649 /**
12650 * Adjust the value of the widget
12651 *
12652 * @param {number} delta Adjustment amount
12653 */
12654 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12655 var n, v = this.getNumericValue();
12656
12657 delta = +delta;
12658 if ( isNaN( delta ) || !isFinite( delta ) ) {
12659 throw new Error( 'Delta must be a finite number' );
12660 }
12661
12662 if ( isNaN( v ) ) {
12663 n = 0;
12664 } else {
12665 n = v + delta;
12666 n = Math.max( Math.min( n, this.max ), this.min );
12667 if ( this.step ) {
12668 n = Math.round( n / this.step ) * this.step;
12669 }
12670 }
12671
12672 if ( n !== v ) {
12673 this.setValue( n );
12674 }
12675 };
12676 /**
12677 * Validate input
12678 *
12679 * @private
12680 * @param {string} value Field value
12681 * @return {boolean}
12682 */
12683 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12684 var n = +value;
12685 if ( value === '' ) {
12686 return !this.isRequired();
12687 }
12688
12689 if ( isNaN( n ) || !isFinite( n ) ) {
12690 return false;
12691 }
12692
12693 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12694 return false;
12695 }
12696
12697 if ( n < this.min || n > this.max ) {
12698 return false;
12699 }
12700
12701 return true;
12702 };
12703
12704 /**
12705 * Handle mouse click events.
12706 *
12707 * @private
12708 * @param {number} dir +1 or -1
12709 */
12710 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12711 this.adjustValue( dir * this.buttonStep );
12712 };
12713
12714 /**
12715 * Handle mouse wheel events.
12716 *
12717 * @private
12718 * @param {jQuery.Event} event
12719 * @return {undefined/boolean} False to prevent default if event is handled
12720 */
12721 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12722 var delta = 0;
12723
12724 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12725 // Standard 'wheel' event
12726 if ( event.originalEvent.deltaMode !== undefined ) {
12727 this.sawWheelEvent = true;
12728 }
12729 if ( event.originalEvent.deltaY ) {
12730 delta = -event.originalEvent.deltaY;
12731 } else if ( event.originalEvent.deltaX ) {
12732 delta = event.originalEvent.deltaX;
12733 }
12734
12735 // Non-standard events
12736 if ( !this.sawWheelEvent ) {
12737 if ( event.originalEvent.wheelDeltaX ) {
12738 delta = -event.originalEvent.wheelDeltaX;
12739 } else if ( event.originalEvent.wheelDeltaY ) {
12740 delta = event.originalEvent.wheelDeltaY;
12741 } else if ( event.originalEvent.wheelDelta ) {
12742 delta = event.originalEvent.wheelDelta;
12743 } else if ( event.originalEvent.detail ) {
12744 delta = -event.originalEvent.detail;
12745 }
12746 }
12747
12748 if ( delta ) {
12749 delta = delta < 0 ? -1 : 1;
12750 this.adjustValue( delta * this.buttonStep );
12751 }
12752
12753 return false;
12754 }
12755 };
12756
12757 /**
12758 * Handle key down events.
12759 *
12760 * @private
12761 * @param {jQuery.Event} e Key down event
12762 * @return {undefined/boolean} False to prevent default if event is handled
12763 */
12764 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12765 if ( !this.isDisabled() ) {
12766 switch ( e.which ) {
12767 case OO.ui.Keys.UP:
12768 this.adjustValue( this.buttonStep );
12769 return false;
12770 case OO.ui.Keys.DOWN:
12771 this.adjustValue( -this.buttonStep );
12772 return false;
12773 case OO.ui.Keys.PAGEUP:
12774 this.adjustValue( this.pageStep );
12775 return false;
12776 case OO.ui.Keys.PAGEDOWN:
12777 this.adjustValue( -this.pageStep );
12778 return false;
12779 }
12780 }
12781 };
12782
12783 /**
12784 * @inheritdoc
12785 */
12786 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12787 // Parent method
12788 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12789
12790 if ( this.minusButton ) {
12791 this.minusButton.setDisabled( this.isDisabled() );
12792 }
12793 if ( this.plusButton ) {
12794 this.plusButton.setDisabled( this.isDisabled() );
12795 }
12796
12797 return this;
12798 };
12799
12800 }( OO ) );
12801
12802 //# sourceMappingURL=oojs-ui-core.js.map.json