Merge "Add special page class for disabling special pages"
[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 'aria-owns': this.menu.getElementId(),
8053 'aria-haspopup': 'listbox'
8054 } )
8055 .append( this.$icon, this.$label, this.$indicator );
8056 this.$element
8057 .addClass( 'oo-ui-dropdownWidget' )
8058 .append( this.$handle );
8059 this.$overlay.append( this.menu.$element );
8060 };
8061
8062 /* Setup */
8063
8064 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8065 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8066 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8067 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8068 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8069 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8070
8071 /* Methods */
8072
8073 /**
8074 * Get the menu.
8075 *
8076 * @return {OO.ui.MenuSelectWidget} Menu of widget
8077 */
8078 OO.ui.DropdownWidget.prototype.getMenu = function () {
8079 return this.menu;
8080 };
8081
8082 /**
8083 * Handles menu select events.
8084 *
8085 * @private
8086 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8087 */
8088 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8089 var selectedLabel;
8090
8091 if ( !item ) {
8092 this.setLabel( null );
8093 return;
8094 }
8095
8096 selectedLabel = item.getLabel();
8097
8098 // If the label is a DOM element, clone it, because setLabel will append() it
8099 if ( selectedLabel instanceof $ ) {
8100 selectedLabel = selectedLabel.clone();
8101 }
8102
8103 this.setLabel( selectedLabel );
8104 };
8105
8106 /**
8107 * Handle menu toggle events.
8108 *
8109 * @private
8110 * @param {boolean} isVisible Open state of the menu
8111 */
8112 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8113 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8114 };
8115
8116 /**
8117 * Handle mouse click events.
8118 *
8119 * @private
8120 * @param {jQuery.Event} e Mouse click event
8121 * @return {undefined/boolean} False to prevent default if event is handled
8122 */
8123 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8124 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8125 this.menu.toggle();
8126 }
8127 return false;
8128 };
8129
8130 /**
8131 * Handle key down events.
8132 *
8133 * @private
8134 * @param {jQuery.Event} e Key down event
8135 * @return {undefined/boolean} False to prevent default if event is handled
8136 */
8137 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8138 if (
8139 !this.isDisabled() &&
8140 (
8141 e.which === OO.ui.Keys.ENTER ||
8142 (
8143 e.which === OO.ui.Keys.SPACE &&
8144 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8145 // Space only closes the menu is the user is not typing to search.
8146 this.menu.keyPressBuffer === ''
8147 ) ||
8148 (
8149 !this.menu.isVisible() &&
8150 (
8151 e.which === OO.ui.Keys.UP ||
8152 e.which === OO.ui.Keys.DOWN
8153 )
8154 )
8155 )
8156 ) {
8157 this.menu.toggle();
8158 return false;
8159 }
8160 };
8161
8162 /**
8163 * RadioOptionWidget is an option widget that looks like a radio button.
8164 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8165 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8166 *
8167 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8168 *
8169 * @class
8170 * @extends OO.ui.OptionWidget
8171 *
8172 * @constructor
8173 * @param {Object} [config] Configuration options
8174 */
8175 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8176 // Configuration initialization
8177 config = config || {};
8178
8179 // Properties (must be done before parent constructor which calls #setDisabled)
8180 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8181
8182 // Parent constructor
8183 OO.ui.RadioOptionWidget.parent.call( this, config );
8184
8185 // Initialization
8186 // Remove implicit role, we're handling it ourselves
8187 this.radio.$input.attr( 'role', 'presentation' );
8188 this.$element
8189 .addClass( 'oo-ui-radioOptionWidget' )
8190 .attr( 'role', 'radio' )
8191 .attr( 'aria-checked', 'false' )
8192 .removeAttr( 'aria-selected' )
8193 .prepend( this.radio.$element );
8194 };
8195
8196 /* Setup */
8197
8198 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8199
8200 /* Static Properties */
8201
8202 /**
8203 * @static
8204 * @inheritdoc
8205 */
8206 OO.ui.RadioOptionWidget.static.highlightable = false;
8207
8208 /**
8209 * @static
8210 * @inheritdoc
8211 */
8212 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8213
8214 /**
8215 * @static
8216 * @inheritdoc
8217 */
8218 OO.ui.RadioOptionWidget.static.pressable = false;
8219
8220 /**
8221 * @static
8222 * @inheritdoc
8223 */
8224 OO.ui.RadioOptionWidget.static.tagName = 'label';
8225
8226 /* Methods */
8227
8228 /**
8229 * @inheritdoc
8230 */
8231 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8232 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8233
8234 this.radio.setSelected( state );
8235 this.$element
8236 .attr( 'aria-checked', state.toString() )
8237 .removeAttr( 'aria-selected' );
8238
8239 return this;
8240 };
8241
8242 /**
8243 * @inheritdoc
8244 */
8245 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8246 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8247
8248 this.radio.setDisabled( this.isDisabled() );
8249
8250 return this;
8251 };
8252
8253 /**
8254 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8255 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8256 * an interface for adding, removing and selecting options.
8257 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8258 *
8259 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8260 * OO.ui.RadioSelectInputWidget instead.
8261 *
8262 * @example
8263 * // A RadioSelectWidget with RadioOptions.
8264 * var option1 = new OO.ui.RadioOptionWidget( {
8265 * data: 'a',
8266 * label: 'Selected radio option'
8267 * } ),
8268 * option2 = new OO.ui.RadioOptionWidget( {
8269 * data: 'b',
8270 * label: 'Unselected radio option'
8271 * } );
8272 * radioSelect = new OO.ui.RadioSelectWidget( {
8273 * items: [ option1, option2 ]
8274 * } );
8275 *
8276 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8277 * radioSelect.selectItem( option1 );
8278 *
8279 * $( document.body ).append( radioSelect.$element );
8280 *
8281 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8282
8283 *
8284 * @class
8285 * @extends OO.ui.SelectWidget
8286 * @mixins OO.ui.mixin.TabIndexedElement
8287 *
8288 * @constructor
8289 * @param {Object} [config] Configuration options
8290 */
8291 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8292 // Parent constructor
8293 OO.ui.RadioSelectWidget.parent.call( this, config );
8294
8295 // Mixin constructors
8296 OO.ui.mixin.TabIndexedElement.call( this, config );
8297
8298 // Events
8299 this.$element.on( {
8300 focus: this.bindDocumentKeyDownListener.bind( this ),
8301 blur: this.unbindDocumentKeyDownListener.bind( this )
8302 } );
8303
8304 // Initialization
8305 this.$element
8306 .addClass( 'oo-ui-radioSelectWidget' )
8307 .attr( 'role', 'radiogroup' );
8308 };
8309
8310 /* Setup */
8311
8312 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8313 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8314
8315 /**
8316 * MultioptionWidgets are special elements that can be selected and configured with data. The
8317 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8318 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8319 * and examples, please see the [OOUI documentation on MediaWiki][1].
8320 *
8321 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8322 *
8323 * @class
8324 * @extends OO.ui.Widget
8325 * @mixins OO.ui.mixin.ItemWidget
8326 * @mixins OO.ui.mixin.LabelElement
8327 * @mixins OO.ui.mixin.TitledElement
8328 *
8329 * @constructor
8330 * @param {Object} [config] Configuration options
8331 * @cfg {boolean} [selected=false] Whether the option is initially selected
8332 */
8333 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8334 // Configuration initialization
8335 config = config || {};
8336
8337 // Parent constructor
8338 OO.ui.MultioptionWidget.parent.call( this, config );
8339
8340 // Mixin constructors
8341 OO.ui.mixin.ItemWidget.call( this );
8342 OO.ui.mixin.LabelElement.call( this, config );
8343 OO.ui.mixin.TitledElement.call( this, config );
8344
8345 // Properties
8346 this.selected = null;
8347
8348 // Initialization
8349 this.$element
8350 .addClass( 'oo-ui-multioptionWidget' )
8351 .append( this.$label );
8352 this.setSelected( config.selected );
8353 };
8354
8355 /* Setup */
8356
8357 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8358 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8359 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8360 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8361
8362 /* Events */
8363
8364 /**
8365 * @event change
8366 *
8367 * A change event is emitted when the selected state of the option changes.
8368 *
8369 * @param {boolean} selected Whether the option is now selected
8370 */
8371
8372 /* Methods */
8373
8374 /**
8375 * Check if the option is selected.
8376 *
8377 * @return {boolean} Item is selected
8378 */
8379 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8380 return this.selected;
8381 };
8382
8383 /**
8384 * Set the option’s selected state. In general, all modifications to the selection
8385 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8386 * method instead of this method.
8387 *
8388 * @param {boolean} [state=false] Select option
8389 * @chainable
8390 * @return {OO.ui.Widget} The widget, for chaining
8391 */
8392 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8393 state = !!state;
8394 if ( this.selected !== state ) {
8395 this.selected = state;
8396 this.emit( 'change', state );
8397 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8398 }
8399 return this;
8400 };
8401
8402 /**
8403 * MultiselectWidget allows selecting multiple options from a list.
8404 *
8405 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8406 *
8407 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8408 *
8409 * @class
8410 * @abstract
8411 * @extends OO.ui.Widget
8412 * @mixins OO.ui.mixin.GroupWidget
8413 * @mixins OO.ui.mixin.TitledElement
8414 *
8415 * @constructor
8416 * @param {Object} [config] Configuration options
8417 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8418 */
8419 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8420 // Parent constructor
8421 OO.ui.MultiselectWidget.parent.call( this, config );
8422
8423 // Configuration initialization
8424 config = config || {};
8425
8426 // Mixin constructors
8427 OO.ui.mixin.GroupWidget.call( this, config );
8428 OO.ui.mixin.TitledElement.call( this, config );
8429
8430 // Events
8431 this.aggregate( { change: 'select' } );
8432 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8433 // by GroupElement only when items are added/removed
8434 this.connect( this, { select: [ 'emit', 'change' ] } );
8435
8436 // Initialization
8437 if ( config.items ) {
8438 this.addItems( config.items );
8439 }
8440 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8441 this.$element.addClass( 'oo-ui-multiselectWidget' )
8442 .append( this.$group );
8443 };
8444
8445 /* Setup */
8446
8447 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8448 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8449 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8450
8451 /* Events */
8452
8453 /**
8454 * @event change
8455 *
8456 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8457 */
8458
8459 /**
8460 * @event select
8461 *
8462 * A select event is emitted when an item is selected or deselected.
8463 */
8464
8465 /* Methods */
8466
8467 /**
8468 * Find options that are selected.
8469 *
8470 * @return {OO.ui.MultioptionWidget[]} Selected options
8471 */
8472 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8473 return this.items.filter( function ( item ) {
8474 return item.isSelected();
8475 } );
8476 };
8477
8478 /**
8479 * Find the data of options that are selected.
8480 *
8481 * @return {Object[]|string[]} Values of selected options
8482 */
8483 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8484 return this.findSelectedItems().map( function ( item ) {
8485 return item.data;
8486 } );
8487 };
8488
8489 /**
8490 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8491 *
8492 * @param {OO.ui.MultioptionWidget[]} items Items to select
8493 * @chainable
8494 * @return {OO.ui.Widget} The widget, for chaining
8495 */
8496 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8497 this.items.forEach( function ( item ) {
8498 var selected = items.indexOf( item ) !== -1;
8499 item.setSelected( selected );
8500 } );
8501 return this;
8502 };
8503
8504 /**
8505 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8506 *
8507 * @param {Object[]|string[]} datas Values of items to select
8508 * @chainable
8509 * @return {OO.ui.Widget} The widget, for chaining
8510 */
8511 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8512 var items,
8513 widget = this;
8514 items = datas.map( function ( data ) {
8515 return widget.findItemFromData( data );
8516 } );
8517 this.selectItems( items );
8518 return this;
8519 };
8520
8521 /**
8522 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8523 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8524 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8525 *
8526 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8527 *
8528 * @class
8529 * @extends OO.ui.MultioptionWidget
8530 *
8531 * @constructor
8532 * @param {Object} [config] Configuration options
8533 */
8534 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8535 // Configuration initialization
8536 config = config || {};
8537
8538 // Properties (must be done before parent constructor which calls #setDisabled)
8539 this.checkbox = new OO.ui.CheckboxInputWidget();
8540
8541 // Parent constructor
8542 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8543
8544 // Events
8545 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8546 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8547
8548 // Initialization
8549 this.$element
8550 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8551 .prepend( this.checkbox.$element );
8552 };
8553
8554 /* Setup */
8555
8556 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8557
8558 /* Static Properties */
8559
8560 /**
8561 * @static
8562 * @inheritdoc
8563 */
8564 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8565
8566 /* Methods */
8567
8568 /**
8569 * Handle checkbox selected state change.
8570 *
8571 * @private
8572 */
8573 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8574 this.setSelected( this.checkbox.isSelected() );
8575 };
8576
8577 /**
8578 * @inheritdoc
8579 */
8580 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8581 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8582 this.checkbox.setSelected( state );
8583 return this;
8584 };
8585
8586 /**
8587 * @inheritdoc
8588 */
8589 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8590 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8591 this.checkbox.setDisabled( this.isDisabled() );
8592 return this;
8593 };
8594
8595 /**
8596 * Focus the widget.
8597 */
8598 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8599 this.checkbox.focus();
8600 };
8601
8602 /**
8603 * Handle key down events.
8604 *
8605 * @protected
8606 * @param {jQuery.Event} e
8607 */
8608 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8609 var
8610 element = this.getElementGroup(),
8611 nextItem;
8612
8613 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8614 nextItem = element.getRelativeFocusableItem( this, -1 );
8615 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8616 nextItem = element.getRelativeFocusableItem( this, 1 );
8617 }
8618
8619 if ( nextItem ) {
8620 e.preventDefault();
8621 nextItem.focus();
8622 }
8623 };
8624
8625 /**
8626 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8627 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8628 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8629 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8630 *
8631 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8632 * OO.ui.CheckboxMultiselectInputWidget instead.
8633 *
8634 * @example
8635 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8636 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8637 * data: 'a',
8638 * selected: true,
8639 * label: 'Selected checkbox'
8640 * } ),
8641 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8642 * data: 'b',
8643 * label: 'Unselected checkbox'
8644 * } ),
8645 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8646 * items: [ option1, option2 ]
8647 * } );
8648 * $( document.body ).append( multiselect.$element );
8649 *
8650 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8651 *
8652 * @class
8653 * @extends OO.ui.MultiselectWidget
8654 *
8655 * @constructor
8656 * @param {Object} [config] Configuration options
8657 */
8658 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8659 // Parent constructor
8660 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8661
8662 // Properties
8663 this.$lastClicked = null;
8664
8665 // Events
8666 this.$group.on( 'click', this.onClick.bind( this ) );
8667
8668 // Initialization
8669 this.$element
8670 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8671 };
8672
8673 /* Setup */
8674
8675 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8676
8677 /* Methods */
8678
8679 /**
8680 * Get an option by its position relative to the specified item (or to the start of the option array,
8681 * if item is `null`). The direction in which to search through the option array is specified with a
8682 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8683 * `null` if there are no options in the array.
8684 *
8685 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8686 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8687 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8688 */
8689 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8690 var currentIndex, nextIndex, i,
8691 increase = direction > 0 ? 1 : -1,
8692 len = this.items.length;
8693
8694 if ( item ) {
8695 currentIndex = this.items.indexOf( item );
8696 nextIndex = ( currentIndex + increase + len ) % len;
8697 } else {
8698 // If no item is selected and moving forward, start at the beginning.
8699 // If moving backward, start at the end.
8700 nextIndex = direction > 0 ? 0 : len - 1;
8701 }
8702
8703 for ( i = 0; i < len; i++ ) {
8704 item = this.items[ nextIndex ];
8705 if ( item && !item.isDisabled() ) {
8706 return item;
8707 }
8708 nextIndex = ( nextIndex + increase + len ) % len;
8709 }
8710 return null;
8711 };
8712
8713 /**
8714 * Handle click events on checkboxes.
8715 *
8716 * @param {jQuery.Event} e
8717 */
8718 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8719 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8720 $lastClicked = this.$lastClicked,
8721 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8722 .not( '.oo-ui-widget-disabled' );
8723
8724 // Allow selecting multiple options at once by Shift-clicking them
8725 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8726 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8727 lastClickedIndex = $options.index( $lastClicked );
8728 nowClickedIndex = $options.index( $nowClicked );
8729 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8730 // browser. In either case we don't need custom handling.
8731 if ( nowClickedIndex !== lastClickedIndex ) {
8732 items = this.items;
8733 wasSelected = items[ nowClickedIndex ].isSelected();
8734 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8735
8736 // This depends on the DOM order of the items and the order of the .items array being the same.
8737 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8738 if ( !items[ i ].isDisabled() ) {
8739 items[ i ].setSelected( !wasSelected );
8740 }
8741 }
8742 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8743 // handling first, then set our value. The order in which events happen is different for
8744 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8745 // non-click actions that change the checkboxes.
8746 e.preventDefault();
8747 setTimeout( function () {
8748 if ( !items[ nowClickedIndex ].isDisabled() ) {
8749 items[ nowClickedIndex ].setSelected( !wasSelected );
8750 }
8751 } );
8752 }
8753 }
8754
8755 if ( $nowClicked.length ) {
8756 this.$lastClicked = $nowClicked;
8757 }
8758 };
8759
8760 /**
8761 * Focus the widget
8762 *
8763 * @chainable
8764 * @return {OO.ui.Widget} The widget, for chaining
8765 */
8766 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8767 var item;
8768 if ( !this.isDisabled() ) {
8769 item = this.getRelativeFocusableItem( null, 1 );
8770 if ( item ) {
8771 item.focus();
8772 }
8773 }
8774 return this;
8775 };
8776
8777 /**
8778 * @inheritdoc
8779 */
8780 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8781 this.focus();
8782 };
8783
8784 /**
8785 * Progress bars visually display the status of an operation, such as a download,
8786 * and can be either determinate or indeterminate:
8787 *
8788 * - **determinate** process bars show the percent of an operation that is complete.
8789 *
8790 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8791 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8792 * not use percentages.
8793 *
8794 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8795 *
8796 * @example
8797 * // Examples of determinate and indeterminate progress bars.
8798 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8799 * progress: 33
8800 * } );
8801 * var progressBar2 = new OO.ui.ProgressBarWidget();
8802 *
8803 * // Create a FieldsetLayout to layout progress bars.
8804 * var fieldset = new OO.ui.FieldsetLayout;
8805 * fieldset.addItems( [
8806 * new OO.ui.FieldLayout( progressBar1, {
8807 * label: 'Determinate',
8808 * align: 'top'
8809 * } ),
8810 * new OO.ui.FieldLayout( progressBar2, {
8811 * label: 'Indeterminate',
8812 * align: 'top'
8813 * } )
8814 * ] );
8815 * $( document.body ).append( fieldset.$element );
8816 *
8817 * @class
8818 * @extends OO.ui.Widget
8819 *
8820 * @constructor
8821 * @param {Object} [config] Configuration options
8822 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8823 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8824 * By default, the progress bar is indeterminate.
8825 */
8826 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8827 // Configuration initialization
8828 config = config || {};
8829
8830 // Parent constructor
8831 OO.ui.ProgressBarWidget.parent.call( this, config );
8832
8833 // Properties
8834 this.$bar = $( '<div>' );
8835 this.progress = null;
8836
8837 // Initialization
8838 this.setProgress( config.progress !== undefined ? config.progress : false );
8839 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8840 this.$element
8841 .attr( {
8842 role: 'progressbar',
8843 'aria-valuemin': 0,
8844 'aria-valuemax': 100
8845 } )
8846 .addClass( 'oo-ui-progressBarWidget' )
8847 .append( this.$bar );
8848 };
8849
8850 /* Setup */
8851
8852 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8853
8854 /* Static Properties */
8855
8856 /**
8857 * @static
8858 * @inheritdoc
8859 */
8860 OO.ui.ProgressBarWidget.static.tagName = 'div';
8861
8862 /* Methods */
8863
8864 /**
8865 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8866 *
8867 * @return {number|boolean} Progress percent
8868 */
8869 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8870 return this.progress;
8871 };
8872
8873 /**
8874 * Set the percent of the process completed or `false` for an indeterminate process.
8875 *
8876 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8877 */
8878 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8879 this.progress = progress;
8880
8881 if ( progress !== false ) {
8882 this.$bar.css( 'width', this.progress + '%' );
8883 this.$element.attr( 'aria-valuenow', this.progress );
8884 } else {
8885 this.$bar.css( 'width', '' );
8886 this.$element.removeAttr( 'aria-valuenow' );
8887 }
8888 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8889 };
8890
8891 /**
8892 * InputWidget is the base class for all input widgets, which
8893 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8894 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8895 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8896 *
8897 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8898 *
8899 * @abstract
8900 * @class
8901 * @extends OO.ui.Widget
8902 * @mixins OO.ui.mixin.FlaggedElement
8903 * @mixins OO.ui.mixin.TabIndexedElement
8904 * @mixins OO.ui.mixin.TitledElement
8905 * @mixins OO.ui.mixin.AccessKeyedElement
8906 *
8907 * @constructor
8908 * @param {Object} [config] Configuration options
8909 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8910 * @cfg {string} [value=''] The value of the input.
8911 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8912 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8913 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8914 * before it is accepted.
8915 */
8916 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8917 // Configuration initialization
8918 config = config || {};
8919
8920 // Parent constructor
8921 OO.ui.InputWidget.parent.call( this, config );
8922
8923 // Properties
8924 // See #reusePreInfuseDOM about config.$input
8925 this.$input = config.$input || this.getInputElement( config );
8926 this.value = '';
8927 this.inputFilter = config.inputFilter;
8928
8929 // Mixin constructors
8930 OO.ui.mixin.FlaggedElement.call( this, config );
8931 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8932 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8933 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8934
8935 // Events
8936 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8937
8938 // Initialization
8939 this.$input
8940 .addClass( 'oo-ui-inputWidget-input' )
8941 .attr( 'name', config.name )
8942 .prop( 'disabled', this.isDisabled() );
8943 this.$element
8944 .addClass( 'oo-ui-inputWidget' )
8945 .append( this.$input );
8946 this.setValue( config.value );
8947 if ( config.dir ) {
8948 this.setDir( config.dir );
8949 }
8950 if ( config.inputId !== undefined ) {
8951 this.setInputId( config.inputId );
8952 }
8953 };
8954
8955 /* Setup */
8956
8957 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8958 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8959 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8960 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8961 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8962
8963 /* Static Methods */
8964
8965 /**
8966 * @inheritdoc
8967 */
8968 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8969 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8970 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8971 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8972 return config;
8973 };
8974
8975 /**
8976 * @inheritdoc
8977 */
8978 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8979 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8980 if ( config.$input && config.$input.length ) {
8981 state.value = config.$input.val();
8982 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8983 state.focus = config.$input.is( ':focus' );
8984 }
8985 return state;
8986 };
8987
8988 /* Events */
8989
8990 /**
8991 * @event change
8992 *
8993 * A change event is emitted when the value of the input changes.
8994 *
8995 * @param {string} value
8996 */
8997
8998 /* Methods */
8999
9000 /**
9001 * Get input element.
9002 *
9003 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9004 * different circumstances. The element must have a `value` property (like form elements).
9005 *
9006 * @protected
9007 * @param {Object} config Configuration options
9008 * @return {jQuery} Input element
9009 */
9010 OO.ui.InputWidget.prototype.getInputElement = function () {
9011 return $( '<input>' );
9012 };
9013
9014 /**
9015 * Handle potentially value-changing events.
9016 *
9017 * @private
9018 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9019 */
9020 OO.ui.InputWidget.prototype.onEdit = function () {
9021 var widget = this;
9022 if ( !this.isDisabled() ) {
9023 // Allow the stack to clear so the value will be updated
9024 setTimeout( function () {
9025 widget.setValue( widget.$input.val() );
9026 } );
9027 }
9028 };
9029
9030 /**
9031 * Get the value of the input.
9032 *
9033 * @return {string} Input value
9034 */
9035 OO.ui.InputWidget.prototype.getValue = function () {
9036 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9037 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9038 var value = this.$input.val();
9039 if ( this.value !== value ) {
9040 this.setValue( value );
9041 }
9042 return this.value;
9043 };
9044
9045 /**
9046 * Set the directionality of the input.
9047 *
9048 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9049 * @chainable
9050 * @return {OO.ui.Widget} The widget, for chaining
9051 */
9052 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9053 this.$input.prop( 'dir', dir );
9054 return this;
9055 };
9056
9057 /**
9058 * Set the value of the input.
9059 *
9060 * @param {string} value New value
9061 * @fires change
9062 * @chainable
9063 * @return {OO.ui.Widget} The widget, for chaining
9064 */
9065 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9066 value = this.cleanUpValue( value );
9067 // Update the DOM if it has changed. Note that with cleanUpValue, it
9068 // is possible for the DOM value to change without this.value changing.
9069 if ( this.$input.val() !== value ) {
9070 this.$input.val( value );
9071 }
9072 if ( this.value !== value ) {
9073 this.value = value;
9074 this.emit( 'change', this.value );
9075 }
9076 // The first time that the value is set (probably while constructing the widget),
9077 // remember it in defaultValue. This property can be later used to check whether
9078 // the value of the input has been changed since it was created.
9079 if ( this.defaultValue === undefined ) {
9080 this.defaultValue = this.value;
9081 this.$input[ 0 ].defaultValue = this.defaultValue;
9082 }
9083 return this;
9084 };
9085
9086 /**
9087 * Clean up incoming value.
9088 *
9089 * Ensures value is a string, and converts undefined and null to empty string.
9090 *
9091 * @private
9092 * @param {string} value Original value
9093 * @return {string} Cleaned up value
9094 */
9095 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9096 if ( value === undefined || value === null ) {
9097 return '';
9098 } else if ( this.inputFilter ) {
9099 return this.inputFilter( String( value ) );
9100 } else {
9101 return String( value );
9102 }
9103 };
9104
9105 /**
9106 * @inheritdoc
9107 */
9108 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9109 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9110 if ( this.$input ) {
9111 this.$input.prop( 'disabled', this.isDisabled() );
9112 }
9113 return this;
9114 };
9115
9116 /**
9117 * Set the 'id' attribute of the `<input>` element.
9118 *
9119 * @param {string} id
9120 * @chainable
9121 * @return {OO.ui.Widget} The widget, for chaining
9122 */
9123 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9124 this.$input.attr( 'id', id );
9125 return this;
9126 };
9127
9128 /**
9129 * @inheritdoc
9130 */
9131 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9132 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9133 if ( state.value !== undefined && state.value !== this.getValue() ) {
9134 this.setValue( state.value );
9135 }
9136 if ( state.focus ) {
9137 this.focus();
9138 }
9139 };
9140
9141 /**
9142 * Data widget intended for creating `<input type="hidden">` inputs.
9143 *
9144 * @class
9145 * @extends OO.ui.Widget
9146 *
9147 * @constructor
9148 * @param {Object} [config] Configuration options
9149 * @cfg {string} [value=''] The value of the input.
9150 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9151 */
9152 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9153 // Configuration initialization
9154 config = $.extend( { value: '', name: '' }, config );
9155
9156 // Parent constructor
9157 OO.ui.HiddenInputWidget.parent.call( this, config );
9158
9159 // Initialization
9160 this.$element.attr( {
9161 type: 'hidden',
9162 value: config.value,
9163 name: config.name
9164 } );
9165 this.$element.removeAttr( 'aria-disabled' );
9166 };
9167
9168 /* Setup */
9169
9170 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9171
9172 /* Static Properties */
9173
9174 /**
9175 * @static
9176 * @inheritdoc
9177 */
9178 OO.ui.HiddenInputWidget.static.tagName = 'input';
9179
9180 /**
9181 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9182 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9183 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9184 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9185 * [OOUI documentation on MediaWiki] [1] for more information.
9186 *
9187 * @example
9188 * // A ButtonInputWidget rendered as an HTML button, the default.
9189 * var button = new OO.ui.ButtonInputWidget( {
9190 * label: 'Input button',
9191 * icon: 'check',
9192 * value: 'check'
9193 * } );
9194 * $( document.body ).append( button.$element );
9195 *
9196 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9197 *
9198 * @class
9199 * @extends OO.ui.InputWidget
9200 * @mixins OO.ui.mixin.ButtonElement
9201 * @mixins OO.ui.mixin.IconElement
9202 * @mixins OO.ui.mixin.IndicatorElement
9203 * @mixins OO.ui.mixin.LabelElement
9204 *
9205 * @constructor
9206 * @param {Object} [config] Configuration options
9207 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
9208 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9209 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
9210 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
9211 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9212 */
9213 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9214 // Configuration initialization
9215 config = $.extend( { type: 'button', useInputTag: false }, config );
9216
9217 // See InputWidget#reusePreInfuseDOM about config.$input
9218 if ( config.$input ) {
9219 config.$input.empty();
9220 }
9221
9222 // Properties (must be set before parent constructor, which calls #setValue)
9223 this.useInputTag = config.useInputTag;
9224
9225 // Parent constructor
9226 OO.ui.ButtonInputWidget.parent.call( this, config );
9227
9228 // Mixin constructors
9229 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
9230 OO.ui.mixin.IconElement.call( this, config );
9231 OO.ui.mixin.IndicatorElement.call( this, config );
9232 OO.ui.mixin.LabelElement.call( this, config );
9233
9234 // Initialization
9235 if ( !config.useInputTag ) {
9236 this.$input.append( this.$icon, this.$label, this.$indicator );
9237 }
9238 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9239 };
9240
9241 /* Setup */
9242
9243 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9244 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9245 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9246 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9247 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9248
9249 /* Static Properties */
9250
9251 /**
9252 * @static
9253 * @inheritdoc
9254 */
9255 OO.ui.ButtonInputWidget.static.tagName = 'span';
9256
9257 /* Methods */
9258
9259 /**
9260 * @inheritdoc
9261 * @protected
9262 */
9263 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9264 var type;
9265 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9266 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9267 };
9268
9269 /**
9270 * Set label value.
9271 *
9272 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9273 *
9274 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9275 * text, or `null` for no label
9276 * @chainable
9277 * @return {OO.ui.Widget} The widget, for chaining
9278 */
9279 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9280 if ( typeof label === 'function' ) {
9281 label = OO.ui.resolveMsg( label );
9282 }
9283
9284 if ( this.useInputTag ) {
9285 // Discard non-plaintext labels
9286 if ( typeof label !== 'string' ) {
9287 label = '';
9288 }
9289
9290 this.$input.val( label );
9291 }
9292
9293 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9294 };
9295
9296 /**
9297 * Set the value of the input.
9298 *
9299 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9300 * they do not support {@link #value values}.
9301 *
9302 * @param {string} value New value
9303 * @chainable
9304 * @return {OO.ui.Widget} The widget, for chaining
9305 */
9306 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9307 if ( !this.useInputTag ) {
9308 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9309 }
9310 return this;
9311 };
9312
9313 /**
9314 * @inheritdoc
9315 */
9316 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9317 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9318 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9319 return null;
9320 };
9321
9322 /**
9323 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9324 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9325 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9326 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9327 *
9328 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9329 *
9330 * @example
9331 * // An example of selected, unselected, and disabled checkbox inputs.
9332 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9333 * value: 'a',
9334 * selected: true
9335 * } ),
9336 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9337 * value: 'b'
9338 * } ),
9339 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9340 * value:'c',
9341 * disabled: true
9342 * } ),
9343 * // Create a fieldset layout with fields for each checkbox.
9344 * fieldset = new OO.ui.FieldsetLayout( {
9345 * label: 'Checkboxes'
9346 * } );
9347 * fieldset.addItems( [
9348 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9349 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9350 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9351 * ] );
9352 * $( document.body ).append( fieldset.$element );
9353 *
9354 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9355 *
9356 * @class
9357 * @extends OO.ui.InputWidget
9358 *
9359 * @constructor
9360 * @param {Object} [config] Configuration options
9361 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9362 */
9363 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9364 // Configuration initialization
9365 config = config || {};
9366
9367 // Parent constructor
9368 OO.ui.CheckboxInputWidget.parent.call( this, config );
9369
9370 // Properties
9371 this.checkIcon = new OO.ui.IconWidget( {
9372 icon: 'check',
9373 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9374 } );
9375
9376 // Initialization
9377 this.$element
9378 .addClass( 'oo-ui-checkboxInputWidget' )
9379 // Required for pretty styling in WikimediaUI theme
9380 .append( this.checkIcon.$element );
9381 this.setSelected( config.selected !== undefined ? config.selected : false );
9382 };
9383
9384 /* Setup */
9385
9386 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9387
9388 /* Static Properties */
9389
9390 /**
9391 * @static
9392 * @inheritdoc
9393 */
9394 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9395
9396 /* Static Methods */
9397
9398 /**
9399 * @inheritdoc
9400 */
9401 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9402 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9403 state.checked = config.$input.prop( 'checked' );
9404 return state;
9405 };
9406
9407 /* Methods */
9408
9409 /**
9410 * @inheritdoc
9411 * @protected
9412 */
9413 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9414 return $( '<input>' ).attr( 'type', 'checkbox' );
9415 };
9416
9417 /**
9418 * @inheritdoc
9419 */
9420 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9421 var widget = this;
9422 if ( !this.isDisabled() ) {
9423 // Allow the stack to clear so the value will be updated
9424 setTimeout( function () {
9425 widget.setSelected( widget.$input.prop( 'checked' ) );
9426 } );
9427 }
9428 };
9429
9430 /**
9431 * Set selection state of this checkbox.
9432 *
9433 * @param {boolean} state `true` for selected
9434 * @chainable
9435 * @return {OO.ui.Widget} The widget, for chaining
9436 */
9437 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9438 state = !!state;
9439 if ( this.selected !== state ) {
9440 this.selected = state;
9441 this.$input.prop( 'checked', this.selected );
9442 this.emit( 'change', this.selected );
9443 }
9444 // The first time that the selection state is set (probably while constructing the widget),
9445 // remember it in defaultSelected. This property can be later used to check whether
9446 // the selection state of the input has been changed since it was created.
9447 if ( this.defaultSelected === undefined ) {
9448 this.defaultSelected = this.selected;
9449 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9450 }
9451 return this;
9452 };
9453
9454 /**
9455 * Check if this checkbox is selected.
9456 *
9457 * @return {boolean} Checkbox is selected
9458 */
9459 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9460 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9461 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9462 var selected = this.$input.prop( 'checked' );
9463 if ( this.selected !== selected ) {
9464 this.setSelected( selected );
9465 }
9466 return this.selected;
9467 };
9468
9469 /**
9470 * @inheritdoc
9471 */
9472 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9473 if ( !this.isDisabled() ) {
9474 this.$input.click();
9475 }
9476 this.focus();
9477 };
9478
9479 /**
9480 * @inheritdoc
9481 */
9482 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9483 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9484 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9485 this.setSelected( state.checked );
9486 }
9487 };
9488
9489 /**
9490 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9491 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9492 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9493 * more information about input widgets.
9494 *
9495 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9496 * are no options. If no `value` configuration option is provided, the first option is selected.
9497 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9498 *
9499 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9500 *
9501 * @example
9502 * // A DropdownInputWidget with three options.
9503 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9504 * options: [
9505 * { data: 'a', label: 'First' },
9506 * { data: 'b', label: 'Second'},
9507 * { data: 'c', label: 'Third' }
9508 * ]
9509 * } );
9510 * $( document.body ).append( dropdownInput.$element );
9511 *
9512 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9513 *
9514 * @class
9515 * @extends OO.ui.InputWidget
9516 *
9517 * @constructor
9518 * @param {Object} [config] Configuration options
9519 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9520 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9521 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9522 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9523 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9524 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9525 */
9526 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9527 // Configuration initialization
9528 config = config || {};
9529
9530 // Properties (must be done before parent constructor which calls #setDisabled)
9531 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9532 {
9533 $overlay: config.$overlay
9534 },
9535 config.dropdown
9536 ) );
9537 // Set up the options before parent constructor, which uses them to validate config.value.
9538 // Use this instead of setOptions() because this.$input is not set up yet.
9539 this.setOptionsData( config.options || [] );
9540
9541 // Parent constructor
9542 OO.ui.DropdownInputWidget.parent.call( this, config );
9543
9544 // Events
9545 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9546
9547 // Initialization
9548 this.$element
9549 .addClass( 'oo-ui-dropdownInputWidget' )
9550 .append( this.dropdownWidget.$element );
9551 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9552 this.setTitledElement( this.dropdownWidget.$handle );
9553 };
9554
9555 /* Setup */
9556
9557 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9558
9559 /* Methods */
9560
9561 /**
9562 * @inheritdoc
9563 * @protected
9564 */
9565 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9566 return $( '<select>' );
9567 };
9568
9569 /**
9570 * Handles menu select events.
9571 *
9572 * @private
9573 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9574 */
9575 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9576 this.setValue( item ? item.getData() : '' );
9577 };
9578
9579 /**
9580 * @inheritdoc
9581 */
9582 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9583 var selected;
9584 value = this.cleanUpValue( value );
9585 // Only allow setting values that are actually present in the dropdown
9586 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9587 this.dropdownWidget.getMenu().findFirstSelectableItem();
9588 this.dropdownWidget.getMenu().selectItem( selected );
9589 value = selected ? selected.getData() : '';
9590 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9591 if ( this.optionsDirty ) {
9592 // We reached this from the constructor or from #setOptions.
9593 // We have to update the <select> element.
9594 this.updateOptionsInterface();
9595 }
9596 return this;
9597 };
9598
9599 /**
9600 * @inheritdoc
9601 */
9602 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9603 this.dropdownWidget.setDisabled( state );
9604 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9605 return this;
9606 };
9607
9608 /**
9609 * Set the options available for this input.
9610 *
9611 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9612 * @chainable
9613 * @return {OO.ui.Widget} The widget, for chaining
9614 */
9615 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9616 var value = this.getValue();
9617
9618 this.setOptionsData( options );
9619
9620 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9621 // In case the previous value is no longer an available option, select the first valid one.
9622 this.setValue( value );
9623
9624 return this;
9625 };
9626
9627 /**
9628 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9629 *
9630 * This method may be called before the parent constructor, so various properties may not be
9631 * intialized yet.
9632 *
9633 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9634 * @private
9635 */
9636 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9637 var
9638 optionWidgets,
9639 widget = this;
9640
9641 this.optionsDirty = true;
9642
9643 optionWidgets = options.map( function ( opt ) {
9644 var optValue;
9645
9646 if ( opt.optgroup !== undefined ) {
9647 return widget.createMenuSectionOptionWidget( opt.optgroup );
9648 }
9649
9650 optValue = widget.cleanUpValue( opt.data );
9651 return widget.createMenuOptionWidget(
9652 optValue,
9653 opt.label !== undefined ? opt.label : optValue
9654 );
9655
9656 } );
9657
9658 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9659 };
9660
9661 /**
9662 * Create a menu option widget.
9663 *
9664 * @protected
9665 * @param {string} data Item data
9666 * @param {string} label Item label
9667 * @return {OO.ui.MenuOptionWidget} Option widget
9668 */
9669 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9670 return new OO.ui.MenuOptionWidget( {
9671 data: data,
9672 label: label
9673 } );
9674 };
9675
9676 /**
9677 * Create a menu section option widget.
9678 *
9679 * @protected
9680 * @param {string} label Section item label
9681 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9682 */
9683 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9684 return new OO.ui.MenuSectionOptionWidget( {
9685 label: label
9686 } );
9687 };
9688
9689 /**
9690 * Update the user-visible interface to match the internal list of options and value.
9691 *
9692 * This method must only be called after the parent constructor.
9693 *
9694 * @private
9695 */
9696 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9697 var
9698 $optionsContainer = this.$input,
9699 defaultValue = this.defaultValue,
9700 widget = this;
9701
9702 this.$input.empty();
9703
9704 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9705 var $optionNode;
9706
9707 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9708 $optionNode = $( '<option>' )
9709 .attr( 'value', optionWidget.getData() )
9710 .text( optionWidget.getLabel() );
9711
9712 // Remember original selection state. This property can be later used to check whether
9713 // the selection state of the input has been changed since it was created.
9714 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9715
9716 $optionsContainer.append( $optionNode );
9717 } else {
9718 $optionNode = $( '<optgroup>' )
9719 .attr( 'label', optionWidget.getLabel() );
9720 widget.$input.append( $optionNode );
9721 $optionsContainer = $optionNode;
9722 }
9723 } );
9724
9725 this.optionsDirty = false;
9726 };
9727
9728 /**
9729 * @inheritdoc
9730 */
9731 OO.ui.DropdownInputWidget.prototype.focus = function () {
9732 this.dropdownWidget.focus();
9733 return this;
9734 };
9735
9736 /**
9737 * @inheritdoc
9738 */
9739 OO.ui.DropdownInputWidget.prototype.blur = function () {
9740 this.dropdownWidget.blur();
9741 return this;
9742 };
9743
9744 /**
9745 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9746 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9747 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9748 * please see the [OOUI documentation on MediaWiki][1].
9749 *
9750 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9751 *
9752 * @example
9753 * // An example of selected, unselected, and disabled radio inputs
9754 * var radio1 = new OO.ui.RadioInputWidget( {
9755 * value: 'a',
9756 * selected: true
9757 * } );
9758 * var radio2 = new OO.ui.RadioInputWidget( {
9759 * value: 'b'
9760 * } );
9761 * var radio3 = new OO.ui.RadioInputWidget( {
9762 * value: 'c',
9763 * disabled: true
9764 * } );
9765 * // Create a fieldset layout with fields for each radio button.
9766 * var fieldset = new OO.ui.FieldsetLayout( {
9767 * label: 'Radio inputs'
9768 * } );
9769 * fieldset.addItems( [
9770 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9771 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9772 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9773 * ] );
9774 * $( document.body ).append( fieldset.$element );
9775 *
9776 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9777 *
9778 * @class
9779 * @extends OO.ui.InputWidget
9780 *
9781 * @constructor
9782 * @param {Object} [config] Configuration options
9783 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9784 */
9785 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9786 // Configuration initialization
9787 config = config || {};
9788
9789 // Parent constructor
9790 OO.ui.RadioInputWidget.parent.call( this, config );
9791
9792 // Initialization
9793 this.$element
9794 .addClass( 'oo-ui-radioInputWidget' )
9795 // Required for pretty styling in WikimediaUI theme
9796 .append( $( '<span>' ) );
9797 this.setSelected( config.selected !== undefined ? config.selected : false );
9798 };
9799
9800 /* Setup */
9801
9802 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9803
9804 /* Static Properties */
9805
9806 /**
9807 * @static
9808 * @inheritdoc
9809 */
9810 OO.ui.RadioInputWidget.static.tagName = 'span';
9811
9812 /* Static Methods */
9813
9814 /**
9815 * @inheritdoc
9816 */
9817 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9818 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9819 state.checked = config.$input.prop( 'checked' );
9820 return state;
9821 };
9822
9823 /* Methods */
9824
9825 /**
9826 * @inheritdoc
9827 * @protected
9828 */
9829 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9830 return $( '<input>' ).attr( 'type', 'radio' );
9831 };
9832
9833 /**
9834 * @inheritdoc
9835 */
9836 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9837 // RadioInputWidget doesn't track its state.
9838 };
9839
9840 /**
9841 * Set selection state of this radio button.
9842 *
9843 * @param {boolean} state `true` for selected
9844 * @chainable
9845 * @return {OO.ui.Widget} The widget, for chaining
9846 */
9847 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9848 // RadioInputWidget doesn't track its state.
9849 this.$input.prop( 'checked', state );
9850 // The first time that the selection state is set (probably while constructing the widget),
9851 // remember it in defaultSelected. This property can be later used to check whether
9852 // the selection state of the input has been changed since it was created.
9853 if ( this.defaultSelected === undefined ) {
9854 this.defaultSelected = state;
9855 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9856 }
9857 return this;
9858 };
9859
9860 /**
9861 * Check if this radio button is selected.
9862 *
9863 * @return {boolean} Radio is selected
9864 */
9865 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9866 return this.$input.prop( 'checked' );
9867 };
9868
9869 /**
9870 * @inheritdoc
9871 */
9872 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9873 if ( !this.isDisabled() ) {
9874 this.$input.click();
9875 }
9876 this.focus();
9877 };
9878
9879 /**
9880 * @inheritdoc
9881 */
9882 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9883 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9884 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9885 this.setSelected( state.checked );
9886 }
9887 };
9888
9889 /**
9890 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9891 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9892 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9893 * more information about input widgets.
9894 *
9895 * This and OO.ui.DropdownInputWidget support the same configuration options.
9896 *
9897 * @example
9898 * // A RadioSelectInputWidget with three options
9899 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9900 * options: [
9901 * { data: 'a', label: 'First' },
9902 * { data: 'b', label: 'Second'},
9903 * { data: 'c', label: 'Third' }
9904 * ]
9905 * } );
9906 * $( document.body ).append( radioSelectInput.$element );
9907 *
9908 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9909 *
9910 * @class
9911 * @extends OO.ui.InputWidget
9912 *
9913 * @constructor
9914 * @param {Object} [config] Configuration options
9915 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9916 */
9917 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9918 // Configuration initialization
9919 config = config || {};
9920
9921 // Properties (must be done before parent constructor which calls #setDisabled)
9922 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9923 // Set up the options before parent constructor, which uses them to validate config.value.
9924 // Use this instead of setOptions() because this.$input is not set up yet
9925 this.setOptionsData( config.options || [] );
9926
9927 // Parent constructor
9928 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9929
9930 // Events
9931 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9932
9933 // Initialization
9934 this.$element
9935 .addClass( 'oo-ui-radioSelectInputWidget' )
9936 .append( this.radioSelectWidget.$element );
9937 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9938 };
9939
9940 /* Setup */
9941
9942 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9943
9944 /* Static Methods */
9945
9946 /**
9947 * @inheritdoc
9948 */
9949 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9950 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9951 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9952 return state;
9953 };
9954
9955 /**
9956 * @inheritdoc
9957 */
9958 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9959 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9960 // Cannot reuse the `<input type=radio>` set
9961 delete config.$input;
9962 return config;
9963 };
9964
9965 /* Methods */
9966
9967 /**
9968 * @inheritdoc
9969 * @protected
9970 */
9971 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9972 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
9973 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
9974 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
9975 };
9976
9977 /**
9978 * Handles menu select events.
9979 *
9980 * @private
9981 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9982 */
9983 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9984 this.setValue( item.getData() );
9985 };
9986
9987 /**
9988 * @inheritdoc
9989 */
9990 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9991 var selected;
9992 value = this.cleanUpValue( value );
9993 // Only allow setting values that are actually present in the dropdown
9994 selected = this.radioSelectWidget.findItemFromData( value ) ||
9995 this.radioSelectWidget.findFirstSelectableItem();
9996 this.radioSelectWidget.selectItem( selected );
9997 value = selected ? selected.getData() : '';
9998 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9999 return this;
10000 };
10001
10002 /**
10003 * @inheritdoc
10004 */
10005 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10006 this.radioSelectWidget.setDisabled( state );
10007 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10008 return this;
10009 };
10010
10011 /**
10012 * Set the options available for this input.
10013 *
10014 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10015 * @chainable
10016 * @return {OO.ui.Widget} The widget, for chaining
10017 */
10018 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10019 var value = this.getValue();
10020
10021 this.setOptionsData( options );
10022
10023 // Re-set the value to update the visible interface (RadioSelectWidget).
10024 // In case the previous value is no longer an available option, select the first valid one.
10025 this.setValue( value );
10026
10027 return this;
10028 };
10029
10030 /**
10031 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10032 *
10033 * This method may be called before the parent constructor, so various properties may not be
10034 * intialized yet.
10035 *
10036 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10037 * @private
10038 */
10039 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10040 var widget = this;
10041
10042 this.radioSelectWidget
10043 .clearItems()
10044 .addItems( options.map( function ( opt ) {
10045 var optValue = widget.cleanUpValue( opt.data );
10046 return new OO.ui.RadioOptionWidget( {
10047 data: optValue,
10048 label: opt.label !== undefined ? opt.label : optValue
10049 } );
10050 } ) );
10051 };
10052
10053 /**
10054 * @inheritdoc
10055 */
10056 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10057 this.radioSelectWidget.focus();
10058 return this;
10059 };
10060
10061 /**
10062 * @inheritdoc
10063 */
10064 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10065 this.radioSelectWidget.blur();
10066 return this;
10067 };
10068
10069 /**
10070 * CheckboxMultiselectInputWidget is a
10071 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10072 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10073 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10074 * more information about input widgets.
10075 *
10076 * @example
10077 * // A CheckboxMultiselectInputWidget with three options.
10078 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10079 * options: [
10080 * { data: 'a', label: 'First' },
10081 * { data: 'b', label: 'Second' },
10082 * { data: 'c', label: 'Third' }
10083 * ]
10084 * } );
10085 * $( document.body ).append( multiselectInput.$element );
10086 *
10087 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10088 *
10089 * @class
10090 * @extends OO.ui.InputWidget
10091 *
10092 * @constructor
10093 * @param {Object} [config] Configuration options
10094 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
10095 */
10096 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10097 // Configuration initialization
10098 config = config || {};
10099
10100 // Properties (must be done before parent constructor which calls #setDisabled)
10101 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10102 // Must be set before the #setOptionsData call below
10103 this.inputName = config.name;
10104 // Set up the options before parent constructor, which uses them to validate config.value.
10105 // Use this instead of setOptions() because this.$input is not set up yet
10106 this.setOptionsData( config.options || [] );
10107
10108 // Parent constructor
10109 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10110
10111 // Events
10112 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
10113
10114 // Initialization
10115 this.$element
10116 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10117 .append( this.checkboxMultiselectWidget.$element );
10118 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10119 this.$input.detach();
10120 };
10121
10122 /* Setup */
10123
10124 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10125
10126 /* Static Methods */
10127
10128 /**
10129 * @inheritdoc
10130 */
10131 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10132 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
10133 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10134 .toArray().map( function ( el ) { return el.value; } );
10135 return state;
10136 };
10137
10138 /**
10139 * @inheritdoc
10140 */
10141 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10142 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10143 // Cannot reuse the `<input type=checkbox>` set
10144 delete config.$input;
10145 return config;
10146 };
10147
10148 /* Methods */
10149
10150 /**
10151 * @inheritdoc
10152 * @protected
10153 */
10154 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10155 // Actually unused
10156 return $( '<unused>' );
10157 };
10158
10159 /**
10160 * Handles CheckboxMultiselectWidget select events.
10161 *
10162 * @private
10163 */
10164 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10165 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10166 };
10167
10168 /**
10169 * @inheritdoc
10170 */
10171 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10172 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10173 .toArray().map( function ( el ) { return el.value; } );
10174 if ( this.value !== value ) {
10175 this.setValue( value );
10176 }
10177 return this.value;
10178 };
10179
10180 /**
10181 * @inheritdoc
10182 */
10183 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10184 value = this.cleanUpValue( value );
10185 this.checkboxMultiselectWidget.selectItemsByData( value );
10186 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10187 if ( this.optionsDirty ) {
10188 // We reached this from the constructor or from #setOptions.
10189 // We have to update the <select> element.
10190 this.updateOptionsInterface();
10191 }
10192 return this;
10193 };
10194
10195 /**
10196 * Clean up incoming value.
10197 *
10198 * @param {string[]} value Original value
10199 * @return {string[]} Cleaned up value
10200 */
10201 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10202 var i, singleValue,
10203 cleanValue = [];
10204 if ( !Array.isArray( value ) ) {
10205 return cleanValue;
10206 }
10207 for ( i = 0; i < value.length; i++ ) {
10208 singleValue =
10209 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
10210 // Remove options that we don't have here
10211 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10212 continue;
10213 }
10214 cleanValue.push( singleValue );
10215 }
10216 return cleanValue;
10217 };
10218
10219 /**
10220 * @inheritdoc
10221 */
10222 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10223 this.checkboxMultiselectWidget.setDisabled( state );
10224 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10225 return this;
10226 };
10227
10228 /**
10229 * Set the options available for this input.
10230 *
10231 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
10232 * @chainable
10233 * @return {OO.ui.Widget} The widget, for chaining
10234 */
10235 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10236 var value = this.getValue();
10237
10238 this.setOptionsData( options );
10239
10240 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10241 // This will also get rid of any stale options that we just removed.
10242 this.setValue( value );
10243
10244 return this;
10245 };
10246
10247 /**
10248 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10249 *
10250 * This method may be called before the parent constructor, so various properties may not be
10251 * intialized yet.
10252 *
10253 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10254 * @private
10255 */
10256 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10257 var widget = this;
10258
10259 this.optionsDirty = true;
10260
10261 this.checkboxMultiselectWidget
10262 .clearItems()
10263 .addItems( options.map( function ( opt ) {
10264 var optValue, item, optDisabled;
10265 optValue =
10266 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
10267 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10268 item = new OO.ui.CheckboxMultioptionWidget( {
10269 data: optValue,
10270 label: opt.label !== undefined ? opt.label : optValue,
10271 disabled: optDisabled
10272 } );
10273 // Set the 'name' and 'value' for form submission
10274 item.checkbox.$input.attr( 'name', widget.inputName );
10275 item.checkbox.setValue( optValue );
10276 return item;
10277 } ) );
10278 };
10279
10280 /**
10281 * Update the user-visible interface to match the internal list of options and value.
10282 *
10283 * This method must only be called after the parent constructor.
10284 *
10285 * @private
10286 */
10287 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10288 var defaultValue = this.defaultValue;
10289
10290 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10291 // Remember original selection state. This property can be later used to check whether
10292 // the selection state of the input has been changed since it was created.
10293 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10294 item.checkbox.defaultSelected = isDefault;
10295 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10296 } );
10297
10298 this.optionsDirty = false;
10299 };
10300
10301 /**
10302 * @inheritdoc
10303 */
10304 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10305 this.checkboxMultiselectWidget.focus();
10306 return this;
10307 };
10308
10309 /**
10310 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10311 * size of the field as well as its presentation. In addition, these widgets can be configured
10312 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10313 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10314 * which modifies incoming values rather than validating them.
10315 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10316 *
10317 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10318 *
10319 * @example
10320 * // A TextInputWidget.
10321 * var textInput = new OO.ui.TextInputWidget( {
10322 * value: 'Text input'
10323 * } )
10324 * $( document.body ).append( textInput.$element );
10325 *
10326 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10327 *
10328 * @class
10329 * @extends OO.ui.InputWidget
10330 * @mixins OO.ui.mixin.IconElement
10331 * @mixins OO.ui.mixin.IndicatorElement
10332 * @mixins OO.ui.mixin.PendingElement
10333 * @mixins OO.ui.mixin.LabelElement
10334 *
10335 * @constructor
10336 * @param {Object} [config] Configuration options
10337 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10338 * 'email', 'url' or 'number'.
10339 * @cfg {string} [placeholder] Placeholder text
10340 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10341 * instruct the browser to focus this widget.
10342 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10343 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10344 *
10345 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10346 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10347 * many emojis) count as 2 characters each.
10348 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10349 * the value or placeholder text: `'before'` or `'after'`
10350 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10351 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10352 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10353 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10354 * leaving it up to the browser).
10355 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10356 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10357 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10358 * value for it to be considered valid; when Function, a function receiving the value as parameter
10359 * that must return true, or promise resolving to true, for it to be considered valid.
10360 */
10361 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10362 // Configuration initialization
10363 config = $.extend( {
10364 type: 'text',
10365 labelPosition: 'after'
10366 }, config );
10367
10368 // Parent constructor
10369 OO.ui.TextInputWidget.parent.call( this, config );
10370
10371 // Mixin constructors
10372 OO.ui.mixin.IconElement.call( this, config );
10373 OO.ui.mixin.IndicatorElement.call( this, config );
10374 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10375 OO.ui.mixin.LabelElement.call( this, config );
10376
10377 // Properties
10378 this.type = this.getSaneType( config );
10379 this.readOnly = false;
10380 this.required = false;
10381 this.validate = null;
10382 this.scrollWidth = null;
10383
10384 this.setValidation( config.validate );
10385 this.setLabelPosition( config.labelPosition );
10386
10387 // Events
10388 this.$input.on( {
10389 keypress: this.onKeyPress.bind( this ),
10390 blur: this.onBlur.bind( this ),
10391 focus: this.onFocus.bind( this )
10392 } );
10393 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10394 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10395 this.on( 'labelChange', this.updatePosition.bind( this ) );
10396 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10397
10398 // Initialization
10399 this.$element
10400 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10401 .append( this.$icon, this.$indicator );
10402 this.setReadOnly( !!config.readOnly );
10403 this.setRequired( !!config.required );
10404 if ( config.placeholder !== undefined ) {
10405 this.$input.attr( 'placeholder', config.placeholder );
10406 }
10407 if ( config.maxLength !== undefined ) {
10408 this.$input.attr( 'maxlength', config.maxLength );
10409 }
10410 if ( config.autofocus ) {
10411 this.$input.attr( 'autofocus', 'autofocus' );
10412 }
10413 if ( config.autocomplete === false ) {
10414 this.$input.attr( 'autocomplete', 'off' );
10415 // Turning off autocompletion also disables "form caching" when the user navigates to a
10416 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10417 $( window ).on( {
10418 beforeunload: function () {
10419 this.$input.removeAttr( 'autocomplete' );
10420 }.bind( this ),
10421 pageshow: function () {
10422 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10423 // whole page... it shouldn't hurt, though.
10424 this.$input.attr( 'autocomplete', 'off' );
10425 }.bind( this )
10426 } );
10427 }
10428 if ( config.spellcheck !== undefined ) {
10429 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10430 }
10431 if ( this.label ) {
10432 this.isWaitingToBeAttached = true;
10433 this.installParentChangeDetector();
10434 }
10435 };
10436
10437 /* Setup */
10438
10439 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10440 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10441 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10442 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10443 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10444
10445 /* Static Properties */
10446
10447 OO.ui.TextInputWidget.static.validationPatterns = {
10448 'non-empty': /.+/,
10449 integer: /^\d+$/
10450 };
10451
10452 /* Events */
10453
10454 /**
10455 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10456 *
10457 * @event enter
10458 */
10459
10460 /* Methods */
10461
10462 /**
10463 * Handle icon mouse down events.
10464 *
10465 * @private
10466 * @param {jQuery.Event} e Mouse down event
10467 * @return {undefined/boolean} False to prevent default if event is handled
10468 */
10469 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10470 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10471 this.focus();
10472 return false;
10473 }
10474 };
10475
10476 /**
10477 * Handle indicator mouse down events.
10478 *
10479 * @private
10480 * @param {jQuery.Event} e Mouse down event
10481 * @return {undefined/boolean} False to prevent default if event is handled
10482 */
10483 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10484 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10485 this.focus();
10486 return false;
10487 }
10488 };
10489
10490 /**
10491 * Handle key press events.
10492 *
10493 * @private
10494 * @param {jQuery.Event} e Key press event
10495 * @fires enter If enter key is pressed
10496 */
10497 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10498 if ( e.which === OO.ui.Keys.ENTER ) {
10499 this.emit( 'enter', e );
10500 }
10501 };
10502
10503 /**
10504 * Handle blur events.
10505 *
10506 * @private
10507 * @param {jQuery.Event} e Blur event
10508 */
10509 OO.ui.TextInputWidget.prototype.onBlur = function () {
10510 this.setValidityFlag();
10511 };
10512
10513 /**
10514 * Handle focus events.
10515 *
10516 * @private
10517 * @param {jQuery.Event} e Focus event
10518 */
10519 OO.ui.TextInputWidget.prototype.onFocus = function () {
10520 if ( this.isWaitingToBeAttached ) {
10521 // If we've received focus, then we must be attached to the document, and if
10522 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10523 this.onElementAttach();
10524 }
10525 this.setValidityFlag( true );
10526 };
10527
10528 /**
10529 * Handle element attach events.
10530 *
10531 * @private
10532 * @param {jQuery.Event} e Element attach event
10533 */
10534 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10535 this.isWaitingToBeAttached = false;
10536 // Any previously calculated size is now probably invalid if we reattached elsewhere
10537 this.valCache = null;
10538 this.positionLabel();
10539 };
10540
10541 /**
10542 * Handle debounced change events.
10543 *
10544 * @param {string} value
10545 * @private
10546 */
10547 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10548 this.setValidityFlag();
10549 };
10550
10551 /**
10552 * Check if the input is {@link #readOnly read-only}.
10553 *
10554 * @return {boolean}
10555 */
10556 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10557 return this.readOnly;
10558 };
10559
10560 /**
10561 * Set the {@link #readOnly read-only} state of the input.
10562 *
10563 * @param {boolean} state Make input read-only
10564 * @chainable
10565 * @return {OO.ui.Widget} The widget, for chaining
10566 */
10567 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10568 this.readOnly = !!state;
10569 this.$input.prop( 'readOnly', this.readOnly );
10570 return this;
10571 };
10572
10573 /**
10574 * Check if the input is {@link #required required}.
10575 *
10576 * @return {boolean}
10577 */
10578 OO.ui.TextInputWidget.prototype.isRequired = function () {
10579 return this.required;
10580 };
10581
10582 /**
10583 * Set the {@link #required required} state of the input.
10584 *
10585 * @param {boolean} state Make input required
10586 * @chainable
10587 * @return {OO.ui.Widget} The widget, for chaining
10588 */
10589 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10590 this.required = !!state;
10591 if ( this.required ) {
10592 this.$input
10593 .prop( 'required', true )
10594 .attr( 'aria-required', 'true' );
10595 if ( this.getIndicator() === null ) {
10596 this.setIndicator( 'required' );
10597 }
10598 } else {
10599 this.$input
10600 .prop( 'required', false )
10601 .removeAttr( 'aria-required' );
10602 if ( this.getIndicator() === 'required' ) {
10603 this.setIndicator( null );
10604 }
10605 }
10606 return this;
10607 };
10608
10609 /**
10610 * Support function for making #onElementAttach work across browsers.
10611 *
10612 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10613 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10614 *
10615 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10616 * first time that the element gets attached to the documented.
10617 */
10618 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10619 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10620 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10621 widget = this;
10622
10623 if ( MutationObserver ) {
10624 // The new way. If only it wasn't so ugly.
10625
10626 if ( this.isElementAttached() ) {
10627 // Widget is attached already, do nothing. This breaks the functionality of this function when
10628 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10629 // would require observation of the whole document, which would hurt performance of other,
10630 // more important code.
10631 return;
10632 }
10633
10634 // Find topmost node in the tree
10635 topmostNode = this.$element[ 0 ];
10636 while ( topmostNode.parentNode ) {
10637 topmostNode = topmostNode.parentNode;
10638 }
10639
10640 // We have no way to detect the $element being attached somewhere without observing the entire
10641 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10642 // parent node of $element, and instead detect when $element is removed from it (and thus
10643 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10644 // doesn't get attached, we end up back here and create the parent.
10645
10646 mutationObserver = new MutationObserver( function ( mutations ) {
10647 var i, j, removedNodes;
10648 for ( i = 0; i < mutations.length; i++ ) {
10649 removedNodes = mutations[ i ].removedNodes;
10650 for ( j = 0; j < removedNodes.length; j++ ) {
10651 if ( removedNodes[ j ] === topmostNode ) {
10652 setTimeout( onRemove, 0 );
10653 return;
10654 }
10655 }
10656 }
10657 } );
10658
10659 onRemove = function () {
10660 // If the node was attached somewhere else, report it
10661 if ( widget.isElementAttached() ) {
10662 widget.onElementAttach();
10663 }
10664 mutationObserver.disconnect();
10665 widget.installParentChangeDetector();
10666 };
10667
10668 // Create a fake parent and observe it
10669 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10670 mutationObserver.observe( fakeParentNode, { childList: true } );
10671 } else {
10672 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10673 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10674 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10675 }
10676 };
10677
10678 /**
10679 * @inheritdoc
10680 * @protected
10681 */
10682 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10683 if ( this.getSaneType( config ) === 'number' ) {
10684 return $( '<input>' )
10685 .attr( 'step', 'any' )
10686 .attr( 'type', 'number' );
10687 } else {
10688 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10689 }
10690 };
10691
10692 /**
10693 * Get sanitized value for 'type' for given config.
10694 *
10695 * @param {Object} config Configuration options
10696 * @return {string|null}
10697 * @protected
10698 */
10699 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10700 var allowedTypes = [
10701 'text',
10702 'password',
10703 'email',
10704 'url',
10705 'number'
10706 ];
10707 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10708 };
10709
10710 /**
10711 * Focus the input and select a specified range within the text.
10712 *
10713 * @param {number} from Select from offset
10714 * @param {number} [to] Select to offset, defaults to from
10715 * @chainable
10716 * @return {OO.ui.Widget} The widget, for chaining
10717 */
10718 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10719 var isBackwards, start, end,
10720 input = this.$input[ 0 ];
10721
10722 to = to || from;
10723
10724 isBackwards = to < from;
10725 start = isBackwards ? to : from;
10726 end = isBackwards ? from : to;
10727
10728 this.focus();
10729
10730 try {
10731 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10732 } catch ( e ) {
10733 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10734 // Rather than expensively check if the input is attached every time, just check
10735 // if it was the cause of an error being thrown. If not, rethrow the error.
10736 if ( this.getElementDocument().body.contains( input ) ) {
10737 throw e;
10738 }
10739 }
10740 return this;
10741 };
10742
10743 /**
10744 * Get an object describing the current selection range in a directional manner
10745 *
10746 * @return {Object} Object containing 'from' and 'to' offsets
10747 */
10748 OO.ui.TextInputWidget.prototype.getRange = function () {
10749 var input = this.$input[ 0 ],
10750 start = input.selectionStart,
10751 end = input.selectionEnd,
10752 isBackwards = input.selectionDirection === 'backward';
10753
10754 return {
10755 from: isBackwards ? end : start,
10756 to: isBackwards ? start : end
10757 };
10758 };
10759
10760 /**
10761 * Get the length of the text input value.
10762 *
10763 * This could differ from the length of #getValue if the
10764 * value gets filtered
10765 *
10766 * @return {number} Input length
10767 */
10768 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10769 return this.$input[ 0 ].value.length;
10770 };
10771
10772 /**
10773 * Focus the input and select the entire text.
10774 *
10775 * @chainable
10776 * @return {OO.ui.Widget} The widget, for chaining
10777 */
10778 OO.ui.TextInputWidget.prototype.select = function () {
10779 return this.selectRange( 0, this.getInputLength() );
10780 };
10781
10782 /**
10783 * Focus the input and move the cursor to the start.
10784 *
10785 * @chainable
10786 * @return {OO.ui.Widget} The widget, for chaining
10787 */
10788 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10789 return this.selectRange( 0 );
10790 };
10791
10792 /**
10793 * Focus the input and move the cursor to the end.
10794 *
10795 * @chainable
10796 * @return {OO.ui.Widget} The widget, for chaining
10797 */
10798 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10799 return this.selectRange( this.getInputLength() );
10800 };
10801
10802 /**
10803 * Insert new content into the input.
10804 *
10805 * @param {string} content Content to be inserted
10806 * @chainable
10807 * @return {OO.ui.Widget} The widget, for chaining
10808 */
10809 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10810 var start, end,
10811 range = this.getRange(),
10812 value = this.getValue();
10813
10814 start = Math.min( range.from, range.to );
10815 end = Math.max( range.from, range.to );
10816
10817 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10818 this.selectRange( start + content.length );
10819 return this;
10820 };
10821
10822 /**
10823 * Insert new content either side of a selection.
10824 *
10825 * @param {string} pre Content to be inserted before the selection
10826 * @param {string} post Content to be inserted after the selection
10827 * @chainable
10828 * @return {OO.ui.Widget} The widget, for chaining
10829 */
10830 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10831 var start, end,
10832 range = this.getRange(),
10833 offset = pre.length;
10834
10835 start = Math.min( range.from, range.to );
10836 end = Math.max( range.from, range.to );
10837
10838 this.selectRange( start ).insertContent( pre );
10839 this.selectRange( offset + end ).insertContent( post );
10840
10841 this.selectRange( offset + start, offset + end );
10842 return this;
10843 };
10844
10845 /**
10846 * Set the validation pattern.
10847 *
10848 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10849 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10850 * value must contain only numbers).
10851 *
10852 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10853 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10854 */
10855 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10856 if ( validate instanceof RegExp || validate instanceof Function ) {
10857 this.validate = validate;
10858 } else {
10859 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10860 }
10861 };
10862
10863 /**
10864 * Sets the 'invalid' flag appropriately.
10865 *
10866 * @param {boolean} [isValid] Optionally override validation result
10867 */
10868 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10869 var widget = this,
10870 setFlag = function ( valid ) {
10871 if ( !valid ) {
10872 widget.$input.attr( 'aria-invalid', 'true' );
10873 } else {
10874 widget.$input.removeAttr( 'aria-invalid' );
10875 }
10876 widget.setFlags( { invalid: !valid } );
10877 };
10878
10879 if ( isValid !== undefined ) {
10880 setFlag( isValid );
10881 } else {
10882 this.getValidity().then( function () {
10883 setFlag( true );
10884 }, function () {
10885 setFlag( false );
10886 } );
10887 }
10888 };
10889
10890 /**
10891 * Get the validity of current value.
10892 *
10893 * This method returns a promise that resolves if the value is valid and rejects if
10894 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10895 *
10896 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10897 */
10898 OO.ui.TextInputWidget.prototype.getValidity = function () {
10899 var result;
10900
10901 function rejectOrResolve( valid ) {
10902 if ( valid ) {
10903 return $.Deferred().resolve().promise();
10904 } else {
10905 return $.Deferred().reject().promise();
10906 }
10907 }
10908
10909 // Check browser validity and reject if it is invalid
10910 if (
10911 this.$input[ 0 ].checkValidity !== undefined &&
10912 this.$input[ 0 ].checkValidity() === false
10913 ) {
10914 return rejectOrResolve( false );
10915 }
10916
10917 // Run our checks if the browser thinks the field is valid
10918 if ( this.validate instanceof Function ) {
10919 result = this.validate( this.getValue() );
10920 if ( result && typeof result.promise === 'function' ) {
10921 return result.promise().then( function ( valid ) {
10922 return rejectOrResolve( valid );
10923 } );
10924 } else {
10925 return rejectOrResolve( result );
10926 }
10927 } else {
10928 return rejectOrResolve( this.getValue().match( this.validate ) );
10929 }
10930 };
10931
10932 /**
10933 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10934 *
10935 * @param {string} labelPosition Label position, 'before' or 'after'
10936 * @chainable
10937 * @return {OO.ui.Widget} The widget, for chaining
10938 */
10939 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10940 this.labelPosition = labelPosition;
10941 if ( this.label ) {
10942 // If there is no label and we only change the position, #updatePosition is a no-op,
10943 // but it takes really a lot of work to do nothing.
10944 this.updatePosition();
10945 }
10946 return this;
10947 };
10948
10949 /**
10950 * Update the position of the inline label.
10951 *
10952 * This method is called by #setLabelPosition, and can also be called on its own if
10953 * something causes the label to be mispositioned.
10954 *
10955 * @chainable
10956 * @return {OO.ui.Widget} The widget, for chaining
10957 */
10958 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10959 var after = this.labelPosition === 'after';
10960
10961 this.$element
10962 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10963 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10964
10965 this.valCache = null;
10966 this.scrollWidth = null;
10967 this.positionLabel();
10968
10969 return this;
10970 };
10971
10972 /**
10973 * Position the label by setting the correct padding on the input.
10974 *
10975 * @private
10976 * @chainable
10977 * @return {OO.ui.Widget} The widget, for chaining
10978 */
10979 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10980 var after, rtl, property, newCss;
10981
10982 if ( this.isWaitingToBeAttached ) {
10983 // #onElementAttach will be called soon, which calls this method
10984 return this;
10985 }
10986
10987 newCss = {
10988 'padding-right': '',
10989 'padding-left': ''
10990 };
10991
10992 if ( this.label ) {
10993 this.$element.append( this.$label );
10994 } else {
10995 this.$label.detach();
10996 // Clear old values if present
10997 this.$input.css( newCss );
10998 return;
10999 }
11000
11001 after = this.labelPosition === 'after';
11002 rtl = this.$element.css( 'direction' ) === 'rtl';
11003 property = after === rtl ? 'padding-left' : 'padding-right';
11004
11005 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11006 // We have to clear the padding on the other side, in case the element direction changed
11007 this.$input.css( newCss );
11008
11009 return this;
11010 };
11011
11012 /**
11013 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11014 * {@link OO.ui.mixin.IconElement search icon} by default.
11015 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11016 *
11017 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11018 *
11019 * @class
11020 * @extends OO.ui.TextInputWidget
11021 *
11022 * @constructor
11023 * @param {Object} [config] Configuration options
11024 */
11025 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11026 config = $.extend( {
11027 icon: 'search'
11028 }, config );
11029
11030 // Parent constructor
11031 OO.ui.SearchInputWidget.parent.call( this, config );
11032
11033 // Events
11034 this.connect( this, {
11035 change: 'onChange'
11036 } );
11037
11038 // Initialization
11039 this.updateSearchIndicator();
11040 this.connect( this, {
11041 disable: 'onDisable'
11042 } );
11043 };
11044
11045 /* Setup */
11046
11047 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11048
11049 /* Methods */
11050
11051 /**
11052 * @inheritdoc
11053 * @protected
11054 */
11055 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11056 return 'search';
11057 };
11058
11059 /**
11060 * @inheritdoc
11061 */
11062 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11063 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11064 // Clear the text field
11065 this.setValue( '' );
11066 this.focus();
11067 return false;
11068 }
11069 };
11070
11071 /**
11072 * Update the 'clear' indicator displayed on type: 'search' text
11073 * fields, hiding it when the field is already empty or when it's not
11074 * editable.
11075 */
11076 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11077 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11078 this.setIndicator( null );
11079 } else {
11080 this.setIndicator( 'clear' );
11081 }
11082 };
11083
11084 /**
11085 * Handle change events.
11086 *
11087 * @private
11088 */
11089 OO.ui.SearchInputWidget.prototype.onChange = function () {
11090 this.updateSearchIndicator();
11091 };
11092
11093 /**
11094 * Handle disable events.
11095 *
11096 * @param {boolean} disabled Element is disabled
11097 * @private
11098 */
11099 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11100 this.updateSearchIndicator();
11101 };
11102
11103 /**
11104 * @inheritdoc
11105 */
11106 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11107 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11108 this.updateSearchIndicator();
11109 return this;
11110 };
11111
11112 /**
11113 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11114 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11115 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11116 * {@link OO.ui.mixin.IndicatorElement indicators}.
11117 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11118 *
11119 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11120 *
11121 * @example
11122 * // A MultilineTextInputWidget.
11123 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11124 * value: 'Text input on multiple lines'
11125 * } )
11126 * $( 'body' ).append( multilineTextInput.$element );
11127 *
11128 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11129 *
11130 * @class
11131 * @extends OO.ui.TextInputWidget
11132 *
11133 * @constructor
11134 * @param {Object} [config] Configuration options
11135 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11136 * specifies minimum number of rows to display.
11137 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11138 * Use the #maxRows config to specify a maximum number of displayed rows.
11139 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11140 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11141 */
11142 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11143 config = $.extend( {
11144 type: 'text'
11145 }, config );
11146 // Parent constructor
11147 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11148
11149 // Properties
11150 this.autosize = !!config.autosize;
11151 this.styleHeight = null;
11152 this.minRows = config.rows !== undefined ? config.rows : '';
11153 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11154
11155 // Clone for resizing
11156 if ( this.autosize ) {
11157 this.$clone = this.$input
11158 .clone()
11159 .removeAttr( 'id' )
11160 .removeAttr( 'name' )
11161 .insertAfter( this.$input )
11162 .attr( 'aria-hidden', 'true' )
11163 .addClass( 'oo-ui-element-hidden' );
11164 }
11165
11166 // Events
11167 this.connect( this, {
11168 change: 'onChange'
11169 } );
11170
11171 // Initialization
11172 if ( config.rows ) {
11173 this.$input.attr( 'rows', config.rows );
11174 }
11175 if ( this.autosize ) {
11176 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11177 this.isWaitingToBeAttached = true;
11178 this.installParentChangeDetector();
11179 }
11180 };
11181
11182 /* Setup */
11183
11184 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11185
11186 /* Static Methods */
11187
11188 /**
11189 * @inheritdoc
11190 */
11191 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11192 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11193 state.scrollTop = config.$input.scrollTop();
11194 return state;
11195 };
11196
11197 /* Methods */
11198
11199 /**
11200 * @inheritdoc
11201 */
11202 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11203 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11204 this.adjustSize();
11205 };
11206
11207 /**
11208 * Handle change events.
11209 *
11210 * @private
11211 */
11212 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11213 this.adjustSize();
11214 };
11215
11216 /**
11217 * @inheritdoc
11218 */
11219 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11220 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11221 this.adjustSize();
11222 };
11223
11224 /**
11225 * @inheritdoc
11226 *
11227 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11228 */
11229 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11230 if (
11231 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11232 // Some platforms emit keycode 10 for ctrl+enter in a textarea
11233 e.which === 10
11234 ) {
11235 this.emit( 'enter', e );
11236 }
11237 };
11238
11239 /**
11240 * Automatically adjust the size of the text input.
11241 *
11242 * This only affects multiline inputs that are {@link #autosize autosized}.
11243 *
11244 * @chainable
11245 * @return {OO.ui.Widget} The widget, for chaining
11246 * @fires resize
11247 */
11248 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11249 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11250 idealHeight, newHeight, scrollWidth, property;
11251
11252 if ( this.$input.val() !== this.valCache ) {
11253 if ( this.autosize ) {
11254 this.$clone
11255 .val( this.$input.val() )
11256 .attr( 'rows', this.minRows )
11257 // Set inline height property to 0 to measure scroll height
11258 .css( 'height', 0 );
11259
11260 this.$clone.removeClass( 'oo-ui-element-hidden' );
11261
11262 this.valCache = this.$input.val();
11263
11264 scrollHeight = this.$clone[ 0 ].scrollHeight;
11265
11266 // Remove inline height property to measure natural heights
11267 this.$clone.css( 'height', '' );
11268 innerHeight = this.$clone.innerHeight();
11269 outerHeight = this.$clone.outerHeight();
11270
11271 // Measure max rows height
11272 this.$clone
11273 .attr( 'rows', this.maxRows )
11274 .css( 'height', 'auto' )
11275 .val( '' );
11276 maxInnerHeight = this.$clone.innerHeight();
11277
11278 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11279 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11280 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11281 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11282
11283 this.$clone.addClass( 'oo-ui-element-hidden' );
11284
11285 // Only apply inline height when expansion beyond natural height is needed
11286 // Use the difference between the inner and outer height as a buffer
11287 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11288 if ( newHeight !== this.styleHeight ) {
11289 this.$input.css( 'height', newHeight );
11290 this.styleHeight = newHeight;
11291 this.emit( 'resize' );
11292 }
11293 }
11294 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11295 if ( scrollWidth !== this.scrollWidth ) {
11296 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11297 // Reset
11298 this.$label.css( { right: '', left: '' } );
11299 this.$indicator.css( { right: '', left: '' } );
11300
11301 if ( scrollWidth ) {
11302 this.$indicator.css( property, scrollWidth );
11303 if ( this.labelPosition === 'after' ) {
11304 this.$label.css( property, scrollWidth );
11305 }
11306 }
11307
11308 this.scrollWidth = scrollWidth;
11309 this.positionLabel();
11310 }
11311 }
11312 return this;
11313 };
11314
11315 /**
11316 * @inheritdoc
11317 * @protected
11318 */
11319 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11320 return $( '<textarea>' );
11321 };
11322
11323 /**
11324 * Check if the input automatically adjusts its size.
11325 *
11326 * @return {boolean}
11327 */
11328 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11329 return !!this.autosize;
11330 };
11331
11332 /**
11333 * @inheritdoc
11334 */
11335 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11336 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11337 if ( state.scrollTop !== undefined ) {
11338 this.$input.scrollTop( state.scrollTop );
11339 }
11340 };
11341
11342 /**
11343 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11344 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11345 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11346 *
11347 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11348 * option, that option will appear to be selected.
11349 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11350 * input field.
11351 *
11352 * After the user chooses an option, its `data` will be used as a new value for the widget.
11353 * A `label` also can be specified for each option: if given, it will be shown instead of the
11354 * `data` in the dropdown menu.
11355 *
11356 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11357 *
11358 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11359 *
11360 * @example
11361 * // A ComboBoxInputWidget.
11362 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11363 * value: 'Option 1',
11364 * options: [
11365 * { data: 'Option 1' },
11366 * { data: 'Option 2' },
11367 * { data: 'Option 3' }
11368 * ]
11369 * } );
11370 * $( document.body ).append( comboBox.$element );
11371 *
11372 * @example
11373 * // Example: A ComboBoxInputWidget with additional option labels.
11374 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11375 * value: 'Option 1',
11376 * options: [
11377 * {
11378 * data: 'Option 1',
11379 * label: 'Option One'
11380 * },
11381 * {
11382 * data: 'Option 2',
11383 * label: 'Option Two'
11384 * },
11385 * {
11386 * data: 'Option 3',
11387 * label: 'Option Three'
11388 * }
11389 * ]
11390 * } );
11391 * $( document.body ).append( comboBox.$element );
11392 *
11393 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11394 *
11395 * @class
11396 * @extends OO.ui.TextInputWidget
11397 *
11398 * @constructor
11399 * @param {Object} [config] Configuration options
11400 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11401 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11402 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11403 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11404 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11405 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11406 */
11407 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11408 // Configuration initialization
11409 config = $.extend( {
11410 autocomplete: false
11411 }, config );
11412
11413 // ComboBoxInputWidget shouldn't support `multiline`
11414 config.multiline = false;
11415
11416 // See InputWidget#reusePreInfuseDOM about `config.$input`
11417 if ( config.$input ) {
11418 config.$input.removeAttr( 'list' );
11419 }
11420
11421 // Parent constructor
11422 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11423
11424 // Properties
11425 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11426 this.dropdownButton = new OO.ui.ButtonWidget( {
11427 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11428 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11429 indicator: 'down',
11430 invisibleLabel: true,
11431 disabled: this.disabled
11432 } );
11433 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11434 {
11435 widget: this,
11436 input: this,
11437 $floatableContainer: this.$element,
11438 disabled: this.isDisabled()
11439 },
11440 config.menu
11441 ) );
11442
11443 // Events
11444 this.connect( this, {
11445 change: 'onInputChange',
11446 enter: 'onInputEnter'
11447 } );
11448 this.dropdownButton.connect( this, {
11449 click: 'onDropdownButtonClick'
11450 } );
11451 this.menu.connect( this, {
11452 choose: 'onMenuChoose',
11453 add: 'onMenuItemsChange',
11454 remove: 'onMenuItemsChange',
11455 toggle: 'onMenuToggle'
11456 } );
11457
11458 // Initialization
11459 this.$input.attr( {
11460 role: 'combobox',
11461 'aria-owns': this.menu.getElementId(),
11462 'aria-autocomplete': 'list'
11463 } );
11464 this.dropdownButton.$button.attr( {
11465 'aria-controls': this.menu.getElementId()
11466 } );
11467 // Do not override options set via config.menu.items
11468 if ( config.options !== undefined ) {
11469 this.setOptions( config.options );
11470 }
11471 this.$field = $( '<div>' )
11472 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11473 .append( this.$input, this.dropdownButton.$element );
11474 this.$element
11475 .addClass( 'oo-ui-comboBoxInputWidget' )
11476 .append( this.$field );
11477 this.$overlay.append( this.menu.$element );
11478 this.onMenuItemsChange();
11479 };
11480
11481 /* Setup */
11482
11483 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11484
11485 /* Methods */
11486
11487 /**
11488 * Get the combobox's menu.
11489 *
11490 * @return {OO.ui.MenuSelectWidget} Menu widget
11491 */
11492 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11493 return this.menu;
11494 };
11495
11496 /**
11497 * Get the combobox's text input widget.
11498 *
11499 * @return {OO.ui.TextInputWidget} Text input widget
11500 */
11501 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11502 return this;
11503 };
11504
11505 /**
11506 * Handle input change events.
11507 *
11508 * @private
11509 * @param {string} value New value
11510 */
11511 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11512 var match = this.menu.findItemFromData( value );
11513
11514 this.menu.selectItem( match );
11515 if ( this.menu.findHighlightedItem() ) {
11516 this.menu.highlightItem( match );
11517 }
11518
11519 if ( !this.isDisabled() ) {
11520 this.menu.toggle( true );
11521 }
11522 };
11523
11524 /**
11525 * Handle input enter events.
11526 *
11527 * @private
11528 */
11529 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11530 if ( !this.isDisabled() ) {
11531 this.menu.toggle( false );
11532 }
11533 };
11534
11535 /**
11536 * Handle button click events.
11537 *
11538 * @private
11539 */
11540 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11541 this.menu.toggle();
11542 this.focus();
11543 };
11544
11545 /**
11546 * Handle menu choose events.
11547 *
11548 * @private
11549 * @param {OO.ui.OptionWidget} item Chosen item
11550 */
11551 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11552 this.setValue( item.getData() );
11553 };
11554
11555 /**
11556 * Handle menu item change events.
11557 *
11558 * @private
11559 */
11560 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11561 var match = this.menu.findItemFromData( this.getValue() );
11562 this.menu.selectItem( match );
11563 if ( this.menu.findHighlightedItem() ) {
11564 this.menu.highlightItem( match );
11565 }
11566 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11567 };
11568
11569 /**
11570 * Handle menu toggle events.
11571 *
11572 * @private
11573 * @param {boolean} isVisible Open state of the menu
11574 */
11575 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11576 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11577 };
11578
11579 /**
11580 * @inheritdoc
11581 */
11582 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11583 // Parent method
11584 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11585
11586 if ( this.dropdownButton ) {
11587 this.dropdownButton.setDisabled( this.isDisabled() );
11588 }
11589 if ( this.menu ) {
11590 this.menu.setDisabled( this.isDisabled() );
11591 }
11592
11593 return this;
11594 };
11595
11596 /**
11597 * Set the options available for this input.
11598 *
11599 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11600 * @chainable
11601 * @return {OO.ui.Widget} The widget, for chaining
11602 */
11603 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11604 this.getMenu()
11605 .clearItems()
11606 .addItems( options.map( function ( opt ) {
11607 return new OO.ui.MenuOptionWidget( {
11608 data: opt.data,
11609 label: opt.label !== undefined ? opt.label : opt.data
11610 } );
11611 } ) );
11612
11613 return this;
11614 };
11615
11616 /**
11617 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11618 * which is a widget that is specified by reference before any optional configuration settings.
11619 *
11620 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11621 *
11622 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11623 * A left-alignment is used for forms with many fields.
11624 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11625 * A right-alignment is used for long but familiar forms which users tab through,
11626 * verifying the current field with a quick glance at the label.
11627 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11628 * that users fill out from top to bottom.
11629 * - **inline**: The label is placed after the field-widget and aligned to the left.
11630 * An inline-alignment is best used with checkboxes or radio buttons.
11631 *
11632 * Help text can either be:
11633 *
11634 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11635 * - shown as a subtle explanation below the label.
11636 *
11637 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`. If it
11638 * is long or not essential, leave `helpInline` to its default, `false`.
11639 *
11640 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11641 *
11642 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11643 *
11644 * @class
11645 * @extends OO.ui.Layout
11646 * @mixins OO.ui.mixin.LabelElement
11647 * @mixins OO.ui.mixin.TitledElement
11648 *
11649 * @constructor
11650 * @param {OO.ui.Widget} fieldWidget Field widget
11651 * @param {Object} [config] Configuration options
11652 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11653 * or 'inline'
11654 * @cfg {Array} [errors] Error messages about the widget, which will be
11655 * displayed below the widget.
11656 * The array may contain strings or OO.ui.HtmlSnippet instances.
11657 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11658 * below the widget.
11659 * The array may contain strings or OO.ui.HtmlSnippet instances.
11660 * These are more visible than `help` messages when `helpInline` is set, and so
11661 * might be good for transient messages.
11662 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11663 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11664 * corner of the rendered field; clicking it will display the text in a popup.
11665 * If `helpInline` is `true`, then a subtle description will be shown after the
11666 * label.
11667 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11668 * or shown when the "help" icon is clicked.
11669 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11670 * `help` is given.
11671 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11672 *
11673 * @throws {Error} An error is thrown if no widget is specified
11674 */
11675 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11676 // Allow passing positional parameters inside the config object
11677 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11678 config = fieldWidget;
11679 fieldWidget = config.fieldWidget;
11680 }
11681
11682 // Make sure we have required constructor arguments
11683 if ( fieldWidget === undefined ) {
11684 throw new Error( 'Widget not found' );
11685 }
11686
11687 // Configuration initialization
11688 config = $.extend( { align: 'left', helpInline: false }, config );
11689
11690 // Parent constructor
11691 OO.ui.FieldLayout.parent.call( this, config );
11692
11693 // Mixin constructors
11694 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11695 $label: $( '<label>' )
11696 } ) );
11697 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11698
11699 // Properties
11700 this.fieldWidget = fieldWidget;
11701 this.errors = [];
11702 this.notices = [];
11703 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11704 this.$messages = $( '<ul>' );
11705 this.$header = $( '<span>' );
11706 this.$body = $( '<div>' );
11707 this.align = null;
11708 this.helpInline = config.helpInline;
11709
11710 // Events
11711 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11712
11713 // Initialization
11714 this.$help = config.help ?
11715 this.createHelpElement( config.help, config.$overlay ) :
11716 $( [] );
11717 if ( this.fieldWidget.getInputId() ) {
11718 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11719 if ( this.helpInline ) {
11720 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11721 }
11722 } else {
11723 this.$label.on( 'click', function () {
11724 this.fieldWidget.simulateLabelClick();
11725 }.bind( this ) );
11726 if ( this.helpInline ) {
11727 this.$help.on( 'click', function () {
11728 this.fieldWidget.simulateLabelClick();
11729 }.bind( this ) );
11730 }
11731 }
11732 this.$element
11733 .addClass( 'oo-ui-fieldLayout' )
11734 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11735 .append( this.$body );
11736 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11737 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11738 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11739 this.$field
11740 .addClass( 'oo-ui-fieldLayout-field' )
11741 .append( this.fieldWidget.$element );
11742
11743 this.setErrors( config.errors || [] );
11744 this.setNotices( config.notices || [] );
11745 this.setAlignment( config.align );
11746 // Call this again to take into account the widget's accessKey
11747 this.updateTitle();
11748 };
11749
11750 /* Setup */
11751
11752 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11753 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11754 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11755
11756 /* Methods */
11757
11758 /**
11759 * Handle field disable events.
11760 *
11761 * @private
11762 * @param {boolean} value Field is disabled
11763 */
11764 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11765 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11766 };
11767
11768 /**
11769 * Get the widget contained by the field.
11770 *
11771 * @return {OO.ui.Widget} Field widget
11772 */
11773 OO.ui.FieldLayout.prototype.getField = function () {
11774 return this.fieldWidget;
11775 };
11776
11777 /**
11778 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11779 * #setAlignment). Return `false` if it can't or if this can't be determined.
11780 *
11781 * @return {boolean}
11782 */
11783 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11784 // This is very simplistic, but should be good enough.
11785 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11786 };
11787
11788 /**
11789 * @protected
11790 * @param {string} kind 'error' or 'notice'
11791 * @param {string|OO.ui.HtmlSnippet} text
11792 * @return {jQuery}
11793 */
11794 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11795 var $listItem, $icon, message;
11796 $listItem = $( '<li>' );
11797 if ( kind === 'error' ) {
11798 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11799 $listItem.attr( 'role', 'alert' );
11800 } else if ( kind === 'notice' ) {
11801 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11802 } else {
11803 $icon = '';
11804 }
11805 message = new OO.ui.LabelWidget( { label: text } );
11806 $listItem
11807 .append( $icon, message.$element )
11808 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11809 return $listItem;
11810 };
11811
11812 /**
11813 * Set the field alignment mode.
11814 *
11815 * @private
11816 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11817 * @chainable
11818 * @return {OO.ui.BookletLayout} The layout, for chaining
11819 */
11820 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11821 if ( value !== this.align ) {
11822 // Default to 'left'
11823 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11824 value = 'left';
11825 }
11826 // Validate
11827 if ( value === 'inline' && !this.isFieldInline() ) {
11828 value = 'top';
11829 }
11830 // Reorder elements
11831
11832 if ( this.helpInline ) {
11833 if ( value === 'top' ) {
11834 this.$header.append( this.$label );
11835 this.$body.append( this.$header, this.$field, this.$help );
11836 } else if ( value === 'inline' ) {
11837 this.$header.append( this.$label, this.$help );
11838 this.$body.append( this.$field, this.$header );
11839 } else {
11840 this.$header.append( this.$label, this.$help );
11841 this.$body.append( this.$header, this.$field );
11842 }
11843 } else {
11844 if ( value === 'top' ) {
11845 this.$header.append( this.$help, this.$label );
11846 this.$body.append( this.$header, this.$field );
11847 } else if ( value === 'inline' ) {
11848 this.$header.append( this.$help, this.$label );
11849 this.$body.append( this.$field, this.$header );
11850 } else {
11851 this.$header.append( this.$label );
11852 this.$body.append( this.$header, this.$help, this.$field );
11853 }
11854 }
11855 // Set classes. The following classes can be used here:
11856 // * oo-ui-fieldLayout-align-left
11857 // * oo-ui-fieldLayout-align-right
11858 // * oo-ui-fieldLayout-align-top
11859 // * oo-ui-fieldLayout-align-inline
11860 if ( this.align ) {
11861 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11862 }
11863 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11864 this.align = value;
11865 }
11866
11867 return this;
11868 };
11869
11870 /**
11871 * Set the list of error messages.
11872 *
11873 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11874 * The array may contain strings or OO.ui.HtmlSnippet instances.
11875 * @chainable
11876 * @return {OO.ui.BookletLayout} The layout, for chaining
11877 */
11878 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11879 this.errors = errors.slice();
11880 this.updateMessages();
11881 return this;
11882 };
11883
11884 /**
11885 * Set the list of notice messages.
11886 *
11887 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11888 * The array may contain strings or OO.ui.HtmlSnippet instances.
11889 * @chainable
11890 * @return {OO.ui.BookletLayout} The layout, for chaining
11891 */
11892 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11893 this.notices = notices.slice();
11894 this.updateMessages();
11895 return this;
11896 };
11897
11898 /**
11899 * Update the rendering of error and notice messages.
11900 *
11901 * @private
11902 */
11903 OO.ui.FieldLayout.prototype.updateMessages = function () {
11904 var i;
11905 this.$messages.empty();
11906
11907 if ( this.errors.length || this.notices.length ) {
11908 this.$body.after( this.$messages );
11909 } else {
11910 this.$messages.remove();
11911 return;
11912 }
11913
11914 for ( i = 0; i < this.notices.length; i++ ) {
11915 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11916 }
11917 for ( i = 0; i < this.errors.length; i++ ) {
11918 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11919 }
11920 };
11921
11922 /**
11923 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11924 * (This is a bit of a hack.)
11925 *
11926 * @protected
11927 * @param {string} title Tooltip label for 'title' attribute
11928 * @return {string}
11929 */
11930 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11931 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11932 return this.fieldWidget.formatTitleWithAccessKey( title );
11933 }
11934 return title;
11935 };
11936
11937 /**
11938 * Creates and returns the help element. Also sets the `aria-describedby`
11939 * attribute on the main element of the `fieldWidget`.
11940 *
11941 * @private
11942 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11943 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11944 * @return {jQuery} The element that should become `this.$help`.
11945 */
11946 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11947 var helpId, helpWidget;
11948
11949 if ( this.helpInline ) {
11950 helpWidget = new OO.ui.LabelWidget( {
11951 label: help,
11952 classes: [ 'oo-ui-inline-help' ]
11953 } );
11954
11955 helpId = helpWidget.getElementId();
11956 } else {
11957 helpWidget = new OO.ui.PopupButtonWidget( {
11958 $overlay: $overlay,
11959 popup: {
11960 padded: true
11961 },
11962 classes: [ 'oo-ui-fieldLayout-help' ],
11963 framed: false,
11964 icon: 'info',
11965 label: OO.ui.msg( 'ooui-field-help' ),
11966 invisibleLabel: true
11967 } );
11968 if ( help instanceof OO.ui.HtmlSnippet ) {
11969 helpWidget.getPopup().$body.html( help.toString() );
11970 } else {
11971 helpWidget.getPopup().$body.text( help );
11972 }
11973
11974 helpId = helpWidget.getPopup().getBodyId();
11975 }
11976
11977 // Set the 'aria-describedby' attribute on the fieldWidget
11978 // Preference given to an input or a button
11979 (
11980 this.fieldWidget.$input ||
11981 this.fieldWidget.$button ||
11982 this.fieldWidget.$element
11983 ).attr( 'aria-describedby', helpId );
11984
11985 return helpWidget.$element;
11986 };
11987
11988 /**
11989 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11990 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11991 * is required and is specified before any optional configuration settings.
11992 *
11993 * Labels can be aligned in one of four ways:
11994 *
11995 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11996 * A left-alignment is used for forms with many fields.
11997 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11998 * A right-alignment is used for long but familiar forms which users tab through,
11999 * verifying the current field with a quick glance at the label.
12000 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12001 * that users fill out from top to bottom.
12002 * - **inline**: The label is placed after the field-widget and aligned to the left.
12003 * An inline-alignment is best used with checkboxes or radio buttons.
12004 *
12005 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
12006 * text is specified.
12007 *
12008 * @example
12009 * // Example of an ActionFieldLayout
12010 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12011 * new OO.ui.TextInputWidget( {
12012 * placeholder: 'Field widget'
12013 * } ),
12014 * new OO.ui.ButtonWidget( {
12015 * label: 'Button'
12016 * } ),
12017 * {
12018 * label: 'An ActionFieldLayout. This label is aligned top',
12019 * align: 'top',
12020 * help: 'This is help text'
12021 * }
12022 * );
12023 *
12024 * $( document.body ).append( actionFieldLayout.$element );
12025 *
12026 * @class
12027 * @extends OO.ui.FieldLayout
12028 *
12029 * @constructor
12030 * @param {OO.ui.Widget} fieldWidget Field widget
12031 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12032 * @param {Object} config
12033 */
12034 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12035 // Allow passing positional parameters inside the config object
12036 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12037 config = fieldWidget;
12038 fieldWidget = config.fieldWidget;
12039 buttonWidget = config.buttonWidget;
12040 }
12041
12042 // Parent constructor
12043 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12044
12045 // Properties
12046 this.buttonWidget = buttonWidget;
12047 this.$button = $( '<span>' );
12048 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12049
12050 // Initialization
12051 this.$element
12052 .addClass( 'oo-ui-actionFieldLayout' );
12053 this.$button
12054 .addClass( 'oo-ui-actionFieldLayout-button' )
12055 .append( this.buttonWidget.$element );
12056 this.$input
12057 .addClass( 'oo-ui-actionFieldLayout-input' )
12058 .append( this.fieldWidget.$element );
12059 this.$field
12060 .append( this.$input, this.$button );
12061 };
12062
12063 /* Setup */
12064
12065 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12066
12067 /**
12068 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12069 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12070 * configured with a label as well. For more information and examples,
12071 * please see the [OOUI documentation on MediaWiki][1].
12072 *
12073 * @example
12074 * // Example of a fieldset layout
12075 * var input1 = new OO.ui.TextInputWidget( {
12076 * placeholder: 'A text input field'
12077 * } );
12078 *
12079 * var input2 = new OO.ui.TextInputWidget( {
12080 * placeholder: 'A text input field'
12081 * } );
12082 *
12083 * var fieldset = new OO.ui.FieldsetLayout( {
12084 * label: 'Example of a fieldset layout'
12085 * } );
12086 *
12087 * fieldset.addItems( [
12088 * new OO.ui.FieldLayout( input1, {
12089 * label: 'Field One'
12090 * } ),
12091 * new OO.ui.FieldLayout( input2, {
12092 * label: 'Field Two'
12093 * } )
12094 * ] );
12095 * $( document.body ).append( fieldset.$element );
12096 *
12097 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12098 *
12099 * @class
12100 * @extends OO.ui.Layout
12101 * @mixins OO.ui.mixin.IconElement
12102 * @mixins OO.ui.mixin.LabelElement
12103 * @mixins OO.ui.mixin.GroupElement
12104 *
12105 * @constructor
12106 * @param {Object} [config] Configuration options
12107 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
12108 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
12109 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
12110 * For important messages, you are advised to use `notices`, as they are always shown.
12111 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12112 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12113 */
12114 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12115 // Configuration initialization
12116 config = config || {};
12117
12118 // Parent constructor
12119 OO.ui.FieldsetLayout.parent.call( this, config );
12120
12121 // Mixin constructors
12122 OO.ui.mixin.IconElement.call( this, config );
12123 OO.ui.mixin.LabelElement.call( this, config );
12124 OO.ui.mixin.GroupElement.call( this, config );
12125
12126 // Properties
12127 this.$header = $( '<legend>' );
12128 if ( config.help ) {
12129 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12130 $overlay: config.$overlay,
12131 popup: {
12132 padded: true
12133 },
12134 classes: [ 'oo-ui-fieldsetLayout-help' ],
12135 framed: false,
12136 icon: 'info',
12137 label: OO.ui.msg( 'ooui-field-help' ),
12138 invisibleLabel: true
12139 } );
12140 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12141 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12142 } else {
12143 this.popupButtonWidget.getPopup().$body.text( config.help );
12144 }
12145 this.$help = this.popupButtonWidget.$element;
12146 } else {
12147 this.$help = $( [] );
12148 }
12149
12150 // Initialization
12151 this.$header
12152 .addClass( 'oo-ui-fieldsetLayout-header' )
12153 .append( this.$icon, this.$label, this.$help );
12154 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12155 this.$element
12156 .addClass( 'oo-ui-fieldsetLayout' )
12157 .prepend( this.$header, this.$group );
12158 if ( Array.isArray( config.items ) ) {
12159 this.addItems( config.items );
12160 }
12161 };
12162
12163 /* Setup */
12164
12165 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12166 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12167 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12168 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12169
12170 /* Static Properties */
12171
12172 /**
12173 * @static
12174 * @inheritdoc
12175 */
12176 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12177
12178 /**
12179 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
12180 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
12181 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
12182 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12183 *
12184 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12185 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12186 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12187 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12188 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12189 * often have simplified APIs to match the capabilities of HTML forms.
12190 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12191 *
12192 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12193 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12194 *
12195 * @example
12196 * // Example of a form layout that wraps a fieldset layout
12197 * var input1 = new OO.ui.TextInputWidget( {
12198 * placeholder: 'Username'
12199 * } );
12200 * var input2 = new OO.ui.TextInputWidget( {
12201 * placeholder: 'Password',
12202 * type: 'password'
12203 * } );
12204 * var submit = new OO.ui.ButtonInputWidget( {
12205 * label: 'Submit'
12206 * } );
12207 *
12208 * var fieldset = new OO.ui.FieldsetLayout( {
12209 * label: 'A form layout'
12210 * } );
12211 * fieldset.addItems( [
12212 * new OO.ui.FieldLayout( input1, {
12213 * label: 'Username',
12214 * align: 'top'
12215 * } ),
12216 * new OO.ui.FieldLayout( input2, {
12217 * label: 'Password',
12218 * align: 'top'
12219 * } ),
12220 * new OO.ui.FieldLayout( submit )
12221 * ] );
12222 * var form = new OO.ui.FormLayout( {
12223 * items: [ fieldset ],
12224 * action: '/api/formhandler',
12225 * method: 'get'
12226 * } )
12227 * $( document.body ).append( form.$element );
12228 *
12229 * @class
12230 * @extends OO.ui.Layout
12231 * @mixins OO.ui.mixin.GroupElement
12232 *
12233 * @constructor
12234 * @param {Object} [config] Configuration options
12235 * @cfg {string} [method] HTML form `method` attribute
12236 * @cfg {string} [action] HTML form `action` attribute
12237 * @cfg {string} [enctype] HTML form `enctype` attribute
12238 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12239 */
12240 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12241 var action;
12242
12243 // Configuration initialization
12244 config = config || {};
12245
12246 // Parent constructor
12247 OO.ui.FormLayout.parent.call( this, config );
12248
12249 // Mixin constructors
12250 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12251
12252 // Events
12253 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12254
12255 // Make sure the action is safe
12256 action = config.action;
12257 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12258 action = './' + action;
12259 }
12260
12261 // Initialization
12262 this.$element
12263 .addClass( 'oo-ui-formLayout' )
12264 .attr( {
12265 method: config.method,
12266 action: action,
12267 enctype: config.enctype
12268 } );
12269 if ( Array.isArray( config.items ) ) {
12270 this.addItems( config.items );
12271 }
12272 };
12273
12274 /* Setup */
12275
12276 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12277 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12278
12279 /* Events */
12280
12281 /**
12282 * A 'submit' event is emitted when the form is submitted.
12283 *
12284 * @event submit
12285 */
12286
12287 /* Static Properties */
12288
12289 /**
12290 * @static
12291 * @inheritdoc
12292 */
12293 OO.ui.FormLayout.static.tagName = 'form';
12294
12295 /* Methods */
12296
12297 /**
12298 * Handle form submit events.
12299 *
12300 * @private
12301 * @param {jQuery.Event} e Submit event
12302 * @fires submit
12303 * @return {OO.ui.FormLayout} The layout, for chaining
12304 */
12305 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12306 if ( this.emit( 'submit' ) ) {
12307 return false;
12308 }
12309 };
12310
12311 /**
12312 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12313 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12314 *
12315 * @example
12316 * // Example of a panel layout
12317 * var panel = new OO.ui.PanelLayout( {
12318 * expanded: false,
12319 * framed: true,
12320 * padded: true,
12321 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12322 * } );
12323 * $( document.body ).append( panel.$element );
12324 *
12325 * @class
12326 * @extends OO.ui.Layout
12327 *
12328 * @constructor
12329 * @param {Object} [config] Configuration options
12330 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12331 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12332 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12333 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12334 */
12335 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12336 // Configuration initialization
12337 config = $.extend( {
12338 scrollable: false,
12339 padded: false,
12340 expanded: true,
12341 framed: false
12342 }, config );
12343
12344 // Parent constructor
12345 OO.ui.PanelLayout.parent.call( this, config );
12346
12347 // Initialization
12348 this.$element.addClass( 'oo-ui-panelLayout' );
12349 if ( config.scrollable ) {
12350 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12351 }
12352 if ( config.padded ) {
12353 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12354 }
12355 if ( config.expanded ) {
12356 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12357 }
12358 if ( config.framed ) {
12359 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12360 }
12361 };
12362
12363 /* Setup */
12364
12365 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12366
12367 /* Methods */
12368
12369 /**
12370 * Focus the panel layout
12371 *
12372 * The default implementation just focuses the first focusable element in the panel
12373 */
12374 OO.ui.PanelLayout.prototype.focus = function () {
12375 OO.ui.findFocusable( this.$element ).focus();
12376 };
12377
12378 /**
12379 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12380 * items), with small margins between them. Convenient when you need to put a number of block-level
12381 * widgets on a single line next to each other.
12382 *
12383 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12384 *
12385 * @example
12386 * // HorizontalLayout with a text input and a label
12387 * var layout = new OO.ui.HorizontalLayout( {
12388 * items: [
12389 * new OO.ui.LabelWidget( { label: 'Label' } ),
12390 * new OO.ui.TextInputWidget( { value: 'Text' } )
12391 * ]
12392 * } );
12393 * $( document.body ).append( layout.$element );
12394 *
12395 * @class
12396 * @extends OO.ui.Layout
12397 * @mixins OO.ui.mixin.GroupElement
12398 *
12399 * @constructor
12400 * @param {Object} [config] Configuration options
12401 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12402 */
12403 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12404 // Configuration initialization
12405 config = config || {};
12406
12407 // Parent constructor
12408 OO.ui.HorizontalLayout.parent.call( this, config );
12409
12410 // Mixin constructors
12411 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12412
12413 // Initialization
12414 this.$element.addClass( 'oo-ui-horizontalLayout' );
12415 if ( Array.isArray( config.items ) ) {
12416 this.addItems( config.items );
12417 }
12418 };
12419
12420 /* Setup */
12421
12422 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12423 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12424
12425 /**
12426 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12427 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12428 * (to adjust the value in increments) to allow the user to enter a number.
12429 *
12430 * @example
12431 * // A NumberInputWidget.
12432 * var numberInput = new OO.ui.NumberInputWidget( {
12433 * label: 'NumberInputWidget',
12434 * input: { value: 5 },
12435 * min: 1,
12436 * max: 10
12437 * } );
12438 * $( document.body ).append( numberInput.$element );
12439 *
12440 * @class
12441 * @extends OO.ui.TextInputWidget
12442 *
12443 * @constructor
12444 * @param {Object} [config] Configuration options
12445 * @cfg {Object} [minusButton] Configuration options to pass to the
12446 * {@link OO.ui.ButtonWidget decrementing button widget}.
12447 * @cfg {Object} [plusButton] Configuration options to pass to the
12448 * {@link OO.ui.ButtonWidget incrementing button widget}.
12449 * @cfg {number} [min=-Infinity] Minimum allowed value
12450 * @cfg {number} [max=Infinity] Maximum allowed value
12451 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12452 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12453 * Defaults to `step` if specified, otherwise `1`.
12454 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12455 * Defaults to 10 times `buttonStep`.
12456 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12457 */
12458 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12459 var $field = $( '<div>' )
12460 .addClass( 'oo-ui-numberInputWidget-field' );
12461
12462 // Configuration initialization
12463 config = $.extend( {
12464 min: -Infinity,
12465 max: Infinity,
12466 showButtons: true
12467 }, config );
12468
12469 // For backward compatibility
12470 $.extend( config, config.input );
12471 this.input = this;
12472
12473 // Parent constructor
12474 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12475 type: 'number'
12476 } ) );
12477
12478 if ( config.showButtons ) {
12479 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12480 {
12481 disabled: this.isDisabled(),
12482 tabIndex: -1,
12483 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12484 icon: 'subtract'
12485 },
12486 config.minusButton
12487 ) );
12488 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12489 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12490 {
12491 disabled: this.isDisabled(),
12492 tabIndex: -1,
12493 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12494 icon: 'add'
12495 },
12496 config.plusButton
12497 ) );
12498 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12499 }
12500
12501 // Events
12502 this.$input.on( {
12503 keydown: this.onKeyDown.bind( this ),
12504 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12505 } );
12506 if ( config.showButtons ) {
12507 this.plusButton.connect( this, {
12508 click: [ 'onButtonClick', +1 ]
12509 } );
12510 this.minusButton.connect( this, {
12511 click: [ 'onButtonClick', -1 ]
12512 } );
12513 }
12514
12515 // Build the field
12516 $field.append( this.$input );
12517 if ( config.showButtons ) {
12518 $field
12519 .prepend( this.minusButton.$element )
12520 .append( this.plusButton.$element );
12521 }
12522
12523 // Initialization
12524 if ( config.allowInteger || config.isInteger ) {
12525 // Backward compatibility
12526 config.step = 1;
12527 }
12528 this.setRange( config.min, config.max );
12529 this.setStep( config.buttonStep, config.pageStep, config.step );
12530 // Set the validation method after we set step and range
12531 // so that it doesn't immediately call setValidityFlag
12532 this.setValidation( this.validateNumber.bind( this ) );
12533
12534 this.$element
12535 .addClass( 'oo-ui-numberInputWidget' )
12536 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12537 .append( $field );
12538 };
12539
12540 /* Setup */
12541
12542 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12543
12544 /* Methods */
12545
12546 // Backward compatibility
12547 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12548 this.setStep( flag ? 1 : null );
12549 };
12550 // Backward compatibility
12551 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12552
12553 // Backward compatibility
12554 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12555 return this.step === 1;
12556 };
12557 // Backward compatibility
12558 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12559
12560 /**
12561 * Set the range of allowed values
12562 *
12563 * @param {number} min Minimum allowed value
12564 * @param {number} max Maximum allowed value
12565 */
12566 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12567 if ( min > max ) {
12568 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12569 }
12570 this.min = min;
12571 this.max = max;
12572 this.$input.attr( 'min', this.min );
12573 this.$input.attr( 'max', this.max );
12574 this.setValidityFlag();
12575 };
12576
12577 /**
12578 * Get the current range
12579 *
12580 * @return {number[]} Minimum and maximum values
12581 */
12582 OO.ui.NumberInputWidget.prototype.getRange = function () {
12583 return [ this.min, this.max ];
12584 };
12585
12586 /**
12587 * Set the stepping deltas
12588 *
12589 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12590 * Defaults to `step` if specified, otherwise `1`.
12591 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12592 * Defaults to 10 times `buttonStep`.
12593 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12594 */
12595 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12596 if ( buttonStep === undefined ) {
12597 buttonStep = step || 1;
12598 }
12599 if ( pageStep === undefined ) {
12600 pageStep = 10 * buttonStep;
12601 }
12602 if ( step !== null && step <= 0 ) {
12603 throw new Error( 'Step value, if given, must be positive' );
12604 }
12605 if ( buttonStep <= 0 ) {
12606 throw new Error( 'Button step value must be positive' );
12607 }
12608 if ( pageStep <= 0 ) {
12609 throw new Error( 'Page step value must be positive' );
12610 }
12611 this.step = step;
12612 this.buttonStep = buttonStep;
12613 this.pageStep = pageStep;
12614 this.$input.attr( 'step', this.step || 'any' );
12615 this.setValidityFlag();
12616 };
12617
12618 /**
12619 * @inheritdoc
12620 */
12621 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12622 if ( value === '' ) {
12623 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12624 // so here we make sure an 'empty' value is actually displayed as such.
12625 this.$input.val( '' );
12626 }
12627 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12628 };
12629
12630 /**
12631 * Get the current stepping values
12632 *
12633 * @return {number[]} Button step, page step, and validity step
12634 */
12635 OO.ui.NumberInputWidget.prototype.getStep = function () {
12636 return [ this.buttonStep, this.pageStep, this.step ];
12637 };
12638
12639 /**
12640 * Get the current value of the widget as a number
12641 *
12642 * @return {number} May be NaN, or an invalid number
12643 */
12644 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12645 return +this.getValue();
12646 };
12647
12648 /**
12649 * Adjust the value of the widget
12650 *
12651 * @param {number} delta Adjustment amount
12652 */
12653 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12654 var n, v = this.getNumericValue();
12655
12656 delta = +delta;
12657 if ( isNaN( delta ) || !isFinite( delta ) ) {
12658 throw new Error( 'Delta must be a finite number' );
12659 }
12660
12661 if ( isNaN( v ) ) {
12662 n = 0;
12663 } else {
12664 n = v + delta;
12665 n = Math.max( Math.min( n, this.max ), this.min );
12666 if ( this.step ) {
12667 n = Math.round( n / this.step ) * this.step;
12668 }
12669 }
12670
12671 if ( n !== v ) {
12672 this.setValue( n );
12673 }
12674 };
12675 /**
12676 * Validate input
12677 *
12678 * @private
12679 * @param {string} value Field value
12680 * @return {boolean}
12681 */
12682 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12683 var n = +value;
12684 if ( value === '' ) {
12685 return !this.isRequired();
12686 }
12687
12688 if ( isNaN( n ) || !isFinite( n ) ) {
12689 return false;
12690 }
12691
12692 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12693 return false;
12694 }
12695
12696 if ( n < this.min || n > this.max ) {
12697 return false;
12698 }
12699
12700 return true;
12701 };
12702
12703 /**
12704 * Handle mouse click events.
12705 *
12706 * @private
12707 * @param {number} dir +1 or -1
12708 */
12709 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12710 this.adjustValue( dir * this.buttonStep );
12711 };
12712
12713 /**
12714 * Handle mouse wheel events.
12715 *
12716 * @private
12717 * @param {jQuery.Event} event
12718 * @return {undefined/boolean} False to prevent default if event is handled
12719 */
12720 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12721 var delta = 0;
12722
12723 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12724 // Standard 'wheel' event
12725 if ( event.originalEvent.deltaMode !== undefined ) {
12726 this.sawWheelEvent = true;
12727 }
12728 if ( event.originalEvent.deltaY ) {
12729 delta = -event.originalEvent.deltaY;
12730 } else if ( event.originalEvent.deltaX ) {
12731 delta = event.originalEvent.deltaX;
12732 }
12733
12734 // Non-standard events
12735 if ( !this.sawWheelEvent ) {
12736 if ( event.originalEvent.wheelDeltaX ) {
12737 delta = -event.originalEvent.wheelDeltaX;
12738 } else if ( event.originalEvent.wheelDeltaY ) {
12739 delta = event.originalEvent.wheelDeltaY;
12740 } else if ( event.originalEvent.wheelDelta ) {
12741 delta = event.originalEvent.wheelDelta;
12742 } else if ( event.originalEvent.detail ) {
12743 delta = -event.originalEvent.detail;
12744 }
12745 }
12746
12747 if ( delta ) {
12748 delta = delta < 0 ? -1 : 1;
12749 this.adjustValue( delta * this.buttonStep );
12750 }
12751
12752 return false;
12753 }
12754 };
12755
12756 /**
12757 * Handle key down events.
12758 *
12759 * @private
12760 * @param {jQuery.Event} e Key down event
12761 * @return {undefined/boolean} False to prevent default if event is handled
12762 */
12763 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12764 if ( !this.isDisabled() ) {
12765 switch ( e.which ) {
12766 case OO.ui.Keys.UP:
12767 this.adjustValue( this.buttonStep );
12768 return false;
12769 case OO.ui.Keys.DOWN:
12770 this.adjustValue( -this.buttonStep );
12771 return false;
12772 case OO.ui.Keys.PAGEUP:
12773 this.adjustValue( this.pageStep );
12774 return false;
12775 case OO.ui.Keys.PAGEDOWN:
12776 this.adjustValue( -this.pageStep );
12777 return false;
12778 }
12779 }
12780 };
12781
12782 /**
12783 * @inheritdoc
12784 */
12785 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12786 // Parent method
12787 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12788
12789 if ( this.minusButton ) {
12790 this.minusButton.setDisabled( this.isDisabled() );
12791 }
12792 if ( this.plusButton ) {
12793 this.plusButton.setDisabled( this.isDisabled() );
12794 }
12795
12796 return this;
12797 };
12798
12799 }( OO ) );
12800
12801 //# sourceMappingURL=oojs-ui-core.js.map.json