Merge "objectcache: Fixes WinCache increment losing TTL."
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.30.2
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-23T01:14:20Z
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
2526 if ( items.length === 0 ) {
2527 return this;
2528 }
2529
2530 // Mixin method
2531 OO.EmitterList.prototype.addItems.call( this, items, index );
2532
2533 this.emit( 'change', this.getItems() );
2534 return this;
2535 };
2536
2537 /**
2538 * @inheritdoc
2539 */
2540 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2541 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2542 this.insertItemElements( items, newIndex );
2543
2544 // Mixin method
2545 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2546
2547 return newIndex;
2548 };
2549
2550 /**
2551 * @inheritdoc
2552 */
2553 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2554 item.setElementGroup( this );
2555 this.insertItemElements( item, index );
2556
2557 // Mixin method
2558 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2559
2560 return index;
2561 };
2562
2563 /**
2564 * Insert elements into the group
2565 *
2566 * @private
2567 * @param {OO.ui.Element} itemWidget Item to insert
2568 * @param {number} index Insertion index
2569 */
2570 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2571 if ( index === undefined || index < 0 || index >= this.items.length ) {
2572 this.$group.append( itemWidget.$element );
2573 } else if ( index === 0 ) {
2574 this.$group.prepend( itemWidget.$element );
2575 } else {
2576 this.items[ index ].$element.before( itemWidget.$element );
2577 }
2578 };
2579
2580 /**
2581 * Remove the specified items from a group.
2582 *
2583 * Removed items are detached (not removed) from the DOM so that they may be reused.
2584 * To remove all items from a group, you may wish to use the #clearItems method instead.
2585 *
2586 * @param {OO.ui.Element[]} items An array of items to remove
2587 * @chainable
2588 * @return {OO.ui.Element} The element, for chaining
2589 */
2590 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2591 var i, len, item, index;
2592
2593 if ( items.length === 0 ) {
2594 return this;
2595 }
2596
2597 // Remove specific items elements
2598 for ( i = 0, len = items.length; i < len; i++ ) {
2599 item = items[ i ];
2600 index = this.items.indexOf( item );
2601 if ( index !== -1 ) {
2602 item.setElementGroup( null );
2603 item.$element.detach();
2604 }
2605 }
2606
2607 // Mixin method
2608 OO.EmitterList.prototype.removeItems.call( this, items );
2609
2610 this.emit( 'change', this.getItems() );
2611 return this;
2612 };
2613
2614 /**
2615 * Clear all items from the group.
2616 *
2617 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2618 * To remove only a subset of items from a group, use the #removeItems method.
2619 *
2620 * @chainable
2621 * @return {OO.ui.Element} The element, for chaining
2622 */
2623 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2624 var i, len;
2625
2626 // Remove all item elements
2627 for ( i = 0, len = this.items.length; i < len; i++ ) {
2628 this.items[ i ].setElementGroup( null );
2629 this.items[ i ].$element.detach();
2630 }
2631
2632 // Mixin method
2633 OO.EmitterList.prototype.clearItems.call( this );
2634
2635 this.emit( 'change', this.getItems() );
2636 return this;
2637 };
2638
2639 /**
2640 * LabelElement is often mixed into other classes to generate a label, which
2641 * helps identify the function of an interface element.
2642 * See the [OOUI documentation on MediaWiki] [1] for more information.
2643 *
2644 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2645 *
2646 * @abstract
2647 * @class
2648 *
2649 * @constructor
2650 * @param {Object} [config] Configuration options
2651 * @cfg {jQuery} [$label] The label element created by the class. If this
2652 * configuration is omitted, the label element will use a generated `<span>`.
2653 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2654 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2655 * in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2656 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2657 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still accessible
2658 * to screen-readers).
2659 */
2660 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2661 // Configuration initialization
2662 config = config || {};
2663
2664 // Properties
2665 this.$label = null;
2666 this.label = null;
2667 this.invisibleLabel = null;
2668
2669 // Initialization
2670 this.setLabel( config.label || this.constructor.static.label );
2671 this.setLabelElement( config.$label || $( '<span>' ) );
2672 this.setInvisibleLabel( config.invisibleLabel );
2673 };
2674
2675 /* Setup */
2676
2677 OO.initClass( OO.ui.mixin.LabelElement );
2678
2679 /* Events */
2680
2681 /**
2682 * @event labelChange
2683 * @param {string} value
2684 */
2685
2686 /* Static Properties */
2687
2688 /**
2689 * The label text. The label can be specified as a plaintext string, a function that will
2690 * produce a string in the future, or `null` for no label. The static value will
2691 * be overridden if a label is specified with the #label config option.
2692 *
2693 * @static
2694 * @inheritable
2695 * @property {string|Function|null}
2696 */
2697 OO.ui.mixin.LabelElement.static.label = null;
2698
2699 /* Static methods */
2700
2701 /**
2702 * Highlight the first occurrence of the query in the given text
2703 *
2704 * @param {string} text Text
2705 * @param {string} query Query to find
2706 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2707 * @return {jQuery} Text with the first match of the query
2708 * sub-string wrapped in highlighted span
2709 */
2710 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2711 var i, tLen, qLen,
2712 offset = -1,
2713 $result = $( '<span>' );
2714
2715 if ( compare ) {
2716 tLen = text.length;
2717 qLen = query.length;
2718 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2719 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2720 offset = i;
2721 }
2722 }
2723 } else {
2724 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2725 }
2726
2727 if ( !query.length || offset === -1 ) {
2728 $result.text( text );
2729 } else {
2730 $result.append(
2731 document.createTextNode( text.slice( 0, offset ) ),
2732 $( '<span>' )
2733 .addClass( 'oo-ui-labelElement-label-highlight' )
2734 .text( text.slice( offset, offset + query.length ) ),
2735 document.createTextNode( text.slice( offset + query.length ) )
2736 );
2737 }
2738 return $result.contents();
2739 };
2740
2741 /* Methods */
2742
2743 /**
2744 * Set the label element.
2745 *
2746 * If an element is already set, it will be cleaned up before setting up the new element.
2747 *
2748 * @param {jQuery} $label Element to use as label
2749 */
2750 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2751 if ( this.$label ) {
2752 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2753 }
2754
2755 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2756 this.setLabelContent( this.label );
2757 };
2758
2759 /**
2760 * Set the label.
2761 *
2762 * An empty string will result in the label being hidden. A string containing only whitespace will
2763 * be converted to a single `&nbsp;`.
2764 *
2765 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
2766 * text; or null for no label
2767 * @chainable
2768 * @return {OO.ui.Element} The element, for chaining
2769 */
2770 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2771 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2772 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2773
2774 if ( this.label !== label ) {
2775 if ( this.$label ) {
2776 this.setLabelContent( label );
2777 }
2778 this.label = label;
2779 this.emit( 'labelChange' );
2780 }
2781
2782 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2783
2784 return this;
2785 };
2786
2787 /**
2788 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2789 *
2790 * @param {boolean} invisibleLabel
2791 * @chainable
2792 * @return {OO.ui.Element} The element, for chaining
2793 */
2794 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2795 invisibleLabel = !!invisibleLabel;
2796
2797 if ( this.invisibleLabel !== invisibleLabel ) {
2798 this.invisibleLabel = invisibleLabel;
2799 this.emit( 'labelChange' );
2800 }
2801
2802 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2803 // Pretend that there is no label, a lot of CSS has been written with this assumption
2804 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2805
2806 return this;
2807 };
2808
2809 /**
2810 * Set the label as plain text with a highlighted query
2811 *
2812 * @param {string} text Text label to set
2813 * @param {string} query Substring of text to highlight
2814 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2815 * @chainable
2816 * @return {OO.ui.Element} The element, for chaining
2817 */
2818 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2819 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2820 };
2821
2822 /**
2823 * Get the label.
2824 *
2825 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2826 * text; or null for no label
2827 */
2828 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2829 return this.label;
2830 };
2831
2832 /**
2833 * Set the content of the label.
2834 *
2835 * Do not call this method until after the label element has been set by #setLabelElement.
2836 *
2837 * @private
2838 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2839 * text; or null for no label
2840 */
2841 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2842 if ( typeof label === 'string' ) {
2843 if ( label.match( /^\s*$/ ) ) {
2844 // Convert whitespace only string to a single non-breaking space
2845 this.$label.html( '&nbsp;' );
2846 } else {
2847 this.$label.text( label );
2848 }
2849 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2850 this.$label.html( label.toString() );
2851 } else if ( label instanceof $ ) {
2852 this.$label.empty().append( label );
2853 } else {
2854 this.$label.empty();
2855 }
2856 };
2857
2858 /**
2859 * IconElement is often mixed into other classes to generate an icon.
2860 * Icons are graphics, about the size of normal text. They are used to aid the user
2861 * in locating a control or to convey information in a space-efficient way. See the
2862 * [OOUI documentation on MediaWiki] [1] for a list of icons
2863 * included in the library.
2864 *
2865 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2866 *
2867 * @abstract
2868 * @class
2869 *
2870 * @constructor
2871 * @param {Object} [config] Configuration options
2872 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2873 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2874 * the icon element be set to an existing icon instead of the one generated by this class, set a
2875 * value using a jQuery selection. For example:
2876 *
2877 * // Use a <div> tag instead of a <span>
2878 * $icon: $( '<div>' )
2879 * // Use an existing icon element instead of the one generated by the class
2880 * $icon: this.$element
2881 * // Use an icon element from a child widget
2882 * $icon: this.childwidget.$element
2883 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2884 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2885 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2886 * by the user's language.
2887 *
2888 * Example of an i18n map:
2889 *
2890 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2891 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2892 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2893 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2894 * text. The icon title is displayed when users move the mouse over the icon.
2895 */
2896 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2897 // Configuration initialization
2898 config = config || {};
2899
2900 // Properties
2901 this.$icon = null;
2902 this.icon = null;
2903 this.iconTitle = null;
2904
2905 // `iconTitle`s are deprecated since 0.30.0
2906 if ( config.iconTitle !== undefined ) {
2907 OO.ui.warnDeprecation( 'IconElement: Widgets with iconTitle set are deprecated, use title instead. See T76638 for details.' );
2908 }
2909
2910 // Initialization
2911 this.setIcon( config.icon || this.constructor.static.icon );
2912 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2913 this.setIconElement( config.$icon || $( '<span>' ) );
2914 };
2915
2916 /* Setup */
2917
2918 OO.initClass( OO.ui.mixin.IconElement );
2919
2920 /* Static Properties */
2921
2922 /**
2923 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2924 * for i18n purposes and contains a `default` icon name and additional names keyed by
2925 * language code. The `default` name is used when no icon is keyed by the user's language.
2926 *
2927 * Example of an i18n map:
2928 *
2929 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2930 *
2931 * Note: the static property will be overridden if the #icon configuration is used.
2932 *
2933 * @static
2934 * @inheritable
2935 * @property {Object|string}
2936 */
2937 OO.ui.mixin.IconElement.static.icon = null;
2938
2939 /**
2940 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2941 * function that returns title text, or `null` for no title.
2942 *
2943 * The static property will be overridden if the #iconTitle configuration is used.
2944 *
2945 * @static
2946 * @inheritable
2947 * @property {string|Function|null}
2948 */
2949 OO.ui.mixin.IconElement.static.iconTitle = null;
2950
2951 /* Methods */
2952
2953 /**
2954 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2955 * applies to the specified icon element instead of the one created by the class. If an icon
2956 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2957 * and mixin methods will no longer affect the element.
2958 *
2959 * @param {jQuery} $icon Element to use as icon
2960 */
2961 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2962 if ( this.$icon ) {
2963 this.$icon
2964 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2965 .removeAttr( 'title' );
2966 }
2967
2968 this.$icon = $icon
2969 .addClass( 'oo-ui-iconElement-icon' )
2970 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2971 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2972 if ( this.iconTitle !== null ) {
2973 this.$icon.attr( 'title', this.iconTitle );
2974 }
2975
2976 this.updateThemeClasses();
2977 };
2978
2979 /**
2980 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2981 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2982 * for an example.
2983 *
2984 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2985 * by language code, or `null` to remove the icon.
2986 * @chainable
2987 * @return {OO.ui.Element} The element, for chaining
2988 */
2989 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2990 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2991 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2992
2993 if ( this.icon !== icon ) {
2994 if ( this.$icon ) {
2995 if ( this.icon !== null ) {
2996 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2997 }
2998 if ( icon !== null ) {
2999 this.$icon.addClass( 'oo-ui-icon-' + icon );
3000 }
3001 }
3002 this.icon = icon;
3003 }
3004
3005 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
3006 if ( this.$icon ) {
3007 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
3008 }
3009 this.updateThemeClasses();
3010
3011 return this;
3012 };
3013
3014 /**
3015 * Set the icon title. Use `null` to remove the title.
3016 *
3017 * @param {string|Function|null} iconTitle A text string used as the icon title,
3018 * a function that returns title text, or `null` for no title.
3019 * @chainable
3020 * @return {OO.ui.Element} The element, for chaining
3021 * @deprecated
3022 */
3023 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
3024 iconTitle =
3025 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
3026 OO.ui.resolveMsg( iconTitle ) : null;
3027
3028 if ( this.iconTitle !== iconTitle ) {
3029 this.iconTitle = iconTitle;
3030 if ( this.$icon ) {
3031 if ( this.iconTitle !== null ) {
3032 this.$icon.attr( 'title', iconTitle );
3033 } else {
3034 this.$icon.removeAttr( 'title' );
3035 }
3036 }
3037 }
3038
3039 // `setIconTitle is deprecated since 0.30.0
3040 if ( iconTitle !== null ) {
3041 // Avoid a warning when this is called from the constructor with no iconTitle set
3042 OO.ui.warnDeprecation( 'IconElement: setIconTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3043 }
3044
3045 return this;
3046 };
3047
3048 /**
3049 * Get the symbolic name of the icon.
3050 *
3051 * @return {string} Icon name
3052 */
3053 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3054 return this.icon;
3055 };
3056
3057 /**
3058 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3059 *
3060 * @return {string} Icon title text
3061 */
3062 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3063 return this.iconTitle;
3064 };
3065
3066 /**
3067 * IndicatorElement is often mixed into other classes to generate an indicator.
3068 * Indicators are small graphics that are generally used in two ways:
3069 *
3070 * - To draw attention to the status of an item. For example, an indicator might be
3071 * used to show that an item in a list has errors that need to be resolved.
3072 * - To clarify the function of a control that acts in an exceptional way (a button
3073 * that opens a menu instead of performing an action directly, for example).
3074 *
3075 * For a list of indicators included in the library, please see the
3076 * [OOUI documentation on MediaWiki] [1].
3077 *
3078 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3079 *
3080 * @abstract
3081 * @class
3082 *
3083 * @constructor
3084 * @param {Object} [config] Configuration options
3085 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3086 * configuration is omitted, the indicator element will use a generated `<span>`.
3087 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3088 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3089 * in the library.
3090 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3091 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
3092 * or a function that returns title text. The indicator title is displayed when users move
3093 * the mouse over the indicator.
3094 */
3095 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3096 // Configuration initialization
3097 config = config || {};
3098
3099 // Properties
3100 this.$indicator = null;
3101 this.indicator = null;
3102 this.indicatorTitle = null;
3103
3104 // `indicatorTitle`s are deprecated since 0.30.0
3105 if ( config.indicatorTitle !== undefined ) {
3106 OO.ui.warnDeprecation( 'IndicatorElement: Widgets with indicatorTitle set are deprecated, use title instead. See T76638 for details.' );
3107 }
3108
3109 // Initialization
3110 this.setIndicator( config.indicator || this.constructor.static.indicator );
3111 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
3112 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3113 };
3114
3115 /* Setup */
3116
3117 OO.initClass( OO.ui.mixin.IndicatorElement );
3118
3119 /* Static Properties */
3120
3121 /**
3122 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3123 * The static property will be overridden if the #indicator configuration is used.
3124 *
3125 * @static
3126 * @inheritable
3127 * @property {string|null}
3128 */
3129 OO.ui.mixin.IndicatorElement.static.indicator = null;
3130
3131 /**
3132 * A text string used as the indicator title, a function that returns title text, or `null`
3133 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
3134 *
3135 * @static
3136 * @inheritable
3137 * @property {string|Function|null}
3138 */
3139 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3140
3141 /* Methods */
3142
3143 /**
3144 * Set the indicator element.
3145 *
3146 * If an element is already set, it will be cleaned up before setting up the new element.
3147 *
3148 * @param {jQuery} $indicator Element to use as indicator
3149 */
3150 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3151 if ( this.$indicator ) {
3152 this.$indicator
3153 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3154 .removeAttr( 'title' );
3155 }
3156
3157 this.$indicator = $indicator
3158 .addClass( 'oo-ui-indicatorElement-indicator' )
3159 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3160 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3161 if ( this.indicatorTitle !== null ) {
3162 this.$indicator.attr( 'title', this.indicatorTitle );
3163 }
3164
3165 this.updateThemeClasses();
3166 };
3167
3168 /**
3169 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null` to remove the indicator.
3170 *
3171 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3172 * @chainable
3173 * @return {OO.ui.Element} The element, for chaining
3174 */
3175 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3176 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3177
3178 if ( this.indicator !== indicator ) {
3179 if ( this.$indicator ) {
3180 if ( this.indicator !== null ) {
3181 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3182 }
3183 if ( indicator !== null ) {
3184 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3185 }
3186 }
3187 this.indicator = indicator;
3188 }
3189
3190 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3191 if ( this.$indicator ) {
3192 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3193 }
3194 this.updateThemeClasses();
3195
3196 return this;
3197 };
3198
3199 /**
3200 * Set the indicator title.
3201 *
3202 * The title is displayed when a user moves the mouse over the indicator.
3203 *
3204 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
3205 * `null` for no indicator title
3206 * @chainable
3207 * @return {OO.ui.Element} The element, for chaining
3208 * @deprecated
3209 */
3210 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
3211 indicatorTitle =
3212 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
3213 OO.ui.resolveMsg( indicatorTitle ) : null;
3214
3215 if ( this.indicatorTitle !== indicatorTitle ) {
3216 this.indicatorTitle = indicatorTitle;
3217 if ( this.$indicator ) {
3218 if ( this.indicatorTitle !== null ) {
3219 this.$indicator.attr( 'title', indicatorTitle );
3220 } else {
3221 this.$indicator.removeAttr( 'title' );
3222 }
3223 }
3224 }
3225
3226 // `setIndicatorTitle is deprecated since 0.30.0
3227 if ( indicatorTitle !== null ) {
3228 // Avoid a warning when this is called from the constructor with no indicatorTitle set
3229 OO.ui.warnDeprecation( 'IndicatorElement: setIndicatorTitle is deprecated, use setTitle of TitledElement instead. See T76638 for details.' );
3230 }
3231
3232 return this;
3233 };
3234
3235 /**
3236 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3237 *
3238 * @return {string} Symbolic name of indicator
3239 */
3240 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3241 return this.indicator;
3242 };
3243
3244 /**
3245 * Get the indicator title.
3246 *
3247 * The title is displayed when a user moves the mouse over the indicator.
3248 *
3249 * @return {string} Indicator title text
3250 */
3251 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3252 return this.indicatorTitle;
3253 };
3254
3255 /**
3256 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3257 * additional functionality to an element created by another class. The class provides
3258 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3259 * which are used to customize the look and feel of a widget to better describe its
3260 * importance and functionality.
3261 *
3262 * The library currently contains the following styling flags for general use:
3263 *
3264 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3265 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3266 *
3267 * The flags affect the appearance of the buttons:
3268 *
3269 * @example
3270 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3271 * var button1 = new OO.ui.ButtonWidget( {
3272 * label: 'Progressive',
3273 * flags: 'progressive'
3274 * } ),
3275 * button2 = new OO.ui.ButtonWidget( {
3276 * label: 'Destructive',
3277 * flags: 'destructive'
3278 * } );
3279 * $( document.body ).append( button1.$element, button2.$element );
3280 *
3281 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3282 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3283 *
3284 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3285 *
3286 * @abstract
3287 * @class
3288 *
3289 * @constructor
3290 * @param {Object} [config] Configuration options
3291 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3292 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3293 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3294 * @cfg {jQuery} [$flagged] The flagged element. By default,
3295 * the flagged functionality is applied to the element created by the class ($element).
3296 * If a different element is specified, the flagged functionality will be applied to it instead.
3297 */
3298 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3299 // Configuration initialization
3300 config = config || {};
3301
3302 // Properties
3303 this.flags = {};
3304 this.$flagged = null;
3305
3306 // Initialization
3307 this.setFlags( config.flags );
3308 this.setFlaggedElement( config.$flagged || this.$element );
3309 };
3310
3311 /* Events */
3312
3313 /**
3314 * @event flag
3315 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3316 * parameter contains the name of each modified flag and indicates whether it was
3317 * added or removed.
3318 *
3319 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3320 * that the flag was added, `false` that the flag was removed.
3321 */
3322
3323 /* Methods */
3324
3325 /**
3326 * Set the flagged element.
3327 *
3328 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3329 * If an element is already set, the method will remove the mixin’s effect on that element.
3330 *
3331 * @param {jQuery} $flagged Element that should be flagged
3332 */
3333 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3334 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3335 return 'oo-ui-flaggedElement-' + flag;
3336 } );
3337
3338 if ( this.$flagged ) {
3339 this.$flagged.removeClass( classNames );
3340 }
3341
3342 this.$flagged = $flagged.addClass( classNames );
3343 };
3344
3345 /**
3346 * Check if the specified flag is set.
3347 *
3348 * @param {string} flag Name of flag
3349 * @return {boolean} The flag is set
3350 */
3351 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3352 // This may be called before the constructor, thus before this.flags is set
3353 return this.flags && ( flag in this.flags );
3354 };
3355
3356 /**
3357 * Get the names of all flags set.
3358 *
3359 * @return {string[]} Flag names
3360 */
3361 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3362 // This may be called before the constructor, thus before this.flags is set
3363 return Object.keys( this.flags || {} );
3364 };
3365
3366 /**
3367 * Clear all flags.
3368 *
3369 * @chainable
3370 * @return {OO.ui.Element} The element, for chaining
3371 * @fires flag
3372 */
3373 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3374 var flag, className,
3375 changes = {},
3376 remove = [],
3377 classPrefix = 'oo-ui-flaggedElement-';
3378
3379 for ( flag in this.flags ) {
3380 className = classPrefix + flag;
3381 changes[ flag ] = false;
3382 delete this.flags[ flag ];
3383 remove.push( className );
3384 }
3385
3386 if ( this.$flagged ) {
3387 this.$flagged.removeClass( remove );
3388 }
3389
3390 this.updateThemeClasses();
3391 this.emit( 'flag', changes );
3392
3393 return this;
3394 };
3395
3396 /**
3397 * Add one or more flags.
3398 *
3399 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3400 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3401 * be added (`true`) or removed (`false`).
3402 * @chainable
3403 * @return {OO.ui.Element} The element, for chaining
3404 * @fires flag
3405 */
3406 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3407 var i, len, flag, className,
3408 changes = {},
3409 add = [],
3410 remove = [],
3411 classPrefix = 'oo-ui-flaggedElement-';
3412
3413 if ( typeof flags === 'string' ) {
3414 className = classPrefix + flags;
3415 // Set
3416 if ( !this.flags[ flags ] ) {
3417 this.flags[ flags ] = true;
3418 add.push( className );
3419 }
3420 } else if ( Array.isArray( flags ) ) {
3421 for ( i = 0, len = flags.length; i < len; i++ ) {
3422 flag = flags[ i ];
3423 className = classPrefix + flag;
3424 // Set
3425 if ( !this.flags[ flag ] ) {
3426 changes[ flag ] = true;
3427 this.flags[ flag ] = true;
3428 add.push( className );
3429 }
3430 }
3431 } else if ( OO.isPlainObject( flags ) ) {
3432 for ( flag in flags ) {
3433 className = classPrefix + flag;
3434 if ( flags[ flag ] ) {
3435 // Set
3436 if ( !this.flags[ flag ] ) {
3437 changes[ flag ] = true;
3438 this.flags[ flag ] = true;
3439 add.push( className );
3440 }
3441 } else {
3442 // Remove
3443 if ( this.flags[ flag ] ) {
3444 changes[ flag ] = false;
3445 delete this.flags[ flag ];
3446 remove.push( className );
3447 }
3448 }
3449 }
3450 }
3451
3452 if ( this.$flagged ) {
3453 this.$flagged
3454 .addClass( add )
3455 .removeClass( remove );
3456 }
3457
3458 this.updateThemeClasses();
3459 this.emit( 'flag', changes );
3460
3461 return this;
3462 };
3463
3464 /**
3465 * TitledElement is mixed into other classes to provide a `title` attribute.
3466 * Titles are rendered by the browser and are made visible when the user moves
3467 * the mouse over the element. Titles are not visible on touch devices.
3468 *
3469 * @example
3470 * // TitledElement provides a `title` attribute to the
3471 * // ButtonWidget class.
3472 * var button = new OO.ui.ButtonWidget( {
3473 * label: 'Button with Title',
3474 * title: 'I am a button'
3475 * } );
3476 * $( document.body ).append( button.$element );
3477 *
3478 * @abstract
3479 * @class
3480 *
3481 * @constructor
3482 * @param {Object} [config] Configuration options
3483 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3484 * If this config is omitted, the title functionality is applied to $element, the
3485 * element created by the class.
3486 * @cfg {string|Function} [title] The title text or a function that returns text. If
3487 * this config is omitted, the value of the {@link #static-title static title} property is used.
3488 */
3489 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3490 // Configuration initialization
3491 config = config || {};
3492
3493 // Properties
3494 this.$titled = null;
3495 this.title = null;
3496
3497 // Initialization
3498 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3499 this.setTitledElement( config.$titled || this.$element );
3500 };
3501
3502 /* Setup */
3503
3504 OO.initClass( OO.ui.mixin.TitledElement );
3505
3506 /* Static Properties */
3507
3508 /**
3509 * The title text, a function that returns text, or `null` for no title. The value of the static property
3510 * is overridden if the #title config option is used.
3511 *
3512 * @static
3513 * @inheritable
3514 * @property {string|Function|null}
3515 */
3516 OO.ui.mixin.TitledElement.static.title = null;
3517
3518 /* Methods */
3519
3520 /**
3521 * Set the titled element.
3522 *
3523 * This method is used to retarget a TitledElement mixin so that its functionality applies to the specified element.
3524 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3525 *
3526 * @param {jQuery} $titled Element that should use the 'titled' functionality
3527 */
3528 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3529 if ( this.$titled ) {
3530 this.$titled.removeAttr( 'title' );
3531 }
3532
3533 this.$titled = $titled;
3534 if ( this.title ) {
3535 this.updateTitle();
3536 }
3537 };
3538
3539 /**
3540 * Set title.
3541 *
3542 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3543 * @chainable
3544 * @return {OO.ui.Element} The element, for chaining
3545 */
3546 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3547 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3548 title = ( typeof title === 'string' && title.length ) ? title : null;
3549
3550 if ( this.title !== title ) {
3551 this.title = title;
3552 this.updateTitle();
3553 }
3554
3555 return this;
3556 };
3557
3558 /**
3559 * Update the title attribute, in case of changes to title or accessKey.
3560 *
3561 * @protected
3562 * @chainable
3563 * @return {OO.ui.Element} The element, for chaining
3564 */
3565 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3566 var title = this.getTitle();
3567 if ( this.$titled ) {
3568 if ( title !== null ) {
3569 // Only if this is an AccessKeyedElement
3570 if ( this.formatTitleWithAccessKey ) {
3571 title = this.formatTitleWithAccessKey( title );
3572 }
3573 this.$titled.attr( 'title', title );
3574 } else {
3575 this.$titled.removeAttr( 'title' );
3576 }
3577 }
3578 return this;
3579 };
3580
3581 /**
3582 * Get title.
3583 *
3584 * @return {string} Title string
3585 */
3586 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3587 return this.title;
3588 };
3589
3590 /**
3591 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3592 * Accesskeys allow an user to go to a specific element by using
3593 * a shortcut combination of a browser specific keys + the key
3594 * set to the field.
3595 *
3596 * @example
3597 * // AccessKeyedElement provides an `accesskey` attribute to the
3598 * // ButtonWidget class.
3599 * var button = new OO.ui.ButtonWidget( {
3600 * label: 'Button with Accesskey',
3601 * accessKey: 'k'
3602 * } );
3603 * $( document.body ).append( button.$element );
3604 *
3605 * @abstract
3606 * @class
3607 *
3608 * @constructor
3609 * @param {Object} [config] Configuration options
3610 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3611 * If this config is omitted, the accesskey functionality is applied to $element, the
3612 * element created by the class.
3613 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3614 * this config is omitted, no accesskey will be added.
3615 */
3616 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3617 // Configuration initialization
3618 config = config || {};
3619
3620 // Properties
3621 this.$accessKeyed = null;
3622 this.accessKey = null;
3623
3624 // Initialization
3625 this.setAccessKey( config.accessKey || null );
3626 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3627
3628 // If this is also a TitledElement and it initialized before we did, we may have
3629 // to update the title with the access key
3630 if ( this.updateTitle ) {
3631 this.updateTitle();
3632 }
3633 };
3634
3635 /* Setup */
3636
3637 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3638
3639 /* Static Properties */
3640
3641 /**
3642 * The access key, a function that returns a key, or `null` for no accesskey.
3643 *
3644 * @static
3645 * @inheritable
3646 * @property {string|Function|null}
3647 */
3648 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3649
3650 /* Methods */
3651
3652 /**
3653 * Set the accesskeyed element.
3654 *
3655 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3656 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3657 *
3658 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyed' functionality
3659 */
3660 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3661 if ( this.$accessKeyed ) {
3662 this.$accessKeyed.removeAttr( 'accesskey' );
3663 }
3664
3665 this.$accessKeyed = $accessKeyed;
3666 if ( this.accessKey ) {
3667 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3668 }
3669 };
3670
3671 /**
3672 * Set accesskey.
3673 *
3674 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3675 * @chainable
3676 * @return {OO.ui.Element} The element, for chaining
3677 */
3678 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3679 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3680
3681 if ( this.accessKey !== accessKey ) {
3682 if ( this.$accessKeyed ) {
3683 if ( accessKey !== null ) {
3684 this.$accessKeyed.attr( 'accesskey', accessKey );
3685 } else {
3686 this.$accessKeyed.removeAttr( 'accesskey' );
3687 }
3688 }
3689 this.accessKey = accessKey;
3690
3691 // Only if this is a TitledElement
3692 if ( this.updateTitle ) {
3693 this.updateTitle();
3694 }
3695 }
3696
3697 return this;
3698 };
3699
3700 /**
3701 * Get accesskey.
3702 *
3703 * @return {string} accessKey string
3704 */
3705 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3706 return this.accessKey;
3707 };
3708
3709 /**
3710 * Add information about the access key to the element's tooltip label.
3711 * (This is only public for hacky usage in FieldLayout.)
3712 *
3713 * @param {string} title Tooltip label for `title` attribute
3714 * @return {string}
3715 */
3716 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3717 var accessKey;
3718
3719 if ( !this.$accessKeyed ) {
3720 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3721 return title;
3722 }
3723 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3724 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3725 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3726 } else {
3727 accessKey = this.getAccessKey();
3728 }
3729 if ( accessKey ) {
3730 title += ' [' + accessKey + ']';
3731 }
3732 return title;
3733 };
3734
3735 /**
3736 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3737 * feels, and functionality can be customized via the class’s configuration options
3738 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3739 * and examples.
3740 *
3741 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3742 *
3743 * @example
3744 * // A button widget.
3745 * var button = new OO.ui.ButtonWidget( {
3746 * label: 'Button with Icon',
3747 * icon: 'trash',
3748 * title: 'Remove'
3749 * } );
3750 * $( document.body ).append( button.$element );
3751 *
3752 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3753 *
3754 * @class
3755 * @extends OO.ui.Widget
3756 * @mixins OO.ui.mixin.ButtonElement
3757 * @mixins OO.ui.mixin.IconElement
3758 * @mixins OO.ui.mixin.IndicatorElement
3759 * @mixins OO.ui.mixin.LabelElement
3760 * @mixins OO.ui.mixin.TitledElement
3761 * @mixins OO.ui.mixin.FlaggedElement
3762 * @mixins OO.ui.mixin.TabIndexedElement
3763 * @mixins OO.ui.mixin.AccessKeyedElement
3764 *
3765 * @constructor
3766 * @param {Object} [config] Configuration options
3767 * @cfg {boolean} [active=false] Whether button should be shown as active
3768 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3769 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3770 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3771 */
3772 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3773 // Configuration initialization
3774 config = config || {};
3775
3776 // Parent constructor
3777 OO.ui.ButtonWidget.parent.call( this, config );
3778
3779 // Mixin constructors
3780 OO.ui.mixin.ButtonElement.call( this, config );
3781 OO.ui.mixin.IconElement.call( this, config );
3782 OO.ui.mixin.IndicatorElement.call( this, config );
3783 OO.ui.mixin.LabelElement.call( this, config );
3784 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3785 OO.ui.mixin.FlaggedElement.call( this, config );
3786 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3787 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3788
3789 // Properties
3790 this.href = null;
3791 this.target = null;
3792 this.noFollow = false;
3793
3794 // Events
3795 this.connect( this, { disable: 'onDisable' } );
3796
3797 // Initialization
3798 this.$button.append( this.$icon, this.$label, this.$indicator );
3799 this.$element
3800 .addClass( 'oo-ui-buttonWidget' )
3801 .append( this.$button );
3802 this.setActive( config.active );
3803 this.setHref( config.href );
3804 this.setTarget( config.target );
3805 this.setNoFollow( config.noFollow );
3806 };
3807
3808 /* Setup */
3809
3810 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3811 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3812 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3813 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3814 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3815 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3816 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3817 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3818 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3819
3820 /* Static Properties */
3821
3822 /**
3823 * @static
3824 * @inheritdoc
3825 */
3826 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3827
3828 /**
3829 * @static
3830 * @inheritdoc
3831 */
3832 OO.ui.ButtonWidget.static.tagName = 'span';
3833
3834 /* Methods */
3835
3836 /**
3837 * Get hyperlink location.
3838 *
3839 * @return {string} Hyperlink location
3840 */
3841 OO.ui.ButtonWidget.prototype.getHref = function () {
3842 return this.href;
3843 };
3844
3845 /**
3846 * Get hyperlink target.
3847 *
3848 * @return {string} Hyperlink target
3849 */
3850 OO.ui.ButtonWidget.prototype.getTarget = function () {
3851 return this.target;
3852 };
3853
3854 /**
3855 * Get search engine traversal hint.
3856 *
3857 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3858 */
3859 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3860 return this.noFollow;
3861 };
3862
3863 /**
3864 * Set hyperlink location.
3865 *
3866 * @param {string|null} href Hyperlink location, null to remove
3867 * @chainable
3868 * @return {OO.ui.Widget} The widget, for chaining
3869 */
3870 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3871 href = typeof href === 'string' ? href : null;
3872 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3873 href = './' + href;
3874 }
3875
3876 if ( href !== this.href ) {
3877 this.href = href;
3878 this.updateHref();
3879 }
3880
3881 return this;
3882 };
3883
3884 /**
3885 * Update the `href` attribute, in case of changes to href or
3886 * disabled state.
3887 *
3888 * @private
3889 * @chainable
3890 * @return {OO.ui.Widget} The widget, for chaining
3891 */
3892 OO.ui.ButtonWidget.prototype.updateHref = function () {
3893 if ( this.href !== null && !this.isDisabled() ) {
3894 this.$button.attr( 'href', this.href );
3895 } else {
3896 this.$button.removeAttr( 'href' );
3897 }
3898
3899 return this;
3900 };
3901
3902 /**
3903 * Handle disable events.
3904 *
3905 * @private
3906 * @param {boolean} disabled Element is disabled
3907 */
3908 OO.ui.ButtonWidget.prototype.onDisable = function () {
3909 this.updateHref();
3910 };
3911
3912 /**
3913 * Set hyperlink target.
3914 *
3915 * @param {string|null} target Hyperlink target, null to remove
3916 * @return {OO.ui.Widget} The widget, for chaining
3917 */
3918 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3919 target = typeof target === 'string' ? target : null;
3920
3921 if ( target !== this.target ) {
3922 this.target = target;
3923 if ( target !== null ) {
3924 this.$button.attr( 'target', target );
3925 } else {
3926 this.$button.removeAttr( 'target' );
3927 }
3928 }
3929
3930 return this;
3931 };
3932
3933 /**
3934 * Set search engine traversal hint.
3935 *
3936 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3937 * @return {OO.ui.Widget} The widget, for chaining
3938 */
3939 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3940 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3941
3942 if ( noFollow !== this.noFollow ) {
3943 this.noFollow = noFollow;
3944 if ( noFollow ) {
3945 this.$button.attr( 'rel', 'nofollow' );
3946 } else {
3947 this.$button.removeAttr( 'rel' );
3948 }
3949 }
3950
3951 return this;
3952 };
3953
3954 // Override method visibility hints from ButtonElement
3955 /**
3956 * @method setActive
3957 * @inheritdoc
3958 */
3959 /**
3960 * @method isActive
3961 * @inheritdoc
3962 */
3963
3964 /**
3965 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3966 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3967 * removed, and cleared from the group.
3968 *
3969 * @example
3970 * // A ButtonGroupWidget with two buttons.
3971 * var button1 = new OO.ui.PopupButtonWidget( {
3972 * label: 'Select a category',
3973 * icon: 'menu',
3974 * popup: {
3975 * $content: $( '<p>List of categories…</p>' ),
3976 * padded: true,
3977 * align: 'left'
3978 * }
3979 * } ),
3980 * button2 = new OO.ui.ButtonWidget( {
3981 * label: 'Add item'
3982 * } ),
3983 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3984 * items: [ button1, button2 ]
3985 * } );
3986 * $( document.body ).append( buttonGroup.$element );
3987 *
3988 * @class
3989 * @extends OO.ui.Widget
3990 * @mixins OO.ui.mixin.GroupElement
3991 * @mixins OO.ui.mixin.TitledElement
3992 *
3993 * @constructor
3994 * @param {Object} [config] Configuration options
3995 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3996 */
3997 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3998 // Configuration initialization
3999 config = config || {};
4000
4001 // Parent constructor
4002 OO.ui.ButtonGroupWidget.parent.call( this, config );
4003
4004 // Mixin constructors
4005 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
4006 OO.ui.mixin.TitledElement.call( this, config );
4007
4008 // Initialization
4009 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
4010 if ( Array.isArray( config.items ) ) {
4011 this.addItems( config.items );
4012 }
4013 };
4014
4015 /* Setup */
4016
4017 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
4018 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
4019 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
4020
4021 /* Static Properties */
4022
4023 /**
4024 * @static
4025 * @inheritdoc
4026 */
4027 OO.ui.ButtonGroupWidget.static.tagName = 'span';
4028
4029 /* Methods */
4030
4031 /**
4032 * Focus the widget
4033 *
4034 * @chainable
4035 * @return {OO.ui.Widget} The widget, for chaining
4036 */
4037 OO.ui.ButtonGroupWidget.prototype.focus = function () {
4038 if ( !this.isDisabled() ) {
4039 if ( this.items[ 0 ] ) {
4040 this.items[ 0 ].focus();
4041 }
4042 }
4043 return this;
4044 };
4045
4046 /**
4047 * @inheritdoc
4048 */
4049 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
4050 this.focus();
4051 };
4052
4053 /**
4054 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
4055 * which creates a label that identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4056 * for a list of icons included in the library.
4057 *
4058 * @example
4059 * // An IconWidget with a label via LabelWidget.
4060 * var myIcon = new OO.ui.IconWidget( {
4061 * icon: 'help',
4062 * title: 'Help'
4063 * } ),
4064 * // Create a label.
4065 * iconLabel = new OO.ui.LabelWidget( {
4066 * label: 'Help'
4067 * } );
4068 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4069 *
4070 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4071 *
4072 * @class
4073 * @extends OO.ui.Widget
4074 * @mixins OO.ui.mixin.IconElement
4075 * @mixins OO.ui.mixin.TitledElement
4076 * @mixins OO.ui.mixin.LabelElement
4077 * @mixins OO.ui.mixin.FlaggedElement
4078 *
4079 * @constructor
4080 * @param {Object} [config] Configuration options
4081 */
4082 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4083 // Configuration initialization
4084 config = config || {};
4085
4086 // Parent constructor
4087 OO.ui.IconWidget.parent.call( this, config );
4088
4089 // Mixin constructors
4090 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
4091 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4092 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4093 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
4094
4095 // Initialization
4096 this.$element.addClass( 'oo-ui-iconWidget' );
4097 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4098 // nested in other widgets, because this widget used to not mix in LabelElement.
4099 this.$element.removeClass( 'oo-ui-labelElement-label' );
4100 };
4101
4102 /* Setup */
4103
4104 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4105 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4106 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4107 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4108 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4109
4110 /* Static Properties */
4111
4112 /**
4113 * @static
4114 * @inheritdoc
4115 */
4116 OO.ui.IconWidget.static.tagName = 'span';
4117
4118 /**
4119 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4120 * attention to the status of an item or to clarify the function within a control. For a list of
4121 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4122 *
4123 * @example
4124 * // An indicator widget.
4125 * var indicator1 = new OO.ui.IndicatorWidget( {
4126 * indicator: 'required'
4127 * } ),
4128 * // Create a fieldset layout to add a label.
4129 * fieldset = new OO.ui.FieldsetLayout();
4130 * fieldset.addItems( [
4131 * new OO.ui.FieldLayout( indicator1, {
4132 * label: 'A required indicator:'
4133 * } )
4134 * ] );
4135 * $( document.body ).append( fieldset.$element );
4136 *
4137 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4138 *
4139 * @class
4140 * @extends OO.ui.Widget
4141 * @mixins OO.ui.mixin.IndicatorElement
4142 * @mixins OO.ui.mixin.TitledElement
4143 * @mixins OO.ui.mixin.LabelElement
4144 *
4145 * @constructor
4146 * @param {Object} [config] Configuration options
4147 */
4148 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4149 // Configuration initialization
4150 config = config || {};
4151
4152 // Parent constructor
4153 OO.ui.IndicatorWidget.parent.call( this, config );
4154
4155 // Mixin constructors
4156 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
4157 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
4158 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element, invisibleLabel: true } ) );
4159
4160 // Initialization
4161 this.$element.addClass( 'oo-ui-indicatorWidget' );
4162 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4163 // nested in other widgets, because this widget used to not mix in LabelElement.
4164 this.$element.removeClass( 'oo-ui-labelElement-label' );
4165 };
4166
4167 /* Setup */
4168
4169 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4170 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4171 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4172 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4173
4174 /* Static Properties */
4175
4176 /**
4177 * @static
4178 * @inheritdoc
4179 */
4180 OO.ui.IndicatorWidget.static.tagName = 'span';
4181
4182 /**
4183 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4184 * be configured with a `label` option that is set to a string, a label node, or a function:
4185 *
4186 * - String: a plaintext string
4187 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4188 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4189 * - Function: a function that will produce a string in the future. Functions are used
4190 * in cases where the value of the label is not currently defined.
4191 *
4192 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4193 * will come into focus when the label is clicked.
4194 *
4195 * @example
4196 * // Two LabelWidgets.
4197 * var label1 = new OO.ui.LabelWidget( {
4198 * label: 'plaintext label'
4199 * } ),
4200 * label2 = new OO.ui.LabelWidget( {
4201 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4202 * } ),
4203 * // Create a fieldset layout with fields for each example.
4204 * fieldset = new OO.ui.FieldsetLayout();
4205 * fieldset.addItems( [
4206 * new OO.ui.FieldLayout( label1 ),
4207 * new OO.ui.FieldLayout( label2 )
4208 * ] );
4209 * $( document.body ).append( fieldset.$element );
4210 *
4211 * @class
4212 * @extends OO.ui.Widget
4213 * @mixins OO.ui.mixin.LabelElement
4214 * @mixins OO.ui.mixin.TitledElement
4215 *
4216 * @constructor
4217 * @param {Object} [config] Configuration options
4218 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4219 * Clicking the label will focus the specified input field.
4220 */
4221 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4222 // Configuration initialization
4223 config = config || {};
4224
4225 // Parent constructor
4226 OO.ui.LabelWidget.parent.call( this, config );
4227
4228 // Mixin constructors
4229 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4230 OO.ui.mixin.TitledElement.call( this, config );
4231
4232 // Properties
4233 this.input = config.input;
4234
4235 // Initialization
4236 if ( this.input ) {
4237 if ( this.input.getInputId() ) {
4238 this.$element.attr( 'for', this.input.getInputId() );
4239 } else {
4240 this.$label.on( 'click', function () {
4241 this.input.simulateLabelClick();
4242 }.bind( this ) );
4243 }
4244 }
4245 this.$element.addClass( 'oo-ui-labelWidget' );
4246 };
4247
4248 /* Setup */
4249
4250 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4251 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4252 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4253
4254 /* Static Properties */
4255
4256 /**
4257 * @static
4258 * @inheritdoc
4259 */
4260 OO.ui.LabelWidget.static.tagName = 'label';
4261
4262 /**
4263 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4264 * and that they should wait before proceeding. The pending state is visually represented with a pending
4265 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4266 * field of a {@link OO.ui.TextInputWidget text input widget}.
4267 *
4268 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4269 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4270 * in process dialogs.
4271 *
4272 * @example
4273 * function MessageDialog( config ) {
4274 * MessageDialog.parent.call( this, config );
4275 * }
4276 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4277 *
4278 * MessageDialog.static.name = 'myMessageDialog';
4279 * MessageDialog.static.actions = [
4280 * { action: 'save', label: 'Done', flags: 'primary' },
4281 * { label: 'Cancel', flags: 'safe' }
4282 * ];
4283 *
4284 * MessageDialog.prototype.initialize = function () {
4285 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4286 * this.content = new OO.ui.PanelLayout( { padded: true } );
4287 * 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>' );
4288 * this.$body.append( this.content.$element );
4289 * };
4290 * MessageDialog.prototype.getBodyHeight = function () {
4291 * return 100;
4292 * }
4293 * MessageDialog.prototype.getActionProcess = function ( action ) {
4294 * var dialog = this;
4295 * if ( action === 'save' ) {
4296 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4297 * return new OO.ui.Process()
4298 * .next( 1000 )
4299 * .next( function () {
4300 * dialog.getActions().get({actions: 'save'})[0].popPending();
4301 * } );
4302 * }
4303 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4304 * };
4305 *
4306 * var windowManager = new OO.ui.WindowManager();
4307 * $( document.body ).append( windowManager.$element );
4308 *
4309 * var dialog = new MessageDialog();
4310 * windowManager.addWindows( [ dialog ] );
4311 * windowManager.openWindow( dialog );
4312 *
4313 * @abstract
4314 * @class
4315 *
4316 * @constructor
4317 * @param {Object} [config] Configuration options
4318 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4319 */
4320 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4321 // Configuration initialization
4322 config = config || {};
4323
4324 // Properties
4325 this.pending = 0;
4326 this.$pending = null;
4327
4328 // Initialisation
4329 this.setPendingElement( config.$pending || this.$element );
4330 };
4331
4332 /* Setup */
4333
4334 OO.initClass( OO.ui.mixin.PendingElement );
4335
4336 /* Methods */
4337
4338 /**
4339 * Set the pending element (and clean up any existing one).
4340 *
4341 * @param {jQuery} $pending The element to set to pending.
4342 */
4343 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4344 if ( this.$pending ) {
4345 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4346 }
4347
4348 this.$pending = $pending;
4349 if ( this.pending > 0 ) {
4350 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4351 }
4352 };
4353
4354 /**
4355 * Check if an element is pending.
4356 *
4357 * @return {boolean} Element is pending
4358 */
4359 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4360 return !!this.pending;
4361 };
4362
4363 /**
4364 * Increase the pending counter. The pending state will remain active until the counter is zero
4365 * (i.e., the number of calls to #pushPending and #popPending is the same).
4366 *
4367 * @chainable
4368 * @return {OO.ui.Element} The element, for chaining
4369 */
4370 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4371 if ( this.pending === 0 ) {
4372 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4373 this.updateThemeClasses();
4374 }
4375 this.pending++;
4376
4377 return this;
4378 };
4379
4380 /**
4381 * Decrease the pending counter. The pending state will remain active until the counter is zero
4382 * (i.e., the number of calls to #pushPending and #popPending is the same).
4383 *
4384 * @chainable
4385 * @return {OO.ui.Element} The element, for chaining
4386 */
4387 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4388 if ( this.pending === 1 ) {
4389 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4390 this.updateThemeClasses();
4391 }
4392 this.pending = Math.max( 0, this.pending - 1 );
4393
4394 return this;
4395 };
4396
4397 /**
4398 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4399 * in the document (for example, in an OO.ui.Window's $overlay).
4400 *
4401 * The elements's position is automatically calculated and maintained when window is resized or the
4402 * page is scrolled. If you reposition the container manually, you have to call #position to make
4403 * sure the element is still placed correctly.
4404 *
4405 * As positioning is only possible when both the element and the container are attached to the DOM
4406 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4407 * the #toggle method to display a floating popup, for example.
4408 *
4409 * @abstract
4410 * @class
4411 *
4412 * @constructor
4413 * @param {Object} [config] Configuration options
4414 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4415 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4416 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4417 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4418 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4419 * 'top': Align the top edge with $floatableContainer's top edge
4420 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4421 * 'center': Vertically align the center with $floatableContainer's center
4422 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4423 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4424 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4425 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4426 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4427 * 'center': Horizontally align the center with $floatableContainer's center
4428 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4429 * is out of view
4430 */
4431 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4432 // Configuration initialization
4433 config = config || {};
4434
4435 // Properties
4436 this.$floatable = null;
4437 this.$floatableContainer = null;
4438 this.$floatableWindow = null;
4439 this.$floatableClosestScrollable = null;
4440 this.floatableOutOfView = false;
4441 this.onFloatableScrollHandler = this.position.bind( this );
4442 this.onFloatableWindowResizeHandler = this.position.bind( this );
4443
4444 // Initialization
4445 this.setFloatableContainer( config.$floatableContainer );
4446 this.setFloatableElement( config.$floatable || this.$element );
4447 this.setVerticalPosition( config.verticalPosition || 'below' );
4448 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4449 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4450 };
4451
4452 /* Methods */
4453
4454 /**
4455 * Set floatable element.
4456 *
4457 * If an element is already set, it will be cleaned up before setting up the new element.
4458 *
4459 * @param {jQuery} $floatable Element to make floatable
4460 */
4461 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4462 if ( this.$floatable ) {
4463 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4464 this.$floatable.css( { left: '', top: '' } );
4465 }
4466
4467 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4468 this.position();
4469 };
4470
4471 /**
4472 * Set floatable container.
4473 *
4474 * The element will be positioned relative to the specified container.
4475 *
4476 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4477 */
4478 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4479 this.$floatableContainer = $floatableContainer;
4480 if ( this.$floatable ) {
4481 this.position();
4482 }
4483 };
4484
4485 /**
4486 * Change how the element is positioned vertically.
4487 *
4488 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4489 */
4490 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4491 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4492 throw new Error( 'Invalid value for vertical position: ' + position );
4493 }
4494 if ( this.verticalPosition !== position ) {
4495 this.verticalPosition = position;
4496 if ( this.$floatable ) {
4497 this.position();
4498 }
4499 }
4500 };
4501
4502 /**
4503 * Change how the element is positioned horizontally.
4504 *
4505 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4506 */
4507 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4508 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4509 throw new Error( 'Invalid value for horizontal position: ' + position );
4510 }
4511 if ( this.horizontalPosition !== position ) {
4512 this.horizontalPosition = position;
4513 if ( this.$floatable ) {
4514 this.position();
4515 }
4516 }
4517 };
4518
4519 /**
4520 * Toggle positioning.
4521 *
4522 * Do not turn positioning on until after the element is attached to the DOM and visible.
4523 *
4524 * @param {boolean} [positioning] Enable positioning, omit to toggle
4525 * @chainable
4526 * @return {OO.ui.Element} The element, for chaining
4527 */
4528 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4529 var closestScrollableOfContainer;
4530
4531 if ( !this.$floatable || !this.$floatableContainer ) {
4532 return this;
4533 }
4534
4535 positioning = positioning === undefined ? !this.positioning : !!positioning;
4536
4537 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4538 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4539 this.warnedUnattached = true;
4540 }
4541
4542 if ( this.positioning !== positioning ) {
4543 this.positioning = positioning;
4544
4545 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4546 // If the scrollable is the root, we have to listen to scroll events
4547 // on the window because of browser inconsistencies.
4548 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4549 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4550 }
4551
4552 if ( positioning ) {
4553 this.$floatableWindow = $( this.getElementWindow() );
4554 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4555
4556 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4557 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4558
4559 // Initial position after visible
4560 this.position();
4561 } else {
4562 if ( this.$floatableWindow ) {
4563 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4564 this.$floatableWindow = null;
4565 }
4566
4567 if ( this.$floatableClosestScrollable ) {
4568 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4569 this.$floatableClosestScrollable = null;
4570 }
4571
4572 this.$floatable.css( { left: '', right: '', top: '' } );
4573 }
4574 }
4575
4576 return this;
4577 };
4578
4579 /**
4580 * Check whether the bottom edge of the given element is within the viewport of the given container.
4581 *
4582 * @private
4583 * @param {jQuery} $element
4584 * @param {jQuery} $container
4585 * @return {boolean}
4586 */
4587 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4588 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4589 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4590 direction = $element.css( 'direction' );
4591
4592 elemRect = $element[ 0 ].getBoundingClientRect();
4593 if ( $container[ 0 ] === window ) {
4594 viewportSpacing = OO.ui.getViewportSpacing();
4595 contRect = {
4596 top: 0,
4597 left: 0,
4598 right: document.documentElement.clientWidth,
4599 bottom: document.documentElement.clientHeight
4600 };
4601 contRect.top += viewportSpacing.top;
4602 contRect.left += viewportSpacing.left;
4603 contRect.right -= viewportSpacing.right;
4604 contRect.bottom -= viewportSpacing.bottom;
4605 } else {
4606 contRect = $container[ 0 ].getBoundingClientRect();
4607 }
4608
4609 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4610 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4611 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4612 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4613 if ( direction === 'rtl' ) {
4614 startEdgeInBounds = rightEdgeInBounds;
4615 endEdgeInBounds = leftEdgeInBounds;
4616 } else {
4617 startEdgeInBounds = leftEdgeInBounds;
4618 endEdgeInBounds = rightEdgeInBounds;
4619 }
4620
4621 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4622 return false;
4623 }
4624 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4625 return false;
4626 }
4627 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4628 return false;
4629 }
4630 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4631 return false;
4632 }
4633
4634 // The other positioning values are all about being inside the container,
4635 // so in those cases all we care about is that any part of the container is visible.
4636 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4637 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4638 };
4639
4640 /**
4641 * Check if the floatable is hidden to the user because it was offscreen.
4642 *
4643 * @return {boolean} Floatable is out of view
4644 */
4645 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4646 return this.floatableOutOfView;
4647 };
4648
4649 /**
4650 * Position the floatable below its container.
4651 *
4652 * This should only be done when both of them are attached to the DOM and visible.
4653 *
4654 * @chainable
4655 * @return {OO.ui.Element} The element, for chaining
4656 */
4657 OO.ui.mixin.FloatableElement.prototype.position = function () {
4658 if ( !this.positioning ) {
4659 return this;
4660 }
4661
4662 if ( !(
4663 // To continue, some things need to be true:
4664 // The element must actually be in the DOM
4665 this.isElementAttached() && (
4666 // The closest scrollable is the current window
4667 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4668 // OR is an element in the element's DOM
4669 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4670 )
4671 ) ) {
4672 // Abort early if important parts of the widget are no longer attached to the DOM
4673 return this;
4674 }
4675
4676 this.floatableOutOfView = this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4677 if ( this.floatableOutOfView ) {
4678 this.$floatable.addClass( 'oo-ui-element-hidden' );
4679 return this;
4680 } else {
4681 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4682 }
4683
4684 this.$floatable.css( this.computePosition() );
4685
4686 // We updated the position, so re-evaluate the clipping state.
4687 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4688 // will not notice the need to update itself.)
4689 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4690 // it not listen to the right events in the right places?
4691 if ( this.clip ) {
4692 this.clip();
4693 }
4694
4695 return this;
4696 };
4697
4698 /**
4699 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4700 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4701 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4702 *
4703 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4704 */
4705 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4706 var isBody, scrollableX, scrollableY, containerPos,
4707 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4708 newPos = { top: '', left: '', bottom: '', right: '' },
4709 direction = this.$floatableContainer.css( 'direction' ),
4710 $offsetParent = this.$floatable.offsetParent();
4711
4712 if ( $offsetParent.is( 'html' ) ) {
4713 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4714 // <html> element, but they do work on the <body>
4715 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4716 }
4717 isBody = $offsetParent.is( 'body' );
4718 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4719 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4720
4721 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4722 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4723 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4724 // or if it isn't scrollable
4725 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4726 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4727
4728 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4729 // if the <body> has a margin
4730 containerPos = isBody ?
4731 this.$floatableContainer.offset() :
4732 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4733 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4734 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4735 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4736 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4737
4738 if ( this.verticalPosition === 'below' ) {
4739 newPos.top = containerPos.bottom;
4740 } else if ( this.verticalPosition === 'above' ) {
4741 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4742 } else if ( this.verticalPosition === 'top' ) {
4743 newPos.top = containerPos.top;
4744 } else if ( this.verticalPosition === 'bottom' ) {
4745 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4746 } else if ( this.verticalPosition === 'center' ) {
4747 newPos.top = containerPos.top +
4748 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4749 }
4750
4751 if ( this.horizontalPosition === 'before' ) {
4752 newPos.end = containerPos.start;
4753 } else if ( this.horizontalPosition === 'after' ) {
4754 newPos.start = containerPos.end;
4755 } else if ( this.horizontalPosition === 'start' ) {
4756 newPos.start = containerPos.start;
4757 } else if ( this.horizontalPosition === 'end' ) {
4758 newPos.end = containerPos.end;
4759 } else if ( this.horizontalPosition === 'center' ) {
4760 newPos.left = containerPos.left +
4761 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4762 }
4763
4764 if ( newPos.start !== undefined ) {
4765 if ( direction === 'rtl' ) {
4766 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4767 } else {
4768 newPos.left = newPos.start;
4769 }
4770 delete newPos.start;
4771 }
4772 if ( newPos.end !== undefined ) {
4773 if ( direction === 'rtl' ) {
4774 newPos.left = newPos.end;
4775 } else {
4776 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4777 }
4778 delete newPos.end;
4779 }
4780
4781 // Account for scroll position
4782 if ( newPos.top !== '' ) {
4783 newPos.top += scrollTop;
4784 }
4785 if ( newPos.bottom !== '' ) {
4786 newPos.bottom -= scrollTop;
4787 }
4788 if ( newPos.left !== '' ) {
4789 newPos.left += scrollLeft;
4790 }
4791 if ( newPos.right !== '' ) {
4792 newPos.right -= scrollLeft;
4793 }
4794
4795 // Account for scrollbar gutter
4796 if ( newPos.bottom !== '' ) {
4797 newPos.bottom -= horizScrollbarHeight;
4798 }
4799 if ( direction === 'rtl' ) {
4800 if ( newPos.left !== '' ) {
4801 newPos.left -= vertScrollbarWidth;
4802 }
4803 } else {
4804 if ( newPos.right !== '' ) {
4805 newPos.right -= vertScrollbarWidth;
4806 }
4807 }
4808
4809 return newPos;
4810 };
4811
4812 /**
4813 * Element that can be automatically clipped to visible boundaries.
4814 *
4815 * Whenever the element's natural height changes, you have to call
4816 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4817 * clipping correctly.
4818 *
4819 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4820 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4821 * then #$clippable will be given a fixed reduced height and/or width and will be made
4822 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4823 * but you can build a static footer by setting #$clippableContainer to an element that contains
4824 * #$clippable and the footer.
4825 *
4826 * @abstract
4827 * @class
4828 *
4829 * @constructor
4830 * @param {Object} [config] Configuration options
4831 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4832 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4833 * omit to use #$clippable
4834 */
4835 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4836 // Configuration initialization
4837 config = config || {};
4838
4839 // Properties
4840 this.$clippable = null;
4841 this.$clippableContainer = null;
4842 this.clipping = false;
4843 this.clippedHorizontally = false;
4844 this.clippedVertically = false;
4845 this.$clippableScrollableContainer = null;
4846 this.$clippableScroller = null;
4847 this.$clippableWindow = null;
4848 this.idealWidth = null;
4849 this.idealHeight = null;
4850 this.onClippableScrollHandler = this.clip.bind( this );
4851 this.onClippableWindowResizeHandler = this.clip.bind( this );
4852
4853 // Initialization
4854 if ( config.$clippableContainer ) {
4855 this.setClippableContainer( config.$clippableContainer );
4856 }
4857 this.setClippableElement( config.$clippable || this.$element );
4858 };
4859
4860 /* Methods */
4861
4862 /**
4863 * Set clippable element.
4864 *
4865 * If an element is already set, it will be cleaned up before setting up the new element.
4866 *
4867 * @param {jQuery} $clippable Element to make clippable
4868 */
4869 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4870 if ( this.$clippable ) {
4871 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4872 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4873 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4874 }
4875
4876 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4877 this.clip();
4878 };
4879
4880 /**
4881 * Set clippable container.
4882 *
4883 * This is the container that will be measured when deciding whether to clip. When clipping,
4884 * #$clippable will be resized in order to keep the clippable container fully visible.
4885 *
4886 * If the clippable container is unset, #$clippable will be used.
4887 *
4888 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4889 */
4890 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4891 this.$clippableContainer = $clippableContainer;
4892 if ( this.$clippable ) {
4893 this.clip();
4894 }
4895 };
4896
4897 /**
4898 * Toggle clipping.
4899 *
4900 * Do not turn clipping on until after the element is attached to the DOM and visible.
4901 *
4902 * @param {boolean} [clipping] Enable clipping, omit to toggle
4903 * @chainable
4904 * @return {OO.ui.Element} The element, for chaining
4905 */
4906 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4907 clipping = clipping === undefined ? !this.clipping : !!clipping;
4908
4909 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4910 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4911 this.warnedUnattached = true;
4912 }
4913
4914 if ( this.clipping !== clipping ) {
4915 this.clipping = clipping;
4916 if ( clipping ) {
4917 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4918 // If the clippable container is the root, we have to listen to scroll events and check
4919 // jQuery.scrollTop on the window because of browser inconsistencies
4920 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4921 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4922 this.$clippableScrollableContainer;
4923 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4924 this.$clippableWindow = $( this.getElementWindow() )
4925 .on( 'resize', this.onClippableWindowResizeHandler );
4926 // Initial clip after visible
4927 this.clip();
4928 } else {
4929 this.$clippable.css( {
4930 width: '',
4931 height: '',
4932 maxWidth: '',
4933 maxHeight: '',
4934 overflowX: '',
4935 overflowY: ''
4936 } );
4937 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4938
4939 this.$clippableScrollableContainer = null;
4940 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4941 this.$clippableScroller = null;
4942 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4943 this.$clippableWindow = null;
4944 }
4945 }
4946
4947 return this;
4948 };
4949
4950 /**
4951 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4952 *
4953 * @return {boolean} Element will be clipped to the visible area
4954 */
4955 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4956 return this.clipping;
4957 };
4958
4959 /**
4960 * Check if the bottom or 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.isClipped = function () {
4965 return this.clippedHorizontally || this.clippedVertically;
4966 };
4967
4968 /**
4969 * Check if the right 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.isClippedHorizontally = function () {
4974 return this.clippedHorizontally;
4975 };
4976
4977 /**
4978 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4979 *
4980 * @return {boolean} Part of the element is being clipped
4981 */
4982 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4983 return this.clippedVertically;
4984 };
4985
4986 /**
4987 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4988 *
4989 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4990 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4991 */
4992 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4993 this.idealWidth = width;
4994 this.idealHeight = height;
4995
4996 if ( !this.clipping ) {
4997 // Update dimensions
4998 this.$clippable.css( { width: width, height: height } );
4999 }
5000 // While clipping, idealWidth and idealHeight are not considered
5001 };
5002
5003 /**
5004 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5005 * ClippableElement will clip the opposite side when reducing element's width.
5006 *
5007 * Classes that mix in ClippableElement should override this to return 'right' if their
5008 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
5009 * If your class also mixes in FloatableElement, this is handled automatically.
5010 *
5011 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5012 * always in pixels, even if they were unset or set to 'auto'.)
5013 *
5014 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
5015 *
5016 * @return {string} 'left' or 'right'
5017 */
5018 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5019 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5020 return 'right';
5021 }
5022 return 'left';
5023 };
5024
5025 /**
5026 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5027 * ClippableElement will clip the opposite side when reducing element's width.
5028 *
5029 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5030 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5031 * If your class also mixes in FloatableElement, this is handled automatically.
5032 *
5033 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5034 * always in pixels, even if they were unset or set to 'auto'.)
5035 *
5036 * When in doubt, 'top' is a sane fallback.
5037 *
5038 * @return {string} 'top' or 'bottom'
5039 */
5040 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5041 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5042 return 'bottom';
5043 }
5044 return 'top';
5045 };
5046
5047 /**
5048 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5049 * when the element's natural height changes.
5050 *
5051 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5052 * overlapped by, the visible area of the nearest scrollable container.
5053 *
5054 * Because calling clip() when the natural height changes isn't always possible, we also set
5055 * max-height when the element isn't being clipped. This means that if the element tries to grow
5056 * beyond the edge, something reasonable will happen before clip() is called.
5057 *
5058 * @chainable
5059 * @return {OO.ui.Element} The element, for chaining
5060 */
5061 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5062 var extraHeight, extraWidth, viewportSpacing,
5063 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5064 naturalWidth, naturalHeight, clipWidth, clipHeight,
5065 $item, itemRect, $viewport, viewportRect, availableRect,
5066 direction, vertScrollbarWidth, horizScrollbarHeight,
5067 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5068 // by one or two pixels. (And also so that we have space to display drop shadows.)
5069 // Chosen by fair dice roll.
5070 buffer = 7;
5071
5072 if ( !this.clipping ) {
5073 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
5074 return this;
5075 }
5076
5077 function rectIntersection( a, b ) {
5078 var out = {};
5079 out.top = Math.max( a.top, b.top );
5080 out.left = Math.max( a.left, b.left );
5081 out.bottom = Math.min( a.bottom, b.bottom );
5082 out.right = Math.min( a.right, b.right );
5083 return out;
5084 }
5085
5086 viewportSpacing = OO.ui.getViewportSpacing();
5087
5088 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5089 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5090 // Dimensions of the browser window, rather than the element!
5091 viewportRect = {
5092 top: 0,
5093 left: 0,
5094 right: document.documentElement.clientWidth,
5095 bottom: document.documentElement.clientHeight
5096 };
5097 viewportRect.top += viewportSpacing.top;
5098 viewportRect.left += viewportSpacing.left;
5099 viewportRect.right -= viewportSpacing.right;
5100 viewportRect.bottom -= viewportSpacing.bottom;
5101 } else {
5102 $viewport = this.$clippableScrollableContainer;
5103 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5104 // Convert into a plain object
5105 viewportRect = $.extend( {}, viewportRect );
5106 }
5107
5108 // Account for scrollbar gutter
5109 direction = $viewport.css( 'direction' );
5110 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5111 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5112 viewportRect.bottom -= horizScrollbarHeight;
5113 if ( direction === 'rtl' ) {
5114 viewportRect.left += vertScrollbarWidth;
5115 } else {
5116 viewportRect.right -= vertScrollbarWidth;
5117 }
5118
5119 // Add arbitrary tolerance
5120 viewportRect.top += buffer;
5121 viewportRect.left += buffer;
5122 viewportRect.right -= buffer;
5123 viewportRect.bottom -= buffer;
5124
5125 $item = this.$clippableContainer || this.$clippable;
5126
5127 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5128 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5129
5130 itemRect = $item[ 0 ].getBoundingClientRect();
5131 // Convert into a plain object
5132 itemRect = $.extend( {}, itemRect );
5133
5134 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5135 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5136 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5137 itemRect.left = viewportRect.left;
5138 } else {
5139 itemRect.right = viewportRect.right;
5140 }
5141 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5142 itemRect.top = viewportRect.top;
5143 } else {
5144 itemRect.bottom = viewportRect.bottom;
5145 }
5146
5147 availableRect = rectIntersection( viewportRect, itemRect );
5148
5149 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5150 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5151 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5152 desiredWidth = Math.min( desiredWidth,
5153 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5154 desiredHeight = Math.min( desiredHeight,
5155 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5156 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5157 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5158 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5159 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5160 clipWidth = allotedWidth < naturalWidth;
5161 clipHeight = allotedHeight < naturalHeight;
5162
5163 if ( clipWidth ) {
5164 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5165 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5166 this.$clippable.css( 'overflowX', 'scroll' );
5167 // eslint-disable-next-line no-void
5168 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5169 this.$clippable.css( {
5170 width: Math.max( 0, allotedWidth ),
5171 maxWidth: ''
5172 } );
5173 } else {
5174 this.$clippable.css( {
5175 overflowX: '',
5176 width: this.idealWidth || '',
5177 maxWidth: Math.max( 0, allotedWidth )
5178 } );
5179 }
5180 if ( clipHeight ) {
5181 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5182 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5183 this.$clippable.css( 'overflowY', 'scroll' );
5184 // eslint-disable-next-line no-void
5185 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5186 this.$clippable.css( {
5187 height: Math.max( 0, allotedHeight ),
5188 maxHeight: ''
5189 } );
5190 } else {
5191 this.$clippable.css( {
5192 overflowY: '',
5193 height: this.idealHeight || '',
5194 maxHeight: Math.max( 0, allotedHeight )
5195 } );
5196 }
5197
5198 // If we stopped clipping in at least one of the dimensions
5199 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5200 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5201 }
5202
5203 this.clippedHorizontally = clipWidth;
5204 this.clippedVertically = clipHeight;
5205
5206 return this;
5207 };
5208
5209 /**
5210 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5211 * By default, each popup has an anchor that points toward its origin.
5212 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5213 *
5214 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5215 *
5216 * @example
5217 * // A PopupWidget.
5218 * var popup = new OO.ui.PopupWidget( {
5219 * $content: $( '<p>Hi there!</p>' ),
5220 * padded: true,
5221 * width: 300
5222 * } );
5223 *
5224 * $( document.body ).append( popup.$element );
5225 * // To display the popup, toggle the visibility to 'true'.
5226 * popup.toggle( true );
5227 *
5228 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5229 *
5230 * @class
5231 * @extends OO.ui.Widget
5232 * @mixins OO.ui.mixin.LabelElement
5233 * @mixins OO.ui.mixin.ClippableElement
5234 * @mixins OO.ui.mixin.FloatableElement
5235 *
5236 * @constructor
5237 * @param {Object} [config] Configuration options
5238 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5239 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5240 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5241 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5242 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5243 * of $floatableContainer
5244 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5245 * of $floatableContainer
5246 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5247 * endwards (right/left) to the vertical center of $floatableContainer
5248 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5249 * startwards (left/right) to the vertical center of $floatableContainer
5250 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5251 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5252 * as possible while still keeping the anchor within the popup;
5253 * if position is before/after, move the popup as far downwards as possible.
5254 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5255 * as possible while still keeping the anchor within the popup;
5256 * if position in before/after, move the popup as far upwards as possible.
5257 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5258 * of the popup with the center of $floatableContainer.
5259 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5260 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5261 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5262 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5263 * desired direction to display the popup without clipping
5264 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5265 * See the [OOUI docs on MediaWiki][3] for an example.
5266 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5267 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5268 * @cfg {jQuery} [$content] Content to append to the popup's body
5269 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5270 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5271 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5272 * This config option is only relevant if #autoClose is set to `true`. See the [OOUI documentation on MediaWiki][2]
5273 * for an example.
5274 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5275 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5276 * button.
5277 * @cfg {boolean} [padded=false] Add padding to the popup's body
5278 */
5279 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5280 // Configuration initialization
5281 config = config || {};
5282
5283 // Parent constructor
5284 OO.ui.PopupWidget.parent.call( this, config );
5285
5286 // Properties (must be set before ClippableElement constructor call)
5287 this.$body = $( '<div>' );
5288 this.$popup = $( '<div>' );
5289
5290 // Mixin constructors
5291 OO.ui.mixin.LabelElement.call( this, config );
5292 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5293 $clippable: this.$body,
5294 $clippableContainer: this.$popup
5295 } ) );
5296 OO.ui.mixin.FloatableElement.call( this, config );
5297
5298 // Properties
5299 this.$anchor = $( '<div>' );
5300 // If undefined, will be computed lazily in computePosition()
5301 this.$container = config.$container;
5302 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5303 this.autoClose = !!config.autoClose;
5304 this.transitionTimeout = null;
5305 this.anchored = false;
5306 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5307 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5308
5309 // Initialization
5310 this.setSize( config.width, config.height );
5311 this.toggleAnchor( config.anchor === undefined || config.anchor );
5312 this.setAlignment( config.align || 'center' );
5313 this.setPosition( config.position || 'below' );
5314 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5315 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5316 this.$body.addClass( 'oo-ui-popupWidget-body' );
5317 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5318 this.$popup
5319 .addClass( 'oo-ui-popupWidget-popup' )
5320 .append( this.$body );
5321 this.$element
5322 .addClass( 'oo-ui-popupWidget' )
5323 .append( this.$popup, this.$anchor );
5324 // Move content, which was added to #$element by OO.ui.Widget, to the body
5325 // FIXME This is gross, we should use '$body' or something for the config
5326 if ( config.$content instanceof $ ) {
5327 this.$body.append( config.$content );
5328 }
5329
5330 if ( config.padded ) {
5331 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5332 }
5333
5334 if ( config.head ) {
5335 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5336 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5337 this.$head = $( '<div>' )
5338 .addClass( 'oo-ui-popupWidget-head' )
5339 .append( this.$label, this.closeButton.$element );
5340 this.$popup.prepend( this.$head );
5341 }
5342
5343 if ( config.$footer ) {
5344 this.$footer = $( '<div>' )
5345 .addClass( 'oo-ui-popupWidget-footer' )
5346 .append( config.$footer );
5347 this.$popup.append( this.$footer );
5348 }
5349
5350 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5351 // that reference properties not initialized at that time of parent class construction
5352 // TODO: Find a better way to handle post-constructor setup
5353 this.visible = false;
5354 this.$element.addClass( 'oo-ui-element-hidden' );
5355 };
5356
5357 /* Setup */
5358
5359 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5360 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5361 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5362 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5363
5364 /* Events */
5365
5366 /**
5367 * @event ready
5368 *
5369 * The popup is ready: it is visible and has been positioned and clipped.
5370 */
5371
5372 /* Methods */
5373
5374 /**
5375 * Handles document mouse down events.
5376 *
5377 * @private
5378 * @param {MouseEvent} e Mouse down event
5379 */
5380 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5381 if (
5382 this.isVisible() &&
5383 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5384 ) {
5385 this.toggle( false );
5386 }
5387 };
5388
5389 // Deprecated alias since 0.28.3
5390 OO.ui.PopupWidget.prototype.onMouseDown = function () {
5391 OO.ui.warnDeprecation( 'onMouseDown is deprecated, use onDocumentMouseDown instead' );
5392 this.onDocumentMouseDown.apply( this, arguments );
5393 };
5394
5395 /**
5396 * Bind document mouse down listener.
5397 *
5398 * @private
5399 */
5400 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5401 // Capture clicks outside popup
5402 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5403 // We add 'click' event because iOS safari needs to respond to this event.
5404 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5405 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5406 // of occasionally not emitting 'click' properly, that event seems to be the standard
5407 // that it should be emitting, so we add it to this and will operate the event handler
5408 // on whichever of these events was triggered first
5409 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5410 };
5411
5412 // Deprecated alias since 0.28.3
5413 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5414 OO.ui.warnDeprecation( 'bindMouseDownListener is deprecated, use bindDocumentMouseDownListener instead' );
5415 this.bindDocumentMouseDownListener.apply( this, arguments );
5416 };
5417
5418 /**
5419 * Handles close button click events.
5420 *
5421 * @private
5422 */
5423 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5424 if ( this.isVisible() ) {
5425 this.toggle( false );
5426 }
5427 };
5428
5429 /**
5430 * Unbind document mouse down listener.
5431 *
5432 * @private
5433 */
5434 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5435 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5436 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5437 };
5438
5439 // Deprecated alias since 0.28.3
5440 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5441 OO.ui.warnDeprecation( 'unbindMouseDownListener is deprecated, use unbindDocumentMouseDownListener instead' );
5442 this.unbindDocumentMouseDownListener.apply( this, arguments );
5443 };
5444
5445 /**
5446 * Handles document key down events.
5447 *
5448 * @private
5449 * @param {KeyboardEvent} e Key down event
5450 */
5451 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5452 if (
5453 e.which === OO.ui.Keys.ESCAPE &&
5454 this.isVisible()
5455 ) {
5456 this.toggle( false );
5457 e.preventDefault();
5458 e.stopPropagation();
5459 }
5460 };
5461
5462 /**
5463 * Bind document key down listener.
5464 *
5465 * @private
5466 */
5467 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5468 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5469 };
5470
5471 // Deprecated alias since 0.28.3
5472 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5473 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
5474 this.bindDocumentKeyDownListener.apply( this, arguments );
5475 };
5476
5477 /**
5478 * Unbind document key down listener.
5479 *
5480 * @private
5481 */
5482 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5483 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5484 };
5485
5486 // Deprecated alias since 0.28.3
5487 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5488 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
5489 this.unbindDocumentKeyDownListener.apply( this, arguments );
5490 };
5491
5492 /**
5493 * Show, hide, or toggle the visibility of the anchor.
5494 *
5495 * @param {boolean} [show] Show anchor, omit to toggle
5496 */
5497 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5498 show = show === undefined ? !this.anchored : !!show;
5499
5500 if ( this.anchored !== show ) {
5501 if ( show ) {
5502 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5503 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5504 } else {
5505 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5506 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5507 }
5508 this.anchored = show;
5509 }
5510 };
5511
5512 /**
5513 * Change which edge the anchor appears on.
5514 *
5515 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5516 */
5517 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5518 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5519 throw new Error( 'Invalid value for edge: ' + edge );
5520 }
5521 if ( this.anchorEdge !== null ) {
5522 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5523 }
5524 this.anchorEdge = edge;
5525 if ( this.anchored ) {
5526 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5527 }
5528 };
5529
5530 /**
5531 * Check if the anchor is visible.
5532 *
5533 * @return {boolean} Anchor is visible
5534 */
5535 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5536 return this.anchored;
5537 };
5538
5539 /**
5540 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5541 * `.toggle( true )` after its #$element is attached to the DOM.
5542 *
5543 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5544 * it in the right place and with the right dimensions only work correctly while it is attached.
5545 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5546 * strictly enforced, so currently it only generates a warning in the browser console.
5547 *
5548 * @fires ready
5549 * @inheritdoc
5550 */
5551 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5552 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5553 show = show === undefined ? !this.isVisible() : !!show;
5554
5555 change = show !== this.isVisible();
5556
5557 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5558 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5559 this.warnedUnattached = true;
5560 }
5561 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5562 // Fall back to the parent node if the floatableContainer is not set
5563 this.setFloatableContainer( this.$element.parent() );
5564 }
5565
5566 if ( change && show && this.autoFlip ) {
5567 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5568 // (e.g. if the user scrolled).
5569 this.isAutoFlipped = false;
5570 }
5571
5572 // Parent method
5573 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5574
5575 if ( change ) {
5576 this.togglePositioning( show && !!this.$floatableContainer );
5577
5578 if ( show ) {
5579 if ( this.autoClose ) {
5580 this.bindDocumentMouseDownListener();
5581 this.bindDocumentKeyDownListener();
5582 }
5583 this.updateDimensions();
5584 this.toggleClipping( true );
5585
5586 if ( this.autoFlip ) {
5587 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5588 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5589 // If opening the popup in the normal direction causes it to be clipped, open
5590 // in the opposite one instead
5591 normalHeight = this.$element.height();
5592 this.isAutoFlipped = !this.isAutoFlipped;
5593 this.position();
5594 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5595 // If that also causes it to be clipped, open in whichever direction
5596 // we have more space
5597 oppositeHeight = this.$element.height();
5598 if ( oppositeHeight < normalHeight ) {
5599 this.isAutoFlipped = !this.isAutoFlipped;
5600 this.position();
5601 }
5602 }
5603 }
5604 }
5605 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5606 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5607 // If opening the popup in the normal direction causes it to be clipped, open
5608 // in the opposite one instead
5609 normalWidth = this.$element.width();
5610 this.isAutoFlipped = !this.isAutoFlipped;
5611 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5612 // which causes positioning to be off. Toggle clipping back and fort to work around.
5613 this.toggleClipping( false );
5614 this.position();
5615 this.toggleClipping( true );
5616 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5617 // If that also causes it to be clipped, open in whichever direction
5618 // we have more space
5619 oppositeWidth = this.$element.width();
5620 if ( oppositeWidth < normalWidth ) {
5621 this.isAutoFlipped = !this.isAutoFlipped;
5622 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5623 // which causes positioning to be off. Toggle clipping back and fort to work around.
5624 this.toggleClipping( false );
5625 this.position();
5626 this.toggleClipping( true );
5627 }
5628 }
5629 }
5630 }
5631 }
5632
5633 this.emit( 'ready' );
5634 } else {
5635 this.toggleClipping( false );
5636 if ( this.autoClose ) {
5637 this.unbindDocumentMouseDownListener();
5638 this.unbindDocumentKeyDownListener();
5639 }
5640 }
5641 }
5642
5643 return this;
5644 };
5645
5646 /**
5647 * Set the size of the popup.
5648 *
5649 * Changing the size may also change the popup's position depending on the alignment.
5650 *
5651 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5652 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5653 * @param {boolean} [transition=false] Use a smooth transition
5654 * @chainable
5655 */
5656 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5657 this.width = width !== undefined ? width : 320;
5658 this.height = height !== undefined ? height : null;
5659 if ( this.isVisible() ) {
5660 this.updateDimensions( transition );
5661 }
5662 };
5663
5664 /**
5665 * Update the size and position.
5666 *
5667 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5668 * be called automatically.
5669 *
5670 * @param {boolean} [transition=false] Use a smooth transition
5671 * @chainable
5672 */
5673 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5674 var widget = this;
5675
5676 // Prevent transition from being interrupted
5677 clearTimeout( this.transitionTimeout );
5678 if ( transition ) {
5679 // Enable transition
5680 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5681 }
5682
5683 this.position();
5684
5685 if ( transition ) {
5686 // Prevent transitioning after transition is complete
5687 this.transitionTimeout = setTimeout( function () {
5688 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5689 }, 200 );
5690 } else {
5691 // Prevent transitioning immediately
5692 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5693 }
5694 };
5695
5696 /**
5697 * @inheritdoc
5698 */
5699 OO.ui.PopupWidget.prototype.computePosition = function () {
5700 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5701 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5702 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5703 popupPos = {},
5704 anchorCss = { left: '', right: '', top: '', bottom: '' },
5705 popupPositionOppositeMap = {
5706 above: 'below',
5707 below: 'above',
5708 before: 'after',
5709 after: 'before'
5710 },
5711 alignMap = {
5712 ltr: {
5713 'force-left': 'backwards',
5714 'force-right': 'forwards'
5715 },
5716 rtl: {
5717 'force-left': 'forwards',
5718 'force-right': 'backwards'
5719 }
5720 },
5721 anchorEdgeMap = {
5722 above: 'bottom',
5723 below: 'top',
5724 before: 'end',
5725 after: 'start'
5726 },
5727 hPosMap = {
5728 forwards: 'start',
5729 center: 'center',
5730 backwards: this.anchored ? 'before' : 'end'
5731 },
5732 vPosMap = {
5733 forwards: 'top',
5734 center: 'center',
5735 backwards: 'bottom'
5736 };
5737
5738 if ( !this.$container ) {
5739 // Lazy-initialize $container if not specified in constructor
5740 this.$container = $( this.getClosestScrollableElementContainer() );
5741 }
5742 direction = this.$container.css( 'direction' );
5743
5744 // Set height and width before we do anything else, since it might cause our measurements
5745 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5746 this.$popup.css( {
5747 width: this.width !== null ? this.width : 'auto',
5748 height: this.height !== null ? this.height : 'auto'
5749 } );
5750
5751 align = alignMap[ direction ][ this.align ] || this.align;
5752 popupPosition = this.popupPosition;
5753 if ( this.isAutoFlipped ) {
5754 popupPosition = popupPositionOppositeMap[ popupPosition ];
5755 }
5756
5757 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5758 vertical = popupPosition === 'before' || popupPosition === 'after';
5759 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5760 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5761 near = vertical ? 'top' : 'left';
5762 far = vertical ? 'bottom' : 'right';
5763 sizeProp = vertical ? 'Height' : 'Width';
5764 popupSize = vertical ? ( this.height || this.$popup.height() ) : ( this.width || this.$popup.width() );
5765
5766 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5767 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5768 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5769
5770 // Parent method
5771 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5772 // Find out which property FloatableElement used for positioning, and adjust that value
5773 positionProp = vertical ?
5774 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5775 ( parentPosition.left !== '' ? 'left' : 'right' );
5776
5777 // Figure out where the near and far edges of the popup and $floatableContainer are
5778 floatablePos = this.$floatableContainer.offset();
5779 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5780 // Measure where the offsetParent is and compute our position based on that and parentPosition
5781 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5782 { top: 0, left: 0 } :
5783 this.$element.offsetParent().offset();
5784
5785 if ( positionProp === near ) {
5786 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5787 popupPos[ far ] = popupPos[ near ] + popupSize;
5788 } else {
5789 popupPos[ far ] = offsetParentPos[ near ] +
5790 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5791 popupPos[ near ] = popupPos[ far ] - popupSize;
5792 }
5793
5794 if ( this.anchored ) {
5795 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5796 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5797 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5798
5799 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5800 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5801 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5802 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5803 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5804 // Not enough space for the anchor on the start side; pull the popup startwards
5805 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5806 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5807 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5808 // Not enough space for the anchor on the end side; pull the popup endwards
5809 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5810 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5811 } else {
5812 positionAdjustment = 0;
5813 }
5814 } else {
5815 positionAdjustment = 0;
5816 }
5817
5818 // Check if the popup will go beyond the edge of this.$container
5819 containerPos = this.$container[ 0 ] === document.documentElement ?
5820 { top: 0, left: 0 } :
5821 this.$container.offset();
5822 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5823 if ( this.$container[ 0 ] === document.documentElement ) {
5824 viewportSpacing = OO.ui.getViewportSpacing();
5825 containerPos[ near ] += viewportSpacing[ near ];
5826 containerPos[ far ] -= viewportSpacing[ far ];
5827 }
5828 // Take into account how much the popup will move because of the adjustments we're going to make
5829 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5830 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5831 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5832 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5833 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5834 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5835 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5836 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5837 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5838 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5839 }
5840
5841 if ( this.anchored ) {
5842 // Adjust anchorOffset for positionAdjustment
5843 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5844
5845 // Position the anchor
5846 anchorCss[ start ] = anchorOffset;
5847 this.$anchor.css( anchorCss );
5848 }
5849
5850 // Move the popup if needed
5851 parentPosition[ positionProp ] += positionAdjustment;
5852
5853 return parentPosition;
5854 };
5855
5856 /**
5857 * Set popup alignment
5858 *
5859 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5860 * `backwards` or `forwards`.
5861 */
5862 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5863 // Validate alignment
5864 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5865 this.align = align;
5866 } else {
5867 this.align = 'center';
5868 }
5869 this.position();
5870 };
5871
5872 /**
5873 * Get popup alignment
5874 *
5875 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5876 * `backwards` or `forwards`.
5877 */
5878 OO.ui.PopupWidget.prototype.getAlignment = function () {
5879 return this.align;
5880 };
5881
5882 /**
5883 * Change the positioning of the popup.
5884 *
5885 * @param {string} position 'above', 'below', 'before' or 'after'
5886 */
5887 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5888 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5889 position = 'below';
5890 }
5891 this.popupPosition = position;
5892 this.position();
5893 };
5894
5895 /**
5896 * Get popup positioning.
5897 *
5898 * @return {string} 'above', 'below', 'before' or 'after'
5899 */
5900 OO.ui.PopupWidget.prototype.getPosition = function () {
5901 return this.popupPosition;
5902 };
5903
5904 /**
5905 * Set popup auto-flipping.
5906 *
5907 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5908 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5909 * desired direction to display the popup without clipping
5910 */
5911 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5912 autoFlip = !!autoFlip;
5913
5914 if ( this.autoFlip !== autoFlip ) {
5915 this.autoFlip = autoFlip;
5916 }
5917 };
5918
5919 /**
5920 * Set which elements will not close the popup when clicked.
5921 *
5922 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5923 *
5924 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5925 */
5926 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5927 this.$autoCloseIgnore = $autoCloseIgnore;
5928 };
5929
5930 /**
5931 * Get an ID of the body element, this can be used as the
5932 * `aria-describedby` attribute for an input field.
5933 *
5934 * @return {string} The ID of the body element
5935 */
5936 OO.ui.PopupWidget.prototype.getBodyId = function () {
5937 var id = this.$body.attr( 'id' );
5938 if ( id === undefined ) {
5939 id = OO.ui.generateElementId();
5940 this.$body.attr( 'id', id );
5941 }
5942 return id;
5943 };
5944
5945 /**
5946 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5947 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5948 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5949 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5950 *
5951 * @abstract
5952 * @class
5953 *
5954 * @constructor
5955 * @param {Object} [config] Configuration options
5956 * @cfg {Object} [popup] Configuration to pass to popup
5957 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5958 */
5959 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5960 // Configuration initialization
5961 config = config || {};
5962
5963 // Properties
5964 this.popup = new OO.ui.PopupWidget( $.extend(
5965 {
5966 autoClose: true,
5967 $floatableContainer: this.$element
5968 },
5969 config.popup,
5970 {
5971 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5972 }
5973 ) );
5974 };
5975
5976 /* Methods */
5977
5978 /**
5979 * Get popup.
5980 *
5981 * @return {OO.ui.PopupWidget} Popup widget
5982 */
5983 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5984 return this.popup;
5985 };
5986
5987 /**
5988 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5989 * which is used to display additional information or options.
5990 *
5991 * @example
5992 * // A PopupButtonWidget.
5993 * var popupButton = new OO.ui.PopupButtonWidget( {
5994 * label: 'Popup button with options',
5995 * icon: 'menu',
5996 * popup: {
5997 * $content: $( '<p>Additional options here.</p>' ),
5998 * padded: true,
5999 * align: 'force-left'
6000 * }
6001 * } );
6002 * // Append the button to the DOM.
6003 * $( document.body ).append( popupButton.$element );
6004 *
6005 * @class
6006 * @extends OO.ui.ButtonWidget
6007 * @mixins OO.ui.mixin.PopupElement
6008 *
6009 * @constructor
6010 * @param {Object} [config] Configuration options
6011 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
6012 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
6013 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
6014 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
6015 */
6016 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
6017 // Configuration initialization
6018 config = config || {};
6019
6020 // Parent constructor
6021 OO.ui.PopupButtonWidget.parent.call( this, config );
6022
6023 // Mixin constructors
6024 OO.ui.mixin.PopupElement.call( this, config );
6025
6026 // Properties
6027 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6028
6029 // Events
6030 this.connect( this, { click: 'onAction' } );
6031
6032 // Initialization
6033 this.$element
6034 .addClass( 'oo-ui-popupButtonWidget' );
6035 this.popup.$element
6036 .addClass( 'oo-ui-popupButtonWidget-popup' )
6037 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6038 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6039 this.$overlay.append( this.popup.$element );
6040 };
6041
6042 /* Setup */
6043
6044 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6045 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6046
6047 /* Methods */
6048
6049 /**
6050 * Handle the button action being triggered.
6051 *
6052 * @private
6053 */
6054 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6055 this.popup.toggle();
6056 };
6057
6058 /**
6059 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6060 *
6061 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6062 *
6063 * @private
6064 * @abstract
6065 * @class
6066 * @mixins OO.ui.mixin.GroupElement
6067 *
6068 * @constructor
6069 * @param {Object} [config] Configuration options
6070 */
6071 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6072 // Mixin constructors
6073 OO.ui.mixin.GroupElement.call( this, config );
6074 };
6075
6076 /* Setup */
6077
6078 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6079
6080 /* Methods */
6081
6082 /**
6083 * Set the disabled state of the widget.
6084 *
6085 * This will also update the disabled state of child widgets.
6086 *
6087 * @param {boolean} disabled Disable widget
6088 * @chainable
6089 * @return {OO.ui.Widget} The widget, for chaining
6090 */
6091 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6092 var i, len;
6093
6094 // Parent method
6095 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6096 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6097
6098 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6099 if ( this.items ) {
6100 for ( i = 0, len = this.items.length; i < len; i++ ) {
6101 this.items[ i ].updateDisabled();
6102 }
6103 }
6104
6105 return this;
6106 };
6107
6108 /**
6109 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6110 *
6111 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
6112 * allows bidirectional communication.
6113 *
6114 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6115 *
6116 * @private
6117 * @abstract
6118 * @class
6119 *
6120 * @constructor
6121 */
6122 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6123 //
6124 };
6125
6126 /* Methods */
6127
6128 /**
6129 * Check if widget is disabled.
6130 *
6131 * Checks parent if present, making disabled state inheritable.
6132 *
6133 * @return {boolean} Widget is disabled
6134 */
6135 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6136 return this.disabled ||
6137 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6138 };
6139
6140 /**
6141 * Set group element is in.
6142 *
6143 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6144 * @chainable
6145 * @return {OO.ui.Widget} The widget, for chaining
6146 */
6147 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6148 // Parent method
6149 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6150 OO.ui.Element.prototype.setElementGroup.call( this, group );
6151
6152 // Initialize item disabled states
6153 this.updateDisabled();
6154
6155 return this;
6156 };
6157
6158 /**
6159 * OptionWidgets are special elements that can be selected and configured with data. The
6160 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6161 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6162 * and examples, please see the [OOUI documentation on MediaWiki][1].
6163 *
6164 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6165 *
6166 * @class
6167 * @extends OO.ui.Widget
6168 * @mixins OO.ui.mixin.ItemWidget
6169 * @mixins OO.ui.mixin.LabelElement
6170 * @mixins OO.ui.mixin.FlaggedElement
6171 * @mixins OO.ui.mixin.AccessKeyedElement
6172 * @mixins OO.ui.mixin.TitledElement
6173 *
6174 * @constructor
6175 * @param {Object} [config] Configuration options
6176 */
6177 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6178 // Configuration initialization
6179 config = config || {};
6180
6181 // Parent constructor
6182 OO.ui.OptionWidget.parent.call( this, config );
6183
6184 // Mixin constructors
6185 OO.ui.mixin.ItemWidget.call( this );
6186 OO.ui.mixin.LabelElement.call( this, config );
6187 OO.ui.mixin.FlaggedElement.call( this, config );
6188 OO.ui.mixin.AccessKeyedElement.call( this, config );
6189 OO.ui.mixin.TitledElement.call( this, config );
6190
6191 // Properties
6192 this.selected = false;
6193 this.highlighted = false;
6194 this.pressed = false;
6195
6196 // Initialization
6197 this.$element
6198 .data( 'oo-ui-optionWidget', this )
6199 // Allow programmatic focussing (and by accesskey), but not tabbing
6200 .attr( 'tabindex', '-1' )
6201 .attr( 'role', 'option' )
6202 .attr( 'aria-selected', 'false' )
6203 .addClass( 'oo-ui-optionWidget' )
6204 .append( this.$label );
6205 };
6206
6207 /* Setup */
6208
6209 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6210 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6211 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6212 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6213 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6214 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6215
6216 /* Static Properties */
6217
6218 /**
6219 * Whether this option can be selected. See #setSelected.
6220 *
6221 * @static
6222 * @inheritable
6223 * @property {boolean}
6224 */
6225 OO.ui.OptionWidget.static.selectable = true;
6226
6227 /**
6228 * Whether this option can be highlighted. See #setHighlighted.
6229 *
6230 * @static
6231 * @inheritable
6232 * @property {boolean}
6233 */
6234 OO.ui.OptionWidget.static.highlightable = true;
6235
6236 /**
6237 * Whether this option can be pressed. See #setPressed.
6238 *
6239 * @static
6240 * @inheritable
6241 * @property {boolean}
6242 */
6243 OO.ui.OptionWidget.static.pressable = true;
6244
6245 /**
6246 * Whether this option will be scrolled into view when it is selected.
6247 *
6248 * @static
6249 * @inheritable
6250 * @property {boolean}
6251 */
6252 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6253
6254 /* Methods */
6255
6256 /**
6257 * Check if the option can be selected.
6258 *
6259 * @return {boolean} Item is selectable
6260 */
6261 OO.ui.OptionWidget.prototype.isSelectable = function () {
6262 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6263 };
6264
6265 /**
6266 * Check if the option can be highlighted. A highlight indicates that the option
6267 * may be selected when a user presses enter or clicks. Disabled items cannot
6268 * be highlighted.
6269 *
6270 * @return {boolean} Item is highlightable
6271 */
6272 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6273 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6274 };
6275
6276 /**
6277 * Check if the option can be pressed. The pressed state occurs when a user mouses
6278 * down on an item, but has not yet let go of the mouse.
6279 *
6280 * @return {boolean} Item is pressable
6281 */
6282 OO.ui.OptionWidget.prototype.isPressable = function () {
6283 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6284 };
6285
6286 /**
6287 * Check if the option is selected.
6288 *
6289 * @return {boolean} Item is selected
6290 */
6291 OO.ui.OptionWidget.prototype.isSelected = function () {
6292 return this.selected;
6293 };
6294
6295 /**
6296 * Check if the option is highlighted. A highlight indicates that the
6297 * item may be selected when a user presses enter or clicks.
6298 *
6299 * @return {boolean} Item is highlighted
6300 */
6301 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6302 return this.highlighted;
6303 };
6304
6305 /**
6306 * Check if the option is pressed. The pressed state occurs when a user mouses
6307 * down on an item, but has not yet let go of the mouse. The item may appear
6308 * selected, but it will not be selected until the user releases the mouse.
6309 *
6310 * @return {boolean} Item is pressed
6311 */
6312 OO.ui.OptionWidget.prototype.isPressed = function () {
6313 return this.pressed;
6314 };
6315
6316 /**
6317 * Set the option’s selected state. In general, all modifications to the selection
6318 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6319 * method instead of this method.
6320 *
6321 * @param {boolean} [state=false] Select option
6322 * @chainable
6323 * @return {OO.ui.Widget} The widget, for chaining
6324 */
6325 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6326 if ( this.constructor.static.selectable ) {
6327 this.selected = !!state;
6328 this.$element
6329 .toggleClass( 'oo-ui-optionWidget-selected', state )
6330 .attr( 'aria-selected', state.toString() );
6331 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6332 this.scrollElementIntoView();
6333 }
6334 this.updateThemeClasses();
6335 }
6336 return this;
6337 };
6338
6339 /**
6340 * Set the option’s highlighted state. In general, all programmatic
6341 * modifications to the highlight should be handled by the
6342 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6343 * method instead of this method.
6344 *
6345 * @param {boolean} [state=false] Highlight option
6346 * @chainable
6347 * @return {OO.ui.Widget} The widget, for chaining
6348 */
6349 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6350 if ( this.constructor.static.highlightable ) {
6351 this.highlighted = !!state;
6352 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6353 this.updateThemeClasses();
6354 }
6355 return this;
6356 };
6357
6358 /**
6359 * Set the option’s pressed state. In general, all
6360 * programmatic modifications to the pressed state should be handled by the
6361 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6362 * method instead of this method.
6363 *
6364 * @param {boolean} [state=false] Press option
6365 * @chainable
6366 * @return {OO.ui.Widget} The widget, for chaining
6367 */
6368 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6369 if ( this.constructor.static.pressable ) {
6370 this.pressed = !!state;
6371 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6372 this.updateThemeClasses();
6373 }
6374 return this;
6375 };
6376
6377 /**
6378 * Get text to match search strings against.
6379 *
6380 * The default implementation returns the label text, but subclasses
6381 * can override this to provide more complex behavior.
6382 *
6383 * @return {string|boolean} String to match search string against
6384 */
6385 OO.ui.OptionWidget.prototype.getMatchText = function () {
6386 var label = this.getLabel();
6387 return typeof label === 'string' ? label : this.$label.text();
6388 };
6389
6390 /**
6391 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6392 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6393 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6394 * menu selects}.
6395 *
6396 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6397 * information, please see the [OOUI documentation on MediaWiki][1].
6398 *
6399 * @example
6400 * // A select widget with three options.
6401 * var select = new OO.ui.SelectWidget( {
6402 * items: [
6403 * new OO.ui.OptionWidget( {
6404 * data: 'a',
6405 * label: 'Option One',
6406 * } ),
6407 * new OO.ui.OptionWidget( {
6408 * data: 'b',
6409 * label: 'Option Two',
6410 * } ),
6411 * new OO.ui.OptionWidget( {
6412 * data: 'c',
6413 * label: 'Option Three',
6414 * } )
6415 * ]
6416 * } );
6417 * $( document.body ).append( select.$element );
6418 *
6419 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6420 *
6421 * @abstract
6422 * @class
6423 * @extends OO.ui.Widget
6424 * @mixins OO.ui.mixin.GroupWidget
6425 *
6426 * @constructor
6427 * @param {Object} [config] Configuration options
6428 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6429 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6430 * the [OOUI documentation on MediaWiki] [2] for examples.
6431 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6432 */
6433 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6434 // Configuration initialization
6435 config = config || {};
6436
6437 // Parent constructor
6438 OO.ui.SelectWidget.parent.call( this, config );
6439
6440 // Mixin constructors
6441 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6442
6443 // Properties
6444 this.pressed = false;
6445 this.selecting = null;
6446 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6447 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6448 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6449 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6450 this.keyPressBuffer = '';
6451 this.keyPressBufferTimer = null;
6452 this.blockMouseOverEvents = 0;
6453
6454 // Events
6455 this.connect( this, {
6456 toggle: 'onToggle'
6457 } );
6458 this.$element.on( {
6459 focusin: this.onFocus.bind( this ),
6460 mousedown: this.onMouseDown.bind( this ),
6461 mouseover: this.onMouseOver.bind( this ),
6462 mouseleave: this.onMouseLeave.bind( this )
6463 } );
6464
6465 // Initialization
6466 this.$element
6467 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6468 .attr( 'role', 'listbox' );
6469 this.setFocusOwner( this.$element );
6470 if ( Array.isArray( config.items ) ) {
6471 this.addItems( config.items );
6472 }
6473 };
6474
6475 /* Setup */
6476
6477 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6478 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6479
6480 /* Events */
6481
6482 /**
6483 * @event highlight
6484 *
6485 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6486 *
6487 * @param {OO.ui.OptionWidget|null} item Highlighted item
6488 */
6489
6490 /**
6491 * @event press
6492 *
6493 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6494 * pressed state of an option.
6495 *
6496 * @param {OO.ui.OptionWidget|null} item Pressed item
6497 */
6498
6499 /**
6500 * @event select
6501 *
6502 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6503 *
6504 * @param {OO.ui.OptionWidget|null} item Selected item
6505 */
6506
6507 /**
6508 * @event choose
6509 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6510 * @param {OO.ui.OptionWidget} item Chosen item
6511 */
6512
6513 /**
6514 * @event add
6515 *
6516 * An `add` event is emitted when options are added to the select with the #addItems method.
6517 *
6518 * @param {OO.ui.OptionWidget[]} items Added items
6519 * @param {number} index Index of insertion point
6520 */
6521
6522 /**
6523 * @event remove
6524 *
6525 * A `remove` event is emitted when options are removed from the select with the #clearItems
6526 * or #removeItems methods.
6527 *
6528 * @param {OO.ui.OptionWidget[]} items Removed items
6529 */
6530
6531 /* Methods */
6532
6533 /**
6534 * Handle focus events
6535 *
6536 * @private
6537 * @param {jQuery.Event} event
6538 */
6539 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6540 var item;
6541 if ( event.target === this.$element[ 0 ] ) {
6542 // This widget was focussed, e.g. by the user tabbing to it.
6543 // The styles for focus state depend on one of the items being selected.
6544 if ( !this.findSelectedItem() ) {
6545 item = this.findFirstSelectableItem();
6546 }
6547 } else {
6548 if ( event.target.tabIndex === -1 ) {
6549 // One of the options got focussed (and the event bubbled up here).
6550 // They can't be tabbed to, but they can be activated using accesskeys.
6551 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6552 item = this.findTargetItem( event );
6553 } else {
6554 // There is something actually user-focusable in one of the labels of the options, and the
6555 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6556 return;
6557 }
6558 }
6559
6560 if ( item ) {
6561 if ( item.constructor.static.highlightable ) {
6562 this.highlightItem( item );
6563 } else {
6564 this.selectItem( item );
6565 }
6566 }
6567
6568 if ( event.target !== this.$element[ 0 ] ) {
6569 this.$focusOwner.focus();
6570 }
6571 };
6572
6573 /**
6574 * Handle mouse down events.
6575 *
6576 * @private
6577 * @param {jQuery.Event} e Mouse down event
6578 * @return {undefined/boolean} False to prevent default if event is handled
6579 */
6580 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6581 var item;
6582
6583 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6584 this.togglePressed( true );
6585 item = this.findTargetItem( e );
6586 if ( item && item.isSelectable() ) {
6587 this.pressItem( item );
6588 this.selecting = item;
6589 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6590 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6591 }
6592 }
6593 return false;
6594 };
6595
6596 /**
6597 * Handle document mouse up events.
6598 *
6599 * @private
6600 * @param {MouseEvent} e Mouse up event
6601 * @return {undefined/boolean} False to prevent default if event is handled
6602 */
6603 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6604 var item;
6605
6606 this.togglePressed( false );
6607 if ( !this.selecting ) {
6608 item = this.findTargetItem( e );
6609 if ( item && item.isSelectable() ) {
6610 this.selecting = item;
6611 }
6612 }
6613 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6614 this.pressItem( null );
6615 this.chooseItem( this.selecting );
6616 this.selecting = null;
6617 }
6618
6619 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6620 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6621
6622 return false;
6623 };
6624
6625 // Deprecated alias since 0.28.3
6626 OO.ui.SelectWidget.prototype.onMouseUp = function () {
6627 OO.ui.warnDeprecation( 'onMouseUp is deprecated, use onDocumentMouseUp instead' );
6628 this.onDocumentMouseUp.apply( this, arguments );
6629 };
6630
6631 /**
6632 * Handle document mouse move events.
6633 *
6634 * @private
6635 * @param {MouseEvent} e Mouse move event
6636 */
6637 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6638 var item;
6639
6640 if ( !this.isDisabled() && this.pressed ) {
6641 item = this.findTargetItem( e );
6642 if ( item && item !== this.selecting && item.isSelectable() ) {
6643 this.pressItem( item );
6644 this.selecting = item;
6645 }
6646 }
6647 };
6648
6649 // Deprecated alias since 0.28.3
6650 OO.ui.SelectWidget.prototype.onMouseMove = function () {
6651 OO.ui.warnDeprecation( 'onMouseMove is deprecated, use onDocumentMouseMove instead' );
6652 this.onDocumentMouseMove.apply( this, arguments );
6653 };
6654
6655 /**
6656 * Handle mouse over events.
6657 *
6658 * @private
6659 * @param {jQuery.Event} e Mouse over event
6660 * @return {undefined/boolean} False to prevent default if event is handled
6661 */
6662 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6663 var item;
6664 if ( this.blockMouseOverEvents ) {
6665 return;
6666 }
6667 if ( !this.isDisabled() ) {
6668 item = this.findTargetItem( e );
6669 this.highlightItem( item && item.isHighlightable() ? item : null );
6670 }
6671 return false;
6672 };
6673
6674 /**
6675 * Handle mouse leave events.
6676 *
6677 * @private
6678 * @param {jQuery.Event} e Mouse over event
6679 * @return {undefined/boolean} False to prevent default if event is handled
6680 */
6681 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6682 if ( !this.isDisabled() ) {
6683 this.highlightItem( null );
6684 }
6685 return false;
6686 };
6687
6688 /**
6689 * Handle document key down events.
6690 *
6691 * @protected
6692 * @param {KeyboardEvent} e Key down event
6693 */
6694 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6695 var nextItem,
6696 handled = false,
6697 currentItem = this.findHighlightedItem() || this.findSelectedItem();
6698
6699 if ( !this.isDisabled() && this.isVisible() ) {
6700 switch ( e.keyCode ) {
6701 case OO.ui.Keys.ENTER:
6702 if ( currentItem && currentItem.constructor.static.highlightable ) {
6703 // Was only highlighted, now let's select it. No-op if already selected.
6704 this.chooseItem( currentItem );
6705 handled = true;
6706 }
6707 break;
6708 case OO.ui.Keys.UP:
6709 case OO.ui.Keys.LEFT:
6710 this.clearKeyPressBuffer();
6711 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6712 handled = true;
6713 break;
6714 case OO.ui.Keys.DOWN:
6715 case OO.ui.Keys.RIGHT:
6716 this.clearKeyPressBuffer();
6717 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6718 handled = true;
6719 break;
6720 case OO.ui.Keys.ESCAPE:
6721 case OO.ui.Keys.TAB:
6722 if ( currentItem && currentItem.constructor.static.highlightable ) {
6723 currentItem.setHighlighted( false );
6724 }
6725 this.unbindDocumentKeyDownListener();
6726 this.unbindDocumentKeyPressListener();
6727 // Don't prevent tabbing away / defocusing
6728 handled = false;
6729 break;
6730 }
6731
6732 if ( nextItem ) {
6733 if ( nextItem.constructor.static.highlightable ) {
6734 this.highlightItem( nextItem );
6735 } else {
6736 this.chooseItem( nextItem );
6737 }
6738 this.scrollItemIntoView( nextItem );
6739 }
6740
6741 if ( handled ) {
6742 e.preventDefault();
6743 e.stopPropagation();
6744 }
6745 }
6746 };
6747
6748 // Deprecated alias since 0.28.3
6749 OO.ui.SelectWidget.prototype.onKeyDown = function () {
6750 OO.ui.warnDeprecation( 'onKeyDown is deprecated, use onDocumentKeyDown instead' );
6751 this.onDocumentKeyDown.apply( this, arguments );
6752 };
6753
6754 /**
6755 * Bind document key down listener.
6756 *
6757 * @protected
6758 */
6759 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6760 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6761 };
6762
6763 // Deprecated alias since 0.28.3
6764 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6765 OO.ui.warnDeprecation( 'bindKeyDownListener is deprecated, use bindDocumentKeyDownListener instead' );
6766 this.bindDocumentKeyDownListener.apply( this, arguments );
6767 };
6768
6769 /**
6770 * Unbind document key down listener.
6771 *
6772 * @protected
6773 */
6774 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6775 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6776 };
6777
6778 // Deprecated alias since 0.28.3
6779 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6780 OO.ui.warnDeprecation( 'unbindKeyDownListener is deprecated, use unbindDocumentKeyDownListener instead' );
6781 this.unbindDocumentKeyDownListener.apply( this, arguments );
6782 };
6783
6784 /**
6785 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6786 *
6787 * @param {OO.ui.OptionWidget} item Item to scroll into view
6788 */
6789 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6790 var widget = this;
6791 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6792 // and around 100-150 ms after it is finished.
6793 this.blockMouseOverEvents++;
6794 item.scrollElementIntoView().done( function () {
6795 setTimeout( function () {
6796 widget.blockMouseOverEvents--;
6797 }, 200 );
6798 } );
6799 };
6800
6801 /**
6802 * Clear the key-press buffer
6803 *
6804 * @protected
6805 */
6806 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6807 if ( this.keyPressBufferTimer ) {
6808 clearTimeout( this.keyPressBufferTimer );
6809 this.keyPressBufferTimer = null;
6810 }
6811 this.keyPressBuffer = '';
6812 };
6813
6814 /**
6815 * Handle key press events.
6816 *
6817 * @protected
6818 * @param {KeyboardEvent} e Key press event
6819 * @return {undefined/boolean} False to prevent default if event is handled
6820 */
6821 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6822 var c, filter, item;
6823
6824 if ( !e.charCode ) {
6825 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6826 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6827 return false;
6828 }
6829 return;
6830 }
6831 // eslint-disable-next-line no-restricted-properties
6832 if ( String.fromCodePoint ) {
6833 // eslint-disable-next-line no-restricted-properties
6834 c = String.fromCodePoint( e.charCode );
6835 } else {
6836 c = String.fromCharCode( e.charCode );
6837 }
6838
6839 if ( this.keyPressBufferTimer ) {
6840 clearTimeout( this.keyPressBufferTimer );
6841 }
6842 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6843
6844 item = this.findHighlightedItem() || this.findSelectedItem();
6845
6846 if ( this.keyPressBuffer === c ) {
6847 // Common (if weird) special case: typing "xxxx" will cycle through all
6848 // the items beginning with "x".
6849 if ( item ) {
6850 item = this.findRelativeSelectableItem( item, 1 );
6851 }
6852 } else {
6853 this.keyPressBuffer += c;
6854 }
6855
6856 filter = this.getItemMatcher( this.keyPressBuffer, false );
6857 if ( !item || !filter( item ) ) {
6858 item = this.findRelativeSelectableItem( item, 1, filter );
6859 }
6860 if ( item ) {
6861 if ( this.isVisible() && item.constructor.static.highlightable ) {
6862 this.highlightItem( item );
6863 } else {
6864 this.chooseItem( item );
6865 }
6866 this.scrollItemIntoView( item );
6867 }
6868
6869 e.preventDefault();
6870 e.stopPropagation();
6871 };
6872
6873 // Deprecated alias since 0.28.3
6874 OO.ui.SelectWidget.prototype.onKeyPress = function () {
6875 OO.ui.warnDeprecation( 'onKeyPress is deprecated, use onDocumentKeyPress instead' );
6876 this.onDocumentKeyPress.apply( this, arguments );
6877 };
6878
6879 /**
6880 * Get a matcher for the specific string
6881 *
6882 * @protected
6883 * @param {string} s String to match against items
6884 * @param {boolean} [exact=false] Only accept exact matches
6885 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6886 */
6887 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6888 var re;
6889
6890 // eslint-disable-next-line no-restricted-properties
6891 if ( s.normalize ) {
6892 // eslint-disable-next-line no-restricted-properties
6893 s = s.normalize();
6894 }
6895 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6896 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6897 if ( exact ) {
6898 re += '\\s*$';
6899 }
6900 re = new RegExp( re, 'i' );
6901 return function ( item ) {
6902 var matchText = item.getMatchText();
6903 // eslint-disable-next-line no-restricted-properties
6904 if ( matchText.normalize ) {
6905 // eslint-disable-next-line no-restricted-properties
6906 matchText = matchText.normalize();
6907 }
6908 return re.test( matchText );
6909 };
6910 };
6911
6912 /**
6913 * Bind document key press listener.
6914 *
6915 * @protected
6916 */
6917 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6918 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6919 };
6920
6921 // Deprecated alias since 0.28.3
6922 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6923 OO.ui.warnDeprecation( 'bindKeyPressListener is deprecated, use bindDocumentKeyPressListener instead' );
6924 this.bindDocumentKeyPressListener.apply( this, arguments );
6925 };
6926
6927 /**
6928 * Unbind document key down listener.
6929 *
6930 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6931 * implementation.
6932 *
6933 * @protected
6934 */
6935 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6936 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6937 this.clearKeyPressBuffer();
6938 };
6939
6940 // Deprecated alias since 0.28.3
6941 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6942 OO.ui.warnDeprecation( 'unbindKeyPressListener is deprecated, use unbindDocumentKeyPressListener instead' );
6943 this.unbindDocumentKeyPressListener.apply( this, arguments );
6944 };
6945
6946 /**
6947 * Visibility change handler
6948 *
6949 * @protected
6950 * @param {boolean} visible
6951 */
6952 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6953 if ( !visible ) {
6954 this.clearKeyPressBuffer();
6955 }
6956 };
6957
6958 /**
6959 * Get the closest item to a jQuery.Event.
6960 *
6961 * @private
6962 * @param {jQuery.Event} e
6963 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6964 */
6965 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6966 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6967 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6968 return null;
6969 }
6970 return $option.data( 'oo-ui-optionWidget' ) || null;
6971 };
6972
6973 /**
6974 * Find selected item.
6975 *
6976 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6977 */
6978 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6979 var i, len;
6980
6981 for ( i = 0, len = this.items.length; i < len; i++ ) {
6982 if ( this.items[ i ].isSelected() ) {
6983 return this.items[ i ];
6984 }
6985 }
6986 return null;
6987 };
6988
6989 /**
6990 * Find highlighted item.
6991 *
6992 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6993 */
6994 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6995 var i, len;
6996
6997 for ( i = 0, len = this.items.length; i < len; i++ ) {
6998 if ( this.items[ i ].isHighlighted() ) {
6999 return this.items[ i ];
7000 }
7001 }
7002 return null;
7003 };
7004
7005 /**
7006 * Toggle pressed state.
7007 *
7008 * Press is a state that occurs when a user mouses down on an item, but
7009 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
7010 * until the user releases the mouse.
7011 *
7012 * @param {boolean} pressed An option is being pressed
7013 */
7014 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
7015 if ( pressed === undefined ) {
7016 pressed = !this.pressed;
7017 }
7018 if ( pressed !== this.pressed ) {
7019 this.$element
7020 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7021 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
7022 this.pressed = pressed;
7023 }
7024 };
7025
7026 /**
7027 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7028 * and any existing highlight will be removed. The highlight is mutually exclusive.
7029 *
7030 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7031 * @fires highlight
7032 * @chainable
7033 * @return {OO.ui.Widget} The widget, for chaining
7034 */
7035 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7036 var i, len, highlighted,
7037 changed = false;
7038
7039 for ( i = 0, len = this.items.length; i < len; i++ ) {
7040 highlighted = this.items[ i ] === item;
7041 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7042 this.items[ i ].setHighlighted( highlighted );
7043 changed = true;
7044 }
7045 }
7046 if ( changed ) {
7047 if ( item ) {
7048 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7049 } else {
7050 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7051 }
7052 this.emit( 'highlight', item );
7053 }
7054
7055 return this;
7056 };
7057
7058 /**
7059 * Fetch an item by its label.
7060 *
7061 * @param {string} label Label of the item to select.
7062 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7063 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7064 */
7065 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7066 var i, item, found,
7067 len = this.items.length,
7068 filter = this.getItemMatcher( label, true );
7069
7070 for ( i = 0; i < len; i++ ) {
7071 item = this.items[ i ];
7072 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7073 return item;
7074 }
7075 }
7076
7077 if ( prefix ) {
7078 found = null;
7079 filter = this.getItemMatcher( label, false );
7080 for ( i = 0; i < len; i++ ) {
7081 item = this.items[ i ];
7082 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7083 if ( found ) {
7084 return null;
7085 }
7086 found = item;
7087 }
7088 }
7089 if ( found ) {
7090 return found;
7091 }
7092 }
7093
7094 return null;
7095 };
7096
7097 /**
7098 * Programmatically select an option by its label. If the item does not exist,
7099 * all options will be deselected.
7100 *
7101 * @param {string} [label] Label of the item to select.
7102 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7103 * @fires select
7104 * @chainable
7105 * @return {OO.ui.Widget} The widget, for chaining
7106 */
7107 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7108 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7109 if ( label === undefined || !itemFromLabel ) {
7110 return this.selectItem();
7111 }
7112 return this.selectItem( itemFromLabel );
7113 };
7114
7115 /**
7116 * Programmatically select an option by its data. If the `data` parameter is omitted,
7117 * or if the item does not exist, all options will be deselected.
7118 *
7119 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7120 * @fires select
7121 * @chainable
7122 * @return {OO.ui.Widget} The widget, for chaining
7123 */
7124 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7125 var itemFromData = this.findItemFromData( data );
7126 if ( data === undefined || !itemFromData ) {
7127 return this.selectItem();
7128 }
7129 return this.selectItem( itemFromData );
7130 };
7131
7132 /**
7133 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7134 * all options will be deselected.
7135 *
7136 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7137 * @fires select
7138 * @chainable
7139 * @return {OO.ui.Widget} The widget, for chaining
7140 */
7141 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7142 var i, len, selected,
7143 changed = false;
7144
7145 for ( i = 0, len = this.items.length; i < len; i++ ) {
7146 selected = this.items[ i ] === item;
7147 if ( this.items[ i ].isSelected() !== selected ) {
7148 this.items[ i ].setSelected( selected );
7149 changed = true;
7150 }
7151 }
7152 if ( changed ) {
7153 if ( item && !item.constructor.static.highlightable ) {
7154 if ( item ) {
7155 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7156 } else {
7157 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7158 }
7159 }
7160 this.emit( 'select', item );
7161 }
7162
7163 return this;
7164 };
7165
7166 /**
7167 * Press an item.
7168 *
7169 * Press is a state that occurs when a user mouses down on an item, but has not
7170 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7171 * releases the mouse.
7172 *
7173 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7174 * @fires press
7175 * @chainable
7176 * @return {OO.ui.Widget} The widget, for chaining
7177 */
7178 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7179 var i, len, pressed,
7180 changed = false;
7181
7182 for ( i = 0, len = this.items.length; i < len; i++ ) {
7183 pressed = this.items[ i ] === item;
7184 if ( this.items[ i ].isPressed() !== pressed ) {
7185 this.items[ i ].setPressed( pressed );
7186 changed = true;
7187 }
7188 }
7189 if ( changed ) {
7190 this.emit( 'press', item );
7191 }
7192
7193 return this;
7194 };
7195
7196 /**
7197 * Choose an item.
7198 *
7199 * Note that ‘choose’ should never be modified programmatically. A user can choose
7200 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7201 * use the #selectItem method.
7202 *
7203 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7204 * when users choose an item with the keyboard or mouse.
7205 *
7206 * @param {OO.ui.OptionWidget} item Item to choose
7207 * @fires choose
7208 * @chainable
7209 * @return {OO.ui.Widget} The widget, for chaining
7210 */
7211 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7212 if ( item ) {
7213 this.selectItem( item );
7214 this.emit( 'choose', item );
7215 }
7216
7217 return this;
7218 };
7219
7220 /**
7221 * Find an option by its position relative to the specified item (or to the start of the option array,
7222 * if item is `null`). The direction in which to search through the option array is specified with a
7223 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
7224 * `null` if there are no options in the array.
7225 *
7226 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
7227 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7228 * @param {Function} [filter] Only consider items for which this function returns
7229 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7230 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7231 */
7232 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7233 var currentIndex, nextIndex, i,
7234 increase = direction > 0 ? 1 : -1,
7235 len = this.items.length;
7236
7237 if ( item instanceof OO.ui.OptionWidget ) {
7238 currentIndex = this.items.indexOf( item );
7239 nextIndex = ( currentIndex + increase + len ) % len;
7240 } else {
7241 // If no item is selected and moving forward, start at the beginning.
7242 // If moving backward, start at the end.
7243 nextIndex = direction > 0 ? 0 : len - 1;
7244 }
7245
7246 for ( i = 0; i < len; i++ ) {
7247 item = this.items[ nextIndex ];
7248 if (
7249 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7250 ( !filter || filter( item ) )
7251 ) {
7252 return item;
7253 }
7254 nextIndex = ( nextIndex + increase + len ) % len;
7255 }
7256 return null;
7257 };
7258
7259 /**
7260 * Find the next selectable item or `null` if there are no selectable items.
7261 * Disabled options and menu-section markers and breaks are not selectable.
7262 *
7263 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7264 */
7265 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7266 return this.findRelativeSelectableItem( null, 1 );
7267 };
7268
7269 /**
7270 * Add an array of options to the select. Optionally, an index number can be used to
7271 * specify an insertion point.
7272 *
7273 * @param {OO.ui.OptionWidget[]} items Items to add
7274 * @param {number} [index] Index to insert items after
7275 * @fires add
7276 * @chainable
7277 * @return {OO.ui.Widget} The widget, for chaining
7278 */
7279 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7280 // Mixin method
7281 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7282
7283 // Always provide an index, even if it was omitted
7284 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7285
7286 return this;
7287 };
7288
7289 /**
7290 * Remove the specified array of options from the select. Options will be detached
7291 * from the DOM, not removed, so they can be reused later. To remove all options from
7292 * the select, you may wish to use the #clearItems method instead.
7293 *
7294 * @param {OO.ui.OptionWidget[]} items Items to remove
7295 * @fires remove
7296 * @chainable
7297 * @return {OO.ui.Widget} The widget, for chaining
7298 */
7299 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7300 var i, len, item;
7301
7302 // Deselect items being removed
7303 for ( i = 0, len = items.length; i < len; i++ ) {
7304 item = items[ i ];
7305 if ( item.isSelected() ) {
7306 this.selectItem( null );
7307 }
7308 }
7309
7310 // Mixin method
7311 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7312
7313 this.emit( 'remove', items );
7314
7315 return this;
7316 };
7317
7318 /**
7319 * Clear all options from the select. Options will be detached from the DOM, not removed,
7320 * so that they can be reused later. To remove a subset of options from the select, use
7321 * the #removeItems method.
7322 *
7323 * @fires remove
7324 * @chainable
7325 * @return {OO.ui.Widget} The widget, for chaining
7326 */
7327 OO.ui.SelectWidget.prototype.clearItems = function () {
7328 var items = this.items.slice();
7329
7330 // Mixin method
7331 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7332
7333 // Clear selection
7334 this.selectItem( null );
7335
7336 this.emit( 'remove', items );
7337
7338 return this;
7339 };
7340
7341 /**
7342 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7343 *
7344 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7345 *
7346 * @protected
7347 * @param {jQuery} $focusOwner
7348 */
7349 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7350 this.$focusOwner = $focusOwner;
7351 };
7352
7353 /**
7354 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7355 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7356 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7357 * options. For more information about options and selects, please see the
7358 * [OOUI documentation on MediaWiki][1].
7359 *
7360 * @example
7361 * // Decorated options in a select widget.
7362 * var select = new OO.ui.SelectWidget( {
7363 * items: [
7364 * new OO.ui.DecoratedOptionWidget( {
7365 * data: 'a',
7366 * label: 'Option with icon',
7367 * icon: 'help'
7368 * } ),
7369 * new OO.ui.DecoratedOptionWidget( {
7370 * data: 'b',
7371 * label: 'Option with indicator',
7372 * indicator: 'next'
7373 * } )
7374 * ]
7375 * } );
7376 * $( document.body ).append( select.$element );
7377 *
7378 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7379 *
7380 * @class
7381 * @extends OO.ui.OptionWidget
7382 * @mixins OO.ui.mixin.IconElement
7383 * @mixins OO.ui.mixin.IndicatorElement
7384 *
7385 * @constructor
7386 * @param {Object} [config] Configuration options
7387 */
7388 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7389 // Parent constructor
7390 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7391
7392 // Mixin constructors
7393 OO.ui.mixin.IconElement.call( this, config );
7394 OO.ui.mixin.IndicatorElement.call( this, config );
7395
7396 // Initialization
7397 this.$element
7398 .addClass( 'oo-ui-decoratedOptionWidget' )
7399 .prepend( this.$icon )
7400 .append( this.$indicator );
7401 };
7402
7403 /* Setup */
7404
7405 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7406 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7407 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7408
7409 /**
7410 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7411 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7412 * the [OOUI documentation on MediaWiki] [1] for more information.
7413 *
7414 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7415 *
7416 * @class
7417 * @extends OO.ui.DecoratedOptionWidget
7418 *
7419 * @constructor
7420 * @param {Object} [config] Configuration options
7421 */
7422 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7423 // Parent constructor
7424 OO.ui.MenuOptionWidget.parent.call( this, config );
7425
7426 // Properties
7427 this.checkIcon = new OO.ui.IconWidget( {
7428 icon: 'check',
7429 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7430 } );
7431
7432 // Initialization
7433 this.$element
7434 .prepend( this.checkIcon.$element )
7435 .addClass( 'oo-ui-menuOptionWidget' );
7436 };
7437
7438 /* Setup */
7439
7440 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7441
7442 /* Static Properties */
7443
7444 /**
7445 * @static
7446 * @inheritdoc
7447 */
7448 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7449
7450 /**
7451 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7452 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7453 *
7454 * @example
7455 * var dropdown = new OO.ui.DropdownWidget( {
7456 * menu: {
7457 * items: [
7458 * new OO.ui.MenuSectionOptionWidget( {
7459 * label: 'Dogs'
7460 * } ),
7461 * new OO.ui.MenuOptionWidget( {
7462 * data: 'corgi',
7463 * label: 'Welsh Corgi'
7464 * } ),
7465 * new OO.ui.MenuOptionWidget( {
7466 * data: 'poodle',
7467 * label: 'Standard Poodle'
7468 * } ),
7469 * new OO.ui.MenuSectionOptionWidget( {
7470 * label: 'Cats'
7471 * } ),
7472 * new OO.ui.MenuOptionWidget( {
7473 * data: 'lion',
7474 * label: 'Lion'
7475 * } )
7476 * ]
7477 * }
7478 * } );
7479 * $( document.body ).append( dropdown.$element );
7480 *
7481 * @class
7482 * @extends OO.ui.DecoratedOptionWidget
7483 *
7484 * @constructor
7485 * @param {Object} [config] Configuration options
7486 */
7487 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7488 // Parent constructor
7489 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7490
7491 // Initialization
7492 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7493 .removeAttr( 'role aria-selected' );
7494 };
7495
7496 /* Setup */
7497
7498 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7499
7500 /* Static Properties */
7501
7502 /**
7503 * @static
7504 * @inheritdoc
7505 */
7506 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7507
7508 /**
7509 * @static
7510 * @inheritdoc
7511 */
7512 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7513
7514 /**
7515 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7516 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7517 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7518 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7519 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7520 * and customized to be opened, closed, and displayed as needed.
7521 *
7522 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7523 * mouse outside the menu.
7524 *
7525 * Menus also have support for keyboard interaction:
7526 *
7527 * - Enter/Return key: choose and select a menu option
7528 * - Up-arrow key: highlight the previous menu option
7529 * - Down-arrow key: highlight the next menu option
7530 * - Esc key: hide the menu
7531 *
7532 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7533 *
7534 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7535 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7536 *
7537 * @class
7538 * @extends OO.ui.SelectWidget
7539 * @mixins OO.ui.mixin.ClippableElement
7540 * @mixins OO.ui.mixin.FloatableElement
7541 *
7542 * @constructor
7543 * @param {Object} [config] Configuration options
7544 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7545 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7546 * and {@link OO.ui.mixin.LookupElement LookupElement}
7547 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7548 * the text the user types. This config is used by {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7549 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7550 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7551 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7552 * that button, unless the button (or its parent widget) is passed in here.
7553 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7554 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7555 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7556 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7557 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7558 * @cfg {number} [width] Width of the menu
7559 */
7560 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7561 // Configuration initialization
7562 config = config || {};
7563
7564 // Parent constructor
7565 OO.ui.MenuSelectWidget.parent.call( this, config );
7566
7567 // Mixin constructors
7568 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7569 OO.ui.mixin.FloatableElement.call( this, config );
7570
7571 // Initial vertical positions other than 'center' will result in
7572 // the menu being flipped if there is not enough space in the container.
7573 // Store the original position so we know what to reset to.
7574 this.originalVerticalPosition = this.verticalPosition;
7575
7576 // Properties
7577 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7578 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7579 this.filterFromInput = !!config.filterFromInput;
7580 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7581 this.$widget = config.widget ? config.widget.$element : null;
7582 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7583 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7584 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7585 this.highlightOnFilter = !!config.highlightOnFilter;
7586 this.width = config.width;
7587
7588 // Initialization
7589 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7590 if ( config.widget ) {
7591 this.setFocusOwner( config.widget.$tabIndexed );
7592 }
7593
7594 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7595 // that reference properties not initialized at that time of parent class construction
7596 // TODO: Find a better way to handle post-constructor setup
7597 this.visible = false;
7598 this.$element.addClass( 'oo-ui-element-hidden' );
7599 this.$focusOwner.attr( 'aria-expanded', 'false' );
7600 };
7601
7602 /* Setup */
7603
7604 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7605 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7606 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7607
7608 /* Events */
7609
7610 /**
7611 * @event ready
7612 *
7613 * The menu is ready: it is visible and has been positioned and clipped.
7614 */
7615
7616 /* Static properties */
7617
7618 /**
7619 * Positions to flip to if there isn't room in the container for the
7620 * menu in a specific direction.
7621 *
7622 * @property {Object.<string,string>}
7623 */
7624 OO.ui.MenuSelectWidget.static.flippedPositions = {
7625 below: 'above',
7626 above: 'below',
7627 top: 'bottom',
7628 bottom: 'top'
7629 };
7630
7631 /* Methods */
7632
7633 /**
7634 * Handles document mouse down events.
7635 *
7636 * @protected
7637 * @param {MouseEvent} e Mouse down event
7638 */
7639 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7640 if (
7641 this.isVisible() &&
7642 !OO.ui.contains(
7643 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7644 e.target,
7645 true
7646 )
7647 ) {
7648 this.toggle( false );
7649 }
7650 };
7651
7652 /**
7653 * @inheritdoc
7654 */
7655 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7656 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7657
7658 if ( !this.isDisabled() && this.isVisible() ) {
7659 switch ( e.keyCode ) {
7660 case OO.ui.Keys.LEFT:
7661 case OO.ui.Keys.RIGHT:
7662 // Do nothing if a text field is associated, arrow keys will be handled natively
7663 if ( !this.$input ) {
7664 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7665 }
7666 break;
7667 case OO.ui.Keys.ESCAPE:
7668 case OO.ui.Keys.TAB:
7669 if ( currentItem ) {
7670 currentItem.setHighlighted( false );
7671 }
7672 this.toggle( false );
7673 // Don't prevent tabbing away, prevent defocusing
7674 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7675 e.preventDefault();
7676 e.stopPropagation();
7677 }
7678 break;
7679 default:
7680 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7681 return;
7682 }
7683 }
7684 };
7685
7686 /**
7687 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7688 * or after items were added/removed (always).
7689 *
7690 * @protected
7691 */
7692 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7693 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7694 anyVisible = false,
7695 len = this.items.length,
7696 showAll = !this.isVisible(),
7697 exactMatch = false;
7698
7699 if ( this.$input && this.filterFromInput ) {
7700 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7701 exactFilter = this.getItemMatcher( this.$input.val(), true );
7702 // Hide non-matching options, and also hide section headers if all options
7703 // in their section are hidden.
7704 for ( i = 0; i < len; i++ ) {
7705 item = this.items[ i ];
7706 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7707 if ( section ) {
7708 // If the previous section was empty, hide its header
7709 section.toggle( showAll || !sectionEmpty );
7710 }
7711 section = item;
7712 sectionEmpty = true;
7713 } else if ( item instanceof OO.ui.OptionWidget ) {
7714 visible = showAll || filter( item );
7715 exactMatch = exactMatch || exactFilter( item );
7716 anyVisible = anyVisible || visible;
7717 sectionEmpty = sectionEmpty && !visible;
7718 item.toggle( visible );
7719 }
7720 }
7721 // Process the final section
7722 if ( section ) {
7723 section.toggle( showAll || !sectionEmpty );
7724 }
7725
7726 if ( anyVisible && this.items.length && !exactMatch ) {
7727 this.scrollItemIntoView( this.items[ 0 ] );
7728 }
7729
7730 if ( !anyVisible ) {
7731 this.highlightItem( null );
7732 }
7733
7734 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7735
7736 if ( this.highlightOnFilter ) {
7737 // Highlight the first item on the list
7738 item = null;
7739 items = this.getItems();
7740 for ( i = 0; i < items.length; i++ ) {
7741 if ( items[ i ].isVisible() ) {
7742 item = items[ i ];
7743 break;
7744 }
7745 }
7746 this.highlightItem( item );
7747 }
7748
7749 }
7750
7751 // Reevaluate clipping
7752 this.clip();
7753 };
7754
7755 /**
7756 * @inheritdoc
7757 */
7758 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7759 if ( this.$input ) {
7760 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7761 } else {
7762 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7763 }
7764 };
7765
7766 /**
7767 * @inheritdoc
7768 */
7769 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7770 if ( this.$input ) {
7771 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7772 } else {
7773 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7774 }
7775 };
7776
7777 /**
7778 * @inheritdoc
7779 */
7780 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7781 if ( this.$input ) {
7782 if ( this.filterFromInput ) {
7783 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7784 this.updateItemVisibility();
7785 }
7786 } else {
7787 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7788 }
7789 };
7790
7791 /**
7792 * @inheritdoc
7793 */
7794 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7795 if ( this.$input ) {
7796 if ( this.filterFromInput ) {
7797 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7798 this.updateItemVisibility();
7799 }
7800 } else {
7801 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7802 }
7803 };
7804
7805 /**
7806 * Choose an item.
7807 *
7808 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7809 *
7810 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7811 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7812 *
7813 * @param {OO.ui.OptionWidget} item Item to choose
7814 * @chainable
7815 * @return {OO.ui.Widget} The widget, for chaining
7816 */
7817 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7818 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7819 if ( this.hideOnChoose ) {
7820 this.toggle( false );
7821 }
7822 return this;
7823 };
7824
7825 /**
7826 * @inheritdoc
7827 */
7828 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7829 // Parent method
7830 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7831
7832 this.updateItemVisibility();
7833
7834 return this;
7835 };
7836
7837 /**
7838 * @inheritdoc
7839 */
7840 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7841 // Parent method
7842 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7843
7844 this.updateItemVisibility();
7845
7846 return this;
7847 };
7848
7849 /**
7850 * @inheritdoc
7851 */
7852 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7853 // Parent method
7854 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7855
7856 this.updateItemVisibility();
7857
7858 return this;
7859 };
7860
7861 /**
7862 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7863 * `.toggle( true )` after its #$element is attached to the DOM.
7864 *
7865 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7866 * it in the right place and with the right dimensions only work correctly while it is attached.
7867 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7868 * strictly enforced, so currently it only generates a warning in the browser console.
7869 *
7870 * @fires ready
7871 * @inheritdoc
7872 */
7873 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7874 var change, originalHeight, flippedHeight;
7875
7876 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7877 change = visible !== this.isVisible();
7878
7879 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7880 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7881 this.warnedUnattached = true;
7882 }
7883
7884 if ( change && visible ) {
7885 // Reset position before showing the popup again. It's possible we no longer need to flip
7886 // (e.g. if the user scrolled).
7887 this.setVerticalPosition( this.originalVerticalPosition );
7888 }
7889
7890 // Parent method
7891 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7892
7893 if ( change ) {
7894 if ( visible ) {
7895
7896 if ( this.width ) {
7897 this.setIdealSize( this.width );
7898 } else if ( this.$floatableContainer ) {
7899 this.$clippable.css( 'width', 'auto' );
7900 this.setIdealSize(
7901 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7902 // Dropdown is smaller than handle so expand to width
7903 this.$floatableContainer[ 0 ].offsetWidth :
7904 // Dropdown is larger than handle so auto size
7905 'auto'
7906 );
7907 this.$clippable.css( 'width', '' );
7908 }
7909
7910 this.togglePositioning( !!this.$floatableContainer );
7911 this.toggleClipping( true );
7912
7913 this.bindDocumentKeyDownListener();
7914 this.bindDocumentKeyPressListener();
7915
7916 if (
7917 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7918 this.originalVerticalPosition !== 'center'
7919 ) {
7920 // If opening the menu in one direction causes it to be clipped, flip it
7921 originalHeight = this.$element.height();
7922 this.setVerticalPosition(
7923 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7924 );
7925 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7926 // If flipping also causes it to be clipped, open in whichever direction
7927 // we have more space
7928 flippedHeight = this.$element.height();
7929 if ( originalHeight > flippedHeight ) {
7930 this.setVerticalPosition( this.originalVerticalPosition );
7931 }
7932 }
7933 }
7934 // Note that we do not flip the menu's opening direction if the clipping changes
7935 // later (e.g. after the user scrolls), that seems like it would be annoying
7936
7937 this.$focusOwner.attr( 'aria-expanded', 'true' );
7938
7939 if ( this.findSelectedItem() ) {
7940 this.$focusOwner.attr( 'aria-activedescendant', this.findSelectedItem().getElementId() );
7941 this.findSelectedItem().scrollElementIntoView( { duration: 0 } );
7942 }
7943
7944 // Auto-hide
7945 if ( this.autoHide ) {
7946 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7947 }
7948
7949 this.emit( 'ready' );
7950 } else {
7951 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7952 this.unbindDocumentKeyDownListener();
7953 this.unbindDocumentKeyPressListener();
7954 this.$focusOwner.attr( 'aria-expanded', 'false' );
7955 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7956 this.togglePositioning( false );
7957 this.toggleClipping( false );
7958 }
7959 }
7960
7961 return this;
7962 };
7963
7964 /**
7965 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7966 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7967 * users can interact with it.
7968 *
7969 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7970 * OO.ui.DropdownInputWidget instead.
7971 *
7972 * @example
7973 * // A DropdownWidget with a menu that contains three options.
7974 * var dropDown = new OO.ui.DropdownWidget( {
7975 * label: 'Dropdown menu: Select a menu option',
7976 * menu: {
7977 * items: [
7978 * new OO.ui.MenuOptionWidget( {
7979 * data: 'a',
7980 * label: 'First'
7981 * } ),
7982 * new OO.ui.MenuOptionWidget( {
7983 * data: 'b',
7984 * label: 'Second'
7985 * } ),
7986 * new OO.ui.MenuOptionWidget( {
7987 * data: 'c',
7988 * label: 'Third'
7989 * } )
7990 * ]
7991 * }
7992 * } );
7993 *
7994 * $( document.body ).append( dropDown.$element );
7995 *
7996 * dropDown.getMenu().selectItemByData( 'b' );
7997 *
7998 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
7999 *
8000 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8001 *
8002 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8003 *
8004 * @class
8005 * @extends OO.ui.Widget
8006 * @mixins OO.ui.mixin.IconElement
8007 * @mixins OO.ui.mixin.IndicatorElement
8008 * @mixins OO.ui.mixin.LabelElement
8009 * @mixins OO.ui.mixin.TitledElement
8010 * @mixins OO.ui.mixin.TabIndexedElement
8011 *
8012 * @constructor
8013 * @param {Object} [config] Configuration options
8014 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
8015 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
8016 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
8017 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
8018 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8019 */
8020 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8021 // Configuration initialization
8022 config = $.extend( { indicator: 'down' }, config );
8023
8024 // Parent constructor
8025 OO.ui.DropdownWidget.parent.call( this, config );
8026
8027 // Properties (must be set before TabIndexedElement constructor call)
8028 this.$handle = $( '<button>' );
8029 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8030
8031 // Mixin constructors
8032 OO.ui.mixin.IconElement.call( this, config );
8033 OO.ui.mixin.IndicatorElement.call( this, config );
8034 OO.ui.mixin.LabelElement.call( this, config );
8035 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
8036 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
8037
8038 // Properties
8039 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8040 widget: this,
8041 $floatableContainer: this.$element
8042 }, config.menu ) );
8043
8044 // Events
8045 this.$handle.on( {
8046 click: this.onClick.bind( this ),
8047 keydown: this.onKeyDown.bind( this ),
8048 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
8049 keypress: this.menu.onDocumentKeyPressHandler,
8050 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8051 } );
8052 this.menu.connect( this, {
8053 select: 'onMenuSelect',
8054 toggle: 'onMenuToggle'
8055 } );
8056
8057 // Initialization
8058 this.$handle
8059 .addClass( 'oo-ui-dropdownWidget-handle' )
8060 .attr( {
8061 type: 'button',
8062 'aria-owns': this.menu.getElementId(),
8063 'aria-haspopup': 'listbox'
8064 } )
8065 .append( this.$icon, this.$label, this.$indicator );
8066 this.$element
8067 .addClass( 'oo-ui-dropdownWidget' )
8068 .append( this.$handle );
8069 this.$overlay.append( this.menu.$element );
8070 };
8071
8072 /* Setup */
8073
8074 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8075 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8076 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8077 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8078 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8079 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8080
8081 /* Methods */
8082
8083 /**
8084 * Get the menu.
8085 *
8086 * @return {OO.ui.MenuSelectWidget} Menu of widget
8087 */
8088 OO.ui.DropdownWidget.prototype.getMenu = function () {
8089 return this.menu;
8090 };
8091
8092 /**
8093 * Handles menu select events.
8094 *
8095 * @private
8096 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8097 */
8098 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8099 var selectedLabel;
8100
8101 if ( !item ) {
8102 this.setLabel( null );
8103 return;
8104 }
8105
8106 selectedLabel = item.getLabel();
8107
8108 // If the label is a DOM element, clone it, because setLabel will append() it
8109 if ( selectedLabel instanceof $ ) {
8110 selectedLabel = selectedLabel.clone();
8111 }
8112
8113 this.setLabel( selectedLabel );
8114 };
8115
8116 /**
8117 * Handle menu toggle events.
8118 *
8119 * @private
8120 * @param {boolean} isVisible Open state of the menu
8121 */
8122 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8123 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8124 };
8125
8126 /**
8127 * Handle mouse click events.
8128 *
8129 * @private
8130 * @param {jQuery.Event} e Mouse click event
8131 * @return {undefined/boolean} False to prevent default if event is handled
8132 */
8133 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8134 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8135 this.menu.toggle();
8136 }
8137 return false;
8138 };
8139
8140 /**
8141 * Handle key down events.
8142 *
8143 * @private
8144 * @param {jQuery.Event} e Key down event
8145 * @return {undefined/boolean} False to prevent default if event is handled
8146 */
8147 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8148 if (
8149 !this.isDisabled() &&
8150 (
8151 e.which === OO.ui.Keys.ENTER ||
8152 (
8153 e.which === OO.ui.Keys.SPACE &&
8154 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8155 // Space only closes the menu is the user is not typing to search.
8156 this.menu.keyPressBuffer === ''
8157 ) ||
8158 (
8159 !this.menu.isVisible() &&
8160 (
8161 e.which === OO.ui.Keys.UP ||
8162 e.which === OO.ui.Keys.DOWN
8163 )
8164 )
8165 )
8166 ) {
8167 this.menu.toggle();
8168 return false;
8169 }
8170 };
8171
8172 /**
8173 * RadioOptionWidget is an option widget that looks like a radio button.
8174 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8175 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8176 *
8177 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8178 *
8179 * @class
8180 * @extends OO.ui.OptionWidget
8181 *
8182 * @constructor
8183 * @param {Object} [config] Configuration options
8184 */
8185 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8186 // Configuration initialization
8187 config = config || {};
8188
8189 // Properties (must be done before parent constructor which calls #setDisabled)
8190 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8191
8192 // Parent constructor
8193 OO.ui.RadioOptionWidget.parent.call( this, config );
8194
8195 // Initialization
8196 // Remove implicit role, we're handling it ourselves
8197 this.radio.$input.attr( 'role', 'presentation' );
8198 this.$element
8199 .addClass( 'oo-ui-radioOptionWidget' )
8200 .attr( 'role', 'radio' )
8201 .attr( 'aria-checked', 'false' )
8202 .removeAttr( 'aria-selected' )
8203 .prepend( this.radio.$element );
8204 };
8205
8206 /* Setup */
8207
8208 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8209
8210 /* Static Properties */
8211
8212 /**
8213 * @static
8214 * @inheritdoc
8215 */
8216 OO.ui.RadioOptionWidget.static.highlightable = false;
8217
8218 /**
8219 * @static
8220 * @inheritdoc
8221 */
8222 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8223
8224 /**
8225 * @static
8226 * @inheritdoc
8227 */
8228 OO.ui.RadioOptionWidget.static.pressable = false;
8229
8230 /**
8231 * @static
8232 * @inheritdoc
8233 */
8234 OO.ui.RadioOptionWidget.static.tagName = 'label';
8235
8236 /* Methods */
8237
8238 /**
8239 * @inheritdoc
8240 */
8241 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8242 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8243
8244 this.radio.setSelected( state );
8245 this.$element
8246 .attr( 'aria-checked', state.toString() )
8247 .removeAttr( 'aria-selected' );
8248
8249 return this;
8250 };
8251
8252 /**
8253 * @inheritdoc
8254 */
8255 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8256 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8257
8258 this.radio.setDisabled( this.isDisabled() );
8259
8260 return this;
8261 };
8262
8263 /**
8264 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8265 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8266 * an interface for adding, removing and selecting options.
8267 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8268 *
8269 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8270 * OO.ui.RadioSelectInputWidget instead.
8271 *
8272 * @example
8273 * // A RadioSelectWidget with RadioOptions.
8274 * var option1 = new OO.ui.RadioOptionWidget( {
8275 * data: 'a',
8276 * label: 'Selected radio option'
8277 * } ),
8278 * option2 = new OO.ui.RadioOptionWidget( {
8279 * data: 'b',
8280 * label: 'Unselected radio option'
8281 * } );
8282 * radioSelect = new OO.ui.RadioSelectWidget( {
8283 * items: [ option1, option2 ]
8284 * } );
8285 *
8286 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8287 * radioSelect.selectItem( option1 );
8288 *
8289 * $( document.body ).append( radioSelect.$element );
8290 *
8291 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8292
8293 *
8294 * @class
8295 * @extends OO.ui.SelectWidget
8296 * @mixins OO.ui.mixin.TabIndexedElement
8297 *
8298 * @constructor
8299 * @param {Object} [config] Configuration options
8300 */
8301 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8302 // Parent constructor
8303 OO.ui.RadioSelectWidget.parent.call( this, config );
8304
8305 // Mixin constructors
8306 OO.ui.mixin.TabIndexedElement.call( this, config );
8307
8308 // Events
8309 this.$element.on( {
8310 focus: this.bindDocumentKeyDownListener.bind( this ),
8311 blur: this.unbindDocumentKeyDownListener.bind( this )
8312 } );
8313
8314 // Initialization
8315 this.$element
8316 .addClass( 'oo-ui-radioSelectWidget' )
8317 .attr( 'role', 'radiogroup' );
8318 };
8319
8320 /* Setup */
8321
8322 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8323 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8324
8325 /**
8326 * MultioptionWidgets are special elements that can be selected and configured with data. The
8327 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8328 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8329 * and examples, please see the [OOUI documentation on MediaWiki][1].
8330 *
8331 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Multioptions
8332 *
8333 * @class
8334 * @extends OO.ui.Widget
8335 * @mixins OO.ui.mixin.ItemWidget
8336 * @mixins OO.ui.mixin.LabelElement
8337 * @mixins OO.ui.mixin.TitledElement
8338 *
8339 * @constructor
8340 * @param {Object} [config] Configuration options
8341 * @cfg {boolean} [selected=false] Whether the option is initially selected
8342 */
8343 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8344 // Configuration initialization
8345 config = config || {};
8346
8347 // Parent constructor
8348 OO.ui.MultioptionWidget.parent.call( this, config );
8349
8350 // Mixin constructors
8351 OO.ui.mixin.ItemWidget.call( this );
8352 OO.ui.mixin.LabelElement.call( this, config );
8353 OO.ui.mixin.TitledElement.call( this, config );
8354
8355 // Properties
8356 this.selected = null;
8357
8358 // Initialization
8359 this.$element
8360 .addClass( 'oo-ui-multioptionWidget' )
8361 .append( this.$label );
8362 this.setSelected( config.selected );
8363 };
8364
8365 /* Setup */
8366
8367 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8368 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8369 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8370 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8371
8372 /* Events */
8373
8374 /**
8375 * @event change
8376 *
8377 * A change event is emitted when the selected state of the option changes.
8378 *
8379 * @param {boolean} selected Whether the option is now selected
8380 */
8381
8382 /* Methods */
8383
8384 /**
8385 * Check if the option is selected.
8386 *
8387 * @return {boolean} Item is selected
8388 */
8389 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8390 return this.selected;
8391 };
8392
8393 /**
8394 * Set the option’s selected state. In general, all modifications to the selection
8395 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8396 * method instead of this method.
8397 *
8398 * @param {boolean} [state=false] Select option
8399 * @chainable
8400 * @return {OO.ui.Widget} The widget, for chaining
8401 */
8402 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8403 state = !!state;
8404 if ( this.selected !== state ) {
8405 this.selected = state;
8406 this.emit( 'change', state );
8407 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8408 }
8409 return this;
8410 };
8411
8412 /**
8413 * MultiselectWidget allows selecting multiple options from a list.
8414 *
8415 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
8416 *
8417 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8418 *
8419 * @class
8420 * @abstract
8421 * @extends OO.ui.Widget
8422 * @mixins OO.ui.mixin.GroupWidget
8423 * @mixins OO.ui.mixin.TitledElement
8424 *
8425 * @constructor
8426 * @param {Object} [config] Configuration options
8427 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8428 */
8429 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8430 // Parent constructor
8431 OO.ui.MultiselectWidget.parent.call( this, config );
8432
8433 // Configuration initialization
8434 config = config || {};
8435
8436 // Mixin constructors
8437 OO.ui.mixin.GroupWidget.call( this, config );
8438 OO.ui.mixin.TitledElement.call( this, config );
8439
8440 // Events
8441 this.aggregate( { change: 'select' } );
8442 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8443 // by GroupElement only when items are added/removed
8444 this.connect( this, { select: [ 'emit', 'change' ] } );
8445
8446 // Initialization
8447 if ( config.items ) {
8448 this.addItems( config.items );
8449 }
8450 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8451 this.$element.addClass( 'oo-ui-multiselectWidget' )
8452 .append( this.$group );
8453 };
8454
8455 /* Setup */
8456
8457 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8458 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8459 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8460
8461 /* Events */
8462
8463 /**
8464 * @event change
8465 *
8466 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8467 */
8468
8469 /**
8470 * @event select
8471 *
8472 * A select event is emitted when an item is selected or deselected.
8473 */
8474
8475 /* Methods */
8476
8477 /**
8478 * Find options that are selected.
8479 *
8480 * @return {OO.ui.MultioptionWidget[]} Selected options
8481 */
8482 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8483 return this.items.filter( function ( item ) {
8484 return item.isSelected();
8485 } );
8486 };
8487
8488 /**
8489 * Find the data of options that are selected.
8490 *
8491 * @return {Object[]|string[]} Values of selected options
8492 */
8493 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8494 return this.findSelectedItems().map( function ( item ) {
8495 return item.data;
8496 } );
8497 };
8498
8499 /**
8500 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8501 *
8502 * @param {OO.ui.MultioptionWidget[]} items Items to select
8503 * @chainable
8504 * @return {OO.ui.Widget} The widget, for chaining
8505 */
8506 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8507 this.items.forEach( function ( item ) {
8508 var selected = items.indexOf( item ) !== -1;
8509 item.setSelected( selected );
8510 } );
8511 return this;
8512 };
8513
8514 /**
8515 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8516 *
8517 * @param {Object[]|string[]} datas Values of items to select
8518 * @chainable
8519 * @return {OO.ui.Widget} The widget, for chaining
8520 */
8521 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8522 var items,
8523 widget = this;
8524 items = datas.map( function ( data ) {
8525 return widget.findItemFromData( data );
8526 } );
8527 this.selectItems( items );
8528 return this;
8529 };
8530
8531 /**
8532 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8533 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8534 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8535 *
8536 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8537 *
8538 * @class
8539 * @extends OO.ui.MultioptionWidget
8540 *
8541 * @constructor
8542 * @param {Object} [config] Configuration options
8543 */
8544 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8545 // Configuration initialization
8546 config = config || {};
8547
8548 // Properties (must be done before parent constructor which calls #setDisabled)
8549 this.checkbox = new OO.ui.CheckboxInputWidget();
8550
8551 // Parent constructor
8552 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8553
8554 // Events
8555 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8556 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8557
8558 // Initialization
8559 this.$element
8560 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8561 .prepend( this.checkbox.$element );
8562 };
8563
8564 /* Setup */
8565
8566 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8567
8568 /* Static Properties */
8569
8570 /**
8571 * @static
8572 * @inheritdoc
8573 */
8574 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8575
8576 /* Methods */
8577
8578 /**
8579 * Handle checkbox selected state change.
8580 *
8581 * @private
8582 */
8583 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8584 this.setSelected( this.checkbox.isSelected() );
8585 };
8586
8587 /**
8588 * @inheritdoc
8589 */
8590 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8591 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8592 this.checkbox.setSelected( state );
8593 return this;
8594 };
8595
8596 /**
8597 * @inheritdoc
8598 */
8599 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8600 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8601 this.checkbox.setDisabled( this.isDisabled() );
8602 return this;
8603 };
8604
8605 /**
8606 * Focus the widget.
8607 */
8608 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8609 this.checkbox.focus();
8610 };
8611
8612 /**
8613 * Handle key down events.
8614 *
8615 * @protected
8616 * @param {jQuery.Event} e
8617 */
8618 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8619 var
8620 element = this.getElementGroup(),
8621 nextItem;
8622
8623 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8624 nextItem = element.getRelativeFocusableItem( this, -1 );
8625 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8626 nextItem = element.getRelativeFocusableItem( this, 1 );
8627 }
8628
8629 if ( nextItem ) {
8630 e.preventDefault();
8631 nextItem.focus();
8632 }
8633 };
8634
8635 /**
8636 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8637 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8638 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8639 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8640 *
8641 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8642 * OO.ui.CheckboxMultiselectInputWidget instead.
8643 *
8644 * @example
8645 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8646 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8647 * data: 'a',
8648 * selected: true,
8649 * label: 'Selected checkbox'
8650 * } ),
8651 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8652 * data: 'b',
8653 * label: 'Unselected checkbox'
8654 * } ),
8655 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8656 * items: [ option1, option2 ]
8657 * } );
8658 * $( document.body ).append( multiselect.$element );
8659 *
8660 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8661 *
8662 * @class
8663 * @extends OO.ui.MultiselectWidget
8664 *
8665 * @constructor
8666 * @param {Object} [config] Configuration options
8667 */
8668 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8669 // Parent constructor
8670 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8671
8672 // Properties
8673 this.$lastClicked = null;
8674
8675 // Events
8676 this.$group.on( 'click', this.onClick.bind( this ) );
8677
8678 // Initialization
8679 this.$element
8680 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8681 };
8682
8683 /* Setup */
8684
8685 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8686
8687 /* Methods */
8688
8689 /**
8690 * Get an option by its position relative to the specified item (or to the start of the option array,
8691 * if item is `null`). The direction in which to search through the option array is specified with a
8692 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8693 * `null` if there are no options in the array.
8694 *
8695 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8696 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8697 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8698 */
8699 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8700 var currentIndex, nextIndex, i,
8701 increase = direction > 0 ? 1 : -1,
8702 len = this.items.length;
8703
8704 if ( item ) {
8705 currentIndex = this.items.indexOf( item );
8706 nextIndex = ( currentIndex + increase + len ) % len;
8707 } else {
8708 // If no item is selected and moving forward, start at the beginning.
8709 // If moving backward, start at the end.
8710 nextIndex = direction > 0 ? 0 : len - 1;
8711 }
8712
8713 for ( i = 0; i < len; i++ ) {
8714 item = this.items[ nextIndex ];
8715 if ( item && !item.isDisabled() ) {
8716 return item;
8717 }
8718 nextIndex = ( nextIndex + increase + len ) % len;
8719 }
8720 return null;
8721 };
8722
8723 /**
8724 * Handle click events on checkboxes.
8725 *
8726 * @param {jQuery.Event} e
8727 */
8728 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8729 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8730 $lastClicked = this.$lastClicked,
8731 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8732 .not( '.oo-ui-widget-disabled' );
8733
8734 // Allow selecting multiple options at once by Shift-clicking them
8735 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8736 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8737 lastClickedIndex = $options.index( $lastClicked );
8738 nowClickedIndex = $options.index( $nowClicked );
8739 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8740 // browser. In either case we don't need custom handling.
8741 if ( nowClickedIndex !== lastClickedIndex ) {
8742 items = this.items;
8743 wasSelected = items[ nowClickedIndex ].isSelected();
8744 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8745
8746 // This depends on the DOM order of the items and the order of the .items array being the same.
8747 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8748 if ( !items[ i ].isDisabled() ) {
8749 items[ i ].setSelected( !wasSelected );
8750 }
8751 }
8752 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8753 // handling first, then set our value. The order in which events happen is different for
8754 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8755 // non-click actions that change the checkboxes.
8756 e.preventDefault();
8757 setTimeout( function () {
8758 if ( !items[ nowClickedIndex ].isDisabled() ) {
8759 items[ nowClickedIndex ].setSelected( !wasSelected );
8760 }
8761 } );
8762 }
8763 }
8764
8765 if ( $nowClicked.length ) {
8766 this.$lastClicked = $nowClicked;
8767 }
8768 };
8769
8770 /**
8771 * Focus the widget
8772 *
8773 * @chainable
8774 * @return {OO.ui.Widget} The widget, for chaining
8775 */
8776 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8777 var item;
8778 if ( !this.isDisabled() ) {
8779 item = this.getRelativeFocusableItem( null, 1 );
8780 if ( item ) {
8781 item.focus();
8782 }
8783 }
8784 return this;
8785 };
8786
8787 /**
8788 * @inheritdoc
8789 */
8790 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8791 this.focus();
8792 };
8793
8794 /**
8795 * Progress bars visually display the status of an operation, such as a download,
8796 * and can be either determinate or indeterminate:
8797 *
8798 * - **determinate** process bars show the percent of an operation that is complete.
8799 *
8800 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8801 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8802 * not use percentages.
8803 *
8804 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8805 *
8806 * @example
8807 * // Examples of determinate and indeterminate progress bars.
8808 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8809 * progress: 33
8810 * } );
8811 * var progressBar2 = new OO.ui.ProgressBarWidget();
8812 *
8813 * // Create a FieldsetLayout to layout progress bars.
8814 * var fieldset = new OO.ui.FieldsetLayout;
8815 * fieldset.addItems( [
8816 * new OO.ui.FieldLayout( progressBar1, {
8817 * label: 'Determinate',
8818 * align: 'top'
8819 * } ),
8820 * new OO.ui.FieldLayout( progressBar2, {
8821 * label: 'Indeterminate',
8822 * align: 'top'
8823 * } )
8824 * ] );
8825 * $( document.body ).append( fieldset.$element );
8826 *
8827 * @class
8828 * @extends OO.ui.Widget
8829 *
8830 * @constructor
8831 * @param {Object} [config] Configuration options
8832 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8833 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8834 * By default, the progress bar is indeterminate.
8835 */
8836 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8837 // Configuration initialization
8838 config = config || {};
8839
8840 // Parent constructor
8841 OO.ui.ProgressBarWidget.parent.call( this, config );
8842
8843 // Properties
8844 this.$bar = $( '<div>' );
8845 this.progress = null;
8846
8847 // Initialization
8848 this.setProgress( config.progress !== undefined ? config.progress : false );
8849 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8850 this.$element
8851 .attr( {
8852 role: 'progressbar',
8853 'aria-valuemin': 0,
8854 'aria-valuemax': 100
8855 } )
8856 .addClass( 'oo-ui-progressBarWidget' )
8857 .append( this.$bar );
8858 };
8859
8860 /* Setup */
8861
8862 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8863
8864 /* Static Properties */
8865
8866 /**
8867 * @static
8868 * @inheritdoc
8869 */
8870 OO.ui.ProgressBarWidget.static.tagName = 'div';
8871
8872 /* Methods */
8873
8874 /**
8875 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8876 *
8877 * @return {number|boolean} Progress percent
8878 */
8879 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8880 return this.progress;
8881 };
8882
8883 /**
8884 * Set the percent of the process completed or `false` for an indeterminate process.
8885 *
8886 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8887 */
8888 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8889 this.progress = progress;
8890
8891 if ( progress !== false ) {
8892 this.$bar.css( 'width', this.progress + '%' );
8893 this.$element.attr( 'aria-valuenow', this.progress );
8894 } else {
8895 this.$bar.css( 'width', '' );
8896 this.$element.removeAttr( 'aria-valuenow' );
8897 }
8898 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8899 };
8900
8901 /**
8902 * InputWidget is the base class for all input widgets, which
8903 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8904 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8905 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8906 *
8907 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8908 *
8909 * @abstract
8910 * @class
8911 * @extends OO.ui.Widget
8912 * @mixins OO.ui.mixin.FlaggedElement
8913 * @mixins OO.ui.mixin.TabIndexedElement
8914 * @mixins OO.ui.mixin.TitledElement
8915 * @mixins OO.ui.mixin.AccessKeyedElement
8916 *
8917 * @constructor
8918 * @param {Object} [config] Configuration options
8919 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8920 * @cfg {string} [value=''] The value of the input.
8921 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8922 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8923 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8924 * before it is accepted.
8925 */
8926 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8927 // Configuration initialization
8928 config = config || {};
8929
8930 // Parent constructor
8931 OO.ui.InputWidget.parent.call( this, config );
8932
8933 // Properties
8934 // See #reusePreInfuseDOM about config.$input
8935 this.$input = config.$input || this.getInputElement( config );
8936 this.value = '';
8937 this.inputFilter = config.inputFilter;
8938
8939 // Mixin constructors
8940 OO.ui.mixin.FlaggedElement.call( this, config );
8941 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8942 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8943 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8944
8945 // Events
8946 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8947
8948 // Initialization
8949 this.$input
8950 .addClass( 'oo-ui-inputWidget-input' )
8951 .attr( 'name', config.name )
8952 .prop( 'disabled', this.isDisabled() );
8953 this.$element
8954 .addClass( 'oo-ui-inputWidget' )
8955 .append( this.$input );
8956 this.setValue( config.value );
8957 if ( config.dir ) {
8958 this.setDir( config.dir );
8959 }
8960 if ( config.inputId !== undefined ) {
8961 this.setInputId( config.inputId );
8962 }
8963 };
8964
8965 /* Setup */
8966
8967 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8968 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8969 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8970 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8971 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8972
8973 /* Static Methods */
8974
8975 /**
8976 * @inheritdoc
8977 */
8978 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8979 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8980 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8981 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8982 return config;
8983 };
8984
8985 /**
8986 * @inheritdoc
8987 */
8988 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8989 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8990 if ( config.$input && config.$input.length ) {
8991 state.value = config.$input.val();
8992 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8993 state.focus = config.$input.is( ':focus' );
8994 }
8995 return state;
8996 };
8997
8998 /* Events */
8999
9000 /**
9001 * @event change
9002 *
9003 * A change event is emitted when the value of the input changes.
9004 *
9005 * @param {string} value
9006 */
9007
9008 /* Methods */
9009
9010 /**
9011 * Get input element.
9012 *
9013 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9014 * different circumstances. The element must have a `value` property (like form elements).
9015 *
9016 * @protected
9017 * @param {Object} config Configuration options
9018 * @return {jQuery} Input element
9019 */
9020 OO.ui.InputWidget.prototype.getInputElement = function () {
9021 return $( '<input>' );
9022 };
9023
9024 /**
9025 * Handle potentially value-changing events.
9026 *
9027 * @private
9028 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9029 */
9030 OO.ui.InputWidget.prototype.onEdit = function () {
9031 var widget = this;
9032 if ( !this.isDisabled() ) {
9033 // Allow the stack to clear so the value will be updated
9034 setTimeout( function () {
9035 widget.setValue( widget.$input.val() );
9036 } );
9037 }
9038 };
9039
9040 /**
9041 * Get the value of the input.
9042 *
9043 * @return {string} Input value
9044 */
9045 OO.ui.InputWidget.prototype.getValue = function () {
9046 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9047 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9048 var value = this.$input.val();
9049 if ( this.value !== value ) {
9050 this.setValue( value );
9051 }
9052 return this.value;
9053 };
9054
9055 /**
9056 * Set the directionality of the input.
9057 *
9058 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9059 * @chainable
9060 * @return {OO.ui.Widget} The widget, for chaining
9061 */
9062 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9063 this.$input.prop( 'dir', dir );
9064 return this;
9065 };
9066
9067 /**
9068 * Set the value of the input.
9069 *
9070 * @param {string} value New value
9071 * @fires change
9072 * @chainable
9073 * @return {OO.ui.Widget} The widget, for chaining
9074 */
9075 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9076 value = this.cleanUpValue( value );
9077 // Update the DOM if it has changed. Note that with cleanUpValue, it
9078 // is possible for the DOM value to change without this.value changing.
9079 if ( this.$input.val() !== value ) {
9080 this.$input.val( value );
9081 }
9082 if ( this.value !== value ) {
9083 this.value = value;
9084 this.emit( 'change', this.value );
9085 }
9086 // The first time that the value is set (probably while constructing the widget),
9087 // remember it in defaultValue. This property can be later used to check whether
9088 // the value of the input has been changed since it was created.
9089 if ( this.defaultValue === undefined ) {
9090 this.defaultValue = this.value;
9091 this.$input[ 0 ].defaultValue = this.defaultValue;
9092 }
9093 return this;
9094 };
9095
9096 /**
9097 * Clean up incoming value.
9098 *
9099 * Ensures value is a string, and converts undefined and null to empty string.
9100 *
9101 * @private
9102 * @param {string} value Original value
9103 * @return {string} Cleaned up value
9104 */
9105 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9106 if ( value === undefined || value === null ) {
9107 return '';
9108 } else if ( this.inputFilter ) {
9109 return this.inputFilter( String( value ) );
9110 } else {
9111 return String( value );
9112 }
9113 };
9114
9115 /**
9116 * @inheritdoc
9117 */
9118 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9119 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9120 if ( this.$input ) {
9121 this.$input.prop( 'disabled', this.isDisabled() );
9122 }
9123 return this;
9124 };
9125
9126 /**
9127 * Set the 'id' attribute of the `<input>` element.
9128 *
9129 * @param {string} id
9130 * @chainable
9131 * @return {OO.ui.Widget} The widget, for chaining
9132 */
9133 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9134 this.$input.attr( 'id', id );
9135 return this;
9136 };
9137
9138 /**
9139 * @inheritdoc
9140 */
9141 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9142 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9143 if ( state.value !== undefined && state.value !== this.getValue() ) {
9144 this.setValue( state.value );
9145 }
9146 if ( state.focus ) {
9147 this.focus();
9148 }
9149 };
9150
9151 /**
9152 * Data widget intended for creating `<input type="hidden">` inputs.
9153 *
9154 * @class
9155 * @extends OO.ui.Widget
9156 *
9157 * @constructor
9158 * @param {Object} [config] Configuration options
9159 * @cfg {string} [value=''] The value of the input.
9160 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9161 */
9162 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9163 // Configuration initialization
9164 config = $.extend( { value: '', name: '' }, config );
9165
9166 // Parent constructor
9167 OO.ui.HiddenInputWidget.parent.call( this, config );
9168
9169 // Initialization
9170 this.$element.attr( {
9171 type: 'hidden',
9172 value: config.value,
9173 name: config.name
9174 } );
9175 this.$element.removeAttr( 'aria-disabled' );
9176 };
9177
9178 /* Setup */
9179
9180 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9181
9182 /* Static Properties */
9183
9184 /**
9185 * @static
9186 * @inheritdoc
9187 */
9188 OO.ui.HiddenInputWidget.static.tagName = 'input';
9189
9190 /**
9191 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9192 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9193 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9194 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9195 * [OOUI documentation on MediaWiki] [1] for more information.
9196 *
9197 * @example
9198 * // A ButtonInputWidget rendered as an HTML button, the default.
9199 * var button = new OO.ui.ButtonInputWidget( {
9200 * label: 'Input button',
9201 * icon: 'check',
9202 * value: 'check'
9203 * } );
9204 * $( document.body ).append( button.$element );
9205 *
9206 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9207 *
9208 * @class
9209 * @extends OO.ui.InputWidget
9210 * @mixins OO.ui.mixin.ButtonElement
9211 * @mixins OO.ui.mixin.IconElement
9212 * @mixins OO.ui.mixin.IndicatorElement
9213 * @mixins OO.ui.mixin.LabelElement
9214 *
9215 * @constructor
9216 * @param {Object} [config] Configuration options
9217 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
9218 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9219 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
9220 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
9221 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9222 */
9223 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9224 // Configuration initialization
9225 config = $.extend( { type: 'button', useInputTag: false }, config );
9226
9227 // See InputWidget#reusePreInfuseDOM about config.$input
9228 if ( config.$input ) {
9229 config.$input.empty();
9230 }
9231
9232 // Properties (must be set before parent constructor, which calls #setValue)
9233 this.useInputTag = config.useInputTag;
9234
9235 // Parent constructor
9236 OO.ui.ButtonInputWidget.parent.call( this, config );
9237
9238 // Mixin constructors
9239 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
9240 OO.ui.mixin.IconElement.call( this, config );
9241 OO.ui.mixin.IndicatorElement.call( this, config );
9242 OO.ui.mixin.LabelElement.call( this, config );
9243
9244 // Initialization
9245 if ( !config.useInputTag ) {
9246 this.$input.append( this.$icon, this.$label, this.$indicator );
9247 }
9248 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9249 };
9250
9251 /* Setup */
9252
9253 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9254 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9255 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9256 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9257 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9258
9259 /* Static Properties */
9260
9261 /**
9262 * @static
9263 * @inheritdoc
9264 */
9265 OO.ui.ButtonInputWidget.static.tagName = 'span';
9266
9267 /* Methods */
9268
9269 /**
9270 * @inheritdoc
9271 * @protected
9272 */
9273 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9274 var type;
9275 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9276 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9277 };
9278
9279 /**
9280 * Set label value.
9281 *
9282 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9283 *
9284 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9285 * text, or `null` for no label
9286 * @chainable
9287 * @return {OO.ui.Widget} The widget, for chaining
9288 */
9289 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9290 if ( typeof label === 'function' ) {
9291 label = OO.ui.resolveMsg( label );
9292 }
9293
9294 if ( this.useInputTag ) {
9295 // Discard non-plaintext labels
9296 if ( typeof label !== 'string' ) {
9297 label = '';
9298 }
9299
9300 this.$input.val( label );
9301 }
9302
9303 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9304 };
9305
9306 /**
9307 * Set the value of the input.
9308 *
9309 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9310 * they do not support {@link #value values}.
9311 *
9312 * @param {string} value New value
9313 * @chainable
9314 * @return {OO.ui.Widget} The widget, for chaining
9315 */
9316 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9317 if ( !this.useInputTag ) {
9318 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9319 }
9320 return this;
9321 };
9322
9323 /**
9324 * @inheritdoc
9325 */
9326 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9327 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
9328 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9329 return null;
9330 };
9331
9332 /**
9333 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9334 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9335 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9336 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9337 *
9338 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9339 *
9340 * @example
9341 * // An example of selected, unselected, and disabled checkbox inputs.
9342 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9343 * value: 'a',
9344 * selected: true
9345 * } ),
9346 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9347 * value: 'b'
9348 * } ),
9349 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9350 * value:'c',
9351 * disabled: true
9352 * } ),
9353 * // Create a fieldset layout with fields for each checkbox.
9354 * fieldset = new OO.ui.FieldsetLayout( {
9355 * label: 'Checkboxes'
9356 * } );
9357 * fieldset.addItems( [
9358 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9359 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9360 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9361 * ] );
9362 * $( document.body ).append( fieldset.$element );
9363 *
9364 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9365 *
9366 * @class
9367 * @extends OO.ui.InputWidget
9368 *
9369 * @constructor
9370 * @param {Object} [config] Configuration options
9371 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9372 */
9373 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9374 // Configuration initialization
9375 config = config || {};
9376
9377 // Parent constructor
9378 OO.ui.CheckboxInputWidget.parent.call( this, config );
9379
9380 // Properties
9381 this.checkIcon = new OO.ui.IconWidget( {
9382 icon: 'check',
9383 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9384 } );
9385
9386 // Initialization
9387 this.$element
9388 .addClass( 'oo-ui-checkboxInputWidget' )
9389 // Required for pretty styling in WikimediaUI theme
9390 .append( this.checkIcon.$element );
9391 this.setSelected( config.selected !== undefined ? config.selected : false );
9392 };
9393
9394 /* Setup */
9395
9396 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9397
9398 /* Static Properties */
9399
9400 /**
9401 * @static
9402 * @inheritdoc
9403 */
9404 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9405
9406 /* Static Methods */
9407
9408 /**
9409 * @inheritdoc
9410 */
9411 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9412 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9413 state.checked = config.$input.prop( 'checked' );
9414 return state;
9415 };
9416
9417 /* Methods */
9418
9419 /**
9420 * @inheritdoc
9421 * @protected
9422 */
9423 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9424 return $( '<input>' ).attr( 'type', 'checkbox' );
9425 };
9426
9427 /**
9428 * @inheritdoc
9429 */
9430 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9431 var widget = this;
9432 if ( !this.isDisabled() ) {
9433 // Allow the stack to clear so the value will be updated
9434 setTimeout( function () {
9435 widget.setSelected( widget.$input.prop( 'checked' ) );
9436 } );
9437 }
9438 };
9439
9440 /**
9441 * Set selection state of this checkbox.
9442 *
9443 * @param {boolean} state `true` for selected
9444 * @chainable
9445 * @return {OO.ui.Widget} The widget, for chaining
9446 */
9447 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9448 state = !!state;
9449 if ( this.selected !== state ) {
9450 this.selected = state;
9451 this.$input.prop( 'checked', this.selected );
9452 this.emit( 'change', this.selected );
9453 }
9454 // The first time that the selection state is set (probably while constructing the widget),
9455 // remember it in defaultSelected. This property can be later used to check whether
9456 // the selection state of the input has been changed since it was created.
9457 if ( this.defaultSelected === undefined ) {
9458 this.defaultSelected = this.selected;
9459 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9460 }
9461 return this;
9462 };
9463
9464 /**
9465 * Check if this checkbox is selected.
9466 *
9467 * @return {boolean} Checkbox is selected
9468 */
9469 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9470 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9471 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9472 var selected = this.$input.prop( 'checked' );
9473 if ( this.selected !== selected ) {
9474 this.setSelected( selected );
9475 }
9476 return this.selected;
9477 };
9478
9479 /**
9480 * @inheritdoc
9481 */
9482 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9483 if ( !this.isDisabled() ) {
9484 this.$input.click();
9485 }
9486 this.focus();
9487 };
9488
9489 /**
9490 * @inheritdoc
9491 */
9492 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9493 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9494 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9495 this.setSelected( state.checked );
9496 }
9497 };
9498
9499 /**
9500 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9501 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9502 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9503 * more information about input widgets.
9504 *
9505 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9506 * are no options. If no `value` configuration option is provided, the first option is selected.
9507 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9508 *
9509 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9510 *
9511 * @example
9512 * // A DropdownInputWidget with three options.
9513 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9514 * options: [
9515 * { data: 'a', label: 'First' },
9516 * { data: 'b', label: 'Second', disabled: true },
9517 * { optgroup: 'Group label' },
9518 * { data: 'c', label: 'First sub-item)' }
9519 * ]
9520 * } );
9521 * $( document.body ).append( dropdownInput.$element );
9522 *
9523 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9524 *
9525 * @class
9526 * @extends OO.ui.InputWidget
9527 *
9528 * @constructor
9529 * @param {Object} [config] Configuration options
9530 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9531 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9532 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
9533 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
9534 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
9535 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9536 */
9537 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9538 // Configuration initialization
9539 config = config || {};
9540
9541 // Properties (must be done before parent constructor which calls #setDisabled)
9542 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9543 {
9544 $overlay: config.$overlay
9545 },
9546 config.dropdown
9547 ) );
9548 // Set up the options before parent constructor, which uses them to validate config.value.
9549 // Use this instead of setOptions() because this.$input is not set up yet.
9550 this.setOptionsData( config.options || [] );
9551
9552 // Parent constructor
9553 OO.ui.DropdownInputWidget.parent.call( this, config );
9554
9555 // Events
9556 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9557
9558 // Initialization
9559 this.$element
9560 .addClass( 'oo-ui-dropdownInputWidget' )
9561 .append( this.dropdownWidget.$element );
9562 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9563 this.setTitledElement( this.dropdownWidget.$handle );
9564 };
9565
9566 /* Setup */
9567
9568 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9569
9570 /* Methods */
9571
9572 /**
9573 * @inheritdoc
9574 * @protected
9575 */
9576 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9577 return $( '<select>' );
9578 };
9579
9580 /**
9581 * Handles menu select events.
9582 *
9583 * @private
9584 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9585 */
9586 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9587 this.setValue( item ? item.getData() : '' );
9588 };
9589
9590 /**
9591 * @inheritdoc
9592 */
9593 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9594 var selected;
9595 value = this.cleanUpValue( value );
9596 // Only allow setting values that are actually present in the dropdown
9597 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9598 this.dropdownWidget.getMenu().findFirstSelectableItem();
9599 this.dropdownWidget.getMenu().selectItem( selected );
9600 value = selected ? selected.getData() : '';
9601 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9602 if ( this.optionsDirty ) {
9603 // We reached this from the constructor or from #setOptions.
9604 // We have to update the <select> element.
9605 this.updateOptionsInterface();
9606 }
9607 return this;
9608 };
9609
9610 /**
9611 * @inheritdoc
9612 */
9613 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9614 this.dropdownWidget.setDisabled( state );
9615 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9616 return this;
9617 };
9618
9619 /**
9620 * Set the options available for this input.
9621 *
9622 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9623 * @chainable
9624 * @return {OO.ui.Widget} The widget, for chaining
9625 */
9626 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9627 var value = this.getValue();
9628
9629 this.setOptionsData( options );
9630
9631 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9632 // In case the previous value is no longer an available option, select the first valid one.
9633 this.setValue( value );
9634
9635 return this;
9636 };
9637
9638 /**
9639 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9640 *
9641 * This method may be called before the parent constructor, so various properties may not be
9642 * initialized yet.
9643 *
9644 * @param {Object[]} options Array of menu options (see #constructor for details).
9645 * @private
9646 */
9647 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9648 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9649 widget = this;
9650
9651 this.optionsDirty = true;
9652
9653 // Go through all the supplied option configs and create either
9654 // MenuSectionOption or MenuOption widgets from each.
9655 optionWidgets = [];
9656 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9657 opt = options[ optIndex ];
9658
9659 if ( opt.optgroup !== undefined ) {
9660 // Create a <optgroup> menu item.
9661 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9662 previousOptgroup = optionWidget;
9663
9664 } else {
9665 // Create a normal <option> menu item.
9666 optValue = widget.cleanUpValue( opt.data );
9667 optionWidget = widget.createMenuOptionWidget(
9668 optValue,
9669 opt.label !== undefined ? opt.label : optValue
9670 );
9671 }
9672
9673 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9674 if ( opt.disabled !== undefined ||
9675 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget && previousOptgroup.isDisabled() ) {
9676 optionWidget.setDisabled( true );
9677 }
9678
9679 optionWidgets.push( optionWidget );
9680 }
9681
9682 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9683 };
9684
9685 /**
9686 * Create a menu option widget.
9687 *
9688 * @protected
9689 * @param {string} data Item data
9690 * @param {string} label Item label
9691 * @return {OO.ui.MenuOptionWidget} Option widget
9692 */
9693 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9694 return new OO.ui.MenuOptionWidget( {
9695 data: data,
9696 label: label
9697 } );
9698 };
9699
9700 /**
9701 * Create a menu section option widget.
9702 *
9703 * @protected
9704 * @param {string} label Section item label
9705 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9706 */
9707 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9708 return new OO.ui.MenuSectionOptionWidget( {
9709 label: label
9710 } );
9711 };
9712
9713 /**
9714 * Update the user-visible interface to match the internal list of options and value.
9715 *
9716 * This method must only be called after the parent constructor.
9717 *
9718 * @private
9719 */
9720 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9721 var
9722 $optionsContainer = this.$input,
9723 defaultValue = this.defaultValue,
9724 widget = this;
9725
9726 this.$input.empty();
9727
9728 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9729 var $optionNode;
9730
9731 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9732 $optionNode = $( '<option>' )
9733 .attr( 'value', optionWidget.getData() )
9734 .text( optionWidget.getLabel() );
9735
9736 // Remember original selection state. This property can be later used to check whether
9737 // the selection state of the input has been changed since it was created.
9738 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9739
9740 $optionsContainer.append( $optionNode );
9741 } else {
9742 $optionNode = $( '<optgroup>' )
9743 .attr( 'label', optionWidget.getLabel() );
9744 widget.$input.append( $optionNode );
9745 $optionsContainer = $optionNode;
9746 }
9747
9748 // Disable the option or optgroup if required.
9749 if ( optionWidget.isDisabled() ) {
9750 $optionNode.prop( 'disabled', true );
9751 }
9752 } );
9753
9754 this.optionsDirty = false;
9755 };
9756
9757 /**
9758 * @inheritdoc
9759 */
9760 OO.ui.DropdownInputWidget.prototype.focus = function () {
9761 this.dropdownWidget.focus();
9762 return this;
9763 };
9764
9765 /**
9766 * @inheritdoc
9767 */
9768 OO.ui.DropdownInputWidget.prototype.blur = function () {
9769 this.dropdownWidget.blur();
9770 return this;
9771 };
9772
9773 /**
9774 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9775 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9776 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9777 * please see the [OOUI documentation on MediaWiki][1].
9778 *
9779 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9780 *
9781 * @example
9782 * // An example of selected, unselected, and disabled radio inputs
9783 * var radio1 = new OO.ui.RadioInputWidget( {
9784 * value: 'a',
9785 * selected: true
9786 * } );
9787 * var radio2 = new OO.ui.RadioInputWidget( {
9788 * value: 'b'
9789 * } );
9790 * var radio3 = new OO.ui.RadioInputWidget( {
9791 * value: 'c',
9792 * disabled: true
9793 * } );
9794 * // Create a fieldset layout with fields for each radio button.
9795 * var fieldset = new OO.ui.FieldsetLayout( {
9796 * label: 'Radio inputs'
9797 * } );
9798 * fieldset.addItems( [
9799 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9800 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9801 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9802 * ] );
9803 * $( document.body ).append( fieldset.$element );
9804 *
9805 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9806 *
9807 * @class
9808 * @extends OO.ui.InputWidget
9809 *
9810 * @constructor
9811 * @param {Object} [config] Configuration options
9812 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9813 */
9814 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9815 // Configuration initialization
9816 config = config || {};
9817
9818 // Parent constructor
9819 OO.ui.RadioInputWidget.parent.call( this, config );
9820
9821 // Initialization
9822 this.$element
9823 .addClass( 'oo-ui-radioInputWidget' )
9824 // Required for pretty styling in WikimediaUI theme
9825 .append( $( '<span>' ) );
9826 this.setSelected( config.selected !== undefined ? config.selected : false );
9827 };
9828
9829 /* Setup */
9830
9831 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9832
9833 /* Static Properties */
9834
9835 /**
9836 * @static
9837 * @inheritdoc
9838 */
9839 OO.ui.RadioInputWidget.static.tagName = 'span';
9840
9841 /* Static Methods */
9842
9843 /**
9844 * @inheritdoc
9845 */
9846 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9847 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9848 state.checked = config.$input.prop( 'checked' );
9849 return state;
9850 };
9851
9852 /* Methods */
9853
9854 /**
9855 * @inheritdoc
9856 * @protected
9857 */
9858 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9859 return $( '<input>' ).attr( 'type', 'radio' );
9860 };
9861
9862 /**
9863 * @inheritdoc
9864 */
9865 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9866 // RadioInputWidget doesn't track its state.
9867 };
9868
9869 /**
9870 * Set selection state of this radio button.
9871 *
9872 * @param {boolean} state `true` for selected
9873 * @chainable
9874 * @return {OO.ui.Widget} The widget, for chaining
9875 */
9876 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9877 // RadioInputWidget doesn't track its state.
9878 this.$input.prop( 'checked', state );
9879 // The first time that the selection state is set (probably while constructing the widget),
9880 // remember it in defaultSelected. This property can be later used to check whether
9881 // the selection state of the input has been changed since it was created.
9882 if ( this.defaultSelected === undefined ) {
9883 this.defaultSelected = state;
9884 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9885 }
9886 return this;
9887 };
9888
9889 /**
9890 * Check if this radio button is selected.
9891 *
9892 * @return {boolean} Radio is selected
9893 */
9894 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9895 return this.$input.prop( 'checked' );
9896 };
9897
9898 /**
9899 * @inheritdoc
9900 */
9901 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9902 if ( !this.isDisabled() ) {
9903 this.$input.click();
9904 }
9905 this.focus();
9906 };
9907
9908 /**
9909 * @inheritdoc
9910 */
9911 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9912 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9913 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9914 this.setSelected( state.checked );
9915 }
9916 };
9917
9918 /**
9919 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9920 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9921 * of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9922 * more information about input widgets.
9923 *
9924 * This and OO.ui.DropdownInputWidget support similar configuration options.
9925 *
9926 * @example
9927 * // A RadioSelectInputWidget with three options
9928 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9929 * options: [
9930 * { data: 'a', label: 'First' },
9931 * { data: 'b', label: 'Second'},
9932 * { data: 'c', label: 'Third' }
9933 * ]
9934 * } );
9935 * $( document.body ).append( radioSelectInput.$element );
9936 *
9937 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9938 *
9939 * @class
9940 * @extends OO.ui.InputWidget
9941 *
9942 * @constructor
9943 * @param {Object} [config] Configuration options
9944 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9945 */
9946 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9947 // Configuration initialization
9948 config = config || {};
9949
9950 // Properties (must be done before parent constructor which calls #setDisabled)
9951 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9952 // Set up the options before parent constructor, which uses them to validate config.value.
9953 // Use this instead of setOptions() because this.$input is not set up yet
9954 this.setOptionsData( config.options || [] );
9955
9956 // Parent constructor
9957 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9958
9959 // Events
9960 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9961
9962 // Initialization
9963 this.$element
9964 .addClass( 'oo-ui-radioSelectInputWidget' )
9965 .append( this.radioSelectWidget.$element );
9966 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
9967 };
9968
9969 /* Setup */
9970
9971 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9972
9973 /* Static Methods */
9974
9975 /**
9976 * @inheritdoc
9977 */
9978 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9979 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9980 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9981 return state;
9982 };
9983
9984 /**
9985 * @inheritdoc
9986 */
9987 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9988 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9989 // Cannot reuse the `<input type=radio>` set
9990 delete config.$input;
9991 return config;
9992 };
9993
9994 /* Methods */
9995
9996 /**
9997 * @inheritdoc
9998 * @protected
9999 */
10000 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10001 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10002 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10003 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10004 };
10005
10006 /**
10007 * Handles menu select events.
10008 *
10009 * @private
10010 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10011 */
10012 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10013 this.setValue( item.getData() );
10014 };
10015
10016 /**
10017 * @inheritdoc
10018 */
10019 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10020 var selected;
10021 value = this.cleanUpValue( value );
10022 // Only allow setting values that are actually present in the dropdown
10023 selected = this.radioSelectWidget.findItemFromData( value ) ||
10024 this.radioSelectWidget.findFirstSelectableItem();
10025 this.radioSelectWidget.selectItem( selected );
10026 value = selected ? selected.getData() : '';
10027 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10028 return this;
10029 };
10030
10031 /**
10032 * @inheritdoc
10033 */
10034 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10035 this.radioSelectWidget.setDisabled( state );
10036 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10037 return this;
10038 };
10039
10040 /**
10041 * Set the options available for this input.
10042 *
10043 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10044 * @chainable
10045 * @return {OO.ui.Widget} The widget, for chaining
10046 */
10047 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10048 var value = this.getValue();
10049
10050 this.setOptionsData( options );
10051
10052 // Re-set the value to update the visible interface (RadioSelectWidget).
10053 // In case the previous value is no longer an available option, select the first valid one.
10054 this.setValue( value );
10055
10056 return this;
10057 };
10058
10059 /**
10060 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10061 *
10062 * This method may be called before the parent constructor, so various properties may not be
10063 * intialized yet.
10064 *
10065 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10066 * @private
10067 */
10068 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10069 var widget = this;
10070
10071 this.radioSelectWidget
10072 .clearItems()
10073 .addItems( options.map( function ( opt ) {
10074 var optValue = widget.cleanUpValue( opt.data );
10075 return new OO.ui.RadioOptionWidget( {
10076 data: optValue,
10077 label: opt.label !== undefined ? opt.label : optValue
10078 } );
10079 } ) );
10080 };
10081
10082 /**
10083 * @inheritdoc
10084 */
10085 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10086 this.radioSelectWidget.focus();
10087 return this;
10088 };
10089
10090 /**
10091 * @inheritdoc
10092 */
10093 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10094 this.radioSelectWidget.blur();
10095 return this;
10096 };
10097
10098 /**
10099 * CheckboxMultiselectInputWidget is a
10100 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10101 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10102 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10103 * more information about input widgets.
10104 *
10105 * @example
10106 * // A CheckboxMultiselectInputWidget with three options.
10107 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10108 * options: [
10109 * { data: 'a', label: 'First' },
10110 * { data: 'b', label: 'Second' },
10111 * { data: 'c', label: 'Third' }
10112 * ]
10113 * } );
10114 * $( document.body ).append( multiselectInput.$element );
10115 *
10116 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10117 *
10118 * @class
10119 * @extends OO.ui.InputWidget
10120 *
10121 * @constructor
10122 * @param {Object} [config] Configuration options
10123 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
10124 */
10125 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10126 // Configuration initialization
10127 config = config || {};
10128
10129 // Properties (must be done before parent constructor which calls #setDisabled)
10130 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10131 // Must be set before the #setOptionsData call below
10132 this.inputName = config.name;
10133 // Set up the options before parent constructor, which uses them to validate config.value.
10134 // Use this instead of setOptions() because this.$input is not set up yet
10135 this.setOptionsData( config.options || [] );
10136
10137 // Parent constructor
10138 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10139
10140 // Events
10141 this.checkboxMultiselectWidget.connect( this, { select: 'onCheckboxesSelect' } );
10142
10143 // Initialization
10144 this.$element
10145 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10146 .append( this.checkboxMultiselectWidget.$element );
10147 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10148 this.$input.detach();
10149 };
10150
10151 /* Setup */
10152
10153 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10154
10155 /* Static Methods */
10156
10157 /**
10158 * @inheritdoc
10159 */
10160 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10161 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
10162 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10163 .toArray().map( function ( el ) { return el.value; } );
10164 return state;
10165 };
10166
10167 /**
10168 * @inheritdoc
10169 */
10170 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10171 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10172 // Cannot reuse the `<input type=checkbox>` set
10173 delete config.$input;
10174 return config;
10175 };
10176
10177 /* Methods */
10178
10179 /**
10180 * @inheritdoc
10181 * @protected
10182 */
10183 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10184 // Actually unused
10185 return $( '<unused>' );
10186 };
10187
10188 /**
10189 * Handles CheckboxMultiselectWidget select events.
10190 *
10191 * @private
10192 */
10193 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10194 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10195 };
10196
10197 /**
10198 * @inheritdoc
10199 */
10200 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10201 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10202 .toArray().map( function ( el ) { return el.value; } );
10203 if ( this.value !== value ) {
10204 this.setValue( value );
10205 }
10206 return this.value;
10207 };
10208
10209 /**
10210 * @inheritdoc
10211 */
10212 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10213 value = this.cleanUpValue( value );
10214 this.checkboxMultiselectWidget.selectItemsByData( value );
10215 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10216 if ( this.optionsDirty ) {
10217 // We reached this from the constructor or from #setOptions.
10218 // We have to update the <select> element.
10219 this.updateOptionsInterface();
10220 }
10221 return this;
10222 };
10223
10224 /**
10225 * Clean up incoming value.
10226 *
10227 * @param {string[]} value Original value
10228 * @return {string[]} Cleaned up value
10229 */
10230 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10231 var i, singleValue,
10232 cleanValue = [];
10233 if ( !Array.isArray( value ) ) {
10234 return cleanValue;
10235 }
10236 for ( i = 0; i < value.length; i++ ) {
10237 singleValue =
10238 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
10239 // Remove options that we don't have here
10240 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10241 continue;
10242 }
10243 cleanValue.push( singleValue );
10244 }
10245 return cleanValue;
10246 };
10247
10248 /**
10249 * @inheritdoc
10250 */
10251 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10252 this.checkboxMultiselectWidget.setDisabled( state );
10253 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10254 return this;
10255 };
10256
10257 /**
10258 * Set the options available for this input.
10259 *
10260 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
10261 * @chainable
10262 * @return {OO.ui.Widget} The widget, for chaining
10263 */
10264 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10265 var value = this.getValue();
10266
10267 this.setOptionsData( options );
10268
10269 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10270 // This will also get rid of any stale options that we just removed.
10271 this.setValue( value );
10272
10273 return this;
10274 };
10275
10276 /**
10277 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10278 *
10279 * This method may be called before the parent constructor, so various properties may not be
10280 * intialized yet.
10281 *
10282 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10283 * @private
10284 */
10285 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10286 var widget = this;
10287
10288 this.optionsDirty = true;
10289
10290 this.checkboxMultiselectWidget
10291 .clearItems()
10292 .addItems( options.map( function ( opt ) {
10293 var optValue, item, optDisabled;
10294 optValue =
10295 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
10296 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10297 item = new OO.ui.CheckboxMultioptionWidget( {
10298 data: optValue,
10299 label: opt.label !== undefined ? opt.label : optValue,
10300 disabled: optDisabled
10301 } );
10302 // Set the 'name' and 'value' for form submission
10303 item.checkbox.$input.attr( 'name', widget.inputName );
10304 item.checkbox.setValue( optValue );
10305 return item;
10306 } ) );
10307 };
10308
10309 /**
10310 * Update the user-visible interface to match the internal list of options and value.
10311 *
10312 * This method must only be called after the parent constructor.
10313 *
10314 * @private
10315 */
10316 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10317 var defaultValue = this.defaultValue;
10318
10319 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10320 // Remember original selection state. This property can be later used to check whether
10321 // the selection state of the input has been changed since it was created.
10322 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10323 item.checkbox.defaultSelected = isDefault;
10324 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10325 } );
10326
10327 this.optionsDirty = false;
10328 };
10329
10330 /**
10331 * @inheritdoc
10332 */
10333 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10334 this.checkboxMultiselectWidget.focus();
10335 return this;
10336 };
10337
10338 /**
10339 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10340 * size of the field as well as its presentation. In addition, these widgets can be configured
10341 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
10342 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
10343 * which modifies incoming values rather than validating them.
10344 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10345 *
10346 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10347 *
10348 * @example
10349 * // A TextInputWidget.
10350 * var textInput = new OO.ui.TextInputWidget( {
10351 * value: 'Text input'
10352 * } )
10353 * $( document.body ).append( textInput.$element );
10354 *
10355 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10356 *
10357 * @class
10358 * @extends OO.ui.InputWidget
10359 * @mixins OO.ui.mixin.IconElement
10360 * @mixins OO.ui.mixin.IndicatorElement
10361 * @mixins OO.ui.mixin.PendingElement
10362 * @mixins OO.ui.mixin.LabelElement
10363 *
10364 * @constructor
10365 * @param {Object} [config] Configuration options
10366 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10367 * 'email', 'url' or 'number'.
10368 * @cfg {string} [placeholder] Placeholder text
10369 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10370 * instruct the browser to focus this widget.
10371 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10372 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10373 *
10374 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10375 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10376 * many emojis) count as 2 characters each.
10377 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10378 * the value or placeholder text: `'before'` or `'after'`
10379 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator: 'required'`.
10380 * Note that `false` & setting `indicator: 'required' will result in no indicator shown.
10381 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10382 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
10383 * leaving it up to the browser).
10384 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10385 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10386 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10387 * value for it to be considered valid; when Function, a function receiving the value as parameter
10388 * that must return true, or promise resolving to true, for it to be considered valid.
10389 */
10390 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10391 // Configuration initialization
10392 config = $.extend( {
10393 type: 'text',
10394 labelPosition: 'after'
10395 }, config );
10396
10397 // Parent constructor
10398 OO.ui.TextInputWidget.parent.call( this, config );
10399
10400 // Mixin constructors
10401 OO.ui.mixin.IconElement.call( this, config );
10402 OO.ui.mixin.IndicatorElement.call( this, config );
10403 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
10404 OO.ui.mixin.LabelElement.call( this, config );
10405
10406 // Properties
10407 this.type = this.getSaneType( config );
10408 this.readOnly = false;
10409 this.required = false;
10410 this.validate = null;
10411 this.scrollWidth = null;
10412
10413 this.setValidation( config.validate );
10414 this.setLabelPosition( config.labelPosition );
10415
10416 // Events
10417 this.$input.on( {
10418 keypress: this.onKeyPress.bind( this ),
10419 blur: this.onBlur.bind( this ),
10420 focus: this.onFocus.bind( this )
10421 } );
10422 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10423 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10424 this.on( 'labelChange', this.updatePosition.bind( this ) );
10425 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10426
10427 // Initialization
10428 this.$element
10429 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10430 .append( this.$icon, this.$indicator );
10431 this.setReadOnly( !!config.readOnly );
10432 this.setRequired( !!config.required );
10433 if ( config.placeholder !== undefined ) {
10434 this.$input.attr( 'placeholder', config.placeholder );
10435 }
10436 if ( config.maxLength !== undefined ) {
10437 this.$input.attr( 'maxlength', config.maxLength );
10438 }
10439 if ( config.autofocus ) {
10440 this.$input.attr( 'autofocus', 'autofocus' );
10441 }
10442 if ( config.autocomplete === false ) {
10443 this.$input.attr( 'autocomplete', 'off' );
10444 // Turning off autocompletion also disables "form caching" when the user navigates to a
10445 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
10446 $( window ).on( {
10447 beforeunload: function () {
10448 this.$input.removeAttr( 'autocomplete' );
10449 }.bind( this ),
10450 pageshow: function () {
10451 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
10452 // whole page... it shouldn't hurt, though.
10453 this.$input.attr( 'autocomplete', 'off' );
10454 }.bind( this )
10455 } );
10456 }
10457 if ( config.spellcheck !== undefined ) {
10458 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10459 }
10460 if ( this.label ) {
10461 this.isWaitingToBeAttached = true;
10462 this.installParentChangeDetector();
10463 }
10464 };
10465
10466 /* Setup */
10467
10468 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10469 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10470 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10471 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10472 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10473
10474 /* Static Properties */
10475
10476 OO.ui.TextInputWidget.static.validationPatterns = {
10477 'non-empty': /.+/,
10478 integer: /^\d+$/
10479 };
10480
10481 /* Events */
10482
10483 /**
10484 * An `enter` event is emitted when the user presses 'enter' inside the text box.
10485 *
10486 * @event enter
10487 */
10488
10489 /* Methods */
10490
10491 /**
10492 * Handle icon mouse down events.
10493 *
10494 * @private
10495 * @param {jQuery.Event} e Mouse down event
10496 * @return {undefined/boolean} False to prevent default if event is handled
10497 */
10498 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10499 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10500 this.focus();
10501 return false;
10502 }
10503 };
10504
10505 /**
10506 * Handle indicator mouse down events.
10507 *
10508 * @private
10509 * @param {jQuery.Event} e Mouse down event
10510 * @return {undefined/boolean} False to prevent default if event is handled
10511 */
10512 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10513 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10514 this.focus();
10515 return false;
10516 }
10517 };
10518
10519 /**
10520 * Handle key press events.
10521 *
10522 * @private
10523 * @param {jQuery.Event} e Key press event
10524 * @fires enter If enter key is pressed
10525 */
10526 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10527 if ( e.which === OO.ui.Keys.ENTER ) {
10528 this.emit( 'enter', e );
10529 }
10530 };
10531
10532 /**
10533 * Handle blur events.
10534 *
10535 * @private
10536 * @param {jQuery.Event} e Blur event
10537 */
10538 OO.ui.TextInputWidget.prototype.onBlur = function () {
10539 this.setValidityFlag();
10540 };
10541
10542 /**
10543 * Handle focus events.
10544 *
10545 * @private
10546 * @param {jQuery.Event} e Focus event
10547 */
10548 OO.ui.TextInputWidget.prototype.onFocus = function () {
10549 if ( this.isWaitingToBeAttached ) {
10550 // If we've received focus, then we must be attached to the document, and if
10551 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10552 this.onElementAttach();
10553 }
10554 this.setValidityFlag( true );
10555 };
10556
10557 /**
10558 * Handle element attach events.
10559 *
10560 * @private
10561 * @param {jQuery.Event} e Element attach event
10562 */
10563 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10564 this.isWaitingToBeAttached = false;
10565 // Any previously calculated size is now probably invalid if we reattached elsewhere
10566 this.valCache = null;
10567 this.positionLabel();
10568 };
10569
10570 /**
10571 * Handle debounced change events.
10572 *
10573 * @param {string} value
10574 * @private
10575 */
10576 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10577 this.setValidityFlag();
10578 };
10579
10580 /**
10581 * Check if the input is {@link #readOnly read-only}.
10582 *
10583 * @return {boolean}
10584 */
10585 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10586 return this.readOnly;
10587 };
10588
10589 /**
10590 * Set the {@link #readOnly read-only} state of the input.
10591 *
10592 * @param {boolean} state Make input read-only
10593 * @chainable
10594 * @return {OO.ui.Widget} The widget, for chaining
10595 */
10596 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10597 this.readOnly = !!state;
10598 this.$input.prop( 'readOnly', this.readOnly );
10599 return this;
10600 };
10601
10602 /**
10603 * Check if the input is {@link #required required}.
10604 *
10605 * @return {boolean}
10606 */
10607 OO.ui.TextInputWidget.prototype.isRequired = function () {
10608 return this.required;
10609 };
10610
10611 /**
10612 * Set the {@link #required required} state of the input.
10613 *
10614 * @param {boolean} state Make input required
10615 * @chainable
10616 * @return {OO.ui.Widget} The widget, for chaining
10617 */
10618 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10619 this.required = !!state;
10620 if ( this.required ) {
10621 this.$input
10622 .prop( 'required', true )
10623 .attr( 'aria-required', 'true' );
10624 if ( this.getIndicator() === null ) {
10625 this.setIndicator( 'required' );
10626 }
10627 } else {
10628 this.$input
10629 .prop( 'required', false )
10630 .removeAttr( 'aria-required' );
10631 if ( this.getIndicator() === 'required' ) {
10632 this.setIndicator( null );
10633 }
10634 }
10635 return this;
10636 };
10637
10638 /**
10639 * Support function for making #onElementAttach work across browsers.
10640 *
10641 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10642 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10643 *
10644 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10645 * first time that the element gets attached to the documented.
10646 */
10647 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10648 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10649 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10650 widget = this;
10651
10652 if ( MutationObserver ) {
10653 // The new way. If only it wasn't so ugly.
10654
10655 if ( this.isElementAttached() ) {
10656 // Widget is attached already, do nothing. This breaks the functionality of this function when
10657 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10658 // would require observation of the whole document, which would hurt performance of other,
10659 // more important code.
10660 return;
10661 }
10662
10663 // Find topmost node in the tree
10664 topmostNode = this.$element[ 0 ];
10665 while ( topmostNode.parentNode ) {
10666 topmostNode = topmostNode.parentNode;
10667 }
10668
10669 // We have no way to detect the $element being attached somewhere without observing the entire
10670 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10671 // parent node of $element, and instead detect when $element is removed from it (and thus
10672 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10673 // doesn't get attached, we end up back here and create the parent.
10674
10675 mutationObserver = new MutationObserver( function ( mutations ) {
10676 var i, j, removedNodes;
10677 for ( i = 0; i < mutations.length; i++ ) {
10678 removedNodes = mutations[ i ].removedNodes;
10679 for ( j = 0; j < removedNodes.length; j++ ) {
10680 if ( removedNodes[ j ] === topmostNode ) {
10681 setTimeout( onRemove, 0 );
10682 return;
10683 }
10684 }
10685 }
10686 } );
10687
10688 onRemove = function () {
10689 // If the node was attached somewhere else, report it
10690 if ( widget.isElementAttached() ) {
10691 widget.onElementAttach();
10692 }
10693 mutationObserver.disconnect();
10694 widget.installParentChangeDetector();
10695 };
10696
10697 // Create a fake parent and observe it
10698 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10699 mutationObserver.observe( fakeParentNode, { childList: true } );
10700 } else {
10701 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10702 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10703 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10704 }
10705 };
10706
10707 /**
10708 * @inheritdoc
10709 * @protected
10710 */
10711 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10712 if ( this.getSaneType( config ) === 'number' ) {
10713 return $( '<input>' )
10714 .attr( 'step', 'any' )
10715 .attr( 'type', 'number' );
10716 } else {
10717 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10718 }
10719 };
10720
10721 /**
10722 * Get sanitized value for 'type' for given config.
10723 *
10724 * @param {Object} config Configuration options
10725 * @return {string|null}
10726 * @protected
10727 */
10728 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10729 var allowedTypes = [
10730 'text',
10731 'password',
10732 'email',
10733 'url',
10734 'number'
10735 ];
10736 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10737 };
10738
10739 /**
10740 * Focus the input and select a specified range within the text.
10741 *
10742 * @param {number} from Select from offset
10743 * @param {number} [to] Select to offset, defaults to from
10744 * @chainable
10745 * @return {OO.ui.Widget} The widget, for chaining
10746 */
10747 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10748 var isBackwards, start, end,
10749 input = this.$input[ 0 ];
10750
10751 to = to || from;
10752
10753 isBackwards = to < from;
10754 start = isBackwards ? to : from;
10755 end = isBackwards ? from : to;
10756
10757 this.focus();
10758
10759 try {
10760 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10761 } catch ( e ) {
10762 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10763 // Rather than expensively check if the input is attached every time, just check
10764 // if it was the cause of an error being thrown. If not, rethrow the error.
10765 if ( this.getElementDocument().body.contains( input ) ) {
10766 throw e;
10767 }
10768 }
10769 return this;
10770 };
10771
10772 /**
10773 * Get an object describing the current selection range in a directional manner
10774 *
10775 * @return {Object} Object containing 'from' and 'to' offsets
10776 */
10777 OO.ui.TextInputWidget.prototype.getRange = function () {
10778 var input = this.$input[ 0 ],
10779 start = input.selectionStart,
10780 end = input.selectionEnd,
10781 isBackwards = input.selectionDirection === 'backward';
10782
10783 return {
10784 from: isBackwards ? end : start,
10785 to: isBackwards ? start : end
10786 };
10787 };
10788
10789 /**
10790 * Get the length of the text input value.
10791 *
10792 * This could differ from the length of #getValue if the
10793 * value gets filtered
10794 *
10795 * @return {number} Input length
10796 */
10797 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10798 return this.$input[ 0 ].value.length;
10799 };
10800
10801 /**
10802 * Focus the input and select the entire text.
10803 *
10804 * @chainable
10805 * @return {OO.ui.Widget} The widget, for chaining
10806 */
10807 OO.ui.TextInputWidget.prototype.select = function () {
10808 return this.selectRange( 0, this.getInputLength() );
10809 };
10810
10811 /**
10812 * Focus the input and move the cursor to the start.
10813 *
10814 * @chainable
10815 * @return {OO.ui.Widget} The widget, for chaining
10816 */
10817 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10818 return this.selectRange( 0 );
10819 };
10820
10821 /**
10822 * Focus the input and move the cursor to the end.
10823 *
10824 * @chainable
10825 * @return {OO.ui.Widget} The widget, for chaining
10826 */
10827 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10828 return this.selectRange( this.getInputLength() );
10829 };
10830
10831 /**
10832 * Insert new content into the input.
10833 *
10834 * @param {string} content Content to be inserted
10835 * @chainable
10836 * @return {OO.ui.Widget} The widget, for chaining
10837 */
10838 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10839 var start, end,
10840 range = this.getRange(),
10841 value = this.getValue();
10842
10843 start = Math.min( range.from, range.to );
10844 end = Math.max( range.from, range.to );
10845
10846 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10847 this.selectRange( start + content.length );
10848 return this;
10849 };
10850
10851 /**
10852 * Insert new content either side of a selection.
10853 *
10854 * @param {string} pre Content to be inserted before the selection
10855 * @param {string} post Content to be inserted after the selection
10856 * @chainable
10857 * @return {OO.ui.Widget} The widget, for chaining
10858 */
10859 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10860 var start, end,
10861 range = this.getRange(),
10862 offset = pre.length;
10863
10864 start = Math.min( range.from, range.to );
10865 end = Math.max( range.from, range.to );
10866
10867 this.selectRange( start ).insertContent( pre );
10868 this.selectRange( offset + end ).insertContent( post );
10869
10870 this.selectRange( offset + start, offset + end );
10871 return this;
10872 };
10873
10874 /**
10875 * Set the validation pattern.
10876 *
10877 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10878 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10879 * value must contain only numbers).
10880 *
10881 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10882 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10883 */
10884 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10885 if ( validate instanceof RegExp || validate instanceof Function ) {
10886 this.validate = validate;
10887 } else {
10888 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10889 }
10890 };
10891
10892 /**
10893 * Sets the 'invalid' flag appropriately.
10894 *
10895 * @param {boolean} [isValid] Optionally override validation result
10896 */
10897 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10898 var widget = this,
10899 setFlag = function ( valid ) {
10900 if ( !valid ) {
10901 widget.$input.attr( 'aria-invalid', 'true' );
10902 } else {
10903 widget.$input.removeAttr( 'aria-invalid' );
10904 }
10905 widget.setFlags( { invalid: !valid } );
10906 };
10907
10908 if ( isValid !== undefined ) {
10909 setFlag( isValid );
10910 } else {
10911 this.getValidity().then( function () {
10912 setFlag( true );
10913 }, function () {
10914 setFlag( false );
10915 } );
10916 }
10917 };
10918
10919 /**
10920 * Get the validity of current value.
10921 *
10922 * This method returns a promise that resolves if the value is valid and rejects if
10923 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10924 *
10925 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10926 */
10927 OO.ui.TextInputWidget.prototype.getValidity = function () {
10928 var result;
10929
10930 function rejectOrResolve( valid ) {
10931 if ( valid ) {
10932 return $.Deferred().resolve().promise();
10933 } else {
10934 return $.Deferred().reject().promise();
10935 }
10936 }
10937
10938 // Check browser validity and reject if it is invalid
10939 if (
10940 this.$input[ 0 ].checkValidity !== undefined &&
10941 this.$input[ 0 ].checkValidity() === false
10942 ) {
10943 return rejectOrResolve( false );
10944 }
10945
10946 // Run our checks if the browser thinks the field is valid
10947 if ( this.validate instanceof Function ) {
10948 result = this.validate( this.getValue() );
10949 if ( result && typeof result.promise === 'function' ) {
10950 return result.promise().then( function ( valid ) {
10951 return rejectOrResolve( valid );
10952 } );
10953 } else {
10954 return rejectOrResolve( result );
10955 }
10956 } else {
10957 return rejectOrResolve( this.getValue().match( this.validate ) );
10958 }
10959 };
10960
10961 /**
10962 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10963 *
10964 * @param {string} labelPosition Label position, 'before' or 'after'
10965 * @chainable
10966 * @return {OO.ui.Widget} The widget, for chaining
10967 */
10968 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10969 this.labelPosition = labelPosition;
10970 if ( this.label ) {
10971 // If there is no label and we only change the position, #updatePosition is a no-op,
10972 // but it takes really a lot of work to do nothing.
10973 this.updatePosition();
10974 }
10975 return this;
10976 };
10977
10978 /**
10979 * Update the position of the inline label.
10980 *
10981 * This method is called by #setLabelPosition, and can also be called on its own if
10982 * something causes the label to be mispositioned.
10983 *
10984 * @chainable
10985 * @return {OO.ui.Widget} The widget, for chaining
10986 */
10987 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10988 var after = this.labelPosition === 'after';
10989
10990 this.$element
10991 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10992 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10993
10994 this.valCache = null;
10995 this.scrollWidth = null;
10996 this.positionLabel();
10997
10998 return this;
10999 };
11000
11001 /**
11002 * Position the label by setting the correct padding on the input.
11003 *
11004 * @private
11005 * @chainable
11006 * @return {OO.ui.Widget} The widget, for chaining
11007 */
11008 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11009 var after, rtl, property, newCss;
11010
11011 if ( this.isWaitingToBeAttached ) {
11012 // #onElementAttach will be called soon, which calls this method
11013 return this;
11014 }
11015
11016 newCss = {
11017 'padding-right': '',
11018 'padding-left': ''
11019 };
11020
11021 if ( this.label ) {
11022 this.$element.append( this.$label );
11023 } else {
11024 this.$label.detach();
11025 // Clear old values if present
11026 this.$input.css( newCss );
11027 return;
11028 }
11029
11030 after = this.labelPosition === 'after';
11031 rtl = this.$element.css( 'direction' ) === 'rtl';
11032 property = after === rtl ? 'padding-left' : 'padding-right';
11033
11034 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11035 // We have to clear the padding on the other side, in case the element direction changed
11036 this.$input.css( newCss );
11037
11038 return this;
11039 };
11040
11041 /**
11042 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11043 * {@link OO.ui.mixin.IconElement search icon} by default.
11044 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11045 *
11046 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11047 *
11048 * @class
11049 * @extends OO.ui.TextInputWidget
11050 *
11051 * @constructor
11052 * @param {Object} [config] Configuration options
11053 */
11054 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11055 config = $.extend( {
11056 icon: 'search'
11057 }, config );
11058
11059 // Parent constructor
11060 OO.ui.SearchInputWidget.parent.call( this, config );
11061
11062 // Events
11063 this.connect( this, {
11064 change: 'onChange'
11065 } );
11066
11067 // Initialization
11068 this.updateSearchIndicator();
11069 this.connect( this, {
11070 disable: 'onDisable'
11071 } );
11072 };
11073
11074 /* Setup */
11075
11076 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11077
11078 /* Methods */
11079
11080 /**
11081 * @inheritdoc
11082 * @protected
11083 */
11084 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11085 return 'search';
11086 };
11087
11088 /**
11089 * @inheritdoc
11090 */
11091 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
11092 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11093 // Clear the text field
11094 this.setValue( '' );
11095 this.focus();
11096 return false;
11097 }
11098 };
11099
11100 /**
11101 * Update the 'clear' indicator displayed on type: 'search' text
11102 * fields, hiding it when the field is already empty or when it's not
11103 * editable.
11104 */
11105 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11106 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11107 this.setIndicator( null );
11108 } else {
11109 this.setIndicator( 'clear' );
11110 }
11111 };
11112
11113 /**
11114 * Handle change events.
11115 *
11116 * @private
11117 */
11118 OO.ui.SearchInputWidget.prototype.onChange = function () {
11119 this.updateSearchIndicator();
11120 };
11121
11122 /**
11123 * Handle disable events.
11124 *
11125 * @param {boolean} disabled Element is disabled
11126 * @private
11127 */
11128 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11129 this.updateSearchIndicator();
11130 };
11131
11132 /**
11133 * @inheritdoc
11134 */
11135 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11136 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11137 this.updateSearchIndicator();
11138 return this;
11139 };
11140
11141 /**
11142 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11143 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11144 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11145 * {@link OO.ui.mixin.IndicatorElement indicators}.
11146 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11147 *
11148 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11149 *
11150 * @example
11151 * // A MultilineTextInputWidget.
11152 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11153 * value: 'Text input on multiple lines'
11154 * } )
11155 * $( 'body' ).append( multilineTextInput.$element );
11156 *
11157 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11158 *
11159 * @class
11160 * @extends OO.ui.TextInputWidget
11161 *
11162 * @constructor
11163 * @param {Object} [config] Configuration options
11164 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11165 * specifies minimum number of rows to display.
11166 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11167 * Use the #maxRows config to specify a maximum number of displayed rows.
11168 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11169 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11170 */
11171 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11172 config = $.extend( {
11173 type: 'text'
11174 }, config );
11175 // Parent constructor
11176 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11177
11178 // Properties
11179 this.autosize = !!config.autosize;
11180 this.styleHeight = null;
11181 this.minRows = config.rows !== undefined ? config.rows : '';
11182 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11183
11184 // Clone for resizing
11185 if ( this.autosize ) {
11186 this.$clone = this.$input
11187 .clone()
11188 .removeAttr( 'id' )
11189 .removeAttr( 'name' )
11190 .insertAfter( this.$input )
11191 .attr( 'aria-hidden', 'true' )
11192 .addClass( 'oo-ui-element-hidden' );
11193 }
11194
11195 // Events
11196 this.connect( this, {
11197 change: 'onChange'
11198 } );
11199
11200 // Initialization
11201 if ( config.rows ) {
11202 this.$input.attr( 'rows', config.rows );
11203 }
11204 if ( this.autosize ) {
11205 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11206 this.isWaitingToBeAttached = true;
11207 this.installParentChangeDetector();
11208 }
11209 };
11210
11211 /* Setup */
11212
11213 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11214
11215 /* Static Methods */
11216
11217 /**
11218 * @inheritdoc
11219 */
11220 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11221 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11222 state.scrollTop = config.$input.scrollTop();
11223 return state;
11224 };
11225
11226 /* Methods */
11227
11228 /**
11229 * @inheritdoc
11230 */
11231 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11232 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11233 this.adjustSize();
11234 };
11235
11236 /**
11237 * Handle change events.
11238 *
11239 * @private
11240 */
11241 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11242 this.adjustSize();
11243 };
11244
11245 /**
11246 * @inheritdoc
11247 */
11248 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11249 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11250 this.adjustSize();
11251 };
11252
11253 /**
11254 * @inheritdoc
11255 *
11256 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11257 */
11258 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11259 if (
11260 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11261 // Some platforms emit keycode 10 for ctrl+enter in a textarea
11262 e.which === 10
11263 ) {
11264 this.emit( 'enter', e );
11265 }
11266 };
11267
11268 /**
11269 * Automatically adjust the size of the text input.
11270 *
11271 * This only affects multiline inputs that are {@link #autosize autosized}.
11272 *
11273 * @chainable
11274 * @return {OO.ui.Widget} The widget, for chaining
11275 * @fires resize
11276 */
11277 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11278 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11279 idealHeight, newHeight, scrollWidth, property;
11280
11281 if ( this.$input.val() !== this.valCache ) {
11282 if ( this.autosize ) {
11283 this.$clone
11284 .val( this.$input.val() )
11285 .attr( 'rows', this.minRows )
11286 // Set inline height property to 0 to measure scroll height
11287 .css( 'height', 0 );
11288
11289 this.$clone.removeClass( 'oo-ui-element-hidden' );
11290
11291 this.valCache = this.$input.val();
11292
11293 scrollHeight = this.$clone[ 0 ].scrollHeight;
11294
11295 // Remove inline height property to measure natural heights
11296 this.$clone.css( 'height', '' );
11297 innerHeight = this.$clone.innerHeight();
11298 outerHeight = this.$clone.outerHeight();
11299
11300 // Measure max rows height
11301 this.$clone
11302 .attr( 'rows', this.maxRows )
11303 .css( 'height', 'auto' )
11304 .val( '' );
11305 maxInnerHeight = this.$clone.innerHeight();
11306
11307 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11308 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11309 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11310 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11311
11312 this.$clone.addClass( 'oo-ui-element-hidden' );
11313
11314 // Only apply inline height when expansion beyond natural height is needed
11315 // Use the difference between the inner and outer height as a buffer
11316 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11317 if ( newHeight !== this.styleHeight ) {
11318 this.$input.css( 'height', newHeight );
11319 this.styleHeight = newHeight;
11320 this.emit( 'resize' );
11321 }
11322 }
11323 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11324 if ( scrollWidth !== this.scrollWidth ) {
11325 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11326 // Reset
11327 this.$label.css( { right: '', left: '' } );
11328 this.$indicator.css( { right: '', left: '' } );
11329
11330 if ( scrollWidth ) {
11331 this.$indicator.css( property, scrollWidth );
11332 if ( this.labelPosition === 'after' ) {
11333 this.$label.css( property, scrollWidth );
11334 }
11335 }
11336
11337 this.scrollWidth = scrollWidth;
11338 this.positionLabel();
11339 }
11340 }
11341 return this;
11342 };
11343
11344 /**
11345 * @inheritdoc
11346 * @protected
11347 */
11348 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11349 return $( '<textarea>' );
11350 };
11351
11352 /**
11353 * Check if the input automatically adjusts its size.
11354 *
11355 * @return {boolean}
11356 */
11357 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11358 return !!this.autosize;
11359 };
11360
11361 /**
11362 * @inheritdoc
11363 */
11364 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11365 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11366 if ( state.scrollTop !== undefined ) {
11367 this.$input.scrollTop( state.scrollTop );
11368 }
11369 };
11370
11371 /**
11372 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11373 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11374 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11375 *
11376 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11377 * option, that option will appear to be selected.
11378 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11379 * input field.
11380 *
11381 * After the user chooses an option, its `data` will be used as a new value for the widget.
11382 * A `label` also can be specified for each option: if given, it will be shown instead of the
11383 * `data` in the dropdown menu.
11384 *
11385 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11386 *
11387 * For more information about menus and options, please see the [OOUI documentation on MediaWiki][1].
11388 *
11389 * @example
11390 * // A ComboBoxInputWidget.
11391 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11392 * value: 'Option 1',
11393 * options: [
11394 * { data: 'Option 1' },
11395 * { data: 'Option 2' },
11396 * { data: 'Option 3' }
11397 * ]
11398 * } );
11399 * $( document.body ).append( comboBox.$element );
11400 *
11401 * @example
11402 * // Example: A ComboBoxInputWidget with additional option labels.
11403 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11404 * value: 'Option 1',
11405 * options: [
11406 * {
11407 * data: 'Option 1',
11408 * label: 'Option One'
11409 * },
11410 * {
11411 * data: 'Option 2',
11412 * label: 'Option Two'
11413 * },
11414 * {
11415 * data: 'Option 3',
11416 * label: 'Option Three'
11417 * }
11418 * ]
11419 * } );
11420 * $( document.body ).append( comboBox.$element );
11421 *
11422 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11423 *
11424 * @class
11425 * @extends OO.ui.TextInputWidget
11426 *
11427 * @constructor
11428 * @param {Object} [config] Configuration options
11429 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11430 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
11431 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
11432 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
11433 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
11434 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11435 */
11436 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11437 // Configuration initialization
11438 config = $.extend( {
11439 autocomplete: false
11440 }, config );
11441
11442 // ComboBoxInputWidget shouldn't support `multiline`
11443 config.multiline = false;
11444
11445 // See InputWidget#reusePreInfuseDOM about `config.$input`
11446 if ( config.$input ) {
11447 config.$input.removeAttr( 'list' );
11448 }
11449
11450 // Parent constructor
11451 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11452
11453 // Properties
11454 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11455 this.dropdownButton = new OO.ui.ButtonWidget( {
11456 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11457 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11458 indicator: 'down',
11459 invisibleLabel: true,
11460 disabled: this.disabled
11461 } );
11462 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11463 {
11464 widget: this,
11465 input: this,
11466 $floatableContainer: this.$element,
11467 disabled: this.isDisabled()
11468 },
11469 config.menu
11470 ) );
11471
11472 // Events
11473 this.connect( this, {
11474 change: 'onInputChange',
11475 enter: 'onInputEnter'
11476 } );
11477 this.dropdownButton.connect( this, {
11478 click: 'onDropdownButtonClick'
11479 } );
11480 this.menu.connect( this, {
11481 choose: 'onMenuChoose',
11482 add: 'onMenuItemsChange',
11483 remove: 'onMenuItemsChange',
11484 toggle: 'onMenuToggle'
11485 } );
11486
11487 // Initialization
11488 this.$input.attr( {
11489 role: 'combobox',
11490 'aria-owns': this.menu.getElementId(),
11491 'aria-autocomplete': 'list'
11492 } );
11493 this.dropdownButton.$button.attr( {
11494 'aria-controls': this.menu.getElementId()
11495 } );
11496 // Do not override options set via config.menu.items
11497 if ( config.options !== undefined ) {
11498 this.setOptions( config.options );
11499 }
11500 this.$field = $( '<div>' )
11501 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11502 .append( this.$input, this.dropdownButton.$element );
11503 this.$element
11504 .addClass( 'oo-ui-comboBoxInputWidget' )
11505 .append( this.$field );
11506 this.$overlay.append( this.menu.$element );
11507 this.onMenuItemsChange();
11508 };
11509
11510 /* Setup */
11511
11512 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11513
11514 /* Methods */
11515
11516 /**
11517 * Get the combobox's menu.
11518 *
11519 * @return {OO.ui.MenuSelectWidget} Menu widget
11520 */
11521 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11522 return this.menu;
11523 };
11524
11525 /**
11526 * Get the combobox's text input widget.
11527 *
11528 * @return {OO.ui.TextInputWidget} Text input widget
11529 */
11530 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11531 return this;
11532 };
11533
11534 /**
11535 * Handle input change events.
11536 *
11537 * @private
11538 * @param {string} value New value
11539 */
11540 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11541 var match = this.menu.findItemFromData( value );
11542
11543 this.menu.selectItem( match );
11544 if ( this.menu.findHighlightedItem() ) {
11545 this.menu.highlightItem( match );
11546 }
11547
11548 if ( !this.isDisabled() ) {
11549 this.menu.toggle( true );
11550 }
11551 };
11552
11553 /**
11554 * Handle input enter events.
11555 *
11556 * @private
11557 */
11558 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11559 if ( !this.isDisabled() ) {
11560 this.menu.toggle( false );
11561 }
11562 };
11563
11564 /**
11565 * Handle button click events.
11566 *
11567 * @private
11568 */
11569 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11570 this.menu.toggle();
11571 this.focus();
11572 };
11573
11574 /**
11575 * Handle menu choose events.
11576 *
11577 * @private
11578 * @param {OO.ui.OptionWidget} item Chosen item
11579 */
11580 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11581 this.setValue( item.getData() );
11582 };
11583
11584 /**
11585 * Handle menu item change events.
11586 *
11587 * @private
11588 */
11589 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11590 var match = this.menu.findItemFromData( this.getValue() );
11591 this.menu.selectItem( match );
11592 if ( this.menu.findHighlightedItem() ) {
11593 this.menu.highlightItem( match );
11594 }
11595 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11596 };
11597
11598 /**
11599 * Handle menu toggle events.
11600 *
11601 * @private
11602 * @param {boolean} isVisible Open state of the menu
11603 */
11604 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11605 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11606 };
11607
11608 /**
11609 * @inheritdoc
11610 */
11611 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11612 // Parent method
11613 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11614
11615 if ( this.dropdownButton ) {
11616 this.dropdownButton.setDisabled( this.isDisabled() );
11617 }
11618 if ( this.menu ) {
11619 this.menu.setDisabled( this.isDisabled() );
11620 }
11621
11622 return this;
11623 };
11624
11625 /**
11626 * Set the options available for this input.
11627 *
11628 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11629 * @chainable
11630 * @return {OO.ui.Widget} The widget, for chaining
11631 */
11632 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11633 this.getMenu()
11634 .clearItems()
11635 .addItems( options.map( function ( opt ) {
11636 return new OO.ui.MenuOptionWidget( {
11637 data: opt.data,
11638 label: opt.label !== undefined ? opt.label : opt.data
11639 } );
11640 } ) );
11641
11642 return this;
11643 };
11644
11645 /**
11646 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11647 * which is a widget that is specified by reference before any optional configuration settings.
11648 *
11649 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11650 *
11651 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11652 * A left-alignment is used for forms with many fields.
11653 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11654 * A right-alignment is used for long but familiar forms which users tab through,
11655 * verifying the current field with a quick glance at the label.
11656 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11657 * that users fill out from top to bottom.
11658 * - **inline**: The label is placed after the field-widget and aligned to the left.
11659 * An inline-alignment is best used with checkboxes or radio buttons.
11660 *
11661 * Help text can either be:
11662 *
11663 * - accessed via a help icon that appears in the upper right corner of the rendered field layout, or
11664 * - shown as a subtle explanation below the label.
11665 *
11666 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`. If it
11667 * is long or not essential, leave `helpInline` to its default, `false`.
11668 *
11669 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11670 *
11671 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11672 *
11673 * @class
11674 * @extends OO.ui.Layout
11675 * @mixins OO.ui.mixin.LabelElement
11676 * @mixins OO.ui.mixin.TitledElement
11677 *
11678 * @constructor
11679 * @param {OO.ui.Widget} fieldWidget Field widget
11680 * @param {Object} [config] Configuration options
11681 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11682 * or 'inline'
11683 * @cfg {Array} [errors] Error messages about the widget, which will be
11684 * displayed below the widget.
11685 * The array may contain strings or OO.ui.HtmlSnippet instances.
11686 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11687 * below the widget.
11688 * The array may contain strings or OO.ui.HtmlSnippet instances.
11689 * These are more visible than `help` messages when `helpInline` is set, and so
11690 * might be good for transient messages.
11691 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11692 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11693 * corner of the rendered field; clicking it will display the text in a popup.
11694 * If `helpInline` is `true`, then a subtle description will be shown after the
11695 * label.
11696 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11697 * or shown when the "help" icon is clicked.
11698 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11699 * `help` is given.
11700 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11701 *
11702 * @throws {Error} An error is thrown if no widget is specified
11703 */
11704 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11705 // Allow passing positional parameters inside the config object
11706 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11707 config = fieldWidget;
11708 fieldWidget = config.fieldWidget;
11709 }
11710
11711 // Make sure we have required constructor arguments
11712 if ( fieldWidget === undefined ) {
11713 throw new Error( 'Widget not found' );
11714 }
11715
11716 // Configuration initialization
11717 config = $.extend( { align: 'left', helpInline: false }, config );
11718
11719 // Parent constructor
11720 OO.ui.FieldLayout.parent.call( this, config );
11721
11722 // Mixin constructors
11723 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11724 $label: $( '<label>' )
11725 } ) );
11726 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11727
11728 // Properties
11729 this.fieldWidget = fieldWidget;
11730 this.errors = [];
11731 this.notices = [];
11732 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11733 this.$messages = $( '<ul>' );
11734 this.$header = $( '<span>' );
11735 this.$body = $( '<div>' );
11736 this.align = null;
11737 this.helpInline = config.helpInline;
11738
11739 // Events
11740 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11741
11742 // Initialization
11743 this.$help = config.help ?
11744 this.createHelpElement( config.help, config.$overlay ) :
11745 $( [] );
11746 if ( this.fieldWidget.getInputId() ) {
11747 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11748 if ( this.helpInline ) {
11749 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11750 }
11751 } else {
11752 this.$label.on( 'click', function () {
11753 this.fieldWidget.simulateLabelClick();
11754 }.bind( this ) );
11755 if ( this.helpInline ) {
11756 this.$help.on( 'click', function () {
11757 this.fieldWidget.simulateLabelClick();
11758 }.bind( this ) );
11759 }
11760 }
11761 this.$element
11762 .addClass( 'oo-ui-fieldLayout' )
11763 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11764 .append( this.$body );
11765 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11766 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11767 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11768 this.$field
11769 .addClass( 'oo-ui-fieldLayout-field' )
11770 .append( this.fieldWidget.$element );
11771
11772 this.setErrors( config.errors || [] );
11773 this.setNotices( config.notices || [] );
11774 this.setAlignment( config.align );
11775 // Call this again to take into account the widget's accessKey
11776 this.updateTitle();
11777 };
11778
11779 /* Setup */
11780
11781 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11782 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11783 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11784
11785 /* Methods */
11786
11787 /**
11788 * Handle field disable events.
11789 *
11790 * @private
11791 * @param {boolean} value Field is disabled
11792 */
11793 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11794 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11795 };
11796
11797 /**
11798 * Get the widget contained by the field.
11799 *
11800 * @return {OO.ui.Widget} Field widget
11801 */
11802 OO.ui.FieldLayout.prototype.getField = function () {
11803 return this.fieldWidget;
11804 };
11805
11806 /**
11807 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11808 * #setAlignment). Return `false` if it can't or if this can't be determined.
11809 *
11810 * @return {boolean}
11811 */
11812 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11813 // This is very simplistic, but should be good enough.
11814 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11815 };
11816
11817 /**
11818 * @protected
11819 * @param {string} kind 'error' or 'notice'
11820 * @param {string|OO.ui.HtmlSnippet} text
11821 * @return {jQuery}
11822 */
11823 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11824 var $listItem, $icon, message;
11825 $listItem = $( '<li>' );
11826 if ( kind === 'error' ) {
11827 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11828 $listItem.attr( 'role', 'alert' );
11829 } else if ( kind === 'notice' ) {
11830 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
11831 } else {
11832 $icon = '';
11833 }
11834 message = new OO.ui.LabelWidget( { label: text } );
11835 $listItem
11836 .append( $icon, message.$element )
11837 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11838 return $listItem;
11839 };
11840
11841 /**
11842 * Set the field alignment mode.
11843 *
11844 * @private
11845 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11846 * @chainable
11847 * @return {OO.ui.BookletLayout} The layout, for chaining
11848 */
11849 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11850 if ( value !== this.align ) {
11851 // Default to 'left'
11852 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11853 value = 'left';
11854 }
11855 // Validate
11856 if ( value === 'inline' && !this.isFieldInline() ) {
11857 value = 'top';
11858 }
11859 // Reorder elements
11860
11861 if ( this.helpInline ) {
11862 if ( value === 'top' ) {
11863 this.$header.append( this.$label );
11864 this.$body.append( this.$header, this.$field, this.$help );
11865 } else if ( value === 'inline' ) {
11866 this.$header.append( this.$label, this.$help );
11867 this.$body.append( this.$field, this.$header );
11868 } else {
11869 this.$header.append( this.$label, this.$help );
11870 this.$body.append( this.$header, this.$field );
11871 }
11872 } else {
11873 if ( value === 'top' ) {
11874 this.$header.append( this.$help, this.$label );
11875 this.$body.append( this.$header, this.$field );
11876 } else if ( value === 'inline' ) {
11877 this.$header.append( this.$help, this.$label );
11878 this.$body.append( this.$field, this.$header );
11879 } else {
11880 this.$header.append( this.$label );
11881 this.$body.append( this.$header, this.$help, this.$field );
11882 }
11883 }
11884 // Set classes. The following classes can be used here:
11885 // * oo-ui-fieldLayout-align-left
11886 // * oo-ui-fieldLayout-align-right
11887 // * oo-ui-fieldLayout-align-top
11888 // * oo-ui-fieldLayout-align-inline
11889 if ( this.align ) {
11890 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11891 }
11892 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11893 this.align = value;
11894 }
11895
11896 return this;
11897 };
11898
11899 /**
11900 * Set the list of error messages.
11901 *
11902 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11903 * The array may contain strings or OO.ui.HtmlSnippet instances.
11904 * @chainable
11905 * @return {OO.ui.BookletLayout} The layout, for chaining
11906 */
11907 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11908 this.errors = errors.slice();
11909 this.updateMessages();
11910 return this;
11911 };
11912
11913 /**
11914 * Set the list of notice messages.
11915 *
11916 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11917 * The array may contain strings or OO.ui.HtmlSnippet instances.
11918 * @chainable
11919 * @return {OO.ui.BookletLayout} The layout, for chaining
11920 */
11921 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11922 this.notices = notices.slice();
11923 this.updateMessages();
11924 return this;
11925 };
11926
11927 /**
11928 * Update the rendering of error and notice messages.
11929 *
11930 * @private
11931 */
11932 OO.ui.FieldLayout.prototype.updateMessages = function () {
11933 var i;
11934 this.$messages.empty();
11935
11936 if ( this.errors.length || this.notices.length ) {
11937 this.$body.after( this.$messages );
11938 } else {
11939 this.$messages.remove();
11940 return;
11941 }
11942
11943 for ( i = 0; i < this.notices.length; i++ ) {
11944 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11945 }
11946 for ( i = 0; i < this.errors.length; i++ ) {
11947 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11948 }
11949 };
11950
11951 /**
11952 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11953 * (This is a bit of a hack.)
11954 *
11955 * @protected
11956 * @param {string} title Tooltip label for 'title' attribute
11957 * @return {string}
11958 */
11959 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11960 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11961 return this.fieldWidget.formatTitleWithAccessKey( title );
11962 }
11963 return title;
11964 };
11965
11966 /**
11967 * Creates and returns the help element. Also sets the `aria-describedby`
11968 * attribute on the main element of the `fieldWidget`.
11969 *
11970 * @private
11971 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
11972 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
11973 * @return {jQuery} The element that should become `this.$help`.
11974 */
11975 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
11976 var helpId, helpWidget;
11977
11978 if ( this.helpInline ) {
11979 helpWidget = new OO.ui.LabelWidget( {
11980 label: help,
11981 classes: [ 'oo-ui-inline-help' ]
11982 } );
11983
11984 helpId = helpWidget.getElementId();
11985 } else {
11986 helpWidget = new OO.ui.PopupButtonWidget( {
11987 $overlay: $overlay,
11988 popup: {
11989 padded: true
11990 },
11991 classes: [ 'oo-ui-fieldLayout-help' ],
11992 framed: false,
11993 icon: 'info',
11994 label: OO.ui.msg( 'ooui-field-help' ),
11995 invisibleLabel: true
11996 } );
11997 if ( help instanceof OO.ui.HtmlSnippet ) {
11998 helpWidget.getPopup().$body.html( help.toString() );
11999 } else {
12000 helpWidget.getPopup().$body.text( help );
12001 }
12002
12003 helpId = helpWidget.getPopup().getBodyId();
12004 }
12005
12006 // Set the 'aria-describedby' attribute on the fieldWidget
12007 // Preference given to an input or a button
12008 (
12009 this.fieldWidget.$input ||
12010 this.fieldWidget.$button ||
12011 this.fieldWidget.$element
12012 ).attr( 'aria-describedby', helpId );
12013
12014 return helpWidget.$element;
12015 };
12016
12017 /**
12018 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
12019 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
12020 * is required and is specified before any optional configuration settings.
12021 *
12022 * Labels can be aligned in one of four ways:
12023 *
12024 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12025 * A left-alignment is used for forms with many fields.
12026 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12027 * A right-alignment is used for long but familiar forms which users tab through,
12028 * verifying the current field with a quick glance at the label.
12029 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12030 * that users fill out from top to bottom.
12031 * - **inline**: The label is placed after the field-widget and aligned to the left.
12032 * An inline-alignment is best used with checkboxes or radio buttons.
12033 *
12034 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
12035 * text is specified.
12036 *
12037 * @example
12038 * // Example of an ActionFieldLayout
12039 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12040 * new OO.ui.TextInputWidget( {
12041 * placeholder: 'Field widget'
12042 * } ),
12043 * new OO.ui.ButtonWidget( {
12044 * label: 'Button'
12045 * } ),
12046 * {
12047 * label: 'An ActionFieldLayout. This label is aligned top',
12048 * align: 'top',
12049 * help: 'This is help text'
12050 * }
12051 * );
12052 *
12053 * $( document.body ).append( actionFieldLayout.$element );
12054 *
12055 * @class
12056 * @extends OO.ui.FieldLayout
12057 *
12058 * @constructor
12059 * @param {OO.ui.Widget} fieldWidget Field widget
12060 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12061 * @param {Object} config
12062 */
12063 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12064 // Allow passing positional parameters inside the config object
12065 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12066 config = fieldWidget;
12067 fieldWidget = config.fieldWidget;
12068 buttonWidget = config.buttonWidget;
12069 }
12070
12071 // Parent constructor
12072 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12073
12074 // Properties
12075 this.buttonWidget = buttonWidget;
12076 this.$button = $( '<span>' );
12077 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12078
12079 // Initialization
12080 this.$element
12081 .addClass( 'oo-ui-actionFieldLayout' );
12082 this.$button
12083 .addClass( 'oo-ui-actionFieldLayout-button' )
12084 .append( this.buttonWidget.$element );
12085 this.$input
12086 .addClass( 'oo-ui-actionFieldLayout-input' )
12087 .append( this.fieldWidget.$element );
12088 this.$field
12089 .append( this.$input, this.$button );
12090 };
12091
12092 /* Setup */
12093
12094 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12095
12096 /**
12097 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12098 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12099 * configured with a label as well. For more information and examples,
12100 * please see the [OOUI documentation on MediaWiki][1].
12101 *
12102 * @example
12103 * // Example of a fieldset layout
12104 * var input1 = new OO.ui.TextInputWidget( {
12105 * placeholder: 'A text input field'
12106 * } );
12107 *
12108 * var input2 = new OO.ui.TextInputWidget( {
12109 * placeholder: 'A text input field'
12110 * } );
12111 *
12112 * var fieldset = new OO.ui.FieldsetLayout( {
12113 * label: 'Example of a fieldset layout'
12114 * } );
12115 *
12116 * fieldset.addItems( [
12117 * new OO.ui.FieldLayout( input1, {
12118 * label: 'Field One'
12119 * } ),
12120 * new OO.ui.FieldLayout( input2, {
12121 * label: 'Field Two'
12122 * } )
12123 * ] );
12124 * $( document.body ).append( fieldset.$element );
12125 *
12126 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12127 *
12128 * @class
12129 * @extends OO.ui.Layout
12130 * @mixins OO.ui.mixin.IconElement
12131 * @mixins OO.ui.mixin.LabelElement
12132 * @mixins OO.ui.mixin.GroupElement
12133 *
12134 * @constructor
12135 * @param {Object} [config] Configuration options
12136 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
12137 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
12138 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
12139 * For important messages, you are advised to use `notices`, as they are always shown.
12140 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12141 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12142 */
12143 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12144 // Configuration initialization
12145 config = config || {};
12146
12147 // Parent constructor
12148 OO.ui.FieldsetLayout.parent.call( this, config );
12149
12150 // Mixin constructors
12151 OO.ui.mixin.IconElement.call( this, config );
12152 OO.ui.mixin.LabelElement.call( this, config );
12153 OO.ui.mixin.GroupElement.call( this, config );
12154
12155 // Properties
12156 this.$header = $( '<legend>' );
12157 if ( config.help ) {
12158 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12159 $overlay: config.$overlay,
12160 popup: {
12161 padded: true
12162 },
12163 classes: [ 'oo-ui-fieldsetLayout-help' ],
12164 framed: false,
12165 icon: 'info',
12166 label: OO.ui.msg( 'ooui-field-help' ),
12167 invisibleLabel: true
12168 } );
12169 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12170 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12171 } else {
12172 this.popupButtonWidget.getPopup().$body.text( config.help );
12173 }
12174 this.$help = this.popupButtonWidget.$element;
12175 } else {
12176 this.$help = $( [] );
12177 }
12178
12179 // Initialization
12180 this.$header
12181 .addClass( 'oo-ui-fieldsetLayout-header' )
12182 .append( this.$icon, this.$label, this.$help );
12183 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12184 this.$element
12185 .addClass( 'oo-ui-fieldsetLayout' )
12186 .prepend( this.$header, this.$group );
12187 if ( Array.isArray( config.items ) ) {
12188 this.addItems( config.items );
12189 }
12190 };
12191
12192 /* Setup */
12193
12194 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12195 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12196 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12197 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12198
12199 /* Static Properties */
12200
12201 /**
12202 * @static
12203 * @inheritdoc
12204 */
12205 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12206
12207 /**
12208 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
12209 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
12210 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
12211 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12212 *
12213 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12214 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12215 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12216 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12217 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12218 * often have simplified APIs to match the capabilities of HTML forms.
12219 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12220 *
12221 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12222 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12223 *
12224 * @example
12225 * // Example of a form layout that wraps a fieldset layout
12226 * var input1 = new OO.ui.TextInputWidget( {
12227 * placeholder: 'Username'
12228 * } );
12229 * var input2 = new OO.ui.TextInputWidget( {
12230 * placeholder: 'Password',
12231 * type: 'password'
12232 * } );
12233 * var submit = new OO.ui.ButtonInputWidget( {
12234 * label: 'Submit'
12235 * } );
12236 *
12237 * var fieldset = new OO.ui.FieldsetLayout( {
12238 * label: 'A form layout'
12239 * } );
12240 * fieldset.addItems( [
12241 * new OO.ui.FieldLayout( input1, {
12242 * label: 'Username',
12243 * align: 'top'
12244 * } ),
12245 * new OO.ui.FieldLayout( input2, {
12246 * label: 'Password',
12247 * align: 'top'
12248 * } ),
12249 * new OO.ui.FieldLayout( submit )
12250 * ] );
12251 * var form = new OO.ui.FormLayout( {
12252 * items: [ fieldset ],
12253 * action: '/api/formhandler',
12254 * method: 'get'
12255 * } )
12256 * $( document.body ).append( form.$element );
12257 *
12258 * @class
12259 * @extends OO.ui.Layout
12260 * @mixins OO.ui.mixin.GroupElement
12261 *
12262 * @constructor
12263 * @param {Object} [config] Configuration options
12264 * @cfg {string} [method] HTML form `method` attribute
12265 * @cfg {string} [action] HTML form `action` attribute
12266 * @cfg {string} [enctype] HTML form `enctype` attribute
12267 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12268 */
12269 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12270 var action;
12271
12272 // Configuration initialization
12273 config = config || {};
12274
12275 // Parent constructor
12276 OO.ui.FormLayout.parent.call( this, config );
12277
12278 // Mixin constructors
12279 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12280
12281 // Events
12282 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12283
12284 // Make sure the action is safe
12285 action = config.action;
12286 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12287 action = './' + action;
12288 }
12289
12290 // Initialization
12291 this.$element
12292 .addClass( 'oo-ui-formLayout' )
12293 .attr( {
12294 method: config.method,
12295 action: action,
12296 enctype: config.enctype
12297 } );
12298 if ( Array.isArray( config.items ) ) {
12299 this.addItems( config.items );
12300 }
12301 };
12302
12303 /* Setup */
12304
12305 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12306 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12307
12308 /* Events */
12309
12310 /**
12311 * A 'submit' event is emitted when the form is submitted.
12312 *
12313 * @event submit
12314 */
12315
12316 /* Static Properties */
12317
12318 /**
12319 * @static
12320 * @inheritdoc
12321 */
12322 OO.ui.FormLayout.static.tagName = 'form';
12323
12324 /* Methods */
12325
12326 /**
12327 * Handle form submit events.
12328 *
12329 * @private
12330 * @param {jQuery.Event} e Submit event
12331 * @fires submit
12332 * @return {OO.ui.FormLayout} The layout, for chaining
12333 */
12334 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12335 if ( this.emit( 'submit' ) ) {
12336 return false;
12337 }
12338 };
12339
12340 /**
12341 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
12342 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
12343 *
12344 * @example
12345 * // Example of a panel layout
12346 * var panel = new OO.ui.PanelLayout( {
12347 * expanded: false,
12348 * framed: true,
12349 * padded: true,
12350 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12351 * } );
12352 * $( document.body ).append( panel.$element );
12353 *
12354 * @class
12355 * @extends OO.ui.Layout
12356 *
12357 * @constructor
12358 * @param {Object} [config] Configuration options
12359 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12360 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12361 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12362 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
12363 */
12364 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12365 // Configuration initialization
12366 config = $.extend( {
12367 scrollable: false,
12368 padded: false,
12369 expanded: true,
12370 framed: false
12371 }, config );
12372
12373 // Parent constructor
12374 OO.ui.PanelLayout.parent.call( this, config );
12375
12376 // Initialization
12377 this.$element.addClass( 'oo-ui-panelLayout' );
12378 if ( config.scrollable ) {
12379 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12380 }
12381 if ( config.padded ) {
12382 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12383 }
12384 if ( config.expanded ) {
12385 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12386 }
12387 if ( config.framed ) {
12388 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12389 }
12390 };
12391
12392 /* Setup */
12393
12394 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12395
12396 /* Methods */
12397
12398 /**
12399 * Focus the panel layout
12400 *
12401 * The default implementation just focuses the first focusable element in the panel
12402 */
12403 OO.ui.PanelLayout.prototype.focus = function () {
12404 OO.ui.findFocusable( this.$element ).focus();
12405 };
12406
12407 /**
12408 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12409 * items), with small margins between them. Convenient when you need to put a number of block-level
12410 * widgets on a single line next to each other.
12411 *
12412 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12413 *
12414 * @example
12415 * // HorizontalLayout with a text input and a label
12416 * var layout = new OO.ui.HorizontalLayout( {
12417 * items: [
12418 * new OO.ui.LabelWidget( { label: 'Label' } ),
12419 * new OO.ui.TextInputWidget( { value: 'Text' } )
12420 * ]
12421 * } );
12422 * $( document.body ).append( layout.$element );
12423 *
12424 * @class
12425 * @extends OO.ui.Layout
12426 * @mixins OO.ui.mixin.GroupElement
12427 *
12428 * @constructor
12429 * @param {Object} [config] Configuration options
12430 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12431 */
12432 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12433 // Configuration initialization
12434 config = config || {};
12435
12436 // Parent constructor
12437 OO.ui.HorizontalLayout.parent.call( this, config );
12438
12439 // Mixin constructors
12440 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
12441
12442 // Initialization
12443 this.$element.addClass( 'oo-ui-horizontalLayout' );
12444 if ( Array.isArray( config.items ) ) {
12445 this.addItems( config.items );
12446 }
12447 };
12448
12449 /* Setup */
12450
12451 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12452 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12453
12454 /**
12455 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12456 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12457 * (to adjust the value in increments) to allow the user to enter a number.
12458 *
12459 * @example
12460 * // A NumberInputWidget.
12461 * var numberInput = new OO.ui.NumberInputWidget( {
12462 * label: 'NumberInputWidget',
12463 * input: { value: 5 },
12464 * min: 1,
12465 * max: 10
12466 * } );
12467 * $( document.body ).append( numberInput.$element );
12468 *
12469 * @class
12470 * @extends OO.ui.TextInputWidget
12471 *
12472 * @constructor
12473 * @param {Object} [config] Configuration options
12474 * @cfg {Object} [minusButton] Configuration options to pass to the
12475 * {@link OO.ui.ButtonWidget decrementing button widget}.
12476 * @cfg {Object} [plusButton] Configuration options to pass to the
12477 * {@link OO.ui.ButtonWidget incrementing button widget}.
12478 * @cfg {number} [min=-Infinity] Minimum allowed value
12479 * @cfg {number} [max=Infinity] Maximum allowed value
12480 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12481 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12482 * Defaults to `step` if specified, otherwise `1`.
12483 * @cfg {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12484 * Defaults to 10 times `buttonStep`.
12485 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12486 */
12487 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12488 var $field = $( '<div>' )
12489 .addClass( 'oo-ui-numberInputWidget-field' );
12490
12491 // Configuration initialization
12492 config = $.extend( {
12493 min: -Infinity,
12494 max: Infinity,
12495 showButtons: true
12496 }, config );
12497
12498 // For backward compatibility
12499 $.extend( config, config.input );
12500 this.input = this;
12501
12502 // Parent constructor
12503 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12504 type: 'number'
12505 } ) );
12506
12507 if ( config.showButtons ) {
12508 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12509 {
12510 disabled: this.isDisabled(),
12511 tabIndex: -1,
12512 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12513 icon: 'subtract'
12514 },
12515 config.minusButton
12516 ) );
12517 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12518 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12519 {
12520 disabled: this.isDisabled(),
12521 tabIndex: -1,
12522 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12523 icon: 'add'
12524 },
12525 config.plusButton
12526 ) );
12527 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12528 }
12529
12530 // Events
12531 this.$input.on( {
12532 keydown: this.onKeyDown.bind( this ),
12533 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12534 } );
12535 if ( config.showButtons ) {
12536 this.plusButton.connect( this, {
12537 click: [ 'onButtonClick', +1 ]
12538 } );
12539 this.minusButton.connect( this, {
12540 click: [ 'onButtonClick', -1 ]
12541 } );
12542 }
12543
12544 // Build the field
12545 $field.append( this.$input );
12546 if ( config.showButtons ) {
12547 $field
12548 .prepend( this.minusButton.$element )
12549 .append( this.plusButton.$element );
12550 }
12551
12552 // Initialization
12553 if ( config.allowInteger || config.isInteger ) {
12554 // Backward compatibility
12555 config.step = 1;
12556 }
12557 this.setRange( config.min, config.max );
12558 this.setStep( config.buttonStep, config.pageStep, config.step );
12559 // Set the validation method after we set step and range
12560 // so that it doesn't immediately call setValidityFlag
12561 this.setValidation( this.validateNumber.bind( this ) );
12562
12563 this.$element
12564 .addClass( 'oo-ui-numberInputWidget' )
12565 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12566 .append( $field );
12567 };
12568
12569 /* Setup */
12570
12571 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12572
12573 /* Methods */
12574
12575 // Backward compatibility
12576 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12577 this.setStep( flag ? 1 : null );
12578 };
12579 // Backward compatibility
12580 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12581
12582 // Backward compatibility
12583 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12584 return this.step === 1;
12585 };
12586 // Backward compatibility
12587 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12588
12589 /**
12590 * Set the range of allowed values
12591 *
12592 * @param {number} min Minimum allowed value
12593 * @param {number} max Maximum allowed value
12594 */
12595 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12596 if ( min > max ) {
12597 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12598 }
12599 this.min = min;
12600 this.max = max;
12601 this.$input.attr( 'min', this.min );
12602 this.$input.attr( 'max', this.max );
12603 this.setValidityFlag();
12604 };
12605
12606 /**
12607 * Get the current range
12608 *
12609 * @return {number[]} Minimum and maximum values
12610 */
12611 OO.ui.NumberInputWidget.prototype.getRange = function () {
12612 return [ this.min, this.max ];
12613 };
12614
12615 /**
12616 * Set the stepping deltas
12617 *
12618 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12619 * Defaults to `step` if specified, otherwise `1`.
12620 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12621 * Defaults to 10 times `buttonStep`.
12622 * @param {number|null} [step] If specified, the field only accepts values that are multiples of this.
12623 */
12624 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12625 if ( buttonStep === undefined ) {
12626 buttonStep = step || 1;
12627 }
12628 if ( pageStep === undefined ) {
12629 pageStep = 10 * buttonStep;
12630 }
12631 if ( step !== null && step <= 0 ) {
12632 throw new Error( 'Step value, if given, must be positive' );
12633 }
12634 if ( buttonStep <= 0 ) {
12635 throw new Error( 'Button step value must be positive' );
12636 }
12637 if ( pageStep <= 0 ) {
12638 throw new Error( 'Page step value must be positive' );
12639 }
12640 this.step = step;
12641 this.buttonStep = buttonStep;
12642 this.pageStep = pageStep;
12643 this.$input.attr( 'step', this.step || 'any' );
12644 this.setValidityFlag();
12645 };
12646
12647 /**
12648 * @inheritdoc
12649 */
12650 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12651 if ( value === '' ) {
12652 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12653 // so here we make sure an 'empty' value is actually displayed as such.
12654 this.$input.val( '' );
12655 }
12656 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12657 };
12658
12659 /**
12660 * Get the current stepping values
12661 *
12662 * @return {number[]} Button step, page step, and validity step
12663 */
12664 OO.ui.NumberInputWidget.prototype.getStep = function () {
12665 return [ this.buttonStep, this.pageStep, this.step ];
12666 };
12667
12668 /**
12669 * Get the current value of the widget as a number
12670 *
12671 * @return {number} May be NaN, or an invalid number
12672 */
12673 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12674 return +this.getValue();
12675 };
12676
12677 /**
12678 * Adjust the value of the widget
12679 *
12680 * @param {number} delta Adjustment amount
12681 */
12682 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12683 var n, v = this.getNumericValue();
12684
12685 delta = +delta;
12686 if ( isNaN( delta ) || !isFinite( delta ) ) {
12687 throw new Error( 'Delta must be a finite number' );
12688 }
12689
12690 if ( isNaN( v ) ) {
12691 n = 0;
12692 } else {
12693 n = v + delta;
12694 n = Math.max( Math.min( n, this.max ), this.min );
12695 if ( this.step ) {
12696 n = Math.round( n / this.step ) * this.step;
12697 }
12698 }
12699
12700 if ( n !== v ) {
12701 this.setValue( n );
12702 }
12703 };
12704 /**
12705 * Validate input
12706 *
12707 * @private
12708 * @param {string} value Field value
12709 * @return {boolean}
12710 */
12711 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12712 var n = +value;
12713 if ( value === '' ) {
12714 return !this.isRequired();
12715 }
12716
12717 if ( isNaN( n ) || !isFinite( n ) ) {
12718 return false;
12719 }
12720
12721 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12722 return false;
12723 }
12724
12725 if ( n < this.min || n > this.max ) {
12726 return false;
12727 }
12728
12729 return true;
12730 };
12731
12732 /**
12733 * Handle mouse click events.
12734 *
12735 * @private
12736 * @param {number} dir +1 or -1
12737 */
12738 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12739 this.adjustValue( dir * this.buttonStep );
12740 };
12741
12742 /**
12743 * Handle mouse wheel events.
12744 *
12745 * @private
12746 * @param {jQuery.Event} event
12747 * @return {undefined/boolean} False to prevent default if event is handled
12748 */
12749 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
12750 var delta = 0;
12751
12752 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
12753 // Standard 'wheel' event
12754 if ( event.originalEvent.deltaMode !== undefined ) {
12755 this.sawWheelEvent = true;
12756 }
12757 if ( event.originalEvent.deltaY ) {
12758 delta = -event.originalEvent.deltaY;
12759 } else if ( event.originalEvent.deltaX ) {
12760 delta = event.originalEvent.deltaX;
12761 }
12762
12763 // Non-standard events
12764 if ( !this.sawWheelEvent ) {
12765 if ( event.originalEvent.wheelDeltaX ) {
12766 delta = -event.originalEvent.wheelDeltaX;
12767 } else if ( event.originalEvent.wheelDeltaY ) {
12768 delta = event.originalEvent.wheelDeltaY;
12769 } else if ( event.originalEvent.wheelDelta ) {
12770 delta = event.originalEvent.wheelDelta;
12771 } else if ( event.originalEvent.detail ) {
12772 delta = -event.originalEvent.detail;
12773 }
12774 }
12775
12776 if ( delta ) {
12777 delta = delta < 0 ? -1 : 1;
12778 this.adjustValue( delta * this.buttonStep );
12779 }
12780
12781 return false;
12782 }
12783 };
12784
12785 /**
12786 * Handle key down events.
12787 *
12788 * @private
12789 * @param {jQuery.Event} e Key down event
12790 * @return {undefined/boolean} False to prevent default if event is handled
12791 */
12792 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
12793 if ( !this.isDisabled() ) {
12794 switch ( e.which ) {
12795 case OO.ui.Keys.UP:
12796 this.adjustValue( this.buttonStep );
12797 return false;
12798 case OO.ui.Keys.DOWN:
12799 this.adjustValue( -this.buttonStep );
12800 return false;
12801 case OO.ui.Keys.PAGEUP:
12802 this.adjustValue( this.pageStep );
12803 return false;
12804 case OO.ui.Keys.PAGEDOWN:
12805 this.adjustValue( -this.pageStep );
12806 return false;
12807 }
12808 }
12809 };
12810
12811 /**
12812 * @inheritdoc
12813 */
12814 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
12815 // Parent method
12816 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
12817
12818 if ( this.minusButton ) {
12819 this.minusButton.setDisabled( this.isDisabled() );
12820 }
12821 if ( this.plusButton ) {
12822 this.plusButton.setDisabled( this.isDisabled() );
12823 }
12824
12825 return this;
12826 };
12827
12828 }( OO ) );
12829
12830 //# sourceMappingURL=oojs-ui-core.js.map.json