Merge "Allow programmatic input in Command"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-core.js
1 /*!
2 * OOjs UI v0.24.4
3 * https://www.mediawiki.org/wiki/OOjs_UI
4 *
5 * Copyright 2011–2018 OOjs UI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2018-01-02T19:08:58Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
15 /**
16 * Namespace for all classes, static methods and static properties.
17 *
18 * @class
19 * @singleton
20 */
21 OO.ui = {};
22
23 OO.ui.bind = $.proxy;
24
25 /**
26 * @property {Object}
27 */
28 OO.ui.Keys = {
29 UNDEFINED: 0,
30 BACKSPACE: 8,
31 DELETE: 46,
32 LEFT: 37,
33 RIGHT: 39,
34 UP: 38,
35 DOWN: 40,
36 ENTER: 13,
37 END: 35,
38 HOME: 36,
39 TAB: 9,
40 PAGEUP: 33,
41 PAGEDOWN: 34,
42 ESCAPE: 27,
43 SHIFT: 16,
44 SPACE: 32
45 };
46
47 /**
48 * Constants for MouseEvent.which
49 *
50 * @property {Object}
51 */
52 OO.ui.MouseButtons = {
53 LEFT: 1,
54 MIDDLE: 2,
55 RIGHT: 3
56 };
57
58 /**
59 * @property {number}
60 * @private
61 */
62 OO.ui.elementId = 0;
63
64 /**
65 * Generate a unique ID for element
66 *
67 * @return {string} ID
68 */
69 OO.ui.generateElementId = function () {
70 OO.ui.elementId++;
71 return 'oojsui-' + OO.ui.elementId;
72 };
73
74 /**
75 * Check if an element is focusable.
76 * Inspired by :focusable in jQueryUI v1.11.4 - 2015-04-14
77 *
78 * @param {jQuery} $element Element to test
79 * @return {boolean} Element is focusable
80 */
81 OO.ui.isFocusableElement = function ( $element ) {
82 var nodeName,
83 element = $element[ 0 ];
84
85 // Anything disabled is not focusable
86 if ( element.disabled ) {
87 return false;
88 }
89
90 // Check if the element is visible
91 if ( !(
92 // This is quicker than calling $element.is( ':visible' )
93 $.expr.pseudos.visible( element ) &&
94 // Check that all parents are visible
95 !$element.parents().addBack().filter( function () {
96 return $.css( this, 'visibility' ) === 'hidden';
97 } ).length
98 ) ) {
99 return false;
100 }
101
102 // Check if the element is ContentEditable, which is the string 'true'
103 if ( element.contentEditable === 'true' ) {
104 return true;
105 }
106
107 // Anything with a non-negative numeric tabIndex is focusable.
108 // Use .prop to avoid browser bugs
109 if ( $element.prop( 'tabIndex' ) >= 0 ) {
110 return true;
111 }
112
113 // Some element types are naturally focusable
114 // (indexOf is much faster than regex in Chrome and about the
115 // same in FF: https://jsperf.com/regex-vs-indexof-array2)
116 nodeName = element.nodeName.toLowerCase();
117 if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
118 return true;
119 }
120
121 // Links and areas are focusable if they have an href
122 if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
123 return true;
124 }
125
126 return false;
127 };
128
129 /**
130 * Find a focusable child
131 *
132 * @param {jQuery} $container Container to search in
133 * @param {boolean} [backwards] Search backwards
134 * @return {jQuery} Focusable child, or an empty jQuery object if none found
135 */
136 OO.ui.findFocusable = function ( $container, backwards ) {
137 var $focusable = $( [] ),
138 // $focusableCandidates is a superset of things that
139 // could get matched by isFocusableElement
140 $focusableCandidates = $container
141 .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
142
143 if ( backwards ) {
144 $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
145 }
146
147 $focusableCandidates.each( function () {
148 var $this = $( this );
149 if ( OO.ui.isFocusableElement( $this ) ) {
150 $focusable = $this;
151 return false;
152 }
153 } );
154 return $focusable;
155 };
156
157 /**
158 * Get the user's language and any fallback languages.
159 *
160 * These language codes are used to localize user interface elements in the user's language.
161 *
162 * In environments that provide a localization system, this function should be overridden to
163 * return the user's language(s). The default implementation returns English (en) only.
164 *
165 * @return {string[]} Language codes, in descending order of priority
166 */
167 OO.ui.getUserLanguages = function () {
168 return [ 'en' ];
169 };
170
171 /**
172 * Get a value in an object keyed by language code.
173 *
174 * @param {Object.<string,Mixed>} obj Object keyed by language code
175 * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
176 * @param {string} [fallback] Fallback code, used if no matching language can be found
177 * @return {Mixed} Local value
178 */
179 OO.ui.getLocalValue = function ( obj, lang, fallback ) {
180 var i, len, langs;
181
182 // Requested language
183 if ( obj[ lang ] ) {
184 return obj[ lang ];
185 }
186 // Known user language
187 langs = OO.ui.getUserLanguages();
188 for ( i = 0, len = langs.length; i < len; i++ ) {
189 lang = langs[ i ];
190 if ( obj[ lang ] ) {
191 return obj[ lang ];
192 }
193 }
194 // Fallback language
195 if ( obj[ fallback ] ) {
196 return obj[ fallback ];
197 }
198 // First existing language
199 for ( lang in obj ) {
200 return obj[ lang ];
201 }
202
203 return undefined;
204 };
205
206 /**
207 * Check if a node is contained within another node
208 *
209 * Similar to jQuery#contains except a list of containers can be supplied
210 * and a boolean argument allows you to include the container in the match list
211 *
212 * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
213 * @param {HTMLElement} contained Node to find
214 * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
215 * @return {boolean} The node is in the list of target nodes
216 */
217 OO.ui.contains = function ( containers, contained, matchContainers ) {
218 var i;
219 if ( !Array.isArray( containers ) ) {
220 containers = [ containers ];
221 }
222 for ( i = containers.length - 1; i >= 0; i-- ) {
223 if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
224 return true;
225 }
226 }
227 return false;
228 };
229
230 /**
231 * Return a function, that, as long as it continues to be invoked, will not
232 * be triggered. The function will be called after it stops being called for
233 * N milliseconds. If `immediate` is passed, trigger the function on the
234 * leading edge, instead of the trailing.
235 *
236 * Ported from: http://underscorejs.org/underscore.js
237 *
238 * @param {Function} func Function to debounce
239 * @param {number} [wait=0] Wait period in milliseconds
240 * @param {boolean} [immediate] Trigger on leading edge
241 * @return {Function} Debounced function
242 */
243 OO.ui.debounce = function ( func, wait, immediate ) {
244 var timeout;
245 return function () {
246 var context = this,
247 args = arguments,
248 later = function () {
249 timeout = null;
250 if ( !immediate ) {
251 func.apply( context, args );
252 }
253 };
254 if ( immediate && !timeout ) {
255 func.apply( context, args );
256 }
257 if ( !timeout || wait ) {
258 clearTimeout( timeout );
259 timeout = setTimeout( later, wait );
260 }
261 };
262 };
263
264 /**
265 * Puts a console warning with provided message.
266 *
267 * @param {string} message Message
268 */
269 OO.ui.warnDeprecation = function ( message ) {
270 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
271 // eslint-disable-next-line no-console
272 console.warn( message );
273 }
274 };
275
276 /**
277 * Returns a function, that, when invoked, will only be triggered at most once
278 * during a given window of time. If called again during that window, it will
279 * wait until the window ends and then trigger itself again.
280 *
281 * As it's not knowable to the caller whether the function will actually run
282 * when the wrapper is called, return values from the function are entirely
283 * discarded.
284 *
285 * @param {Function} func Function to throttle
286 * @param {number} wait Throttle window length, in milliseconds
287 * @return {Function} Throttled function
288 */
289 OO.ui.throttle = function ( func, wait ) {
290 var context, args, timeout,
291 previous = 0,
292 run = function () {
293 timeout = null;
294 previous = OO.ui.now();
295 func.apply( context, args );
296 };
297 return function () {
298 // Check how long it's been since the last time the function was
299 // called, and whether it's more or less than the requested throttle
300 // period. If it's less, run the function immediately. If it's more,
301 // set a timeout for the remaining time -- but don't replace an
302 // existing timeout, since that'd indefinitely prolong the wait.
303 var remaining = wait - ( OO.ui.now() - previous );
304 context = this;
305 args = arguments;
306 if ( remaining <= 0 ) {
307 // Note: unless wait was ridiculously large, this means we'll
308 // automatically run the first time the function was called in a
309 // given period. (If you provide a wait period larger than the
310 // current Unix timestamp, you *deserve* unexpected behavior.)
311 clearTimeout( timeout );
312 run();
313 } else if ( !timeout ) {
314 timeout = setTimeout( run, remaining );
315 }
316 };
317 };
318
319 /**
320 * A (possibly faster) way to get the current timestamp as an integer
321 *
322 * @return {number} Current timestamp, in milliseconds since the Unix epoch
323 */
324 OO.ui.now = Date.now || function () {
325 return new Date().getTime();
326 };
327
328 /**
329 * Reconstitute a JavaScript object corresponding to a widget created by
330 * the PHP implementation.
331 *
332 * This is an alias for `OO.ui.Element.static.infuse()`.
333 *
334 * @param {string|HTMLElement|jQuery} idOrNode
335 * A DOM id (if a string) or node for the widget to infuse.
336 * @return {OO.ui.Element}
337 * The `OO.ui.Element` corresponding to this (infusable) document node.
338 */
339 OO.ui.infuse = function ( idOrNode ) {
340 return OO.ui.Element.static.infuse( idOrNode );
341 };
342
343 ( function () {
344 /**
345 * Message store for the default implementation of OO.ui.msg
346 *
347 * Environments that provide a localization system should not use this, but should override
348 * OO.ui.msg altogether.
349 *
350 * @private
351 */
352 var messages = {
353 // Tool tip for a button that moves items in a list down one place
354 'ooui-outline-control-move-down': 'Move item down',
355 // Tool tip for a button that moves items in a list up one place
356 'ooui-outline-control-move-up': 'Move item up',
357 // Tool tip for a button that removes items from a list
358 'ooui-outline-control-remove': 'Remove item',
359 // Label for the toolbar group that contains a list of all other available tools
360 'ooui-toolbar-more': 'More',
361 // Label for the fake tool that expands the full list of tools in a toolbar group
362 'ooui-toolgroup-expand': 'More',
363 // Label for the fake tool that collapses the full list of tools in a toolbar group
364 'ooui-toolgroup-collapse': 'Fewer',
365 // Default label for the tooltip for the button that removes a tag item
366 'ooui-item-remove': 'Remove',
367 // Default label for the accept button of a confirmation dialog
368 'ooui-dialog-message-accept': 'OK',
369 // Default label for the reject button of a confirmation dialog
370 'ooui-dialog-message-reject': 'Cancel',
371 // Title for process dialog error description
372 'ooui-dialog-process-error': 'Something went wrong',
373 // Label for process dialog dismiss error button, visible when describing errors
374 'ooui-dialog-process-dismiss': 'Dismiss',
375 // Label for process dialog retry action button, visible when describing only recoverable errors
376 'ooui-dialog-process-retry': 'Try again',
377 // Label for process dialog retry action button, visible when describing only warnings
378 'ooui-dialog-process-continue': 'Continue',
379 // Label for the file selection widget's select file button
380 'ooui-selectfile-button-select': 'Select a file',
381 // Label for the file selection widget if file selection is not supported
382 'ooui-selectfile-not-supported': 'File selection is not supported',
383 // Label for the file selection widget when no file is currently selected
384 'ooui-selectfile-placeholder': 'No file is selected',
385 // Label for the file selection widget's drop target
386 'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
387 };
388
389 /**
390 * Get a localized message.
391 *
392 * After the message key, message parameters may optionally be passed. In the default implementation,
393 * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
394 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
395 * they support unnamed, ordered message parameters.
396 *
397 * In environments that provide a localization system, this function should be overridden to
398 * return the message translated in the user's language. The default implementation always returns
399 * English messages. An example of doing this with [jQuery.i18n](https://github.com/wikimedia/jquery.i18n)
400 * follows.
401 *
402 * @example
403 * var i, iLen, button,
404 * messagePath = 'oojs-ui/dist/i18n/',
405 * languages = [ $.i18n().locale, 'ur', 'en' ],
406 * languageMap = {};
407 *
408 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
409 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
410 * }
411 *
412 * $.i18n().load( languageMap ).done( function() {
413 * // Replace the built-in `msg` only once we've loaded the internationalization.
414 * // OOjs UI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
415 * // you put off creating any widgets until this promise is complete, no English
416 * // will be displayed.
417 * OO.ui.msg = $.i18n;
418 *
419 * // A button displaying "OK" in the default locale
420 * button = new OO.ui.ButtonWidget( {
421 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
422 * icon: 'check'
423 * } );
424 * $( 'body' ).append( button.$element );
425 *
426 * // A button displaying "OK" in Urdu
427 * $.i18n().locale = 'ur';
428 * button = new OO.ui.ButtonWidget( {
429 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
430 * icon: 'check'
431 * } );
432 * $( 'body' ).append( button.$element );
433 * } );
434 *
435 * @param {string} key Message key
436 * @param {...Mixed} [params] Message parameters
437 * @return {string} Translated message with parameters substituted
438 */
439 OO.ui.msg = function ( key ) {
440 var message = messages[ key ],
441 params = Array.prototype.slice.call( arguments, 1 );
442 if ( typeof message === 'string' ) {
443 // Perform $1 substitution
444 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
445 var i = parseInt( n, 10 );
446 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
447 } );
448 } else {
449 // Return placeholder if message not found
450 message = '[' + key + ']';
451 }
452 return message;
453 };
454 }() );
455
456 /**
457 * Package a message and arguments for deferred resolution.
458 *
459 * Use this when you are statically specifying a message and the message may not yet be present.
460 *
461 * @param {string} key Message key
462 * @param {...Mixed} [params] Message parameters
463 * @return {Function} Function that returns the resolved message when executed
464 */
465 OO.ui.deferMsg = function () {
466 var args = arguments;
467 return function () {
468 return OO.ui.msg.apply( OO.ui, args );
469 };
470 };
471
472 /**
473 * Resolve a message.
474 *
475 * If the message is a function it will be executed, otherwise it will pass through directly.
476 *
477 * @param {Function|string} msg Deferred message, or message text
478 * @return {string} Resolved message
479 */
480 OO.ui.resolveMsg = function ( msg ) {
481 if ( $.isFunction( msg ) ) {
482 return msg();
483 }
484 return msg;
485 };
486
487 /**
488 * @param {string} url
489 * @return {boolean}
490 */
491 OO.ui.isSafeUrl = function ( url ) {
492 // Keep this function in sync with php/Tag.php
493 var i, protocolWhitelist;
494
495 function stringStartsWith( haystack, needle ) {
496 return haystack.substr( 0, needle.length ) === needle;
497 }
498
499 protocolWhitelist = [
500 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
501 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
502 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
503 ];
504
505 if ( url === '' ) {
506 return true;
507 }
508
509 for ( i = 0; i < protocolWhitelist.length; i++ ) {
510 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
511 return true;
512 }
513 }
514
515 // This matches '//' too
516 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
517 return true;
518 }
519 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
520 return true;
521 }
522
523 return false;
524 };
525
526 /**
527 * Check if the user has a 'mobile' device.
528 *
529 * For our purposes this means the user is primarily using an
530 * on-screen keyboard, touch input instead of a mouse and may
531 * have a physically small display.
532 *
533 * It is left up to implementors to decide how to compute this
534 * so the default implementation always returns false.
535 *
536 * @return {boolean} Use is on a mobile device
537 */
538 OO.ui.isMobile = function () {
539 return false;
540 };
541
542 /**
543 * Get the additional spacing that should be taken into account when displaying elements that are
544 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
545 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
546 *
547 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
548 * the extra spacing from that edge of viewport (in pixels)
549 */
550 OO.ui.getViewportSpacing = function () {
551 return {
552 top: 0,
553 right: 0,
554 bottom: 0,
555 left: 0
556 };
557 };
558
559 /**
560 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
561 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
562 *
563 * @return {jQuery} Default overlay node
564 */
565 OO.ui.getDefaultOverlay = function () {
566 if ( !OO.ui.$defaultOverlay ) {
567 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
568 $( 'body' ).append( OO.ui.$defaultOverlay );
569 }
570 return OO.ui.$defaultOverlay;
571 };
572
573 /*!
574 * Mixin namespace.
575 */
576
577 /**
578 * Namespace for OOjs UI mixins.
579 *
580 * Mixins are named according to the type of object they are intended to
581 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
582 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
583 * is intended to be mixed in to an instance of OO.ui.Widget.
584 *
585 * @class
586 * @singleton
587 */
588 OO.ui.mixin = {};
589
590 /**
591 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
592 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
593 * connected to them and can't be interacted with.
594 *
595 * @abstract
596 * @class
597 *
598 * @constructor
599 * @param {Object} [config] Configuration options
600 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
601 * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
602 * for an example.
603 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
604 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
605 * @cfg {string} [text] Text to insert
606 * @cfg {Array} [content] An array of content elements to append (after #text).
607 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
608 * Instances of OO.ui.Element will have their $element appended.
609 * @cfg {jQuery} [$content] Content elements to append (after #text).
610 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
611 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
612 * Data can also be specified with the #setData method.
613 */
614 OO.ui.Element = function OoUiElement( config ) {
615 if ( OO.ui.isDemo ) {
616 this.initialConfig = config;
617 }
618 // Configuration initialization
619 config = config || {};
620
621 // Properties
622 this.$ = $;
623 this.elementId = null;
624 this.visible = true;
625 this.data = config.data;
626 this.$element = config.$element ||
627 $( document.createElement( this.getTagName() ) );
628 this.elementGroup = null;
629
630 // Initialization
631 if ( Array.isArray( config.classes ) ) {
632 this.$element.addClass( config.classes.join( ' ' ) );
633 }
634 if ( config.id ) {
635 this.setElementId( config.id );
636 }
637 if ( config.text ) {
638 this.$element.text( config.text );
639 }
640 if ( config.content ) {
641 // The `content` property treats plain strings as text; use an
642 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
643 // appropriate $element appended.
644 this.$element.append( config.content.map( function ( v ) {
645 if ( typeof v === 'string' ) {
646 // Escape string so it is properly represented in HTML.
647 return document.createTextNode( v );
648 } else if ( v instanceof OO.ui.HtmlSnippet ) {
649 // Bypass escaping.
650 return v.toString();
651 } else if ( v instanceof OO.ui.Element ) {
652 return v.$element;
653 }
654 return v;
655 } ) );
656 }
657 if ( config.$content ) {
658 // The `$content` property treats plain strings as HTML.
659 this.$element.append( config.$content );
660 }
661 };
662
663 /* Setup */
664
665 OO.initClass( OO.ui.Element );
666
667 /* Static Properties */
668
669 /**
670 * The name of the HTML tag used by the element.
671 *
672 * The static value may be ignored if the #getTagName method is overridden.
673 *
674 * @static
675 * @inheritable
676 * @property {string}
677 */
678 OO.ui.Element.static.tagName = 'div';
679
680 /* Static Methods */
681
682 /**
683 * Reconstitute a JavaScript object corresponding to a widget created
684 * by the PHP implementation.
685 *
686 * @param {string|HTMLElement|jQuery} idOrNode
687 * A DOM id (if a string) or node for the widget to infuse.
688 * @return {OO.ui.Element}
689 * The `OO.ui.Element` corresponding to this (infusable) document node.
690 * For `Tag` objects emitted on the HTML side (used occasionally for content)
691 * the value returned is a newly-created Element wrapping around the existing
692 * DOM node.
693 */
694 OO.ui.Element.static.infuse = function ( idOrNode ) {
695 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
696 // Verify that the type matches up.
697 // FIXME: uncomment after T89721 is fixed, see T90929.
698 /*
699 if ( !( obj instanceof this['class'] ) ) {
700 throw new Error( 'Infusion type mismatch!' );
701 }
702 */
703 return obj;
704 };
705
706 /**
707 * Implementation helper for `infuse`; skips the type check and has an
708 * extra property so that only the top-level invocation touches the DOM.
709 *
710 * @private
711 * @param {string|HTMLElement|jQuery} idOrNode
712 * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
713 * when the top-level widget of this infusion is inserted into DOM,
714 * replacing the original node; or false for top-level invocation.
715 * @return {OO.ui.Element}
716 */
717 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
718 // look for a cached result of a previous infusion.
719 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
720 if ( typeof idOrNode === 'string' ) {
721 id = idOrNode;
722 $elem = $( document.getElementById( id ) );
723 } else {
724 $elem = $( idOrNode );
725 id = $elem.attr( 'id' );
726 }
727 if ( !$elem.length ) {
728 if ( typeof idOrNode === 'string' ) {
729 error = 'Widget not found: ' + idOrNode;
730 } else if ( idOrNode && idOrNode.selector ) {
731 error = 'Widget not found: ' + idOrNode.selector;
732 } else {
733 error = 'Widget not found';
734 }
735 throw new Error( error );
736 }
737 if ( $elem[ 0 ].oouiInfused ) {
738 $elem = $elem[ 0 ].oouiInfused;
739 }
740 data = $elem.data( 'ooui-infused' );
741 if ( data ) {
742 // cached!
743 if ( data === true ) {
744 throw new Error( 'Circular dependency! ' + id );
745 }
746 if ( domPromise ) {
747 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
748 state = data.constructor.static.gatherPreInfuseState( $elem, data );
749 // restore dynamic state after the new element is re-inserted into DOM under infused parent
750 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
751 infusedChildren = $elem.data( 'ooui-infused-children' );
752 if ( infusedChildren && infusedChildren.length ) {
753 infusedChildren.forEach( function ( data ) {
754 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
755 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
756 } );
757 }
758 }
759 return data;
760 }
761 data = $elem.attr( 'data-ooui' );
762 if ( !data ) {
763 throw new Error( 'No infusion data found: ' + id );
764 }
765 try {
766 data = JSON.parse( data );
767 } catch ( _ ) {
768 data = null;
769 }
770 if ( !( data && data._ ) ) {
771 throw new Error( 'No valid infusion data found: ' + id );
772 }
773 if ( data._ === 'Tag' ) {
774 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
775 return new OO.ui.Element( { $element: $elem } );
776 }
777 parts = data._.split( '.' );
778 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
779 if ( cls === undefined ) {
780 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
781 }
782
783 // Verify that we're creating an OO.ui.Element instance
784 parent = cls.parent;
785
786 while ( parent !== undefined ) {
787 if ( parent === OO.ui.Element ) {
788 // Safe
789 break;
790 }
791
792 parent = parent.parent;
793 }
794
795 if ( parent !== OO.ui.Element ) {
796 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
797 }
798
799 if ( domPromise === false ) {
800 top = $.Deferred();
801 domPromise = top.promise();
802 }
803 $elem.data( 'ooui-infused', true ); // prevent loops
804 data.id = id; // implicit
805 infusedChildren = [];
806 data = OO.copy( data, null, function deserialize( value ) {
807 var infused;
808 if ( OO.isPlainObject( value ) ) {
809 if ( value.tag ) {
810 infused = OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
811 infusedChildren.push( infused );
812 // Flatten the structure
813 infusedChildren.push.apply( infusedChildren, infused.$element.data( 'ooui-infused-children' ) || [] );
814 infused.$element.removeData( 'ooui-infused-children' );
815 return infused;
816 }
817 if ( value.html !== undefined ) {
818 return new OO.ui.HtmlSnippet( value.html );
819 }
820 }
821 } );
822 // allow widgets to reuse parts of the DOM
823 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
824 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
825 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
826 // rebuild widget
827 // eslint-disable-next-line new-cap
828 obj = new cls( data );
829 // now replace old DOM with this new DOM.
830 if ( top ) {
831 // An efficient constructor might be able to reuse the entire DOM tree of the original element,
832 // so only mutate the DOM if we need to.
833 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
834 $elem.replaceWith( obj.$element );
835 // This element is now gone from the DOM, but if anyone is holding a reference to it,
836 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
837 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
838 $elem[ 0 ].oouiInfused = obj.$element;
839 }
840 top.resolve();
841 }
842 obj.$element.data( 'ooui-infused', obj );
843 obj.$element.data( 'ooui-infused-children', infusedChildren );
844 // set the 'data-ooui' attribute so we can identify infused widgets
845 obj.$element.attr( 'data-ooui', '' );
846 // restore dynamic state after the new element is inserted into DOM
847 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
848 return obj;
849 };
850
851 /**
852 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
853 *
854 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
855 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
856 * constructor, which will be given the enhanced config.
857 *
858 * @protected
859 * @param {HTMLElement} node
860 * @param {Object} config
861 * @return {Object}
862 */
863 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
864 return config;
865 };
866
867 /**
868 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM node
869 * (and its children) that represent an Element of the same class and the given configuration,
870 * generated by the PHP implementation.
871 *
872 * This method is called just before `node` is detached from the DOM. The return value of this
873 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
874 * is inserted into DOM to replace `node`.
875 *
876 * @protected
877 * @param {HTMLElement} node
878 * @param {Object} config
879 * @return {Object}
880 */
881 OO.ui.Element.static.gatherPreInfuseState = function () {
882 return {};
883 };
884
885 /**
886 * Get a jQuery function within a specific document.
887 *
888 * @static
889 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
890 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
891 * not in an iframe
892 * @return {Function} Bound jQuery function
893 */
894 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
895 function wrapper( selector ) {
896 return $( selector, wrapper.context );
897 }
898
899 wrapper.context = this.getDocument( context );
900
901 if ( $iframe ) {
902 wrapper.$iframe = $iframe;
903 }
904
905 return wrapper;
906 };
907
908 /**
909 * Get the document of an element.
910 *
911 * @static
912 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
913 * @return {HTMLDocument|null} Document object
914 */
915 OO.ui.Element.static.getDocument = function ( obj ) {
916 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
917 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
918 // Empty jQuery selections might have a context
919 obj.context ||
920 // HTMLElement
921 obj.ownerDocument ||
922 // Window
923 obj.document ||
924 // HTMLDocument
925 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
926 null;
927 };
928
929 /**
930 * Get the window of an element or document.
931 *
932 * @static
933 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
934 * @return {Window} Window object
935 */
936 OO.ui.Element.static.getWindow = function ( obj ) {
937 var doc = this.getDocument( obj );
938 return doc.defaultView;
939 };
940
941 /**
942 * Get the direction of an element or document.
943 *
944 * @static
945 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
946 * @return {string} Text direction, either 'ltr' or 'rtl'
947 */
948 OO.ui.Element.static.getDir = function ( obj ) {
949 var isDoc, isWin;
950
951 if ( obj instanceof jQuery ) {
952 obj = obj[ 0 ];
953 }
954 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
955 isWin = obj.document !== undefined;
956 if ( isDoc || isWin ) {
957 if ( isWin ) {
958 obj = obj.document;
959 }
960 obj = obj.body;
961 }
962 return $( obj ).css( 'direction' );
963 };
964
965 /**
966 * Get the offset between two frames.
967 *
968 * TODO: Make this function not use recursion.
969 *
970 * @static
971 * @param {Window} from Window of the child frame
972 * @param {Window} [to=window] Window of the parent frame
973 * @param {Object} [offset] Offset to start with, used internally
974 * @return {Object} Offset object, containing left and top properties
975 */
976 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
977 var i, len, frames, frame, rect;
978
979 if ( !to ) {
980 to = window;
981 }
982 if ( !offset ) {
983 offset = { top: 0, left: 0 };
984 }
985 if ( from.parent === from ) {
986 return offset;
987 }
988
989 // Get iframe element
990 frames = from.parent.document.getElementsByTagName( 'iframe' );
991 for ( i = 0, len = frames.length; i < len; i++ ) {
992 if ( frames[ i ].contentWindow === from ) {
993 frame = frames[ i ];
994 break;
995 }
996 }
997
998 // Recursively accumulate offset values
999 if ( frame ) {
1000 rect = frame.getBoundingClientRect();
1001 offset.left += rect.left;
1002 offset.top += rect.top;
1003 if ( from !== to ) {
1004 this.getFrameOffset( from.parent, offset );
1005 }
1006 }
1007 return offset;
1008 };
1009
1010 /**
1011 * Get the offset between two elements.
1012 *
1013 * The two elements may be in a different frame, but in that case the frame $element is in must
1014 * be contained in the frame $anchor is in.
1015 *
1016 * @static
1017 * @param {jQuery} $element Element whose position to get
1018 * @param {jQuery} $anchor Element to get $element's position relative to
1019 * @return {Object} Translated position coordinates, containing top and left properties
1020 */
1021 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1022 var iframe, iframePos,
1023 pos = $element.offset(),
1024 anchorPos = $anchor.offset(),
1025 elementDocument = this.getDocument( $element ),
1026 anchorDocument = this.getDocument( $anchor );
1027
1028 // If $element isn't in the same document as $anchor, traverse up
1029 while ( elementDocument !== anchorDocument ) {
1030 iframe = elementDocument.defaultView.frameElement;
1031 if ( !iframe ) {
1032 throw new Error( '$element frame is not contained in $anchor frame' );
1033 }
1034 iframePos = $( iframe ).offset();
1035 pos.left += iframePos.left;
1036 pos.top += iframePos.top;
1037 elementDocument = iframe.ownerDocument;
1038 }
1039 pos.left -= anchorPos.left;
1040 pos.top -= anchorPos.top;
1041 return pos;
1042 };
1043
1044 /**
1045 * Get element border sizes.
1046 *
1047 * @static
1048 * @param {HTMLElement} el Element to measure
1049 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1050 */
1051 OO.ui.Element.static.getBorders = function ( el ) {
1052 var doc = el.ownerDocument,
1053 win = doc.defaultView,
1054 style = win.getComputedStyle( el, null ),
1055 $el = $( el ),
1056 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1057 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1058 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1059 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1060
1061 return {
1062 top: top,
1063 left: left,
1064 bottom: bottom,
1065 right: right
1066 };
1067 };
1068
1069 /**
1070 * Get dimensions of an element or window.
1071 *
1072 * @static
1073 * @param {HTMLElement|Window} el Element to measure
1074 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1075 */
1076 OO.ui.Element.static.getDimensions = function ( el ) {
1077 var $el, $win,
1078 doc = el.ownerDocument || el.document,
1079 win = doc.defaultView;
1080
1081 if ( win === el || el === doc.documentElement ) {
1082 $win = $( win );
1083 return {
1084 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1085 scroll: {
1086 top: $win.scrollTop(),
1087 left: $win.scrollLeft()
1088 },
1089 scrollbar: { right: 0, bottom: 0 },
1090 rect: {
1091 top: 0,
1092 left: 0,
1093 bottom: $win.innerHeight(),
1094 right: $win.innerWidth()
1095 }
1096 };
1097 } else {
1098 $el = $( el );
1099 return {
1100 borders: this.getBorders( el ),
1101 scroll: {
1102 top: $el.scrollTop(),
1103 left: $el.scrollLeft()
1104 },
1105 scrollbar: {
1106 right: $el.innerWidth() - el.clientWidth,
1107 bottom: $el.innerHeight() - el.clientHeight
1108 },
1109 rect: el.getBoundingClientRect()
1110 };
1111 }
1112 };
1113
1114 /**
1115 * Get the number of pixels that an element's content is scrolled to the left.
1116 *
1117 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1118 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1119 *
1120 * This function smooths out browser inconsistencies (nicely described in the README at
1121 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1122 * with Firefox's 'scrollLeft', which seems the sanest.
1123 *
1124 * @static
1125 * @method
1126 * @param {HTMLElement|Window} el Element to measure
1127 * @return {number} Scroll position from the left.
1128 * If the element's direction is LTR, this is a positive number between `0` (initial scroll position)
1129 * and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1130 * If the element's direction is RTL, this is a negative number between `0` (initial scroll position)
1131 * and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1132 */
1133 OO.ui.Element.static.getScrollLeft = ( function () {
1134 var rtlScrollType = null;
1135
1136 function test() {
1137 var $definer = $( '<div dir="rtl" style="font-size: 14px; width: 1px; height: 1px; position: absolute; top: -1000px; overflow: scroll">A</div>' ),
1138 definer = $definer[ 0 ];
1139
1140 $definer.appendTo( 'body' );
1141 if ( definer.scrollLeft > 0 ) {
1142 // Safari, Chrome
1143 rtlScrollType = 'default';
1144 } else {
1145 definer.scrollLeft = 1;
1146 if ( definer.scrollLeft === 0 ) {
1147 // Firefox, old Opera
1148 rtlScrollType = 'negative';
1149 } else {
1150 // Internet Explorer, Edge
1151 rtlScrollType = 'reverse';
1152 }
1153 }
1154 $definer.remove();
1155 }
1156
1157 return function getScrollLeft( el ) {
1158 var isRoot = el.window === el ||
1159 el === el.ownerDocument.body ||
1160 el === el.ownerDocument.documentElement,
1161 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1162 // All browsers use the correct scroll type ('negative') on the root, so don't
1163 // do any fixups when looking at the root element
1164 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1165
1166 if ( direction === 'rtl' ) {
1167 if ( rtlScrollType === null ) {
1168 test();
1169 }
1170 if ( rtlScrollType === 'reverse' ) {
1171 scrollLeft = -scrollLeft;
1172 } else if ( rtlScrollType === 'default' ) {
1173 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1174 }
1175 }
1176
1177 return scrollLeft;
1178 };
1179 }() );
1180
1181 /**
1182 * Get the root scrollable element of given element's document.
1183 *
1184 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1185 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1186 * lets us use 'body' or 'documentElement' based on what is working.
1187 *
1188 * https://code.google.com/p/chromium/issues/detail?id=303131
1189 *
1190 * @static
1191 * @param {HTMLElement} el Element to find root scrollable parent for
1192 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1193 * depending on browser
1194 */
1195 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1196 var scrollTop, body;
1197
1198 if ( OO.ui.scrollableElement === undefined ) {
1199 body = el.ownerDocument.body;
1200 scrollTop = body.scrollTop;
1201 body.scrollTop = 1;
1202
1203 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1204 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1205 if ( Math.round( body.scrollTop ) === 1 ) {
1206 body.scrollTop = scrollTop;
1207 OO.ui.scrollableElement = 'body';
1208 } else {
1209 OO.ui.scrollableElement = 'documentElement';
1210 }
1211 }
1212
1213 return el.ownerDocument[ OO.ui.scrollableElement ];
1214 };
1215
1216 /**
1217 * Get closest scrollable container.
1218 *
1219 * Traverses up until either a scrollable element or the root is reached, in which case the root
1220 * scrollable element will be returned (see #getRootScrollableElement).
1221 *
1222 * @static
1223 * @param {HTMLElement} el Element to find scrollable container for
1224 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1225 * @return {HTMLElement} Closest scrollable container
1226 */
1227 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1228 var i, val,
1229 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1230 // 'overflow-y' have different values, so we need to check the separate properties.
1231 props = [ 'overflow-x', 'overflow-y' ],
1232 $parent = $( el ).parent();
1233
1234 if ( dimension === 'x' || dimension === 'y' ) {
1235 props = [ 'overflow-' + dimension ];
1236 }
1237
1238 // Special case for the document root (which doesn't really have any scrollable container, since
1239 // it is the ultimate scrollable container, but this is probably saner than null or exception)
1240 if ( $( el ).is( 'html, body' ) ) {
1241 return this.getRootScrollableElement( el );
1242 }
1243
1244 while ( $parent.length ) {
1245 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1246 return $parent[ 0 ];
1247 }
1248 i = props.length;
1249 while ( i-- ) {
1250 val = $parent.css( props[ i ] );
1251 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will never be
1252 // scrolled in that direction, but they can actually be scrolled programatically. The user can
1253 // unintentionally perform a scroll in such case even if the application doesn't scroll
1254 // programatically, e.g. when jumping to an anchor, or when using built-in find functionality.
1255 // This could cause funny issues...
1256 if ( val === 'auto' || val === 'scroll' ) {
1257 return $parent[ 0 ];
1258 }
1259 }
1260 $parent = $parent.parent();
1261 }
1262 // The element is unattached... return something mostly sane
1263 return this.getRootScrollableElement( el );
1264 };
1265
1266 /**
1267 * Scroll element into view.
1268 *
1269 * @static
1270 * @param {HTMLElement} el Element to scroll into view
1271 * @param {Object} [config] Configuration options
1272 * @param {string} [config.duration='fast'] jQuery animation duration value
1273 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1274 * to scroll in both directions
1275 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1276 */
1277 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1278 var position, animations, container, $container, elementDimensions, containerDimensions, $window,
1279 deferred = $.Deferred();
1280
1281 // Configuration initialization
1282 config = config || {};
1283
1284 animations = {};
1285 container = this.getClosestScrollableContainer( el, config.direction );
1286 $container = $( container );
1287 elementDimensions = this.getDimensions( el );
1288 containerDimensions = this.getDimensions( container );
1289 $window = $( this.getWindow( el ) );
1290
1291 // Compute the element's position relative to the container
1292 if ( $container.is( 'html, body' ) ) {
1293 // If the scrollable container is the root, this is easy
1294 position = {
1295 top: elementDimensions.rect.top,
1296 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1297 left: elementDimensions.rect.left,
1298 right: $window.innerWidth() - elementDimensions.rect.right
1299 };
1300 } else {
1301 // Otherwise, we have to subtract el's coordinates from container's coordinates
1302 position = {
1303 top: elementDimensions.rect.top - ( containerDimensions.rect.top + containerDimensions.borders.top ),
1304 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom - containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1305 left: elementDimensions.rect.left - ( containerDimensions.rect.left + containerDimensions.borders.left ),
1306 right: containerDimensions.rect.right - containerDimensions.borders.right - containerDimensions.scrollbar.right - elementDimensions.rect.right
1307 };
1308 }
1309
1310 if ( !config.direction || config.direction === 'y' ) {
1311 if ( position.top < 0 ) {
1312 animations.scrollTop = containerDimensions.scroll.top + position.top;
1313 } else if ( position.top > 0 && position.bottom < 0 ) {
1314 animations.scrollTop = containerDimensions.scroll.top + Math.min( position.top, -position.bottom );
1315 }
1316 }
1317 if ( !config.direction || config.direction === 'x' ) {
1318 if ( position.left < 0 ) {
1319 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1320 } else if ( position.left > 0 && position.right < 0 ) {
1321 animations.scrollLeft = containerDimensions.scroll.left + Math.min( position.left, -position.right );
1322 }
1323 }
1324 if ( !$.isEmptyObject( animations ) ) {
1325 $container.stop( true ).animate( animations, config.duration === undefined ? 'fast' : config.duration );
1326 $container.queue( function ( next ) {
1327 deferred.resolve();
1328 next();
1329 } );
1330 } else {
1331 deferred.resolve();
1332 }
1333 return deferred.promise();
1334 };
1335
1336 /**
1337 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1338 * and reserve space for them, because it probably doesn't.
1339 *
1340 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1341 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1342 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
1343 * and then reattach (or show) them back.
1344 *
1345 * @static
1346 * @param {HTMLElement} el Element to reconsider the scrollbars on
1347 */
1348 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1349 var i, len, scrollLeft, scrollTop, nodes = [];
1350 // Save scroll position
1351 scrollLeft = el.scrollLeft;
1352 scrollTop = el.scrollTop;
1353 // Detach all children
1354 while ( el.firstChild ) {
1355 nodes.push( el.firstChild );
1356 el.removeChild( el.firstChild );
1357 }
1358 // Force reflow
1359 void el.offsetHeight;
1360 // Reattach all children
1361 for ( i = 0, len = nodes.length; i < len; i++ ) {
1362 el.appendChild( nodes[ i ] );
1363 }
1364 // Restore scroll position (no-op if scrollbars disappeared)
1365 el.scrollLeft = scrollLeft;
1366 el.scrollTop = scrollTop;
1367 };
1368
1369 /* Methods */
1370
1371 /**
1372 * Toggle visibility of an element.
1373 *
1374 * @param {boolean} [show] Make element visible, omit to toggle visibility
1375 * @fires visible
1376 * @chainable
1377 */
1378 OO.ui.Element.prototype.toggle = function ( show ) {
1379 show = show === undefined ? !this.visible : !!show;
1380
1381 if ( show !== this.isVisible() ) {
1382 this.visible = show;
1383 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1384 this.emit( 'toggle', show );
1385 }
1386
1387 return this;
1388 };
1389
1390 /**
1391 * Check if element is visible.
1392 *
1393 * @return {boolean} element is visible
1394 */
1395 OO.ui.Element.prototype.isVisible = function () {
1396 return this.visible;
1397 };
1398
1399 /**
1400 * Get element data.
1401 *
1402 * @return {Mixed} Element data
1403 */
1404 OO.ui.Element.prototype.getData = function () {
1405 return this.data;
1406 };
1407
1408 /**
1409 * Set element data.
1410 *
1411 * @param {Mixed} data Element data
1412 * @chainable
1413 */
1414 OO.ui.Element.prototype.setData = function ( data ) {
1415 this.data = data;
1416 return this;
1417 };
1418
1419 /**
1420 * Set the element has an 'id' attribute.
1421 *
1422 * @param {string} id
1423 * @chainable
1424 */
1425 OO.ui.Element.prototype.setElementId = function ( id ) {
1426 this.elementId = id;
1427 this.$element.attr( 'id', id );
1428 return this;
1429 };
1430
1431 /**
1432 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1433 * and return its value.
1434 *
1435 * @return {string}
1436 */
1437 OO.ui.Element.prototype.getElementId = function () {
1438 if ( this.elementId === null ) {
1439 this.setElementId( OO.ui.generateElementId() );
1440 }
1441 return this.elementId;
1442 };
1443
1444 /**
1445 * Check if element supports one or more methods.
1446 *
1447 * @param {string|string[]} methods Method or list of methods to check
1448 * @return {boolean} All methods are supported
1449 */
1450 OO.ui.Element.prototype.supports = function ( methods ) {
1451 var i, len,
1452 support = 0;
1453
1454 methods = Array.isArray( methods ) ? methods : [ methods ];
1455 for ( i = 0, len = methods.length; i < len; i++ ) {
1456 if ( $.isFunction( this[ methods[ i ] ] ) ) {
1457 support++;
1458 }
1459 }
1460
1461 return methods.length === support;
1462 };
1463
1464 /**
1465 * Update the theme-provided classes.
1466 *
1467 * @localdoc This is called in element mixins and widget classes any time state changes.
1468 * Updating is debounced, minimizing overhead of changing multiple attributes and
1469 * guaranteeing that theme updates do not occur within an element's constructor
1470 */
1471 OO.ui.Element.prototype.updateThemeClasses = function () {
1472 OO.ui.theme.queueUpdateElementClasses( this );
1473 };
1474
1475 /**
1476 * Get the HTML tag name.
1477 *
1478 * Override this method to base the result on instance information.
1479 *
1480 * @return {string} HTML tag name
1481 */
1482 OO.ui.Element.prototype.getTagName = function () {
1483 return this.constructor.static.tagName;
1484 };
1485
1486 /**
1487 * Check if the element is attached to the DOM
1488 *
1489 * @return {boolean} The element is attached to the DOM
1490 */
1491 OO.ui.Element.prototype.isElementAttached = function () {
1492 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1493 };
1494
1495 /**
1496 * Get the DOM document.
1497 *
1498 * @return {HTMLDocument} Document object
1499 */
1500 OO.ui.Element.prototype.getElementDocument = function () {
1501 // Don't cache this in other ways either because subclasses could can change this.$element
1502 return OO.ui.Element.static.getDocument( this.$element );
1503 };
1504
1505 /**
1506 * Get the DOM window.
1507 *
1508 * @return {Window} Window object
1509 */
1510 OO.ui.Element.prototype.getElementWindow = function () {
1511 return OO.ui.Element.static.getWindow( this.$element );
1512 };
1513
1514 /**
1515 * Get closest scrollable container.
1516 *
1517 * @return {HTMLElement} Closest scrollable container
1518 */
1519 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1520 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1521 };
1522
1523 /**
1524 * Get group element is in.
1525 *
1526 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1527 */
1528 OO.ui.Element.prototype.getElementGroup = function () {
1529 return this.elementGroup;
1530 };
1531
1532 /**
1533 * Set group element is in.
1534 *
1535 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1536 * @chainable
1537 */
1538 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1539 this.elementGroup = group;
1540 return this;
1541 };
1542
1543 /**
1544 * Scroll element into view.
1545 *
1546 * @param {Object} [config] Configuration options
1547 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1548 */
1549 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1550 if (
1551 !this.isElementAttached() ||
1552 !this.isVisible() ||
1553 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1554 ) {
1555 return $.Deferred().resolve();
1556 }
1557 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1558 };
1559
1560 /**
1561 * Restore the pre-infusion dynamic state for this widget.
1562 *
1563 * This method is called after #$element has been inserted into DOM. The parameter is the return
1564 * value of #gatherPreInfuseState.
1565 *
1566 * @protected
1567 * @param {Object} state
1568 */
1569 OO.ui.Element.prototype.restorePreInfuseState = function () {
1570 };
1571
1572 /**
1573 * Wraps an HTML snippet for use with configuration values which default
1574 * to strings. This bypasses the default html-escaping done to string
1575 * values.
1576 *
1577 * @class
1578 *
1579 * @constructor
1580 * @param {string} [content] HTML content
1581 */
1582 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1583 // Properties
1584 this.content = content;
1585 };
1586
1587 /* Setup */
1588
1589 OO.initClass( OO.ui.HtmlSnippet );
1590
1591 /* Methods */
1592
1593 /**
1594 * Render into HTML.
1595 *
1596 * @return {string} Unchanged HTML snippet.
1597 */
1598 OO.ui.HtmlSnippet.prototype.toString = function () {
1599 return this.content;
1600 };
1601
1602 /**
1603 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
1604 * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
1605 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
1606 * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1607 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
1608 *
1609 * @abstract
1610 * @class
1611 * @extends OO.ui.Element
1612 * @mixins OO.EventEmitter
1613 *
1614 * @constructor
1615 * @param {Object} [config] Configuration options
1616 */
1617 OO.ui.Layout = function OoUiLayout( config ) {
1618 // Configuration initialization
1619 config = config || {};
1620
1621 // Parent constructor
1622 OO.ui.Layout.parent.call( this, config );
1623
1624 // Mixin constructors
1625 OO.EventEmitter.call( this );
1626
1627 // Initialization
1628 this.$element.addClass( 'oo-ui-layout' );
1629 };
1630
1631 /* Setup */
1632
1633 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1634 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1635
1636 /**
1637 * Widgets are compositions of one or more OOjs UI elements that users can both view
1638 * and interact with. All widgets can be configured and modified via a standard API,
1639 * and their state can change dynamically according to a model.
1640 *
1641 * @abstract
1642 * @class
1643 * @extends OO.ui.Element
1644 * @mixins OO.EventEmitter
1645 *
1646 * @constructor
1647 * @param {Object} [config] Configuration options
1648 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1649 * appearance reflects this state.
1650 */
1651 OO.ui.Widget = function OoUiWidget( config ) {
1652 // Initialize config
1653 config = $.extend( { disabled: false }, config );
1654
1655 // Parent constructor
1656 OO.ui.Widget.parent.call( this, config );
1657
1658 // Mixin constructors
1659 OO.EventEmitter.call( this );
1660
1661 // Properties
1662 this.disabled = null;
1663 this.wasDisabled = null;
1664
1665 // Initialization
1666 this.$element.addClass( 'oo-ui-widget' );
1667 this.setDisabled( !!config.disabled );
1668 };
1669
1670 /* Setup */
1671
1672 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1673 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1674
1675 /* Events */
1676
1677 /**
1678 * @event disable
1679 *
1680 * A 'disable' event is emitted when the disabled state of the widget changes
1681 * (i.e. on disable **and** enable).
1682 *
1683 * @param {boolean} disabled Widget is disabled
1684 */
1685
1686 /**
1687 * @event toggle
1688 *
1689 * A 'toggle' event is emitted when the visibility of the widget changes.
1690 *
1691 * @param {boolean} visible Widget is visible
1692 */
1693
1694 /* Methods */
1695
1696 /**
1697 * Check if the widget is disabled.
1698 *
1699 * @return {boolean} Widget is disabled
1700 */
1701 OO.ui.Widget.prototype.isDisabled = function () {
1702 return this.disabled;
1703 };
1704
1705 /**
1706 * Set the 'disabled' state of the widget.
1707 *
1708 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1709 *
1710 * @param {boolean} disabled Disable widget
1711 * @chainable
1712 */
1713 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1714 var isDisabled;
1715
1716 this.disabled = !!disabled;
1717 isDisabled = this.isDisabled();
1718 if ( isDisabled !== this.wasDisabled ) {
1719 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1720 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1721 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1722 this.emit( 'disable', isDisabled );
1723 this.updateThemeClasses();
1724 }
1725 this.wasDisabled = isDisabled;
1726
1727 return this;
1728 };
1729
1730 /**
1731 * Update the disabled state, in case of changes in parent widget.
1732 *
1733 * @chainable
1734 */
1735 OO.ui.Widget.prototype.updateDisabled = function () {
1736 this.setDisabled( this.disabled );
1737 return this;
1738 };
1739
1740 /**
1741 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1742 * value.
1743 *
1744 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1745 * instead.
1746 *
1747 * @return {string|null} The ID of the labelable element
1748 */
1749 OO.ui.Widget.prototype.getInputId = function () {
1750 return null;
1751 };
1752
1753 /**
1754 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1755 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1756 * override this method to provide intuitive, accessible behavior.
1757 *
1758 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1759 * Individual widgets may override it too.
1760 *
1761 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1762 * directly.
1763 */
1764 OO.ui.Widget.prototype.simulateLabelClick = function () {
1765 };
1766
1767 /**
1768 * Theme logic.
1769 *
1770 * @abstract
1771 * @class
1772 *
1773 * @constructor
1774 */
1775 OO.ui.Theme = function OoUiTheme() {
1776 this.elementClassesQueue = [];
1777 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1778 };
1779
1780 /* Setup */
1781
1782 OO.initClass( OO.ui.Theme );
1783
1784 /* Methods */
1785
1786 /**
1787 * Get a list of classes to be applied to a widget.
1788 *
1789 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1790 * otherwise state transitions will not work properly.
1791 *
1792 * @param {OO.ui.Element} element Element for which to get classes
1793 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1794 */
1795 OO.ui.Theme.prototype.getElementClasses = function () {
1796 return { on: [], off: [] };
1797 };
1798
1799 /**
1800 * Update CSS classes provided by the theme.
1801 *
1802 * For elements with theme logic hooks, this should be called any time there's a state change.
1803 *
1804 * @param {OO.ui.Element} element Element for which to update classes
1805 */
1806 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1807 var $elements = $( [] ),
1808 classes = this.getElementClasses( element );
1809
1810 if ( element.$icon ) {
1811 $elements = $elements.add( element.$icon );
1812 }
1813 if ( element.$indicator ) {
1814 $elements = $elements.add( element.$indicator );
1815 }
1816
1817 $elements
1818 .removeClass( classes.off.join( ' ' ) )
1819 .addClass( classes.on.join( ' ' ) );
1820 };
1821
1822 /**
1823 * @private
1824 */
1825 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1826 var i;
1827 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1828 this.updateElementClasses( this.elementClassesQueue[ i ] );
1829 }
1830 // Clear the queue
1831 this.elementClassesQueue = [];
1832 };
1833
1834 /**
1835 * Queue #updateElementClasses to be called for this element.
1836 *
1837 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1838 * to make them synchronous.
1839 *
1840 * @param {OO.ui.Element} element Element for which to update classes
1841 */
1842 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1843 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1844 // the most common case (this method is often called repeatedly for the same element).
1845 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1846 return;
1847 }
1848 this.elementClassesQueue.push( element );
1849 this.debouncedUpdateQueuedElementClasses();
1850 };
1851
1852 /**
1853 * Get the transition duration in milliseconds for dialogs opening/closing
1854 *
1855 * The dialog should be fully rendered this many milliseconds after the
1856 * ready process has executed.
1857 *
1858 * @return {number} Transition duration in milliseconds
1859 */
1860 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1861 return 0;
1862 };
1863
1864 /**
1865 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1866 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1867 * order in which users will navigate through the focusable elements via the "tab" key.
1868 *
1869 * @example
1870 * // TabIndexedElement is mixed into the ButtonWidget class
1871 * // to provide a tabIndex property.
1872 * var button1 = new OO.ui.ButtonWidget( {
1873 * label: 'fourth',
1874 * tabIndex: 4
1875 * } );
1876 * var button2 = new OO.ui.ButtonWidget( {
1877 * label: 'second',
1878 * tabIndex: 2
1879 * } );
1880 * var button3 = new OO.ui.ButtonWidget( {
1881 * label: 'third',
1882 * tabIndex: 3
1883 * } );
1884 * var button4 = new OO.ui.ButtonWidget( {
1885 * label: 'first',
1886 * tabIndex: 1
1887 * } );
1888 * $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
1889 *
1890 * @abstract
1891 * @class
1892 *
1893 * @constructor
1894 * @param {Object} [config] Configuration options
1895 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1896 * the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
1897 * functionality will be applied to it instead.
1898 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
1899 * order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
1900 * to remove the element from the tab-navigation flow.
1901 */
1902 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1903 // Configuration initialization
1904 config = $.extend( { tabIndex: 0 }, config );
1905
1906 // Properties
1907 this.$tabIndexed = null;
1908 this.tabIndex = null;
1909
1910 // Events
1911 this.connect( this, { disable: 'onTabIndexedElementDisable' } );
1912
1913 // Initialization
1914 this.setTabIndex( config.tabIndex );
1915 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1916 };
1917
1918 /* Setup */
1919
1920 OO.initClass( OO.ui.mixin.TabIndexedElement );
1921
1922 /* Methods */
1923
1924 /**
1925 * Set the element that should use the tabindex functionality.
1926 *
1927 * This method is used to retarget a tabindex mixin so that its functionality applies
1928 * to the specified element. If an element is currently using the functionality, the mixin’s
1929 * effect on that element is removed before the new element is set up.
1930 *
1931 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1932 * @chainable
1933 */
1934 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1935 var tabIndex = this.tabIndex;
1936 // Remove attributes from old $tabIndexed
1937 this.setTabIndex( null );
1938 // Force update of new $tabIndexed
1939 this.$tabIndexed = $tabIndexed;
1940 this.tabIndex = tabIndex;
1941 return this.updateTabIndex();
1942 };
1943
1944 /**
1945 * Set the value of the tabindex.
1946 *
1947 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
1948 * @chainable
1949 */
1950 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
1951 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
1952
1953 if ( this.tabIndex !== tabIndex ) {
1954 this.tabIndex = tabIndex;
1955 this.updateTabIndex();
1956 }
1957
1958 return this;
1959 };
1960
1961 /**
1962 * Update the `tabindex` attribute, in case of changes to tab index or
1963 * disabled state.
1964 *
1965 * @private
1966 * @chainable
1967 */
1968 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
1969 if ( this.$tabIndexed ) {
1970 if ( this.tabIndex !== null ) {
1971 // Do not index over disabled elements
1972 this.$tabIndexed.attr( {
1973 tabindex: this.isDisabled() ? -1 : this.tabIndex,
1974 // Support: ChromeVox and NVDA
1975 // These do not seem to inherit aria-disabled from parent elements
1976 'aria-disabled': this.isDisabled().toString()
1977 } );
1978 } else {
1979 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
1980 }
1981 }
1982 return this;
1983 };
1984
1985 /**
1986 * Handle disable events.
1987 *
1988 * @private
1989 * @param {boolean} disabled Element is disabled
1990 */
1991 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
1992 this.updateTabIndex();
1993 };
1994
1995 /**
1996 * Get the value of the tabindex.
1997 *
1998 * @return {number|null} Tabindex value
1999 */
2000 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2001 return this.tabIndex;
2002 };
2003
2004 /**
2005 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2006 *
2007 * If the element already has an ID then that is returned, otherwise unique ID is
2008 * generated, set on the element, and returned.
2009 *
2010 * @return {string|null} The ID of the focusable element
2011 */
2012 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2013 var id;
2014
2015 if ( !this.$tabIndexed ) {
2016 return null;
2017 }
2018 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2019 return null;
2020 }
2021
2022 id = this.$tabIndexed.attr( 'id' );
2023 if ( id === undefined ) {
2024 id = OO.ui.generateElementId();
2025 this.$tabIndexed.attr( 'id', id );
2026 }
2027
2028 return id;
2029 };
2030
2031 /**
2032 * Whether the node is 'labelable' according to the HTML spec
2033 * (i.e., whether it can be interacted with through a `<label for="…">`).
2034 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2035 *
2036 * @private
2037 * @param {jQuery} $node
2038 * @return {boolean}
2039 */
2040 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2041 var
2042 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2043 tagName = $node.prop( 'tagName' ).toLowerCase();
2044
2045 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2046 return true;
2047 }
2048 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2049 return true;
2050 }
2051 return false;
2052 };
2053
2054 /**
2055 * Focus this element.
2056 *
2057 * @chainable
2058 */
2059 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2060 if ( !this.isDisabled() ) {
2061 this.$tabIndexed.focus();
2062 }
2063 return this;
2064 };
2065
2066 /**
2067 * Blur this element.
2068 *
2069 * @chainable
2070 */
2071 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2072 this.$tabIndexed.blur();
2073 return this;
2074 };
2075
2076 /**
2077 * @inheritdoc OO.ui.Widget
2078 */
2079 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2080 this.focus();
2081 };
2082
2083 /**
2084 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2085 * interface element that can be configured with access keys for accessibility.
2086 * See the [OOjs UI documentation on MediaWiki] [1] for examples.
2087 *
2088 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons
2089 *
2090 * @abstract
2091 * @class
2092 *
2093 * @constructor
2094 * @param {Object} [config] Configuration options
2095 * @cfg {jQuery} [$button] The button element created by the class.
2096 * If this configuration is omitted, the button element will use a generated `<a>`.
2097 * @cfg {boolean} [framed=true] Render the button with a frame
2098 */
2099 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2100 // Configuration initialization
2101 config = config || {};
2102
2103 // Properties
2104 this.$button = null;
2105 this.framed = null;
2106 this.active = config.active !== undefined && config.active;
2107 this.onMouseUpHandler = this.onMouseUp.bind( this );
2108 this.onMouseDownHandler = this.onMouseDown.bind( this );
2109 this.onKeyDownHandler = this.onKeyDown.bind( this );
2110 this.onKeyUpHandler = this.onKeyUp.bind( this );
2111 this.onClickHandler = this.onClick.bind( this );
2112 this.onKeyPressHandler = this.onKeyPress.bind( this );
2113
2114 // Initialization
2115 this.$element.addClass( 'oo-ui-buttonElement' );
2116 this.toggleFramed( config.framed === undefined || config.framed );
2117 this.setButtonElement( config.$button || $( '<a>' ) );
2118 };
2119
2120 /* Setup */
2121
2122 OO.initClass( OO.ui.mixin.ButtonElement );
2123
2124 /* Static Properties */
2125
2126 /**
2127 * Cancel mouse down events.
2128 *
2129 * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
2130 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
2131 * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
2132 * parent widget.
2133 *
2134 * @static
2135 * @inheritable
2136 * @property {boolean}
2137 */
2138 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2139
2140 /* Events */
2141
2142 /**
2143 * A 'click' event is emitted when the button element is clicked.
2144 *
2145 * @event click
2146 */
2147
2148 /* Methods */
2149
2150 /**
2151 * Set the button element.
2152 *
2153 * This method is used to retarget a button mixin so that its functionality applies to
2154 * the specified button element instead of the one created by the class. If a button element
2155 * is already set, the method will remove the mixin’s effect on that element.
2156 *
2157 * @param {jQuery} $button Element to use as button
2158 */
2159 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2160 if ( this.$button ) {
2161 this.$button
2162 .removeClass( 'oo-ui-buttonElement-button' )
2163 .removeAttr( 'role accesskey' )
2164 .off( {
2165 mousedown: this.onMouseDownHandler,
2166 keydown: this.onKeyDownHandler,
2167 click: this.onClickHandler,
2168 keypress: this.onKeyPressHandler
2169 } );
2170 }
2171
2172 this.$button = $button
2173 .addClass( 'oo-ui-buttonElement-button' )
2174 .on( {
2175 mousedown: this.onMouseDownHandler,
2176 keydown: this.onKeyDownHandler,
2177 click: this.onClickHandler,
2178 keypress: this.onKeyPressHandler
2179 } );
2180
2181 // Add `role="button"` on `<a>` elements, where it's needed
2182 // `toUppercase()` is added for XHTML documents
2183 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2184 this.$button.attr( 'role', 'button' );
2185 }
2186 };
2187
2188 /**
2189 * Handles mouse down events.
2190 *
2191 * @protected
2192 * @param {jQuery.Event} e Mouse down event
2193 */
2194 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2195 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2196 return;
2197 }
2198 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2199 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2200 // reliably remove the pressed class
2201 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
2202 // Prevent change of focus unless specifically configured otherwise
2203 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2204 return false;
2205 }
2206 };
2207
2208 /**
2209 * Handles mouse up events.
2210 *
2211 * @protected
2212 * @param {MouseEvent} e Mouse up event
2213 */
2214 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
2215 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2216 return;
2217 }
2218 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2219 // Stop listening for mouseup, since we only needed this once
2220 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
2221 };
2222
2223 /**
2224 * Handles mouse click events.
2225 *
2226 * @protected
2227 * @param {jQuery.Event} e Mouse click event
2228 * @fires click
2229 */
2230 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2231 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2232 if ( this.emit( 'click' ) ) {
2233 return false;
2234 }
2235 }
2236 };
2237
2238 /**
2239 * Handles key down events.
2240 *
2241 * @protected
2242 * @param {jQuery.Event} e Key down event
2243 */
2244 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2245 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2246 return;
2247 }
2248 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2249 // Run the keyup handler no matter where the key is when the button is let go, so we can
2250 // reliably remove the pressed class
2251 this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
2252 };
2253
2254 /**
2255 * Handles key up events.
2256 *
2257 * @protected
2258 * @param {KeyboardEvent} e Key up event
2259 */
2260 OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
2261 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2262 return;
2263 }
2264 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2265 // Stop listening for keyup, since we only needed this once
2266 this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
2267 };
2268
2269 /**
2270 * Handles key press events.
2271 *
2272 * @protected
2273 * @param {jQuery.Event} e Key press event
2274 * @fires click
2275 */
2276 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2277 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2278 if ( this.emit( 'click' ) ) {
2279 return false;
2280 }
2281 }
2282 };
2283
2284 /**
2285 * Check if button has a frame.
2286 *
2287 * @return {boolean} Button is framed
2288 */
2289 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2290 return this.framed;
2291 };
2292
2293 /**
2294 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
2295 *
2296 * @param {boolean} [framed] Make button framed, omit to toggle
2297 * @chainable
2298 */
2299 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2300 framed = framed === undefined ? !this.framed : !!framed;
2301 if ( framed !== this.framed ) {
2302 this.framed = framed;
2303 this.$element
2304 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2305 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2306 this.updateThemeClasses();
2307 }
2308
2309 return this;
2310 };
2311
2312 /**
2313 * Set the button's active state.
2314 *
2315 * The active state can be set on:
2316 *
2317 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2318 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2319 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2320 *
2321 * @protected
2322 * @param {boolean} value Make button active
2323 * @chainable
2324 */
2325 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2326 this.active = !!value;
2327 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2328 this.updateThemeClasses();
2329 return this;
2330 };
2331
2332 /**
2333 * Check if the button is active
2334 *
2335 * @protected
2336 * @return {boolean} The button is active
2337 */
2338 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2339 return this.active;
2340 };
2341
2342 /**
2343 * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2344 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2345 * items from the group is done through the interface the class provides.
2346 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
2347 *
2348 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups
2349 *
2350 * @abstract
2351 * @mixins OO.EmitterList
2352 * @class
2353 *
2354 * @constructor
2355 * @param {Object} [config] Configuration options
2356 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2357 * is omitted, the group element will use a generated `<div>`.
2358 */
2359 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2360 // Configuration initialization
2361 config = config || {};
2362
2363 // Mixin constructors
2364 OO.EmitterList.call( this, config );
2365
2366 // Properties
2367 this.$group = null;
2368
2369 // Initialization
2370 this.setGroupElement( config.$group || $( '<div>' ) );
2371 };
2372
2373 /* Setup */
2374
2375 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2376
2377 /* Events */
2378
2379 /**
2380 * @event change
2381 *
2382 * A change event is emitted when the set of selected items changes.
2383 *
2384 * @param {OO.ui.Element[]} items Items currently in the group
2385 */
2386
2387 /* Methods */
2388
2389 /**
2390 * Set the group element.
2391 *
2392 * If an element is already set, items will be moved to the new element.
2393 *
2394 * @param {jQuery} $group Element to use as group
2395 */
2396 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2397 var i, len;
2398
2399 this.$group = $group;
2400 for ( i = 0, len = this.items.length; i < len; i++ ) {
2401 this.$group.append( this.items[ i ].$element );
2402 }
2403 };
2404
2405 /**
2406 * Get an item by its data.
2407 *
2408 * Only the first item with matching data will be returned. To return all matching items,
2409 * use the #getItemsFromData method.
2410 *
2411 * @param {Object} data Item data to search for
2412 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2413 */
2414 OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
2415 var i, len, item,
2416 hash = OO.getHash( data );
2417
2418 for ( i = 0, len = this.items.length; i < len; i++ ) {
2419 item = this.items[ i ];
2420 if ( hash === OO.getHash( item.getData() ) ) {
2421 return item;
2422 }
2423 }
2424
2425 return null;
2426 };
2427
2428 /**
2429 * Get items by their data.
2430 *
2431 * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
2432 *
2433 * @param {Object} data Item data to search for
2434 * @return {OO.ui.Element[]} Items with equivalent data
2435 */
2436 OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
2437 var i, len, item,
2438 hash = OO.getHash( data ),
2439 items = [];
2440
2441 for ( i = 0, len = this.items.length; i < len; i++ ) {
2442 item = this.items[ i ];
2443 if ( hash === OO.getHash( item.getData() ) ) {
2444 items.push( item );
2445 }
2446 }
2447
2448 return items;
2449 };
2450
2451 /**
2452 * Add items to the group.
2453 *
2454 * Items will be added to the end of the group array unless the optional `index` parameter specifies
2455 * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
2456 *
2457 * @param {OO.ui.Element[]} items An array of items to add to the group
2458 * @param {number} [index] Index of the insertion point
2459 * @chainable
2460 */
2461 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2462 // Mixin method
2463 OO.EmitterList.prototype.addItems.call( this, items, index );
2464
2465 this.emit( 'change', this.getItems() );
2466 return this;
2467 };
2468
2469 /**
2470 * @inheritdoc
2471 */
2472 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2473 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2474 this.insertItemElements( items, newIndex );
2475
2476 // Mixin method
2477 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2478
2479 return newIndex;
2480 };
2481
2482 /**
2483 * @inheritdoc
2484 */
2485 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2486 item.setElementGroup( this );
2487 this.insertItemElements( item, index );
2488
2489 // Mixin method
2490 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2491
2492 return index;
2493 };
2494
2495 /**
2496 * Insert elements into the group
2497 *
2498 * @private
2499 * @param {OO.ui.Element} itemWidget Item to insert
2500 * @param {number} index Insertion index
2501 */
2502 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2503 if ( index === undefined || index < 0 || index >= this.items.length ) {
2504 this.$group.append( itemWidget.$element );
2505 } else if ( index === 0 ) {
2506 this.$group.prepend( itemWidget.$element );
2507 } else {
2508 this.items[ index ].$element.before( itemWidget.$element );
2509 }
2510 };
2511
2512 /**
2513 * Remove the specified items from a group.
2514 *
2515 * Removed items are detached (not removed) from the DOM so that they may be reused.
2516 * To remove all items from a group, you may wish to use the #clearItems method instead.
2517 *
2518 * @param {OO.ui.Element[]} items An array of items to remove
2519 * @chainable
2520 */
2521 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2522 var i, len, item, index;
2523
2524 // Remove specific items elements
2525 for ( i = 0, len = items.length; i < len; i++ ) {
2526 item = items[ i ];
2527 index = this.items.indexOf( item );
2528 if ( index !== -1 ) {
2529 item.setElementGroup( null );
2530 item.$element.detach();
2531 }
2532 }
2533
2534 // Mixin method
2535 OO.EmitterList.prototype.removeItems.call( this, items );
2536
2537 this.emit( 'change', this.getItems() );
2538 return this;
2539 };
2540
2541 /**
2542 * Clear all items from the group.
2543 *
2544 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2545 * To remove only a subset of items from a group, use the #removeItems method.
2546 *
2547 * @chainable
2548 */
2549 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2550 var i, len;
2551
2552 // Remove all item elements
2553 for ( i = 0, len = this.items.length; i < len; i++ ) {
2554 this.items[ i ].setElementGroup( null );
2555 this.items[ i ].$element.detach();
2556 }
2557
2558 // Mixin method
2559 OO.EmitterList.prototype.clearItems.call( this );
2560
2561 this.emit( 'change', this.getItems() );
2562 return this;
2563 };
2564
2565 /**
2566 * IconElement is often mixed into other classes to generate an icon.
2567 * Icons are graphics, about the size of normal text. They are used to aid the user
2568 * in locating a control or to convey information in a space-efficient way. See the
2569 * [OOjs UI documentation on MediaWiki] [1] for a list of icons
2570 * included in the library.
2571 *
2572 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2573 *
2574 * @abstract
2575 * @class
2576 *
2577 * @constructor
2578 * @param {Object} [config] Configuration options
2579 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2580 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2581 * the icon element be set to an existing icon instead of the one generated by this class, set a
2582 * value using a jQuery selection. For example:
2583 *
2584 * // Use a <div> tag instead of a <span>
2585 * $icon: $("<div>")
2586 * // Use an existing icon element instead of the one generated by the class
2587 * $icon: this.$element
2588 * // Use an icon element from a child widget
2589 * $icon: this.childwidget.$element
2590 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of
2591 * symbolic names. A map is used for i18n purposes and contains a `default` icon
2592 * name and additional names keyed by language code. The `default` name is used when no icon is keyed
2593 * by the user's language.
2594 *
2595 * Example of an i18n map:
2596 *
2597 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2598 * See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
2599 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
2600 * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
2601 * text. The icon title is displayed when users move the mouse over the icon.
2602 */
2603 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2604 // Configuration initialization
2605 config = config || {};
2606
2607 // Properties
2608 this.$icon = null;
2609 this.icon = null;
2610 this.iconTitle = null;
2611
2612 // Initialization
2613 this.setIcon( config.icon || this.constructor.static.icon );
2614 this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
2615 this.setIconElement( config.$icon || $( '<span>' ) );
2616 };
2617
2618 /* Setup */
2619
2620 OO.initClass( OO.ui.mixin.IconElement );
2621
2622 /* Static Properties */
2623
2624 /**
2625 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used
2626 * for i18n purposes and contains a `default` icon name and additional names keyed by
2627 * language code. The `default` name is used when no icon is keyed by the user's language.
2628 *
2629 * Example of an i18n map:
2630 *
2631 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2632 *
2633 * Note: the static property will be overridden if the #icon configuration is used.
2634 *
2635 * @static
2636 * @inheritable
2637 * @property {Object|string}
2638 */
2639 OO.ui.mixin.IconElement.static.icon = null;
2640
2641 /**
2642 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2643 * function that returns title text, or `null` for no title.
2644 *
2645 * The static property will be overridden if the #iconTitle configuration is used.
2646 *
2647 * @static
2648 * @inheritable
2649 * @property {string|Function|null}
2650 */
2651 OO.ui.mixin.IconElement.static.iconTitle = null;
2652
2653 /* Methods */
2654
2655 /**
2656 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2657 * applies to the specified icon element instead of the one created by the class. If an icon
2658 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2659 * and mixin methods will no longer affect the element.
2660 *
2661 * @param {jQuery} $icon Element to use as icon
2662 */
2663 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2664 if ( this.$icon ) {
2665 this.$icon
2666 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2667 .removeAttr( 'title' );
2668 }
2669
2670 this.$icon = $icon
2671 .addClass( 'oo-ui-iconElement-icon' )
2672 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2673 if ( this.iconTitle !== null ) {
2674 this.$icon.attr( 'title', this.iconTitle );
2675 }
2676
2677 this.updateThemeClasses();
2678 };
2679
2680 /**
2681 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2682 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2683 * for an example.
2684 *
2685 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2686 * by language code, or `null` to remove the icon.
2687 * @chainable
2688 */
2689 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2690 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2691 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2692
2693 if ( this.icon !== icon ) {
2694 if ( this.$icon ) {
2695 if ( this.icon !== null ) {
2696 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2697 }
2698 if ( icon !== null ) {
2699 this.$icon.addClass( 'oo-ui-icon-' + icon );
2700 }
2701 }
2702 this.icon = icon;
2703 }
2704
2705 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
2706 this.updateThemeClasses();
2707
2708 return this;
2709 };
2710
2711 /**
2712 * Set the icon title. Use `null` to remove the title.
2713 *
2714 * @param {string|Function|null} iconTitle A text string used as the icon title,
2715 * a function that returns title text, or `null` for no title.
2716 * @chainable
2717 */
2718 OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
2719 iconTitle =
2720 ( typeof iconTitle === 'function' || ( typeof iconTitle === 'string' && iconTitle.length ) ) ?
2721 OO.ui.resolveMsg( iconTitle ) : null;
2722
2723 if ( this.iconTitle !== iconTitle ) {
2724 this.iconTitle = iconTitle;
2725 if ( this.$icon ) {
2726 if ( this.iconTitle !== null ) {
2727 this.$icon.attr( 'title', iconTitle );
2728 } else {
2729 this.$icon.removeAttr( 'title' );
2730 }
2731 }
2732 }
2733
2734 return this;
2735 };
2736
2737 /**
2738 * Get the symbolic name of the icon.
2739 *
2740 * @return {string} Icon name
2741 */
2742 OO.ui.mixin.IconElement.prototype.getIcon = function () {
2743 return this.icon;
2744 };
2745
2746 /**
2747 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
2748 *
2749 * @return {string} Icon title text
2750 */
2751 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
2752 return this.iconTitle;
2753 };
2754
2755 /**
2756 * IndicatorElement is often mixed into other classes to generate an indicator.
2757 * Indicators are small graphics that are generally used in two ways:
2758 *
2759 * - To draw attention to the status of an item. For example, an indicator might be
2760 * used to show that an item in a list has errors that need to be resolved.
2761 * - To clarify the function of a control that acts in an exceptional way (a button
2762 * that opens a menu instead of performing an action directly, for example).
2763 *
2764 * For a list of indicators included in the library, please see the
2765 * [OOjs UI documentation on MediaWiki] [1].
2766 *
2767 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2768 *
2769 * @abstract
2770 * @class
2771 *
2772 * @constructor
2773 * @param {Object} [config] Configuration options
2774 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
2775 * configuration is omitted, the indicator element will use a generated `<span>`.
2776 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2777 * See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
2778 * in the library.
2779 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
2780 * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
2781 * or a function that returns title text. The indicator title is displayed when users move
2782 * the mouse over the indicator.
2783 */
2784 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
2785 // Configuration initialization
2786 config = config || {};
2787
2788 // Properties
2789 this.$indicator = null;
2790 this.indicator = null;
2791 this.indicatorTitle = null;
2792
2793 // Initialization
2794 this.setIndicator( config.indicator || this.constructor.static.indicator );
2795 this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
2796 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
2797 };
2798
2799 /* Setup */
2800
2801 OO.initClass( OO.ui.mixin.IndicatorElement );
2802
2803 /* Static Properties */
2804
2805 /**
2806 * Symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2807 * The static property will be overridden if the #indicator configuration is used.
2808 *
2809 * @static
2810 * @inheritable
2811 * @property {string|null}
2812 */
2813 OO.ui.mixin.IndicatorElement.static.indicator = null;
2814
2815 /**
2816 * A text string used as the indicator title, a function that returns title text, or `null`
2817 * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
2818 *
2819 * @static
2820 * @inheritable
2821 * @property {string|Function|null}
2822 */
2823 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
2824
2825 /* Methods */
2826
2827 /**
2828 * Set the indicator element.
2829 *
2830 * If an element is already set, it will be cleaned up before setting up the new element.
2831 *
2832 * @param {jQuery} $indicator Element to use as indicator
2833 */
2834 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
2835 if ( this.$indicator ) {
2836 this.$indicator
2837 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
2838 .removeAttr( 'title' );
2839 }
2840
2841 this.$indicator = $indicator
2842 .addClass( 'oo-ui-indicatorElement-indicator' )
2843 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
2844 if ( this.indicatorTitle !== null ) {
2845 this.$indicator.attr( 'title', this.indicatorTitle );
2846 }
2847
2848 this.updateThemeClasses();
2849 };
2850
2851 /**
2852 * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
2853 *
2854 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
2855 * @chainable
2856 */
2857 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
2858 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
2859
2860 if ( this.indicator !== indicator ) {
2861 if ( this.$indicator ) {
2862 if ( this.indicator !== null ) {
2863 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
2864 }
2865 if ( indicator !== null ) {
2866 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
2867 }
2868 }
2869 this.indicator = indicator;
2870 }
2871
2872 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
2873 this.updateThemeClasses();
2874
2875 return this;
2876 };
2877
2878 /**
2879 * Set the indicator title.
2880 *
2881 * The title is displayed when a user moves the mouse over the indicator.
2882 *
2883 * @param {string|Function|null} indicatorTitle Indicator title text, a function that returns text, or
2884 * `null` for no indicator title
2885 * @chainable
2886 */
2887 OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
2888 indicatorTitle =
2889 ( typeof indicatorTitle === 'function' || ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ) ?
2890 OO.ui.resolveMsg( indicatorTitle ) : null;
2891
2892 if ( this.indicatorTitle !== indicatorTitle ) {
2893 this.indicatorTitle = indicatorTitle;
2894 if ( this.$indicator ) {
2895 if ( this.indicatorTitle !== null ) {
2896 this.$indicator.attr( 'title', indicatorTitle );
2897 } else {
2898 this.$indicator.removeAttr( 'title' );
2899 }
2900 }
2901 }
2902
2903 return this;
2904 };
2905
2906 /**
2907 * Get the symbolic name of the indicator (e.g., ‘alert’ or ‘down’).
2908 *
2909 * @return {string} Symbolic name of indicator
2910 */
2911 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
2912 return this.indicator;
2913 };
2914
2915 /**
2916 * Get the indicator title.
2917 *
2918 * The title is displayed when a user moves the mouse over the indicator.
2919 *
2920 * @return {string} Indicator title text
2921 */
2922 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
2923 return this.indicatorTitle;
2924 };
2925
2926 /**
2927 * LabelElement is often mixed into other classes to generate a label, which
2928 * helps identify the function of an interface element.
2929 * See the [OOjs UI documentation on MediaWiki] [1] for more information.
2930 *
2931 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2932 *
2933 * @abstract
2934 * @class
2935 *
2936 * @constructor
2937 * @param {Object} [config] Configuration options
2938 * @cfg {jQuery} [$label] The label element created by the class. If this
2939 * configuration is omitted, the label element will use a generated `<span>`.
2940 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
2941 * as a plaintext string, a jQuery selection of elements, or a function that will produce a string
2942 * in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
2943 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
2944 */
2945 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2946 // Configuration initialization
2947 config = config || {};
2948
2949 // Properties
2950 this.$label = null;
2951 this.label = null;
2952
2953 // Initialization
2954 this.setLabel( config.label || this.constructor.static.label );
2955 this.setLabelElement( config.$label || $( '<span>' ) );
2956 };
2957
2958 /* Setup */
2959
2960 OO.initClass( OO.ui.mixin.LabelElement );
2961
2962 /* Events */
2963
2964 /**
2965 * @event labelChange
2966 * @param {string} value
2967 */
2968
2969 /* Static Properties */
2970
2971 /**
2972 * The label text. The label can be specified as a plaintext string, a function that will
2973 * produce a string in the future, or `null` for no label. The static value will
2974 * be overridden if a label is specified with the #label config option.
2975 *
2976 * @static
2977 * @inheritable
2978 * @property {string|Function|null}
2979 */
2980 OO.ui.mixin.LabelElement.static.label = null;
2981
2982 /* Static methods */
2983
2984 /**
2985 * Highlight the first occurrence of the query in the given text
2986 *
2987 * @param {string} text Text
2988 * @param {string} query Query to find
2989 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2990 * @return {jQuery} Text with the first match of the query
2991 * sub-string wrapped in highlighted span
2992 */
2993 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2994 var i, tLen, qLen,
2995 offset = -1,
2996 $result = $( '<span>' );
2997
2998 if ( compare ) {
2999 tLen = text.length;
3000 qLen = query.length;
3001 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
3002 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
3003 offset = i;
3004 }
3005 }
3006 } else {
3007 offset = text.toLowerCase().indexOf( query.toLowerCase() );
3008 }
3009
3010 if ( !query.length || offset === -1 ) {
3011 $result.text( text );
3012 } else {
3013 $result.append(
3014 document.createTextNode( text.slice( 0, offset ) ),
3015 $( '<span>' )
3016 .addClass( 'oo-ui-labelElement-label-highlight' )
3017 .text( text.slice( offset, offset + query.length ) ),
3018 document.createTextNode( text.slice( offset + query.length ) )
3019 );
3020 }
3021 return $result.contents();
3022 };
3023
3024 /* Methods */
3025
3026 /**
3027 * Set the label element.
3028 *
3029 * If an element is already set, it will be cleaned up before setting up the new element.
3030 *
3031 * @param {jQuery} $label Element to use as label
3032 */
3033 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
3034 if ( this.$label ) {
3035 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
3036 }
3037
3038 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
3039 this.setLabelContent( this.label );
3040 };
3041
3042 /**
3043 * Set the label.
3044 *
3045 * An empty string will result in the label being hidden. A string containing only whitespace will
3046 * be converted to a single `&nbsp;`.
3047 *
3048 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
3049 * text; or null for no label
3050 * @chainable
3051 */
3052 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
3053 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
3054 label = ( ( typeof label === 'string' || label instanceof jQuery ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
3055
3056 if ( this.label !== label ) {
3057 if ( this.$label ) {
3058 this.setLabelContent( label );
3059 }
3060 this.label = label;
3061 this.emit( 'labelChange' );
3062 }
3063
3064 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label );
3065
3066 return this;
3067 };
3068
3069 /**
3070 * Set the label as plain text with a highlighted query
3071 *
3072 * @param {string} text Text label to set
3073 * @param {string} query Substring of text to highlight
3074 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
3075 * @chainable
3076 */
3077 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
3078 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
3079 };
3080
3081 /**
3082 * Get the label.
3083 *
3084 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
3085 * text; or null for no label
3086 */
3087 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
3088 return this.label;
3089 };
3090
3091 /**
3092 * Set the content of the label.
3093 *
3094 * Do not call this method until after the label element has been set by #setLabelElement.
3095 *
3096 * @private
3097 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
3098 * text; or null for no label
3099 */
3100 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
3101 if ( typeof label === 'string' ) {
3102 if ( label.match( /^\s*$/ ) ) {
3103 // Convert whitespace only string to a single non-breaking space
3104 this.$label.html( '&nbsp;' );
3105 } else {
3106 this.$label.text( label );
3107 }
3108 } else if ( label instanceof OO.ui.HtmlSnippet ) {
3109 this.$label.html( label.toString() );
3110 } else if ( label instanceof jQuery ) {
3111 this.$label.empty().append( label );
3112 } else {
3113 this.$label.empty();
3114 }
3115 };
3116
3117 /**
3118 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3119 * additional functionality to an element created by another class. The class provides
3120 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3121 * which are used to customize the look and feel of a widget to better describe its
3122 * importance and functionality.
3123 *
3124 * The library currently contains the following styling flags for general use:
3125 *
3126 * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process.
3127 * - **destructive**: Destructive styling is applied to convey that the widget will remove something.
3128 * - **constructive**: Constructive styling is deprecated since v0.23.2 and equivalent to progressive.
3129 *
3130 * The flags affect the appearance of the buttons:
3131 *
3132 * @example
3133 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3134 * var button1 = new OO.ui.ButtonWidget( {
3135 * label: 'Progressive',
3136 * flags: 'progressive'
3137 * } );
3138 * var button2 = new OO.ui.ButtonWidget( {
3139 * label: 'Destructive',
3140 * flags: 'destructive'
3141 * } );
3142 * $( 'body' ).append( button1.$element, button2.$element );
3143 *
3144 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**.
3145 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
3146 *
3147 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3148 *
3149 * @abstract
3150 * @class
3151 *
3152 * @constructor
3153 * @param {Object} [config] Configuration options
3154 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary') to apply.
3155 * Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
3156 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
3157 * @cfg {jQuery} [$flagged] The flagged element. By default,
3158 * the flagged functionality is applied to the element created by the class ($element).
3159 * If a different element is specified, the flagged functionality will be applied to it instead.
3160 */
3161 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3162 // Configuration initialization
3163 config = config || {};
3164
3165 // Properties
3166 this.flags = {};
3167 this.$flagged = null;
3168
3169 // Initialization
3170 this.setFlags( config.flags );
3171 this.setFlaggedElement( config.$flagged || this.$element );
3172 };
3173
3174 /* Events */
3175
3176 /**
3177 * @event flag
3178 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3179 * parameter contains the name of each modified flag and indicates whether it was
3180 * added or removed.
3181 *
3182 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3183 * that the flag was added, `false` that the flag was removed.
3184 */
3185
3186 /* Methods */
3187
3188 /**
3189 * Set the flagged element.
3190 *
3191 * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
3192 * If an element is already set, the method will remove the mixin’s effect on that element.
3193 *
3194 * @param {jQuery} $flagged Element that should be flagged
3195 */
3196 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3197 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3198 return 'oo-ui-flaggedElement-' + flag;
3199 } ).join( ' ' );
3200
3201 if ( this.$flagged ) {
3202 this.$flagged.removeClass( classNames );
3203 }
3204
3205 this.$flagged = $flagged.addClass( classNames );
3206 };
3207
3208 /**
3209 * Check if the specified flag is set.
3210 *
3211 * @param {string} flag Name of flag
3212 * @return {boolean} The flag is set
3213 */
3214 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3215 // This may be called before the constructor, thus before this.flags is set
3216 return this.flags && ( flag in this.flags );
3217 };
3218
3219 /**
3220 * Get the names of all flags set.
3221 *
3222 * @return {string[]} Flag names
3223 */
3224 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3225 // This may be called before the constructor, thus before this.flags is set
3226 return Object.keys( this.flags || {} );
3227 };
3228
3229 /**
3230 * Clear all flags.
3231 *
3232 * @chainable
3233 * @fires flag
3234 */
3235 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3236 var flag, className,
3237 changes = {},
3238 remove = [],
3239 classPrefix = 'oo-ui-flaggedElement-';
3240
3241 for ( flag in this.flags ) {
3242 className = classPrefix + flag;
3243 changes[ flag ] = false;
3244 delete this.flags[ flag ];
3245 remove.push( className );
3246 }
3247
3248 if ( this.$flagged ) {
3249 this.$flagged.removeClass( remove.join( ' ' ) );
3250 }
3251
3252 this.updateThemeClasses();
3253 this.emit( 'flag', changes );
3254
3255 return this;
3256 };
3257
3258 /**
3259 * Add one or more flags.
3260 *
3261 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3262 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3263 * be added (`true`) or removed (`false`).
3264 * @chainable
3265 * @fires flag
3266 */
3267 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3268 var i, len, flag, className,
3269 changes = {},
3270 add = [],
3271 remove = [],
3272 classPrefix = 'oo-ui-flaggedElement-';
3273
3274 if ( typeof flags === 'string' ) {
3275 className = classPrefix + flags;
3276 // Set
3277 if ( !this.flags[ flags ] ) {
3278 this.flags[ flags ] = true;
3279 add.push( className );
3280 }
3281 } else if ( Array.isArray( flags ) ) {
3282 for ( i = 0, len = flags.length; i < len; i++ ) {
3283 flag = flags[ i ];
3284 className = classPrefix + flag;
3285 // Set
3286 if ( !this.flags[ flag ] ) {
3287 changes[ flag ] = true;
3288 this.flags[ flag ] = true;
3289 add.push( className );
3290 }
3291 }
3292 } else if ( OO.isPlainObject( flags ) ) {
3293 for ( flag in flags ) {
3294 className = classPrefix + flag;
3295 if ( flags[ flag ] ) {
3296 // Set
3297 if ( !this.flags[ flag ] ) {
3298 changes[ flag ] = true;
3299 this.flags[ flag ] = true;
3300 add.push( className );
3301 }
3302 } else {
3303 // Remove
3304 if ( this.flags[ flag ] ) {
3305 changes[ flag ] = false;
3306 delete this.flags[ flag ];
3307 remove.push( className );
3308 }
3309 }
3310 }
3311 }
3312
3313 if ( this.$flagged ) {
3314 this.$flagged
3315 .addClass( add.join( ' ' ) )
3316 .removeClass( remove.join( ' ' ) );
3317 }
3318
3319 this.updateThemeClasses();
3320 this.emit( 'flag', changes );
3321
3322 return this;
3323 };
3324
3325 /**
3326 * TitledElement is mixed into other classes to provide a `title` attribute.
3327 * Titles are rendered by the browser and are made visible when the user moves
3328 * the mouse over the element. Titles are not visible on touch devices.
3329 *
3330 * @example
3331 * // TitledElement provides a 'title' attribute to the
3332 * // ButtonWidget class
3333 * var button = new OO.ui.ButtonWidget( {
3334 * label: 'Button with Title',
3335 * title: 'I am a button'
3336 * } );
3337 * $( 'body' ).append( button.$element );
3338 *
3339 * @abstract
3340 * @class
3341 *
3342 * @constructor
3343 * @param {Object} [config] Configuration options
3344 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3345 * If this config is omitted, the title functionality is applied to $element, the
3346 * element created by the class.
3347 * @cfg {string|Function} [title] The title text or a function that returns text. If
3348 * this config is omitted, the value of the {@link #static-title static title} property is used.
3349 */
3350 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3351 // Configuration initialization
3352 config = config || {};
3353
3354 // Properties
3355 this.$titled = null;
3356 this.title = null;
3357
3358 // Initialization
3359 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3360 this.setTitledElement( config.$titled || this.$element );
3361 };
3362
3363 /* Setup */
3364
3365 OO.initClass( OO.ui.mixin.TitledElement );
3366
3367 /* Static Properties */
3368
3369 /**
3370 * The title text, a function that returns text, or `null` for no title. The value of the static property
3371 * is overridden if the #title config option is used.
3372 *
3373 * @static
3374 * @inheritable
3375 * @property {string|Function|null}
3376 */
3377 OO.ui.mixin.TitledElement.static.title = null;
3378
3379 /* Methods */
3380
3381 /**
3382 * Set the titled element.
3383 *
3384 * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
3385 * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
3386 *
3387 * @param {jQuery} $titled Element that should use the 'titled' functionality
3388 */
3389 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3390 if ( this.$titled ) {
3391 this.$titled.removeAttr( 'title' );
3392 }
3393
3394 this.$titled = $titled;
3395 if ( this.title ) {
3396 this.updateTitle();
3397 }
3398 };
3399
3400 /**
3401 * Set title.
3402 *
3403 * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
3404 * @chainable
3405 */
3406 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3407 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3408 title = ( typeof title === 'string' && title.length ) ? title : null;
3409
3410 if ( this.title !== title ) {
3411 this.title = title;
3412 this.updateTitle();
3413 }
3414
3415 return this;
3416 };
3417
3418 /**
3419 * Update the title attribute, in case of changes to title or accessKey.
3420 *
3421 * @protected
3422 * @chainable
3423 */
3424 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3425 var title = this.getTitle();
3426 if ( this.$titled ) {
3427 if ( title !== null ) {
3428 // Only if this is an AccessKeyedElement
3429 if ( this.formatTitleWithAccessKey ) {
3430 title = this.formatTitleWithAccessKey( title );
3431 }
3432 this.$titled.attr( 'title', title );
3433 } else {
3434 this.$titled.removeAttr( 'title' );
3435 }
3436 }
3437 return this;
3438 };
3439
3440 /**
3441 * Get title.
3442 *
3443 * @return {string} Title string
3444 */
3445 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3446 return this.title;
3447 };
3448
3449 /**
3450 * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
3451 * Accesskeys allow an user to go to a specific element by using
3452 * a shortcut combination of a browser specific keys + the key
3453 * set to the field.
3454 *
3455 * @example
3456 * // AccessKeyedElement provides an 'accesskey' attribute to the
3457 * // ButtonWidget class
3458 * var button = new OO.ui.ButtonWidget( {
3459 * label: 'Button with Accesskey',
3460 * accessKey: 'k'
3461 * } );
3462 * $( 'body' ).append( button.$element );
3463 *
3464 * @abstract
3465 * @class
3466 *
3467 * @constructor
3468 * @param {Object} [config] Configuration options
3469 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3470 * If this config is omitted, the accesskey functionality is applied to $element, the
3471 * element created by the class.
3472 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3473 * this config is omitted, no accesskey will be added.
3474 */
3475 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3476 // Configuration initialization
3477 config = config || {};
3478
3479 // Properties
3480 this.$accessKeyed = null;
3481 this.accessKey = null;
3482
3483 // Initialization
3484 this.setAccessKey( config.accessKey || null );
3485 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3486
3487 // If this is also a TitledElement and it initialized before we did, we may have
3488 // to update the title with the access key
3489 if ( this.updateTitle ) {
3490 this.updateTitle();
3491 }
3492 };
3493
3494 /* Setup */
3495
3496 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3497
3498 /* Static Properties */
3499
3500 /**
3501 * The access key, a function that returns a key, or `null` for no accesskey.
3502 *
3503 * @static
3504 * @inheritable
3505 * @property {string|Function|null}
3506 */
3507 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3508
3509 /* Methods */
3510
3511 /**
3512 * Set the accesskeyed element.
3513 *
3514 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
3515 * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
3516 *
3517 * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
3518 */
3519 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3520 if ( this.$accessKeyed ) {
3521 this.$accessKeyed.removeAttr( 'accesskey' );
3522 }
3523
3524 this.$accessKeyed = $accessKeyed;
3525 if ( this.accessKey ) {
3526 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3527 }
3528 };
3529
3530 /**
3531 * Set accesskey.
3532 *
3533 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no accesskey
3534 * @chainable
3535 */
3536 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3537 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3538
3539 if ( this.accessKey !== accessKey ) {
3540 if ( this.$accessKeyed ) {
3541 if ( accessKey !== null ) {
3542 this.$accessKeyed.attr( 'accesskey', accessKey );
3543 } else {
3544 this.$accessKeyed.removeAttr( 'accesskey' );
3545 }
3546 }
3547 this.accessKey = accessKey;
3548
3549 // Only if this is a TitledElement
3550 if ( this.updateTitle ) {
3551 this.updateTitle();
3552 }
3553 }
3554
3555 return this;
3556 };
3557
3558 /**
3559 * Get accesskey.
3560 *
3561 * @return {string} accessKey string
3562 */
3563 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3564 return this.accessKey;
3565 };
3566
3567 /**
3568 * Add information about the access key to the element's tooltip label.
3569 * (This is only public for hacky usage in FieldLayout.)
3570 *
3571 * @param {string} title Tooltip label for `title` attribute
3572 * @return {string}
3573 */
3574 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3575 var accessKey;
3576
3577 if ( !this.$accessKeyed ) {
3578 // Not initialized yet; the constructor will call updateTitle() which will rerun this function
3579 return title;
3580 }
3581 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the single key
3582 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3583 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3584 } else {
3585 accessKey = this.getAccessKey();
3586 }
3587 if ( accessKey ) {
3588 title += ' [' + accessKey + ']';
3589 }
3590 return title;
3591 };
3592
3593 /**
3594 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3595 * feels, and functionality can be customized via the class’s configuration options
3596 * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information
3597 * and examples.
3598 *
3599 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches
3600 *
3601 * @example
3602 * // A button widget
3603 * var button = new OO.ui.ButtonWidget( {
3604 * label: 'Button with Icon',
3605 * icon: 'trash',
3606 * iconTitle: 'Remove'
3607 * } );
3608 * $( 'body' ).append( button.$element );
3609 *
3610 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3611 *
3612 * @class
3613 * @extends OO.ui.Widget
3614 * @mixins OO.ui.mixin.ButtonElement
3615 * @mixins OO.ui.mixin.IconElement
3616 * @mixins OO.ui.mixin.IndicatorElement
3617 * @mixins OO.ui.mixin.LabelElement
3618 * @mixins OO.ui.mixin.TitledElement
3619 * @mixins OO.ui.mixin.FlaggedElement
3620 * @mixins OO.ui.mixin.TabIndexedElement
3621 * @mixins OO.ui.mixin.AccessKeyedElement
3622 *
3623 * @constructor
3624 * @param {Object} [config] Configuration options
3625 * @cfg {boolean} [active=false] Whether button should be shown as active
3626 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3627 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3628 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3629 */
3630 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3631 // Configuration initialization
3632 config = config || {};
3633
3634 // Parent constructor
3635 OO.ui.ButtonWidget.parent.call( this, config );
3636
3637 // Mixin constructors
3638 OO.ui.mixin.ButtonElement.call( this, config );
3639 OO.ui.mixin.IconElement.call( this, config );
3640 OO.ui.mixin.IndicatorElement.call( this, config );
3641 OO.ui.mixin.LabelElement.call( this, config );
3642 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3643 OO.ui.mixin.FlaggedElement.call( this, config );
3644 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
3645 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
3646
3647 // Properties
3648 this.href = null;
3649 this.target = null;
3650 this.noFollow = false;
3651
3652 // Events
3653 this.connect( this, { disable: 'onDisable' } );
3654
3655 // Initialization
3656 this.$button.append( this.$icon, this.$label, this.$indicator );
3657 this.$element
3658 .addClass( 'oo-ui-buttonWidget' )
3659 .append( this.$button );
3660 this.setActive( config.active );
3661 this.setHref( config.href );
3662 this.setTarget( config.target );
3663 this.setNoFollow( config.noFollow );
3664 };
3665
3666 /* Setup */
3667
3668 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3669 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3670 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3671 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3672 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3673 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3674 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3675 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3676 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3677
3678 /* Static Properties */
3679
3680 /**
3681 * @static
3682 * @inheritdoc
3683 */
3684 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3685
3686 /**
3687 * @static
3688 * @inheritdoc
3689 */
3690 OO.ui.ButtonWidget.static.tagName = 'span';
3691
3692 /* Methods */
3693
3694 /**
3695 * Get hyperlink location.
3696 *
3697 * @return {string} Hyperlink location
3698 */
3699 OO.ui.ButtonWidget.prototype.getHref = function () {
3700 return this.href;
3701 };
3702
3703 /**
3704 * Get hyperlink target.
3705 *
3706 * @return {string} Hyperlink target
3707 */
3708 OO.ui.ButtonWidget.prototype.getTarget = function () {
3709 return this.target;
3710 };
3711
3712 /**
3713 * Get search engine traversal hint.
3714 *
3715 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3716 */
3717 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3718 return this.noFollow;
3719 };
3720
3721 /**
3722 * Set hyperlink location.
3723 *
3724 * @param {string|null} href Hyperlink location, null to remove
3725 */
3726 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3727 href = typeof href === 'string' ? href : null;
3728 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3729 href = './' + href;
3730 }
3731
3732 if ( href !== this.href ) {
3733 this.href = href;
3734 this.updateHref();
3735 }
3736
3737 return this;
3738 };
3739
3740 /**
3741 * Update the `href` attribute, in case of changes to href or
3742 * disabled state.
3743 *
3744 * @private
3745 * @chainable
3746 */
3747 OO.ui.ButtonWidget.prototype.updateHref = function () {
3748 if ( this.href !== null && !this.isDisabled() ) {
3749 this.$button.attr( 'href', this.href );
3750 } else {
3751 this.$button.removeAttr( 'href' );
3752 }
3753
3754 return this;
3755 };
3756
3757 /**
3758 * Handle disable events.
3759 *
3760 * @private
3761 * @param {boolean} disabled Element is disabled
3762 */
3763 OO.ui.ButtonWidget.prototype.onDisable = function () {
3764 this.updateHref();
3765 };
3766
3767 /**
3768 * Set hyperlink target.
3769 *
3770 * @param {string|null} target Hyperlink target, null to remove
3771 */
3772 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3773 target = typeof target === 'string' ? target : null;
3774
3775 if ( target !== this.target ) {
3776 this.target = target;
3777 if ( target !== null ) {
3778 this.$button.attr( 'target', target );
3779 } else {
3780 this.$button.removeAttr( 'target' );
3781 }
3782 }
3783
3784 return this;
3785 };
3786
3787 /**
3788 * Set search engine traversal hint.
3789 *
3790 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3791 */
3792 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3793 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3794
3795 if ( noFollow !== this.noFollow ) {
3796 this.noFollow = noFollow;
3797 if ( noFollow ) {
3798 this.$button.attr( 'rel', 'nofollow' );
3799 } else {
3800 this.$button.removeAttr( 'rel' );
3801 }
3802 }
3803
3804 return this;
3805 };
3806
3807 // Override method visibility hints from ButtonElement
3808 /**
3809 * @method setActive
3810 * @inheritdoc
3811 */
3812 /**
3813 * @method isActive
3814 * @inheritdoc
3815 */
3816
3817 /**
3818 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3819 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3820 * removed, and cleared from the group.
3821 *
3822 * @example
3823 * // Example: A ButtonGroupWidget with two buttons
3824 * var button1 = new OO.ui.PopupButtonWidget( {
3825 * label: 'Select a category',
3826 * icon: 'menu',
3827 * popup: {
3828 * $content: $( '<p>List of categories...</p>' ),
3829 * padded: true,
3830 * align: 'left'
3831 * }
3832 * } );
3833 * var button2 = new OO.ui.ButtonWidget( {
3834 * label: 'Add item'
3835 * });
3836 * var buttonGroup = new OO.ui.ButtonGroupWidget( {
3837 * items: [button1, button2]
3838 * } );
3839 * $( 'body' ).append( buttonGroup.$element );
3840 *
3841 * @class
3842 * @extends OO.ui.Widget
3843 * @mixins OO.ui.mixin.GroupElement
3844 *
3845 * @constructor
3846 * @param {Object} [config] Configuration options
3847 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3848 */
3849 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3850 // Configuration initialization
3851 config = config || {};
3852
3853 // Parent constructor
3854 OO.ui.ButtonGroupWidget.parent.call( this, config );
3855
3856 // Mixin constructors
3857 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
3858
3859 // Initialization
3860 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3861 if ( Array.isArray( config.items ) ) {
3862 this.addItems( config.items );
3863 }
3864 };
3865
3866 /* Setup */
3867
3868 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3869 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3870
3871 /* Static Properties */
3872
3873 /**
3874 * @static
3875 * @inheritdoc
3876 */
3877 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3878
3879 /* Methods */
3880
3881 /**
3882 * Focus the widget
3883 *
3884 * @chainable
3885 */
3886 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3887 if ( !this.isDisabled() ) {
3888 if ( this.items[ 0 ] ) {
3889 this.items[ 0 ].focus();
3890 }
3891 }
3892 return this;
3893 };
3894
3895 /**
3896 * @inheritdoc
3897 */
3898 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3899 this.focus();
3900 };
3901
3902 /**
3903 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget,
3904 * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1]
3905 * for a list of icons included in the library.
3906 *
3907 * @example
3908 * // An icon widget with a label
3909 * var myIcon = new OO.ui.IconWidget( {
3910 * icon: 'help',
3911 * iconTitle: 'Help'
3912 * } );
3913 * // Create a label.
3914 * var iconLabel = new OO.ui.LabelWidget( {
3915 * label: 'Help'
3916 * } );
3917 * $( 'body' ).append( myIcon.$element, iconLabel.$element );
3918 *
3919 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
3920 *
3921 * @class
3922 * @extends OO.ui.Widget
3923 * @mixins OO.ui.mixin.IconElement
3924 * @mixins OO.ui.mixin.TitledElement
3925 * @mixins OO.ui.mixin.FlaggedElement
3926 *
3927 * @constructor
3928 * @param {Object} [config] Configuration options
3929 */
3930 OO.ui.IconWidget = function OoUiIconWidget( config ) {
3931 // Configuration initialization
3932 config = config || {};
3933
3934 // Parent constructor
3935 OO.ui.IconWidget.parent.call( this, config );
3936
3937 // Mixin constructors
3938 OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
3939 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3940 OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
3941
3942 // Initialization
3943 this.$element.addClass( 'oo-ui-iconWidget' );
3944 };
3945
3946 /* Setup */
3947
3948 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
3949 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
3950 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
3951 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
3952
3953 /* Static Properties */
3954
3955 /**
3956 * @static
3957 * @inheritdoc
3958 */
3959 OO.ui.IconWidget.static.tagName = 'span';
3960
3961 /**
3962 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
3963 * attention to the status of an item or to clarify the function of a control. For a list of
3964 * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
3965 *
3966 * @example
3967 * // Example of an indicator widget
3968 * var indicator1 = new OO.ui.IndicatorWidget( {
3969 * indicator: 'alert'
3970 * } );
3971 *
3972 * // Create a fieldset layout to add a label
3973 * var fieldset = new OO.ui.FieldsetLayout();
3974 * fieldset.addItems( [
3975 * new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
3976 * ] );
3977 * $( 'body' ).append( fieldset.$element );
3978 *
3979 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3980 *
3981 * @class
3982 * @extends OO.ui.Widget
3983 * @mixins OO.ui.mixin.IndicatorElement
3984 * @mixins OO.ui.mixin.TitledElement
3985 *
3986 * @constructor
3987 * @param {Object} [config] Configuration options
3988 */
3989 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
3990 // Configuration initialization
3991 config = config || {};
3992
3993 // Parent constructor
3994 OO.ui.IndicatorWidget.parent.call( this, config );
3995
3996 // Mixin constructors
3997 OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
3998 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
3999
4000 // Initialization
4001 this.$element.addClass( 'oo-ui-indicatorWidget' );
4002 };
4003
4004 /* Setup */
4005
4006 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4007 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4008 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4009
4010 /* Static Properties */
4011
4012 /**
4013 * @static
4014 * @inheritdoc
4015 */
4016 OO.ui.IndicatorWidget.static.tagName = 'span';
4017
4018 /**
4019 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4020 * be configured with a `label` option that is set to a string, a label node, or a function:
4021 *
4022 * - String: a plaintext string
4023 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4024 * label that includes a link or special styling, such as a gray color or additional graphical elements.
4025 * - Function: a function that will produce a string in the future. Functions are used
4026 * in cases where the value of the label is not currently defined.
4027 *
4028 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
4029 * will come into focus when the label is clicked.
4030 *
4031 * @example
4032 * // Examples of LabelWidgets
4033 * var label1 = new OO.ui.LabelWidget( {
4034 * label: 'plaintext label'
4035 * } );
4036 * var label2 = new OO.ui.LabelWidget( {
4037 * label: $( '<a href="default.html">jQuery label</a>' )
4038 * } );
4039 * // Create a fieldset layout with fields for each example
4040 * var fieldset = new OO.ui.FieldsetLayout();
4041 * fieldset.addItems( [
4042 * new OO.ui.FieldLayout( label1 ),
4043 * new OO.ui.FieldLayout( label2 )
4044 * ] );
4045 * $( 'body' ).append( fieldset.$element );
4046 *
4047 * @class
4048 * @extends OO.ui.Widget
4049 * @mixins OO.ui.mixin.LabelElement
4050 * @mixins OO.ui.mixin.TitledElement
4051 *
4052 * @constructor
4053 * @param {Object} [config] Configuration options
4054 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4055 * Clicking the label will focus the specified input field.
4056 */
4057 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4058 // Configuration initialization
4059 config = config || {};
4060
4061 // Parent constructor
4062 OO.ui.LabelWidget.parent.call( this, config );
4063
4064 // Mixin constructors
4065 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
4066 OO.ui.mixin.TitledElement.call( this, config );
4067
4068 // Properties
4069 this.input = config.input;
4070
4071 // Initialization
4072 if ( this.input ) {
4073 if ( this.input.getInputId() ) {
4074 this.$element.attr( 'for', this.input.getInputId() );
4075 } else {
4076 this.$label.on( 'click', function () {
4077 this.input.simulateLabelClick();
4078 return false;
4079 }.bind( this ) );
4080 }
4081 }
4082 this.$element.addClass( 'oo-ui-labelWidget' );
4083 };
4084
4085 /* Setup */
4086
4087 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4088 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4089 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4090
4091 /* Static Properties */
4092
4093 /**
4094 * @static
4095 * @inheritdoc
4096 */
4097 OO.ui.LabelWidget.static.tagName = 'label';
4098
4099 /**
4100 * PendingElement is a mixin that is used to create elements that notify users that something is happening
4101 * and that they should wait before proceeding. The pending state is visually represented with a pending
4102 * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
4103 * field of a {@link OO.ui.TextInputWidget text input widget}.
4104 *
4105 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
4106 * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
4107 * in process dialogs.
4108 *
4109 * @example
4110 * function MessageDialog( config ) {
4111 * MessageDialog.parent.call( this, config );
4112 * }
4113 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4114 *
4115 * MessageDialog.static.name = 'myMessageDialog';
4116 * MessageDialog.static.actions = [
4117 * { action: 'save', label: 'Done', flags: 'primary' },
4118 * { label: 'Cancel', flags: 'safe' }
4119 * ];
4120 *
4121 * MessageDialog.prototype.initialize = function () {
4122 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4123 * this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
4124 * 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>' );
4125 * this.$body.append( this.content.$element );
4126 * };
4127 * MessageDialog.prototype.getBodyHeight = function () {
4128 * return 100;
4129 * }
4130 * MessageDialog.prototype.getActionProcess = function ( action ) {
4131 * var dialog = this;
4132 * if ( action === 'save' ) {
4133 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4134 * return new OO.ui.Process()
4135 * .next( 1000 )
4136 * .next( function () {
4137 * dialog.getActions().get({actions: 'save'})[0].popPending();
4138 * } );
4139 * }
4140 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4141 * };
4142 *
4143 * var windowManager = new OO.ui.WindowManager();
4144 * $( 'body' ).append( windowManager.$element );
4145 *
4146 * var dialog = new MessageDialog();
4147 * windowManager.addWindows( [ dialog ] );
4148 * windowManager.openWindow( dialog );
4149 *
4150 * @abstract
4151 * @class
4152 *
4153 * @constructor
4154 * @param {Object} [config] Configuration options
4155 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4156 */
4157 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4158 // Configuration initialization
4159 config = config || {};
4160
4161 // Properties
4162 this.pending = 0;
4163 this.$pending = null;
4164
4165 // Initialisation
4166 this.setPendingElement( config.$pending || this.$element );
4167 };
4168
4169 /* Setup */
4170
4171 OO.initClass( OO.ui.mixin.PendingElement );
4172
4173 /* Methods */
4174
4175 /**
4176 * Set the pending element (and clean up any existing one).
4177 *
4178 * @param {jQuery} $pending The element to set to pending.
4179 */
4180 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4181 if ( this.$pending ) {
4182 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4183 }
4184
4185 this.$pending = $pending;
4186 if ( this.pending > 0 ) {
4187 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4188 }
4189 };
4190
4191 /**
4192 * Check if an element is pending.
4193 *
4194 * @return {boolean} Element is pending
4195 */
4196 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4197 return !!this.pending;
4198 };
4199
4200 /**
4201 * Increase the pending counter. The pending state will remain active until the counter is zero
4202 * (i.e., the number of calls to #pushPending and #popPending is the same).
4203 *
4204 * @chainable
4205 */
4206 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4207 if ( this.pending === 0 ) {
4208 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4209 this.updateThemeClasses();
4210 }
4211 this.pending++;
4212
4213 return this;
4214 };
4215
4216 /**
4217 * Decrease the pending counter. The pending state will remain active until the counter is zero
4218 * (i.e., the number of calls to #pushPending and #popPending is the same).
4219 *
4220 * @chainable
4221 */
4222 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4223 if ( this.pending === 1 ) {
4224 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4225 this.updateThemeClasses();
4226 }
4227 this.pending = Math.max( 0, this.pending - 1 );
4228
4229 return this;
4230 };
4231
4232 /**
4233 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4234 * in the document (for example, in an OO.ui.Window's $overlay).
4235 *
4236 * The elements's position is automatically calculated and maintained when window is resized or the
4237 * page is scrolled. If you reposition the container manually, you have to call #position to make
4238 * sure the element is still placed correctly.
4239 *
4240 * As positioning is only possible when both the element and the container are attached to the DOM
4241 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4242 * the #toggle method to display a floating popup, for example.
4243 *
4244 * @abstract
4245 * @class
4246 *
4247 * @constructor
4248 * @param {Object} [config] Configuration options
4249 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4250 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4251 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4252 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4253 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4254 * 'top': Align the top edge with $floatableContainer's top edge
4255 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4256 * 'center': Vertically align the center with $floatableContainer's center
4257 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4258 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4259 * 'after': Directly after $floatableContainer, algining f's start edge with fC's end edge
4260 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4261 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4262 * 'center': Horizontally align the center with $floatableContainer's center
4263 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4264 * is out of view
4265 */
4266 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4267 // Configuration initialization
4268 config = config || {};
4269
4270 // Properties
4271 this.$floatable = null;
4272 this.$floatableContainer = null;
4273 this.$floatableWindow = null;
4274 this.$floatableClosestScrollable = null;
4275 this.onFloatableScrollHandler = this.position.bind( this );
4276 this.onFloatableWindowResizeHandler = this.position.bind( this );
4277
4278 // Initialization
4279 this.setFloatableContainer( config.$floatableContainer );
4280 this.setFloatableElement( config.$floatable || this.$element );
4281 this.setVerticalPosition( config.verticalPosition || 'below' );
4282 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4283 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ? true : !!config.hideWhenOutOfView;
4284 };
4285
4286 /* Methods */
4287
4288 /**
4289 * Set floatable element.
4290 *
4291 * If an element is already set, it will be cleaned up before setting up the new element.
4292 *
4293 * @param {jQuery} $floatable Element to make floatable
4294 */
4295 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4296 if ( this.$floatable ) {
4297 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4298 this.$floatable.css( { left: '', top: '' } );
4299 }
4300
4301 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4302 this.position();
4303 };
4304
4305 /**
4306 * Set floatable container.
4307 *
4308 * The element will be positioned relative to the specified container.
4309 *
4310 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4311 */
4312 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4313 this.$floatableContainer = $floatableContainer;
4314 if ( this.$floatable ) {
4315 this.position();
4316 }
4317 };
4318
4319 /**
4320 * Change how the element is positioned vertically.
4321 *
4322 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4323 */
4324 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4325 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4326 throw new Error( 'Invalid value for vertical position: ' + position );
4327 }
4328 if ( this.verticalPosition !== position ) {
4329 this.verticalPosition = position;
4330 if ( this.$floatable ) {
4331 this.position();
4332 }
4333 }
4334 };
4335
4336 /**
4337 * Change how the element is positioned horizontally.
4338 *
4339 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4340 */
4341 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4342 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4343 throw new Error( 'Invalid value for horizontal position: ' + position );
4344 }
4345 if ( this.horizontalPosition !== position ) {
4346 this.horizontalPosition = position;
4347 if ( this.$floatable ) {
4348 this.position();
4349 }
4350 }
4351 };
4352
4353 /**
4354 * Toggle positioning.
4355 *
4356 * Do not turn positioning on until after the element is attached to the DOM and visible.
4357 *
4358 * @param {boolean} [positioning] Enable positioning, omit to toggle
4359 * @chainable
4360 */
4361 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4362 var closestScrollableOfContainer;
4363
4364 if ( !this.$floatable || !this.$floatableContainer ) {
4365 return this;
4366 }
4367
4368 positioning = positioning === undefined ? !this.positioning : !!positioning;
4369
4370 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4371 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4372 this.warnedUnattached = true;
4373 }
4374
4375 if ( this.positioning !== positioning ) {
4376 this.positioning = positioning;
4377
4378 this.needsCustomPosition =
4379 this.verticalPostion !== 'below' ||
4380 this.horizontalPosition !== 'start' ||
4381 !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
4382
4383 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
4384 // If the scrollable is the root, we have to listen to scroll events
4385 // on the window because of browser inconsistencies.
4386 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4387 closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
4388 }
4389
4390 if ( positioning ) {
4391 this.$floatableWindow = $( this.getElementWindow() );
4392 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4393
4394 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4395 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4396
4397 // Initial position after visible
4398 this.position();
4399 } else {
4400 if ( this.$floatableWindow ) {
4401 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4402 this.$floatableWindow = null;
4403 }
4404
4405 if ( this.$floatableClosestScrollable ) {
4406 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4407 this.$floatableClosestScrollable = null;
4408 }
4409
4410 this.$floatable.css( { left: '', right: '', top: '' } );
4411 }
4412 }
4413
4414 return this;
4415 };
4416
4417 /**
4418 * Check whether the bottom edge of the given element is within the viewport of the given container.
4419 *
4420 * @private
4421 * @param {jQuery} $element
4422 * @param {jQuery} $container
4423 * @return {boolean}
4424 */
4425 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4426 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds, rightEdgeInBounds,
4427 startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4428 direction = $element.css( 'direction' );
4429
4430 elemRect = $element[ 0 ].getBoundingClientRect();
4431 if ( $container[ 0 ] === window ) {
4432 viewportSpacing = OO.ui.getViewportSpacing();
4433 contRect = {
4434 top: 0,
4435 left: 0,
4436 right: document.documentElement.clientWidth,
4437 bottom: document.documentElement.clientHeight
4438 };
4439 contRect.top += viewportSpacing.top;
4440 contRect.left += viewportSpacing.left;
4441 contRect.right -= viewportSpacing.right;
4442 contRect.bottom -= viewportSpacing.bottom;
4443 } else {
4444 contRect = $container[ 0 ].getBoundingClientRect();
4445 }
4446
4447 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4448 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4449 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4450 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4451 if ( direction === 'rtl' ) {
4452 startEdgeInBounds = rightEdgeInBounds;
4453 endEdgeInBounds = leftEdgeInBounds;
4454 } else {
4455 startEdgeInBounds = leftEdgeInBounds;
4456 endEdgeInBounds = rightEdgeInBounds;
4457 }
4458
4459 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4460 return false;
4461 }
4462 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4463 return false;
4464 }
4465 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4466 return false;
4467 }
4468 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4469 return false;
4470 }
4471
4472 // The other positioning values are all about being inside the container,
4473 // so in those cases all we care about is that any part of the container is visible.
4474 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4475 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4476 };
4477
4478 /**
4479 * Position the floatable below its container.
4480 *
4481 * This should only be done when both of them are attached to the DOM and visible.
4482 *
4483 * @chainable
4484 */
4485 OO.ui.mixin.FloatableElement.prototype.position = function () {
4486 if ( !this.positioning ) {
4487 return this;
4488 }
4489
4490 if ( !(
4491 // To continue, some things need to be true:
4492 // The element must actually be in the DOM
4493 this.isElementAttached() && (
4494 // The closest scrollable is the current window
4495 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4496 // OR is an element in the element's DOM
4497 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4498 )
4499 ) ) {
4500 // Abort early if important parts of the widget are no longer attached to the DOM
4501 return this;
4502 }
4503
4504 if ( this.hideWhenOutOfView && !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
4505 this.$floatable.addClass( 'oo-ui-element-hidden' );
4506 return this;
4507 } else {
4508 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4509 }
4510
4511 if ( !this.needsCustomPosition ) {
4512 return this;
4513 }
4514
4515 this.$floatable.css( this.computePosition() );
4516
4517 // We updated the position, so re-evaluate the clipping state.
4518 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4519 // will not notice the need to update itself.)
4520 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
4521 // it not listen to the right events in the right places?
4522 if ( this.clip ) {
4523 this.clip();
4524 }
4525
4526 return this;
4527 };
4528
4529 /**
4530 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4531 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4532 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4533 *
4534 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4535 */
4536 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4537 var isBody, scrollableX, scrollableY, containerPos,
4538 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4539 newPos = { top: '', left: '', bottom: '', right: '' },
4540 direction = this.$floatableContainer.css( 'direction' ),
4541 $offsetParent = this.$floatable.offsetParent();
4542
4543 if ( $offsetParent.is( 'html' ) ) {
4544 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4545 // <html> element, but they do work on the <body>
4546 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4547 }
4548 isBody = $offsetParent.is( 'body' );
4549 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' || $offsetParent.css( 'overflow-x' ) === 'auto';
4550 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' || $offsetParent.css( 'overflow-y' ) === 'auto';
4551
4552 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4553 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4554 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container is the body,
4555 // or if it isn't scrollable
4556 scrollTop = scrollableY && !isBody ? $offsetParent.scrollTop() : 0;
4557 scrollLeft = scrollableX && !isBody ? OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4558
4559 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4560 // if the <body> has a margin
4561 containerPos = isBody ?
4562 this.$floatableContainer.offset() :
4563 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4564 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4565 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4566 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4567 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4568
4569 if ( this.verticalPosition === 'below' ) {
4570 newPos.top = containerPos.bottom;
4571 } else if ( this.verticalPosition === 'above' ) {
4572 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4573 } else if ( this.verticalPosition === 'top' ) {
4574 newPos.top = containerPos.top;
4575 } else if ( this.verticalPosition === 'bottom' ) {
4576 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4577 } else if ( this.verticalPosition === 'center' ) {
4578 newPos.top = containerPos.top +
4579 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4580 }
4581
4582 if ( this.horizontalPosition === 'before' ) {
4583 newPos.end = containerPos.start;
4584 } else if ( this.horizontalPosition === 'after' ) {
4585 newPos.start = containerPos.end;
4586 } else if ( this.horizontalPosition === 'start' ) {
4587 newPos.start = containerPos.start;
4588 } else if ( this.horizontalPosition === 'end' ) {
4589 newPos.end = containerPos.end;
4590 } else if ( this.horizontalPosition === 'center' ) {
4591 newPos.left = containerPos.left +
4592 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4593 }
4594
4595 if ( newPos.start !== undefined ) {
4596 if ( direction === 'rtl' ) {
4597 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.start;
4598 } else {
4599 newPos.left = newPos.start;
4600 }
4601 delete newPos.start;
4602 }
4603 if ( newPos.end !== undefined ) {
4604 if ( direction === 'rtl' ) {
4605 newPos.left = newPos.end;
4606 } else {
4607 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) : $offsetParent ).outerWidth() - newPos.end;
4608 }
4609 delete newPos.end;
4610 }
4611
4612 // Account for scroll position
4613 if ( newPos.top !== '' ) {
4614 newPos.top += scrollTop;
4615 }
4616 if ( newPos.bottom !== '' ) {
4617 newPos.bottom -= scrollTop;
4618 }
4619 if ( newPos.left !== '' ) {
4620 newPos.left += scrollLeft;
4621 }
4622 if ( newPos.right !== '' ) {
4623 newPos.right -= scrollLeft;
4624 }
4625
4626 // Account for scrollbar gutter
4627 if ( newPos.bottom !== '' ) {
4628 newPos.bottom -= horizScrollbarHeight;
4629 }
4630 if ( direction === 'rtl' ) {
4631 if ( newPos.left !== '' ) {
4632 newPos.left -= vertScrollbarWidth;
4633 }
4634 } else {
4635 if ( newPos.right !== '' ) {
4636 newPos.right -= vertScrollbarWidth;
4637 }
4638 }
4639
4640 return newPos;
4641 };
4642
4643 /**
4644 * Element that can be automatically clipped to visible boundaries.
4645 *
4646 * Whenever the element's natural height changes, you have to call
4647 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4648 * clipping correctly.
4649 *
4650 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4651 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4652 * then #$clippable will be given a fixed reduced height and/or width and will be made
4653 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4654 * but you can build a static footer by setting #$clippableContainer to an element that contains
4655 * #$clippable and the footer.
4656 *
4657 * @abstract
4658 * @class
4659 *
4660 * @constructor
4661 * @param {Object} [config] Configuration options
4662 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4663 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4664 * omit to use #$clippable
4665 */
4666 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4667 // Configuration initialization
4668 config = config || {};
4669
4670 // Properties
4671 this.$clippable = null;
4672 this.$clippableContainer = null;
4673 this.clipping = false;
4674 this.clippedHorizontally = false;
4675 this.clippedVertically = false;
4676 this.$clippableScrollableContainer = null;
4677 this.$clippableScroller = null;
4678 this.$clippableWindow = null;
4679 this.idealWidth = null;
4680 this.idealHeight = null;
4681 this.onClippableScrollHandler = this.clip.bind( this );
4682 this.onClippableWindowResizeHandler = this.clip.bind( this );
4683
4684 // Initialization
4685 if ( config.$clippableContainer ) {
4686 this.setClippableContainer( config.$clippableContainer );
4687 }
4688 this.setClippableElement( config.$clippable || this.$element );
4689 };
4690
4691 /* Methods */
4692
4693 /**
4694 * Set clippable element.
4695 *
4696 * If an element is already set, it will be cleaned up before setting up the new element.
4697 *
4698 * @param {jQuery} $clippable Element to make clippable
4699 */
4700 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4701 if ( this.$clippable ) {
4702 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4703 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4704 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4705 }
4706
4707 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4708 this.clip();
4709 };
4710
4711 /**
4712 * Set clippable container.
4713 *
4714 * This is the container that will be measured when deciding whether to clip. When clipping,
4715 * #$clippable will be resized in order to keep the clippable container fully visible.
4716 *
4717 * If the clippable container is unset, #$clippable will be used.
4718 *
4719 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4720 */
4721 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4722 this.$clippableContainer = $clippableContainer;
4723 if ( this.$clippable ) {
4724 this.clip();
4725 }
4726 };
4727
4728 /**
4729 * Toggle clipping.
4730 *
4731 * Do not turn clipping on until after the element is attached to the DOM and visible.
4732 *
4733 * @param {boolean} [clipping] Enable clipping, omit to toggle
4734 * @chainable
4735 */
4736 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4737 clipping = clipping === undefined ? !this.clipping : !!clipping;
4738
4739 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4740 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4741 this.warnedUnattached = true;
4742 }
4743
4744 if ( this.clipping !== clipping ) {
4745 this.clipping = clipping;
4746 if ( clipping ) {
4747 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4748 // If the clippable container is the root, we have to listen to scroll events and check
4749 // jQuery.scrollTop on the window because of browser inconsistencies
4750 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4751 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4752 this.$clippableScrollableContainer;
4753 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4754 this.$clippableWindow = $( this.getElementWindow() )
4755 .on( 'resize', this.onClippableWindowResizeHandler );
4756 // Initial clip after visible
4757 this.clip();
4758 } else {
4759 this.$clippable.css( {
4760 width: '',
4761 height: '',
4762 maxWidth: '',
4763 maxHeight: '',
4764 overflowX: '',
4765 overflowY: ''
4766 } );
4767 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4768
4769 this.$clippableScrollableContainer = null;
4770 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4771 this.$clippableScroller = null;
4772 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4773 this.$clippableWindow = null;
4774 }
4775 }
4776
4777 return this;
4778 };
4779
4780 /**
4781 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4782 *
4783 * @return {boolean} Element will be clipped to the visible area
4784 */
4785 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4786 return this.clipping;
4787 };
4788
4789 /**
4790 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4791 *
4792 * @return {boolean} Part of the element is being clipped
4793 */
4794 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4795 return this.clippedHorizontally || this.clippedVertically;
4796 };
4797
4798 /**
4799 * Check if the right of the element is being clipped by the nearest scrollable container.
4800 *
4801 * @return {boolean} Part of the element is being clipped
4802 */
4803 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4804 return this.clippedHorizontally;
4805 };
4806
4807 /**
4808 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4809 *
4810 * @return {boolean} Part of the element is being clipped
4811 */
4812 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4813 return this.clippedVertically;
4814 };
4815
4816 /**
4817 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4818 *
4819 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4820 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4821 */
4822 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4823 this.idealWidth = width;
4824 this.idealHeight = height;
4825
4826 if ( !this.clipping ) {
4827 // Update dimensions
4828 this.$clippable.css( { width: width, height: height } );
4829 }
4830 // While clipping, idealWidth and idealHeight are not considered
4831 };
4832
4833 /**
4834 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4835 * ClippableElement will clip the opposite side when reducing element's width.
4836 *
4837 * Classes that mix in ClippableElement should override this to return 'right' if their
4838 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4839 * If your class also mixes in FloatableElement, this is handled automatically.
4840 *
4841 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4842 * always in pixels, even if they were unset or set to 'auto'.)
4843 *
4844 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4845 *
4846 * @return {string} 'left' or 'right'
4847 */
4848 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
4849 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
4850 return 'right';
4851 }
4852 return 'left';
4853 };
4854
4855 /**
4856 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4857 * ClippableElement will clip the opposite side when reducing element's width.
4858 *
4859 * Classes that mix in ClippableElement should override this to return 'bottom' if their
4860 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
4861 * If your class also mixes in FloatableElement, this is handled automatically.
4862 *
4863 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4864 * always in pixels, even if they were unset or set to 'auto'.)
4865 *
4866 * When in doubt, 'top' is a sane fallback.
4867 *
4868 * @return {string} 'top' or 'bottom'
4869 */
4870 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
4871 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
4872 return 'bottom';
4873 }
4874 return 'top';
4875 };
4876
4877 /**
4878 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
4879 * when the element's natural height changes.
4880 *
4881 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
4882 * overlapped by, the visible area of the nearest scrollable container.
4883 *
4884 * Because calling clip() when the natural height changes isn't always possible, we also set
4885 * max-height when the element isn't being clipped. This means that if the element tries to grow
4886 * beyond the edge, something reasonable will happen before clip() is called.
4887 *
4888 * @chainable
4889 */
4890 OO.ui.mixin.ClippableElement.prototype.clip = function () {
4891 var extraHeight, extraWidth, viewportSpacing,
4892 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
4893 naturalWidth, naturalHeight, clipWidth, clipHeight,
4894 $item, itemRect, $viewport, viewportRect, availableRect,
4895 direction, vertScrollbarWidth, horizScrollbarHeight,
4896 // Extra tolerance so that the sloppy code below doesn't result in results that are off
4897 // by one or two pixels. (And also so that we have space to display drop shadows.)
4898 // Chosen by fair dice roll.
4899 buffer = 7;
4900
4901 if ( !this.clipping ) {
4902 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
4903 return this;
4904 }
4905
4906 function rectIntersection( a, b ) {
4907 var out = {};
4908 out.top = Math.max( a.top, b.top );
4909 out.left = Math.max( a.left, b.left );
4910 out.bottom = Math.min( a.bottom, b.bottom );
4911 out.right = Math.min( a.right, b.right );
4912 return out;
4913 }
4914
4915 viewportSpacing = OO.ui.getViewportSpacing();
4916
4917 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
4918 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
4919 // Dimensions of the browser window, rather than the element!
4920 viewportRect = {
4921 top: 0,
4922 left: 0,
4923 right: document.documentElement.clientWidth,
4924 bottom: document.documentElement.clientHeight
4925 };
4926 viewportRect.top += viewportSpacing.top;
4927 viewportRect.left += viewportSpacing.left;
4928 viewportRect.right -= viewportSpacing.right;
4929 viewportRect.bottom -= viewportSpacing.bottom;
4930 } else {
4931 $viewport = this.$clippableScrollableContainer;
4932 viewportRect = $viewport[ 0 ].getBoundingClientRect();
4933 // Convert into a plain object
4934 viewportRect = $.extend( {}, viewportRect );
4935 }
4936
4937 // Account for scrollbar gutter
4938 direction = $viewport.css( 'direction' );
4939 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
4940 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
4941 viewportRect.bottom -= horizScrollbarHeight;
4942 if ( direction === 'rtl' ) {
4943 viewportRect.left += vertScrollbarWidth;
4944 } else {
4945 viewportRect.right -= vertScrollbarWidth;
4946 }
4947
4948 // Add arbitrary tolerance
4949 viewportRect.top += buffer;
4950 viewportRect.left += buffer;
4951 viewportRect.right -= buffer;
4952 viewportRect.bottom -= buffer;
4953
4954 $item = this.$clippableContainer || this.$clippable;
4955
4956 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
4957 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
4958
4959 itemRect = $item[ 0 ].getBoundingClientRect();
4960 // Convert into a plain object
4961 itemRect = $.extend( {}, itemRect );
4962
4963 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
4964 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
4965 if ( this.getHorizontalAnchorEdge() === 'right' ) {
4966 itemRect.left = viewportRect.left;
4967 } else {
4968 itemRect.right = viewportRect.right;
4969 }
4970 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
4971 itemRect.top = viewportRect.top;
4972 } else {
4973 itemRect.bottom = viewportRect.bottom;
4974 }
4975
4976 availableRect = rectIntersection( viewportRect, itemRect );
4977
4978 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
4979 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
4980 // It should never be desirable to exceed the dimensions of the browser viewport... right?
4981 desiredWidth = Math.min( desiredWidth,
4982 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
4983 desiredHeight = Math.min( desiredHeight,
4984 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
4985 allotedWidth = Math.ceil( desiredWidth - extraWidth );
4986 allotedHeight = Math.ceil( desiredHeight - extraHeight );
4987 naturalWidth = this.$clippable.prop( 'scrollWidth' );
4988 naturalHeight = this.$clippable.prop( 'scrollHeight' );
4989 clipWidth = allotedWidth < naturalWidth;
4990 clipHeight = allotedHeight < naturalHeight;
4991
4992 if ( clipWidth ) {
4993 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
4994 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
4995 this.$clippable.css( 'overflowX', 'scroll' );
4996 void this.$clippable[ 0 ].offsetHeight; // Force reflow
4997 this.$clippable.css( {
4998 width: Math.max( 0, allotedWidth ),
4999 maxWidth: ''
5000 } );
5001 } else {
5002 this.$clippable.css( {
5003 overflowX: '',
5004 width: this.idealWidth || '',
5005 maxWidth: Math.max( 0, allotedWidth )
5006 } );
5007 }
5008 if ( clipHeight ) {
5009 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars. See T157672.
5010 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for this case.
5011 this.$clippable.css( 'overflowY', 'scroll' );
5012 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5013 this.$clippable.css( {
5014 height: Math.max( 0, allotedHeight ),
5015 maxHeight: ''
5016 } );
5017 } else {
5018 this.$clippable.css( {
5019 overflowY: '',
5020 height: this.idealHeight || '',
5021 maxHeight: Math.max( 0, allotedHeight )
5022 } );
5023 }
5024
5025 // If we stopped clipping in at least one of the dimensions
5026 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5027 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5028 }
5029
5030 this.clippedHorizontally = clipWidth;
5031 this.clippedVertically = clipHeight;
5032
5033 return this;
5034 };
5035
5036 /**
5037 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5038 * By default, each popup has an anchor that points toward its origin.
5039 * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
5040 *
5041 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5042 *
5043 * @example
5044 * // A popup widget.
5045 * var popup = new OO.ui.PopupWidget( {
5046 * $content: $( '<p>Hi there!</p>' ),
5047 * padded: true,
5048 * width: 300
5049 * } );
5050 *
5051 * $( 'body' ).append( popup.$element );
5052 * // To display the popup, toggle the visibility to 'true'.
5053 * popup.toggle( true );
5054 *
5055 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
5056 *
5057 * @class
5058 * @extends OO.ui.Widget
5059 * @mixins OO.ui.mixin.LabelElement
5060 * @mixins OO.ui.mixin.ClippableElement
5061 * @mixins OO.ui.mixin.FloatableElement
5062 *
5063 * @constructor
5064 * @param {Object} [config] Configuration options
5065 * @cfg {number} [width=320] Width of popup in pixels
5066 * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
5067 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5068 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5069 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5070 * of $floatableContainer
5071 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5072 * of $floatableContainer
5073 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5074 * endwards (right/left) to the vertical center of $floatableContainer
5075 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5076 * startwards (left/right) to the vertical center of $floatableContainer
5077 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5078 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in RTL)
5079 * as possible while still keeping the anchor within the popup;
5080 * if position is before/after, move the popup as far downwards as possible.
5081 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in RTL)
5082 * as possible while still keeping the anchor within the popup;
5083 * if position in before/after, move the popup as far upwards as possible.
5084 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the center
5085 * of the popup with the center of $floatableContainer.
5086 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5087 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5088 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5089 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5090 * desired direction to display the popup without clipping
5091 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5092 * See the [OOjs UI docs on MediaWiki][3] for an example.
5093 * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
5094 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
5095 * @cfg {jQuery} [$content] Content to append to the popup's body
5096 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5097 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5098 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5099 * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
5100 * for an example.
5101 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
5102 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5103 * button.
5104 * @cfg {boolean} [padded=false] Add padding to the popup's body
5105 */
5106 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5107 // Configuration initialization
5108 config = config || {};
5109
5110 // Parent constructor
5111 OO.ui.PopupWidget.parent.call( this, config );
5112
5113 // Properties (must be set before ClippableElement constructor call)
5114 this.$body = $( '<div>' );
5115 this.$popup = $( '<div>' );
5116
5117 // Mixin constructors
5118 OO.ui.mixin.LabelElement.call( this, config );
5119 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
5120 $clippable: this.$body,
5121 $clippableContainer: this.$popup
5122 } ) );
5123 OO.ui.mixin.FloatableElement.call( this, config );
5124
5125 // Properties
5126 this.$anchor = $( '<div>' );
5127 // If undefined, will be computed lazily in computePosition()
5128 this.$container = config.$container;
5129 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5130 this.autoClose = !!config.autoClose;
5131 this.$autoCloseIgnore = config.$autoCloseIgnore;
5132 this.transitionTimeout = null;
5133 this.anchored = false;
5134 this.width = config.width !== undefined ? config.width : 320;
5135 this.height = config.height !== undefined ? config.height : null;
5136 this.onMouseDownHandler = this.onMouseDown.bind( this );
5137 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5138
5139 // Initialization
5140 this.toggleAnchor( config.anchor === undefined || config.anchor );
5141 this.setAlignment( config.align || 'center' );
5142 this.setPosition( config.position || 'below' );
5143 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5144 this.$body.addClass( 'oo-ui-popupWidget-body' );
5145 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5146 this.$popup
5147 .addClass( 'oo-ui-popupWidget-popup' )
5148 .append( this.$body );
5149 this.$element
5150 .addClass( 'oo-ui-popupWidget' )
5151 .append( this.$popup, this.$anchor );
5152 // Move content, which was added to #$element by OO.ui.Widget, to the body
5153 // FIXME This is gross, we should use '$body' or something for the config
5154 if ( config.$content instanceof jQuery ) {
5155 this.$body.append( config.$content );
5156 }
5157
5158 if ( config.padded ) {
5159 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5160 }
5161
5162 if ( config.head ) {
5163 this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
5164 this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
5165 this.$head = $( '<div>' )
5166 .addClass( 'oo-ui-popupWidget-head' )
5167 .append( this.$label, this.closeButton.$element );
5168 this.$popup.prepend( this.$head );
5169 }
5170
5171 if ( config.$footer ) {
5172 this.$footer = $( '<div>' )
5173 .addClass( 'oo-ui-popupWidget-footer' )
5174 .append( config.$footer );
5175 this.$popup.append( this.$footer );
5176 }
5177
5178 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5179 // that reference properties not initialized at that time of parent class construction
5180 // TODO: Find a better way to handle post-constructor setup
5181 this.visible = false;
5182 this.$element.addClass( 'oo-ui-element-hidden' );
5183 };
5184
5185 /* Setup */
5186
5187 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5188 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5189 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5190 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5191
5192 /* Events */
5193
5194 /**
5195 * @event ready
5196 *
5197 * The popup is ready: it is visible and has been positioned and clipped.
5198 */
5199
5200 /* Methods */
5201
5202 /**
5203 * Handles mouse down events.
5204 *
5205 * @private
5206 * @param {MouseEvent} e Mouse down event
5207 */
5208 OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
5209 if (
5210 this.isVisible() &&
5211 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5212 ) {
5213 this.toggle( false );
5214 }
5215 };
5216
5217 /**
5218 * Bind mouse down listener.
5219 *
5220 * @private
5221 */
5222 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
5223 // Capture clicks outside popup
5224 this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
5225 };
5226
5227 /**
5228 * Handles close button click events.
5229 *
5230 * @private
5231 */
5232 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5233 if ( this.isVisible() ) {
5234 this.toggle( false );
5235 }
5236 };
5237
5238 /**
5239 * Unbind mouse down listener.
5240 *
5241 * @private
5242 */
5243 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
5244 this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
5245 };
5246
5247 /**
5248 * Handles key down events.
5249 *
5250 * @private
5251 * @param {KeyboardEvent} e Key down event
5252 */
5253 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5254 if (
5255 e.which === OO.ui.Keys.ESCAPE &&
5256 this.isVisible()
5257 ) {
5258 this.toggle( false );
5259 e.preventDefault();
5260 e.stopPropagation();
5261 }
5262 };
5263
5264 /**
5265 * Bind key down listener.
5266 *
5267 * @private
5268 */
5269 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
5270 this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5271 };
5272
5273 /**
5274 * Unbind key down listener.
5275 *
5276 * @private
5277 */
5278 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
5279 this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5280 };
5281
5282 /**
5283 * Show, hide, or toggle the visibility of the anchor.
5284 *
5285 * @param {boolean} [show] Show anchor, omit to toggle
5286 */
5287 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5288 show = show === undefined ? !this.anchored : !!show;
5289
5290 if ( this.anchored !== show ) {
5291 if ( show ) {
5292 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5293 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5294 } else {
5295 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5296 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5297 }
5298 this.anchored = show;
5299 }
5300 };
5301
5302 /**
5303 * Change which edge the anchor appears on.
5304 *
5305 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5306 */
5307 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5308 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5309 throw new Error( 'Invalid value for edge: ' + edge );
5310 }
5311 if ( this.anchorEdge !== null ) {
5312 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5313 }
5314 this.anchorEdge = edge;
5315 if ( this.anchored ) {
5316 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5317 }
5318 };
5319
5320 /**
5321 * Check if the anchor is visible.
5322 *
5323 * @return {boolean} Anchor is visible
5324 */
5325 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5326 return this.anchored;
5327 };
5328
5329 /**
5330 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5331 * `.toggle( true )` after its #$element is attached to the DOM.
5332 *
5333 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5334 * it in the right place and with the right dimensions only work correctly while it is attached.
5335 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5336 * strictly enforced, so currently it only generates a warning in the browser console.
5337 *
5338 * @fires ready
5339 * @inheritdoc
5340 */
5341 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5342 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5343 show = show === undefined ? !this.isVisible() : !!show;
5344
5345 change = show !== this.isVisible();
5346
5347 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5348 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5349 this.warnedUnattached = true;
5350 }
5351 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5352 // Fall back to the parent node if the floatableContainer is not set
5353 this.setFloatableContainer( this.$element.parent() );
5354 }
5355
5356 if ( change && show && this.autoFlip ) {
5357 // Reset auto-flipping before showing the popup again. It's possible we no longer need to flip
5358 // (e.g. if the user scrolled).
5359 this.isAutoFlipped = false;
5360 }
5361
5362 // Parent method
5363 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5364
5365 if ( change ) {
5366 this.togglePositioning( show && !!this.$floatableContainer );
5367
5368 if ( show ) {
5369 if ( this.autoClose ) {
5370 this.bindMouseDownListener();
5371 this.bindKeyDownListener();
5372 }
5373 this.updateDimensions();
5374 this.toggleClipping( true );
5375
5376 if ( this.autoFlip ) {
5377 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5378 if ( this.isClippedVertically() ) {
5379 // If opening the popup in the normal direction causes it to be clipped, open
5380 // in the opposite one instead
5381 normalHeight = this.$element.height();
5382 this.isAutoFlipped = !this.isAutoFlipped;
5383 this.position();
5384 if ( this.isClippedVertically() ) {
5385 // If that also causes it to be clipped, open in whichever direction
5386 // we have more space
5387 oppositeHeight = this.$element.height();
5388 if ( oppositeHeight < normalHeight ) {
5389 this.isAutoFlipped = !this.isAutoFlipped;
5390 this.position();
5391 }
5392 }
5393 }
5394 }
5395 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5396 if ( this.isClippedHorizontally() ) {
5397 // If opening the popup in the normal direction causes it to be clipped, open
5398 // in the opposite one instead
5399 normalWidth = this.$element.width();
5400 this.isAutoFlipped = !this.isAutoFlipped;
5401 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5402 // which causes positioning to be off. Toggle clipping back and fort to work around.
5403 this.toggleClipping( false );
5404 this.position();
5405 this.toggleClipping( true );
5406 if ( this.isClippedHorizontally() ) {
5407 // If that also causes it to be clipped, open in whichever direction
5408 // we have more space
5409 oppositeWidth = this.$element.width();
5410 if ( oppositeWidth < normalWidth ) {
5411 this.isAutoFlipped = !this.isAutoFlipped;
5412 // Due to T180173 horizontally clipped PopupWidgets have messed up dimensions,
5413 // which causes positioning to be off. Toggle clipping back and fort to work around.
5414 this.toggleClipping( false );
5415 this.position();
5416 this.toggleClipping( true );
5417 }
5418 }
5419 }
5420 }
5421 }
5422
5423 this.emit( 'ready' );
5424 } else {
5425 this.toggleClipping( false );
5426 if ( this.autoClose ) {
5427 this.unbindMouseDownListener();
5428 this.unbindKeyDownListener();
5429 }
5430 }
5431 }
5432
5433 return this;
5434 };
5435
5436 /**
5437 * Set the size of the popup.
5438 *
5439 * Changing the size may also change the popup's position depending on the alignment.
5440 *
5441 * @param {number} width Width in pixels
5442 * @param {number} height Height in pixels
5443 * @param {boolean} [transition=false] Use a smooth transition
5444 * @chainable
5445 */
5446 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5447 this.width = width;
5448 this.height = height !== undefined ? height : null;
5449 if ( this.isVisible() ) {
5450 this.updateDimensions( transition );
5451 }
5452 };
5453
5454 /**
5455 * Update the size and position.
5456 *
5457 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5458 * be called automatically.
5459 *
5460 * @param {boolean} [transition=false] Use a smooth transition
5461 * @chainable
5462 */
5463 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5464 var widget = this;
5465
5466 // Prevent transition from being interrupted
5467 clearTimeout( this.transitionTimeout );
5468 if ( transition ) {
5469 // Enable transition
5470 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5471 }
5472
5473 this.position();
5474
5475 if ( transition ) {
5476 // Prevent transitioning after transition is complete
5477 this.transitionTimeout = setTimeout( function () {
5478 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5479 }, 200 );
5480 } else {
5481 // Prevent transitioning immediately
5482 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5483 }
5484 };
5485
5486 /**
5487 * @inheritdoc
5488 */
5489 OO.ui.PopupWidget.prototype.computePosition = function () {
5490 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize, anchorPos,
5491 anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment, floatablePos,
5492 offsetParentPos, containerPos, popupPosition, viewportSpacing,
5493 popupPos = {},
5494 anchorCss = { left: '', right: '', top: '', bottom: '' },
5495 popupPositionOppositeMap = {
5496 above: 'below',
5497 below: 'above',
5498 before: 'after',
5499 after: 'before'
5500 },
5501 alignMap = {
5502 ltr: {
5503 'force-left': 'backwards',
5504 'force-right': 'forwards'
5505 },
5506 rtl: {
5507 'force-left': 'forwards',
5508 'force-right': 'backwards'
5509 }
5510 },
5511 anchorEdgeMap = {
5512 above: 'bottom',
5513 below: 'top',
5514 before: 'end',
5515 after: 'start'
5516 },
5517 hPosMap = {
5518 forwards: 'start',
5519 center: 'center',
5520 backwards: this.anchored ? 'before' : 'end'
5521 },
5522 vPosMap = {
5523 forwards: 'top',
5524 center: 'center',
5525 backwards: 'bottom'
5526 };
5527
5528 if ( !this.$container ) {
5529 // Lazy-initialize $container if not specified in constructor
5530 this.$container = $( this.getClosestScrollableElementContainer() );
5531 }
5532 direction = this.$container.css( 'direction' );
5533
5534 // Set height and width before we do anything else, since it might cause our measurements
5535 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5536 this.$popup.css( {
5537 width: this.width,
5538 height: this.height !== null ? this.height : 'auto'
5539 } );
5540
5541 align = alignMap[ direction ][ this.align ] || this.align;
5542 popupPosition = this.popupPosition;
5543 if ( this.isAutoFlipped ) {
5544 popupPosition = popupPositionOppositeMap[ popupPosition ];
5545 }
5546
5547 // If the popup is positioned before or after, then the anchor positioning is vertical, otherwise horizontal
5548 vertical = popupPosition === 'before' || popupPosition === 'after';
5549 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5550 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5551 near = vertical ? 'top' : 'left';
5552 far = vertical ? 'bottom' : 'right';
5553 sizeProp = vertical ? 'Height' : 'Width';
5554 popupSize = vertical ? ( this.height || this.$popup.height() ) : this.width;
5555
5556 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5557 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5558 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5559
5560 // Parent method
5561 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5562 // Find out which property FloatableElement used for positioning, and adjust that value
5563 positionProp = vertical ?
5564 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5565 ( parentPosition.left !== '' ? 'left' : 'right' );
5566
5567 // Figure out where the near and far edges of the popup and $floatableContainer are
5568 floatablePos = this.$floatableContainer.offset();
5569 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5570 // Measure where the offsetParent is and compute our position based on that and parentPosition
5571 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5572 { top: 0, left: 0 } :
5573 this.$element.offsetParent().offset();
5574
5575 if ( positionProp === near ) {
5576 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5577 popupPos[ far ] = popupPos[ near ] + popupSize;
5578 } else {
5579 popupPos[ far ] = offsetParentPos[ near ] +
5580 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5581 popupPos[ near ] = popupPos[ far ] - popupSize;
5582 }
5583
5584 if ( this.anchored ) {
5585 // Position the anchor (which is positioned relative to the popup) to point to $floatableContainer
5586 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5587 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5588
5589 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more space
5590 // this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use scrollWidth/Height
5591 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5592 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5593 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5594 // Not enough space for the anchor on the start side; pull the popup startwards
5595 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5596 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5597 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5598 // Not enough space for the anchor on the end side; pull the popup endwards
5599 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5600 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5601 } else {
5602 positionAdjustment = 0;
5603 }
5604 } else {
5605 positionAdjustment = 0;
5606 }
5607
5608 // Check if the popup will go beyond the edge of this.$container
5609 containerPos = this.$container[ 0 ] === document.documentElement ?
5610 { top: 0, left: 0 } :
5611 this.$container.offset();
5612 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5613 if ( this.$container[ 0 ] === document.documentElement ) {
5614 viewportSpacing = OO.ui.getViewportSpacing();
5615 containerPos[ near ] += viewportSpacing[ near ];
5616 containerPos[ far ] -= viewportSpacing[ far ];
5617 }
5618 // Take into account how much the popup will move because of the adjustments we're going to make
5619 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5620 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5621 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5622 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5623 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5624 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5625 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5626 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5627 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5628 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5629 }
5630
5631 if ( this.anchored ) {
5632 // Adjust anchorOffset for positionAdjustment
5633 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5634
5635 // Position the anchor
5636 anchorCss[ start ] = anchorOffset;
5637 this.$anchor.css( anchorCss );
5638 }
5639
5640 // Move the popup if needed
5641 parentPosition[ positionProp ] += positionAdjustment;
5642
5643 return parentPosition;
5644 };
5645
5646 /**
5647 * Set popup alignment
5648 *
5649 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5650 * `backwards` or `forwards`.
5651 */
5652 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5653 // Validate alignment
5654 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5655 this.align = align;
5656 } else {
5657 this.align = 'center';
5658 }
5659 this.position();
5660 };
5661
5662 /**
5663 * Get popup alignment
5664 *
5665 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5666 * `backwards` or `forwards`.
5667 */
5668 OO.ui.PopupWidget.prototype.getAlignment = function () {
5669 return this.align;
5670 };
5671
5672 /**
5673 * Change the positioning of the popup.
5674 *
5675 * @param {string} position 'above', 'below', 'before' or 'after'
5676 */
5677 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5678 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5679 position = 'below';
5680 }
5681 this.popupPosition = position;
5682 this.position();
5683 };
5684
5685 /**
5686 * Get popup positioning.
5687 *
5688 * @return {string} 'above', 'below', 'before' or 'after'
5689 */
5690 OO.ui.PopupWidget.prototype.getPosition = function () {
5691 return this.popupPosition;
5692 };
5693
5694 /**
5695 * Set popup auto-flipping.
5696 *
5697 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5698 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5699 * desired direction to display the popup without clipping
5700 */
5701 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5702 autoFlip = !!autoFlip;
5703
5704 if ( this.autoFlip !== autoFlip ) {
5705 this.autoFlip = autoFlip;
5706 }
5707 };
5708
5709 /**
5710 * Get an ID of the body element, this can be used as the
5711 * `aria-describedby` attribute for an input field.
5712 *
5713 * @return {string} The ID of the body element
5714 */
5715 OO.ui.PopupWidget.prototype.getBodyId = function () {
5716 var id = this.$body.attr( 'id' );
5717 if ( id === undefined ) {
5718 id = OO.ui.generateElementId();
5719 this.$body.attr( 'id', id );
5720 }
5721 return id;
5722 };
5723
5724 /**
5725 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5726 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5727 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5728 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5729 *
5730 * @abstract
5731 * @class
5732 *
5733 * @constructor
5734 * @param {Object} [config] Configuration options
5735 * @cfg {Object} [popup] Configuration to pass to popup
5736 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5737 */
5738 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5739 // Configuration initialization
5740 config = config || {};
5741
5742 // Properties
5743 this.popup = new OO.ui.PopupWidget( $.extend(
5744 {
5745 autoClose: true,
5746 $floatableContainer: this.$element
5747 },
5748 config.popup,
5749 {
5750 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5751 }
5752 ) );
5753 };
5754
5755 /* Methods */
5756
5757 /**
5758 * Get popup.
5759 *
5760 * @return {OO.ui.PopupWidget} Popup widget
5761 */
5762 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5763 return this.popup;
5764 };
5765
5766 /**
5767 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5768 * which is used to display additional information or options.
5769 *
5770 * @example
5771 * // Example of a popup button.
5772 * var popupButton = new OO.ui.PopupButtonWidget( {
5773 * label: 'Popup button with options',
5774 * icon: 'menu',
5775 * popup: {
5776 * $content: $( '<p>Additional options here.</p>' ),
5777 * padded: true,
5778 * align: 'force-left'
5779 * }
5780 * } );
5781 * // Append the button to the DOM.
5782 * $( 'body' ).append( popupButton.$element );
5783 *
5784 * @class
5785 * @extends OO.ui.ButtonWidget
5786 * @mixins OO.ui.mixin.PopupElement
5787 *
5788 * @constructor
5789 * @param {Object} [config] Configuration options
5790 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
5791 * the expanded popup is larger than its containing `<div>`. The specified overlay layer is usually on top of the
5792 * containing `<div>` and has a larger area. By default, the popup uses relative positioning.
5793 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
5794 */
5795 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5796 // Configuration initialization
5797 config = config || {};
5798
5799 // Parent constructor
5800 OO.ui.PopupButtonWidget.parent.call( this, config );
5801
5802 // Mixin constructors
5803 OO.ui.mixin.PopupElement.call( this, config );
5804
5805 // Properties
5806 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5807
5808 // Events
5809 this.connect( this, { click: 'onAction' } );
5810
5811 // Initialization
5812 this.$element
5813 .addClass( 'oo-ui-popupButtonWidget' )
5814 .attr( 'aria-haspopup', 'true' );
5815 this.popup.$element
5816 .addClass( 'oo-ui-popupButtonWidget-popup' )
5817 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
5818 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
5819 this.$overlay.append( this.popup.$element );
5820 };
5821
5822 /* Setup */
5823
5824 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
5825 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
5826
5827 /* Methods */
5828
5829 /**
5830 * Handle the button action being triggered.
5831 *
5832 * @private
5833 */
5834 OO.ui.PopupButtonWidget.prototype.onAction = function () {
5835 this.popup.toggle();
5836 };
5837
5838 /**
5839 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
5840 *
5841 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
5842 *
5843 * @private
5844 * @abstract
5845 * @class
5846 * @mixins OO.ui.mixin.GroupElement
5847 *
5848 * @constructor
5849 * @param {Object} [config] Configuration options
5850 */
5851 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
5852 // Mixin constructors
5853 OO.ui.mixin.GroupElement.call( this, config );
5854 };
5855
5856 /* Setup */
5857
5858 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
5859
5860 /* Methods */
5861
5862 /**
5863 * Set the disabled state of the widget.
5864 *
5865 * This will also update the disabled state of child widgets.
5866 *
5867 * @param {boolean} disabled Disable widget
5868 * @chainable
5869 */
5870 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
5871 var i, len;
5872
5873 // Parent method
5874 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
5875 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
5876
5877 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
5878 if ( this.items ) {
5879 for ( i = 0, len = this.items.length; i < len; i++ ) {
5880 this.items[ i ].updateDisabled();
5881 }
5882 }
5883
5884 return this;
5885 };
5886
5887 /**
5888 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
5889 *
5890 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
5891 * allows bidirectional communication.
5892 *
5893 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
5894 *
5895 * @private
5896 * @abstract
5897 * @class
5898 *
5899 * @constructor
5900 */
5901 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
5902 //
5903 };
5904
5905 /* Methods */
5906
5907 /**
5908 * Check if widget is disabled.
5909 *
5910 * Checks parent if present, making disabled state inheritable.
5911 *
5912 * @return {boolean} Widget is disabled
5913 */
5914 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
5915 return this.disabled ||
5916 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
5917 };
5918
5919 /**
5920 * Set group element is in.
5921 *
5922 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
5923 * @chainable
5924 */
5925 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
5926 // Parent method
5927 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
5928 OO.ui.Element.prototype.setElementGroup.call( this, group );
5929
5930 // Initialize item disabled states
5931 this.updateDisabled();
5932
5933 return this;
5934 };
5935
5936 /**
5937 * OptionWidgets are special elements that can be selected and configured with data. The
5938 * data is often unique for each option, but it does not have to be. OptionWidgets are used
5939 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
5940 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
5941 *
5942 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
5943 *
5944 * @class
5945 * @extends OO.ui.Widget
5946 * @mixins OO.ui.mixin.ItemWidget
5947 * @mixins OO.ui.mixin.LabelElement
5948 * @mixins OO.ui.mixin.FlaggedElement
5949 * @mixins OO.ui.mixin.AccessKeyedElement
5950 *
5951 * @constructor
5952 * @param {Object} [config] Configuration options
5953 */
5954 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
5955 // Configuration initialization
5956 config = config || {};
5957
5958 // Parent constructor
5959 OO.ui.OptionWidget.parent.call( this, config );
5960
5961 // Mixin constructors
5962 OO.ui.mixin.ItemWidget.call( this );
5963 OO.ui.mixin.LabelElement.call( this, config );
5964 OO.ui.mixin.FlaggedElement.call( this, config );
5965 OO.ui.mixin.AccessKeyedElement.call( this, config );
5966
5967 // Properties
5968 this.selected = false;
5969 this.highlighted = false;
5970 this.pressed = false;
5971
5972 // Initialization
5973 this.$element
5974 .data( 'oo-ui-optionWidget', this )
5975 // Allow programmatic focussing (and by accesskey), but not tabbing
5976 .attr( 'tabindex', '-1' )
5977 .attr( 'role', 'option' )
5978 .attr( 'aria-selected', 'false' )
5979 .addClass( 'oo-ui-optionWidget' )
5980 .append( this.$label );
5981 };
5982
5983 /* Setup */
5984
5985 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
5986 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
5987 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
5988 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
5989 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
5990
5991 /* Static Properties */
5992
5993 /**
5994 * Whether this option can be selected. See #setSelected.
5995 *
5996 * @static
5997 * @inheritable
5998 * @property {boolean}
5999 */
6000 OO.ui.OptionWidget.static.selectable = true;
6001
6002 /**
6003 * Whether this option can be highlighted. See #setHighlighted.
6004 *
6005 * @static
6006 * @inheritable
6007 * @property {boolean}
6008 */
6009 OO.ui.OptionWidget.static.highlightable = true;
6010
6011 /**
6012 * Whether this option can be pressed. See #setPressed.
6013 *
6014 * @static
6015 * @inheritable
6016 * @property {boolean}
6017 */
6018 OO.ui.OptionWidget.static.pressable = true;
6019
6020 /**
6021 * Whether this option will be scrolled into view when it is selected.
6022 *
6023 * @static
6024 * @inheritable
6025 * @property {boolean}
6026 */
6027 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6028
6029 /* Methods */
6030
6031 /**
6032 * Check if the option can be selected.
6033 *
6034 * @return {boolean} Item is selectable
6035 */
6036 OO.ui.OptionWidget.prototype.isSelectable = function () {
6037 return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
6038 };
6039
6040 /**
6041 * Check if the option can be highlighted. A highlight indicates that the option
6042 * may be selected when a user presses enter or clicks. Disabled items cannot
6043 * be highlighted.
6044 *
6045 * @return {boolean} Item is highlightable
6046 */
6047 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6048 return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
6049 };
6050
6051 /**
6052 * Check if the option can be pressed. The pressed state occurs when a user mouses
6053 * down on an item, but has not yet let go of the mouse.
6054 *
6055 * @return {boolean} Item is pressable
6056 */
6057 OO.ui.OptionWidget.prototype.isPressable = function () {
6058 return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
6059 };
6060
6061 /**
6062 * Check if the option is selected.
6063 *
6064 * @return {boolean} Item is selected
6065 */
6066 OO.ui.OptionWidget.prototype.isSelected = function () {
6067 return this.selected;
6068 };
6069
6070 /**
6071 * Check if the option is highlighted. A highlight indicates that the
6072 * item may be selected when a user presses enter or clicks.
6073 *
6074 * @return {boolean} Item is highlighted
6075 */
6076 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6077 return this.highlighted;
6078 };
6079
6080 /**
6081 * Check if the option is pressed. The pressed state occurs when a user mouses
6082 * down on an item, but has not yet let go of the mouse. The item may appear
6083 * selected, but it will not be selected until the user releases the mouse.
6084 *
6085 * @return {boolean} Item is pressed
6086 */
6087 OO.ui.OptionWidget.prototype.isPressed = function () {
6088 return this.pressed;
6089 };
6090
6091 /**
6092 * Set the option’s selected state. In general, all modifications to the selection
6093 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
6094 * method instead of this method.
6095 *
6096 * @param {boolean} [state=false] Select option
6097 * @chainable
6098 */
6099 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6100 if ( this.constructor.static.selectable ) {
6101 this.selected = !!state;
6102 this.$element
6103 .toggleClass( 'oo-ui-optionWidget-selected', state )
6104 .attr( 'aria-selected', state.toString() );
6105 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6106 this.scrollElementIntoView();
6107 }
6108 this.updateThemeClasses();
6109 }
6110 return this;
6111 };
6112
6113 /**
6114 * Set the option’s highlighted state. In general, all programmatic
6115 * modifications to the highlight should be handled by the
6116 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6117 * method instead of this method.
6118 *
6119 * @param {boolean} [state=false] Highlight option
6120 * @chainable
6121 */
6122 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6123 if ( this.constructor.static.highlightable ) {
6124 this.highlighted = !!state;
6125 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6126 this.updateThemeClasses();
6127 }
6128 return this;
6129 };
6130
6131 /**
6132 * Set the option’s pressed state. In general, all
6133 * programmatic modifications to the pressed state should be handled by the
6134 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6135 * method instead of this method.
6136 *
6137 * @param {boolean} [state=false] Press option
6138 * @chainable
6139 */
6140 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6141 if ( this.constructor.static.pressable ) {
6142 this.pressed = !!state;
6143 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6144 this.updateThemeClasses();
6145 }
6146 return this;
6147 };
6148
6149 /**
6150 * Get text to match search strings against.
6151 *
6152 * The default implementation returns the label text, but subclasses
6153 * can override this to provide more complex behavior.
6154 *
6155 * @return {string|boolean} String to match search string against
6156 */
6157 OO.ui.OptionWidget.prototype.getMatchText = function () {
6158 var label = this.getLabel();
6159 return typeof label === 'string' ? label : this.$label.text();
6160 };
6161
6162 /**
6163 * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
6164 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6165 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6166 * menu selects}.
6167 *
6168 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
6169 * information, please see the [OOjs UI documentation on MediaWiki][1].
6170 *
6171 * @example
6172 * // Example of a select widget with three options
6173 * var select = new OO.ui.SelectWidget( {
6174 * items: [
6175 * new OO.ui.OptionWidget( {
6176 * data: 'a',
6177 * label: 'Option One',
6178 * } ),
6179 * new OO.ui.OptionWidget( {
6180 * data: 'b',
6181 * label: 'Option Two',
6182 * } ),
6183 * new OO.ui.OptionWidget( {
6184 * data: 'c',
6185 * label: 'Option Three',
6186 * } )
6187 * ]
6188 * } );
6189 * $( 'body' ).append( select.$element );
6190 *
6191 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6192 *
6193 * @abstract
6194 * @class
6195 * @extends OO.ui.Widget
6196 * @mixins OO.ui.mixin.GroupWidget
6197 *
6198 * @constructor
6199 * @param {Object} [config] Configuration options
6200 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6201 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6202 * the [OOjs UI documentation on MediaWiki] [2] for examples.
6203 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
6204 */
6205 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6206 // Configuration initialization
6207 config = config || {};
6208
6209 // Parent constructor
6210 OO.ui.SelectWidget.parent.call( this, config );
6211
6212 // Mixin constructors
6213 OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
6214
6215 // Properties
6216 this.pressed = false;
6217 this.selecting = null;
6218 this.onMouseUpHandler = this.onMouseUp.bind( this );
6219 this.onMouseMoveHandler = this.onMouseMove.bind( this );
6220 this.onKeyDownHandler = this.onKeyDown.bind( this );
6221 this.onKeyPressHandler = this.onKeyPress.bind( this );
6222 this.keyPressBuffer = '';
6223 this.keyPressBufferTimer = null;
6224 this.blockMouseOverEvents = 0;
6225
6226 // Events
6227 this.connect( this, {
6228 toggle: 'onToggle'
6229 } );
6230 this.$element.on( {
6231 focusin: this.onFocus.bind( this ),
6232 mousedown: this.onMouseDown.bind( this ),
6233 mouseover: this.onMouseOver.bind( this ),
6234 mouseleave: this.onMouseLeave.bind( this )
6235 } );
6236
6237 // Initialization
6238 this.$element
6239 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
6240 .attr( 'role', 'listbox' );
6241 this.setFocusOwner( this.$element );
6242 if ( Array.isArray( config.items ) ) {
6243 this.addItems( config.items );
6244 }
6245 };
6246
6247 /* Setup */
6248
6249 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6250 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6251
6252 /* Events */
6253
6254 /**
6255 * @event highlight
6256 *
6257 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6258 *
6259 * @param {OO.ui.OptionWidget|null} item Highlighted item
6260 */
6261
6262 /**
6263 * @event press
6264 *
6265 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6266 * pressed state of an option.
6267 *
6268 * @param {OO.ui.OptionWidget|null} item Pressed item
6269 */
6270
6271 /**
6272 * @event select
6273 *
6274 * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
6275 *
6276 * @param {OO.ui.OptionWidget|null} item Selected item
6277 */
6278
6279 /**
6280 * @event choose
6281 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6282 * @param {OO.ui.OptionWidget} item Chosen item
6283 */
6284
6285 /**
6286 * @event add
6287 *
6288 * An `add` event is emitted when options are added to the select with the #addItems method.
6289 *
6290 * @param {OO.ui.OptionWidget[]} items Added items
6291 * @param {number} index Index of insertion point
6292 */
6293
6294 /**
6295 * @event remove
6296 *
6297 * A `remove` event is emitted when options are removed from the select with the #clearItems
6298 * or #removeItems methods.
6299 *
6300 * @param {OO.ui.OptionWidget[]} items Removed items
6301 */
6302
6303 /* Methods */
6304
6305 /**
6306 * Handle focus events
6307 *
6308 * @private
6309 * @param {jQuery.Event} event
6310 */
6311 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6312 var item;
6313 if ( event.target === this.$element[ 0 ] ) {
6314 // This widget was focussed, e.g. by the user tabbing to it.
6315 // The styles for focus state depend on one of the items being selected.
6316 if ( !this.getSelectedItem() ) {
6317 item = this.findFirstSelectableItem();
6318 }
6319 } else {
6320 if ( event.target.tabIndex === -1 ) {
6321 // One of the options got focussed (and the event bubbled up here).
6322 // They can't be tabbed to, but they can be activated using accesskeys.
6323 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6324 item = this.findTargetItem( event );
6325 } else {
6326 // There is something actually user-focusable in one of the labels of the options, and the
6327 // user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change the focus).
6328 return;
6329 }
6330 }
6331
6332 if ( item ) {
6333 if ( item.constructor.static.highlightable ) {
6334 this.highlightItem( item );
6335 } else {
6336 this.selectItem( item );
6337 }
6338 }
6339
6340 if ( event.target !== this.$element[ 0 ] ) {
6341 this.$focusOwner.focus();
6342 }
6343 };
6344
6345 /**
6346 * Handle mouse down events.
6347 *
6348 * @private
6349 * @param {jQuery.Event} e Mouse down event
6350 */
6351 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6352 var item;
6353
6354 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6355 this.togglePressed( true );
6356 item = this.findTargetItem( e );
6357 if ( item && item.isSelectable() ) {
6358 this.pressItem( item );
6359 this.selecting = item;
6360 this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
6361 this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
6362 }
6363 }
6364 return false;
6365 };
6366
6367 /**
6368 * Handle mouse up events.
6369 *
6370 * @private
6371 * @param {MouseEvent} e Mouse up event
6372 */
6373 OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
6374 var item;
6375
6376 this.togglePressed( false );
6377 if ( !this.selecting ) {
6378 item = this.findTargetItem( e );
6379 if ( item && item.isSelectable() ) {
6380 this.selecting = item;
6381 }
6382 }
6383 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6384 this.pressItem( null );
6385 this.chooseItem( this.selecting );
6386 this.selecting = null;
6387 }
6388
6389 this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
6390 this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
6391
6392 return false;
6393 };
6394
6395 /**
6396 * Handle mouse move events.
6397 *
6398 * @private
6399 * @param {MouseEvent} e Mouse move event
6400 */
6401 OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
6402 var item;
6403
6404 if ( !this.isDisabled() && this.pressed ) {
6405 item = this.findTargetItem( e );
6406 if ( item && item !== this.selecting && item.isSelectable() ) {
6407 this.pressItem( item );
6408 this.selecting = item;
6409 }
6410 }
6411 };
6412
6413 /**
6414 * Handle mouse over events.
6415 *
6416 * @private
6417 * @param {jQuery.Event} e Mouse over event
6418 */
6419 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6420 var item;
6421 if ( this.blockMouseOverEvents ) {
6422 return;
6423 }
6424 if ( !this.isDisabled() ) {
6425 item = this.findTargetItem( e );
6426 this.highlightItem( item && item.isHighlightable() ? item : null );
6427 }
6428 return false;
6429 };
6430
6431 /**
6432 * Handle mouse leave events.
6433 *
6434 * @private
6435 * @param {jQuery.Event} e Mouse over event
6436 */
6437 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6438 if ( !this.isDisabled() ) {
6439 this.highlightItem( null );
6440 }
6441 return false;
6442 };
6443
6444 /**
6445 * Handle key down events.
6446 *
6447 * @protected
6448 * @param {KeyboardEvent} e Key down event
6449 */
6450 OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
6451 var nextItem,
6452 handled = false,
6453 currentItem = this.findHighlightedItem() || this.getSelectedItem();
6454
6455 if ( !this.isDisabled() && this.isVisible() ) {
6456 switch ( e.keyCode ) {
6457 case OO.ui.Keys.ENTER:
6458 if ( currentItem && currentItem.constructor.static.highlightable ) {
6459 // Was only highlighted, now let's select it. No-op if already selected.
6460 this.chooseItem( currentItem );
6461 handled = true;
6462 }
6463 break;
6464 case OO.ui.Keys.UP:
6465 case OO.ui.Keys.LEFT:
6466 this.clearKeyPressBuffer();
6467 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
6468 handled = true;
6469 break;
6470 case OO.ui.Keys.DOWN:
6471 case OO.ui.Keys.RIGHT:
6472 this.clearKeyPressBuffer();
6473 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
6474 handled = true;
6475 break;
6476 case OO.ui.Keys.ESCAPE:
6477 case OO.ui.Keys.TAB:
6478 if ( currentItem && currentItem.constructor.static.highlightable ) {
6479 currentItem.setHighlighted( false );
6480 }
6481 this.unbindKeyDownListener();
6482 this.unbindKeyPressListener();
6483 // Don't prevent tabbing away / defocusing
6484 handled = false;
6485 break;
6486 }
6487
6488 if ( nextItem ) {
6489 if ( nextItem.constructor.static.highlightable ) {
6490 this.highlightItem( nextItem );
6491 } else {
6492 this.chooseItem( nextItem );
6493 }
6494 this.scrollItemIntoView( nextItem );
6495 }
6496
6497 if ( handled ) {
6498 e.preventDefault();
6499 e.stopPropagation();
6500 }
6501 }
6502 };
6503
6504 /**
6505 * Bind key down listener.
6506 *
6507 * @protected
6508 */
6509 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
6510 this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
6511 };
6512
6513 /**
6514 * Unbind key down listener.
6515 *
6516 * @protected
6517 */
6518 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
6519 this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
6520 };
6521
6522 /**
6523 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6524 *
6525 * @param {OO.ui.OptionWidget} item Item to scroll into view
6526 */
6527 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6528 var widget = this;
6529 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic scrolling
6530 // and around 100-150 ms after it is finished.
6531 this.blockMouseOverEvents++;
6532 item.scrollElementIntoView().done( function () {
6533 setTimeout( function () {
6534 widget.blockMouseOverEvents--;
6535 }, 200 );
6536 } );
6537 };
6538
6539 /**
6540 * Clear the key-press buffer
6541 *
6542 * @protected
6543 */
6544 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6545 if ( this.keyPressBufferTimer ) {
6546 clearTimeout( this.keyPressBufferTimer );
6547 this.keyPressBufferTimer = null;
6548 }
6549 this.keyPressBuffer = '';
6550 };
6551
6552 /**
6553 * Handle key press events.
6554 *
6555 * @protected
6556 * @param {KeyboardEvent} e Key press event
6557 */
6558 OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
6559 var c, filter, item;
6560
6561 if ( !e.charCode ) {
6562 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6563 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6564 return false;
6565 }
6566 return;
6567 }
6568 if ( String.fromCodePoint ) {
6569 c = String.fromCodePoint( e.charCode );
6570 } else {
6571 c = String.fromCharCode( e.charCode );
6572 }
6573
6574 if ( this.keyPressBufferTimer ) {
6575 clearTimeout( this.keyPressBufferTimer );
6576 }
6577 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6578
6579 item = this.findHighlightedItem() || this.getSelectedItem();
6580
6581 if ( this.keyPressBuffer === c ) {
6582 // Common (if weird) special case: typing "xxxx" will cycle through all
6583 // the items beginning with "x".
6584 if ( item ) {
6585 item = this.findRelativeSelectableItem( item, 1 );
6586 }
6587 } else {
6588 this.keyPressBuffer += c;
6589 }
6590
6591 filter = this.getItemMatcher( this.keyPressBuffer, false );
6592 if ( !item || !filter( item ) ) {
6593 item = this.findRelativeSelectableItem( item, 1, filter );
6594 }
6595 if ( item ) {
6596 if ( this.isVisible() && item.constructor.static.highlightable ) {
6597 this.highlightItem( item );
6598 } else {
6599 this.chooseItem( item );
6600 }
6601 this.scrollItemIntoView( item );
6602 }
6603
6604 e.preventDefault();
6605 e.stopPropagation();
6606 };
6607
6608 /**
6609 * Get a matcher for the specific string
6610 *
6611 * @protected
6612 * @param {string} s String to match against items
6613 * @param {boolean} [exact=false] Only accept exact matches
6614 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6615 */
6616 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
6617 var re;
6618
6619 if ( s.normalize ) {
6620 s = s.normalize();
6621 }
6622 s = exact ? s.trim() : s.replace( /^\s+/, '' );
6623 re = '^\\s*' + s.replace( /([\\{}()|.?*+\-^$[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
6624 if ( exact ) {
6625 re += '\\s*$';
6626 }
6627 re = new RegExp( re, 'i' );
6628 return function ( item ) {
6629 var matchText = item.getMatchText();
6630 if ( matchText.normalize ) {
6631 matchText = matchText.normalize();
6632 }
6633 return re.test( matchText );
6634 };
6635 };
6636
6637 /**
6638 * Bind key press listener.
6639 *
6640 * @protected
6641 */
6642 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
6643 this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
6644 };
6645
6646 /**
6647 * Unbind key down listener.
6648 *
6649 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6650 * implementation.
6651 *
6652 * @protected
6653 */
6654 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
6655 this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
6656 this.clearKeyPressBuffer();
6657 };
6658
6659 /**
6660 * Visibility change handler
6661 *
6662 * @protected
6663 * @param {boolean} visible
6664 */
6665 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6666 if ( !visible ) {
6667 this.clearKeyPressBuffer();
6668 }
6669 };
6670
6671 /**
6672 * Get the closest item to a jQuery.Event.
6673 *
6674 * @private
6675 * @param {jQuery.Event} e
6676 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6677 */
6678 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6679 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6680 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6681 return null;
6682 }
6683 return $option.data( 'oo-ui-optionWidget' ) || null;
6684 };
6685
6686 /**
6687 * Get selected item.
6688 *
6689 * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
6690 */
6691 OO.ui.SelectWidget.prototype.getSelectedItem = function () {
6692 var i, len;
6693
6694 for ( i = 0, len = this.items.length; i < len; i++ ) {
6695 if ( this.items[ i ].isSelected() ) {
6696 return this.items[ i ];
6697 }
6698 }
6699 return null;
6700 };
6701
6702 /**
6703 * Find highlighted item.
6704 *
6705 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6706 */
6707 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6708 var i, len;
6709
6710 for ( i = 0, len = this.items.length; i < len; i++ ) {
6711 if ( this.items[ i ].isHighlighted() ) {
6712 return this.items[ i ];
6713 }
6714 }
6715 return null;
6716 };
6717
6718 /**
6719 * Get highlighted item.
6720 *
6721 * @deprecated 0.23.1 Use {@link #findHighlightedItem} instead.
6722 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6723 */
6724 OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
6725 OO.ui.warnDeprecation( 'SelectWidget#getHighlightedItem: Deprecated function. Use findHighlightedItem instead. See T76630.' );
6726 return this.findHighlightedItem();
6727 };
6728
6729 /**
6730 * Toggle pressed state.
6731 *
6732 * Press is a state that occurs when a user mouses down on an item, but
6733 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6734 * until the user releases the mouse.
6735 *
6736 * @param {boolean} pressed An option is being pressed
6737 */
6738 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6739 if ( pressed === undefined ) {
6740 pressed = !this.pressed;
6741 }
6742 if ( pressed !== this.pressed ) {
6743 this.$element
6744 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
6745 .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
6746 this.pressed = pressed;
6747 }
6748 };
6749
6750 /**
6751 * Highlight an option. If the `item` param is omitted, no options will be highlighted
6752 * and any existing highlight will be removed. The highlight is mutually exclusive.
6753 *
6754 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
6755 * @fires highlight
6756 * @chainable
6757 */
6758 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
6759 var i, len, highlighted,
6760 changed = false;
6761
6762 for ( i = 0, len = this.items.length; i < len; i++ ) {
6763 highlighted = this.items[ i ] === item;
6764 if ( this.items[ i ].isHighlighted() !== highlighted ) {
6765 this.items[ i ].setHighlighted( highlighted );
6766 changed = true;
6767 }
6768 }
6769 if ( changed ) {
6770 if ( item ) {
6771 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6772 } else {
6773 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6774 }
6775 this.emit( 'highlight', item );
6776 }
6777
6778 return this;
6779 };
6780
6781 /**
6782 * Fetch an item by its label.
6783 *
6784 * @param {string} label Label of the item to select.
6785 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6786 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
6787 */
6788 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
6789 var i, item, found,
6790 len = this.items.length,
6791 filter = this.getItemMatcher( label, true );
6792
6793 for ( i = 0; i < len; i++ ) {
6794 item = this.items[ i ];
6795 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6796 return item;
6797 }
6798 }
6799
6800 if ( prefix ) {
6801 found = null;
6802 filter = this.getItemMatcher( label, false );
6803 for ( i = 0; i < len; i++ ) {
6804 item = this.items[ i ];
6805 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
6806 if ( found ) {
6807 return null;
6808 }
6809 found = item;
6810 }
6811 }
6812 if ( found ) {
6813 return found;
6814 }
6815 }
6816
6817 return null;
6818 };
6819
6820 /**
6821 * Programmatically select an option by its label. If the item does not exist,
6822 * all options will be deselected.
6823 *
6824 * @param {string} [label] Label of the item to select.
6825 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
6826 * @fires select
6827 * @chainable
6828 */
6829 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
6830 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
6831 if ( label === undefined || !itemFromLabel ) {
6832 return this.selectItem();
6833 }
6834 return this.selectItem( itemFromLabel );
6835 };
6836
6837 /**
6838 * Programmatically select an option by its data. If the `data` parameter is omitted,
6839 * or if the item does not exist, all options will be deselected.
6840 *
6841 * @param {Object|string} [data] Value of the item to select, omit to deselect all
6842 * @fires select
6843 * @chainable
6844 */
6845 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
6846 var itemFromData = this.getItemFromData( data );
6847 if ( data === undefined || !itemFromData ) {
6848 return this.selectItem();
6849 }
6850 return this.selectItem( itemFromData );
6851 };
6852
6853 /**
6854 * Programmatically select an option by its reference. If the `item` parameter is omitted,
6855 * all options will be deselected.
6856 *
6857 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
6858 * @fires select
6859 * @chainable
6860 */
6861 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
6862 var i, len, selected,
6863 changed = false;
6864
6865 for ( i = 0, len = this.items.length; i < len; i++ ) {
6866 selected = this.items[ i ] === item;
6867 if ( this.items[ i ].isSelected() !== selected ) {
6868 this.items[ i ].setSelected( selected );
6869 changed = true;
6870 }
6871 }
6872 if ( changed ) {
6873 if ( item && !item.constructor.static.highlightable ) {
6874 if ( item ) {
6875 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
6876 } else {
6877 this.$focusOwner.removeAttr( 'aria-activedescendant' );
6878 }
6879 }
6880 this.emit( 'select', item );
6881 }
6882
6883 return this;
6884 };
6885
6886 /**
6887 * Press an item.
6888 *
6889 * Press is a state that occurs when a user mouses down on an item, but has not
6890 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
6891 * releases the mouse.
6892 *
6893 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
6894 * @fires press
6895 * @chainable
6896 */
6897 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
6898 var i, len, pressed,
6899 changed = false;
6900
6901 for ( i = 0, len = this.items.length; i < len; i++ ) {
6902 pressed = this.items[ i ] === item;
6903 if ( this.items[ i ].isPressed() !== pressed ) {
6904 this.items[ i ].setPressed( pressed );
6905 changed = true;
6906 }
6907 }
6908 if ( changed ) {
6909 this.emit( 'press', item );
6910 }
6911
6912 return this;
6913 };
6914
6915 /**
6916 * Choose an item.
6917 *
6918 * Note that ‘choose’ should never be modified programmatically. A user can choose
6919 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
6920 * use the #selectItem method.
6921 *
6922 * This method is identical to #selectItem, but may vary in subclasses that take additional action
6923 * when users choose an item with the keyboard or mouse.
6924 *
6925 * @param {OO.ui.OptionWidget} item Item to choose
6926 * @fires choose
6927 * @chainable
6928 */
6929 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
6930 if ( item ) {
6931 this.selectItem( item );
6932 this.emit( 'choose', item );
6933 }
6934
6935 return this;
6936 };
6937
6938 /**
6939 * Find an option by its position relative to the specified item (or to the start of the option array,
6940 * if item is `null`). The direction in which to search through the option array is specified with a
6941 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6942 * `null` if there are no options in the array.
6943 *
6944 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6945 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6946 * @param {Function} [filter] Only consider items for which this function returns
6947 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6948 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6949 */
6950 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
6951 var currentIndex, nextIndex, i,
6952 increase = direction > 0 ? 1 : -1,
6953 len = this.items.length;
6954
6955 if ( item instanceof OO.ui.OptionWidget ) {
6956 currentIndex = this.items.indexOf( item );
6957 nextIndex = ( currentIndex + increase + len ) % len;
6958 } else {
6959 // If no item is selected and moving forward, start at the beginning.
6960 // If moving backward, start at the end.
6961 nextIndex = direction > 0 ? 0 : len - 1;
6962 }
6963
6964 for ( i = 0; i < len; i++ ) {
6965 item = this.items[ nextIndex ];
6966 if (
6967 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
6968 ( !filter || filter( item ) )
6969 ) {
6970 return item;
6971 }
6972 nextIndex = ( nextIndex + increase + len ) % len;
6973 }
6974 return null;
6975 };
6976
6977 /**
6978 * Get an option by its position relative to the specified item (or to the start of the option array,
6979 * if item is `null`). The direction in which to search through the option array is specified with a
6980 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
6981 * `null` if there are no options in the array.
6982 *
6983 * @deprecated 0.23.1 Use {@link #findRelativeSelectableItem} instead
6984 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
6985 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
6986 * @param {Function} [filter] Only consider items for which this function returns
6987 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
6988 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
6989 */
6990 OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
6991 OO.ui.warnDeprecation( 'SelectWidget#getRelativeSelectableItem: Deprecated function. Use findRelativeSelectableItem instead. See T76630.' );
6992 return this.findRelativeSelectableItem( item, direction, filter );
6993 };
6994
6995 /**
6996 * Find the next selectable item or `null` if there are no selectable items.
6997 * Disabled options and menu-section markers and breaks are not selectable.
6998 *
6999 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7000 */
7001 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7002 return this.findRelativeSelectableItem( null, 1 );
7003 };
7004
7005 /**
7006 * Get the next selectable item or `null` if there are no selectable items.
7007 * Disabled options and menu-section markers and breaks are not selectable.
7008 *
7009 * @deprecated 0.23.1 Use {@link OO.ui.SelectWidget#findFirstSelectableItem} instead.
7010 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7011 */
7012 OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
7013 OO.ui.warnDeprecation( 'SelectWidget#getFirstSelectableItem: Deprecated function. Use findFirstSelectableItem instead. See T76630.' );
7014 return this.findFirstSelectableItem();
7015 };
7016
7017 /**
7018 * Add an array of options to the select. Optionally, an index number can be used to
7019 * specify an insertion point.
7020 *
7021 * @param {OO.ui.OptionWidget[]} items Items to add
7022 * @param {number} [index] Index to insert items after
7023 * @fires add
7024 * @chainable
7025 */
7026 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7027 // Mixin method
7028 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7029
7030 // Always provide an index, even if it was omitted
7031 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7032
7033 return this;
7034 };
7035
7036 /**
7037 * Remove the specified array of options from the select. Options will be detached
7038 * from the DOM, not removed, so they can be reused later. To remove all options from
7039 * the select, you may wish to use the #clearItems method instead.
7040 *
7041 * @param {OO.ui.OptionWidget[]} items Items to remove
7042 * @fires remove
7043 * @chainable
7044 */
7045 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7046 var i, len, item;
7047
7048 // Deselect items being removed
7049 for ( i = 0, len = items.length; i < len; i++ ) {
7050 item = items[ i ];
7051 if ( item.isSelected() ) {
7052 this.selectItem( null );
7053 }
7054 }
7055
7056 // Mixin method
7057 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7058
7059 this.emit( 'remove', items );
7060
7061 return this;
7062 };
7063
7064 /**
7065 * Clear all options from the select. Options will be detached from the DOM, not removed,
7066 * so that they can be reused later. To remove a subset of options from the select, use
7067 * the #removeItems method.
7068 *
7069 * @fires remove
7070 * @chainable
7071 */
7072 OO.ui.SelectWidget.prototype.clearItems = function () {
7073 var items = this.items.slice();
7074
7075 // Mixin method
7076 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7077
7078 // Clear selection
7079 this.selectItem( null );
7080
7081 this.emit( 'remove', items );
7082
7083 return this;
7084 };
7085
7086 /**
7087 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7088 *
7089 * Currently this is just used to set `aria-activedescendant` on it.
7090 *
7091 * @protected
7092 * @param {jQuery} $focusOwner
7093 */
7094 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7095 this.$focusOwner = $focusOwner;
7096 };
7097
7098 /**
7099 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7100 * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
7101 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7102 * options. For more information about options and selects, please see the
7103 * [OOjs UI documentation on MediaWiki][1].
7104 *
7105 * @example
7106 * // Decorated options in a select widget
7107 * var select = new OO.ui.SelectWidget( {
7108 * items: [
7109 * new OO.ui.DecoratedOptionWidget( {
7110 * data: 'a',
7111 * label: 'Option with icon',
7112 * icon: 'help'
7113 * } ),
7114 * new OO.ui.DecoratedOptionWidget( {
7115 * data: 'b',
7116 * label: 'Option with indicator',
7117 * indicator: 'next'
7118 * } )
7119 * ]
7120 * } );
7121 * $( 'body' ).append( select.$element );
7122 *
7123 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7124 *
7125 * @class
7126 * @extends OO.ui.OptionWidget
7127 * @mixins OO.ui.mixin.IconElement
7128 * @mixins OO.ui.mixin.IndicatorElement
7129 *
7130 * @constructor
7131 * @param {Object} [config] Configuration options
7132 */
7133 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7134 // Parent constructor
7135 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7136
7137 // Mixin constructors
7138 OO.ui.mixin.IconElement.call( this, config );
7139 OO.ui.mixin.IndicatorElement.call( this, config );
7140
7141 // Initialization
7142 this.$element
7143 .addClass( 'oo-ui-decoratedOptionWidget' )
7144 .prepend( this.$icon )
7145 .append( this.$indicator );
7146 };
7147
7148 /* Setup */
7149
7150 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7151 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7152 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7153
7154 /**
7155 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7156 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7157 * the [OOjs UI documentation on MediaWiki] [1] for more information.
7158 *
7159 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7160 *
7161 * @class
7162 * @extends OO.ui.DecoratedOptionWidget
7163 *
7164 * @constructor
7165 * @param {Object} [config] Configuration options
7166 */
7167 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7168 // Parent constructor
7169 OO.ui.MenuOptionWidget.parent.call( this, config );
7170
7171 // Initialization
7172 this.$element.addClass( 'oo-ui-menuOptionWidget' );
7173 };
7174
7175 /* Setup */
7176
7177 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7178
7179 /* Static Properties */
7180
7181 /**
7182 * @static
7183 * @inheritdoc
7184 */
7185 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7186
7187 /**
7188 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
7189 * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
7190 *
7191 * @example
7192 * var myDropdown = new OO.ui.DropdownWidget( {
7193 * menu: {
7194 * items: [
7195 * new OO.ui.MenuSectionOptionWidget( {
7196 * label: 'Dogs'
7197 * } ),
7198 * new OO.ui.MenuOptionWidget( {
7199 * data: 'corgi',
7200 * label: 'Welsh Corgi'
7201 * } ),
7202 * new OO.ui.MenuOptionWidget( {
7203 * data: 'poodle',
7204 * label: 'Standard Poodle'
7205 * } ),
7206 * new OO.ui.MenuSectionOptionWidget( {
7207 * label: 'Cats'
7208 * } ),
7209 * new OO.ui.MenuOptionWidget( {
7210 * data: 'lion',
7211 * label: 'Lion'
7212 * } )
7213 * ]
7214 * }
7215 * } );
7216 * $( 'body' ).append( myDropdown.$element );
7217 *
7218 * @class
7219 * @extends OO.ui.DecoratedOptionWidget
7220 *
7221 * @constructor
7222 * @param {Object} [config] Configuration options
7223 */
7224 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7225 // Parent constructor
7226 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7227
7228 // Initialization
7229 this.$element.addClass( 'oo-ui-menuSectionOptionWidget' )
7230 .removeAttr( 'role aria-selected' );
7231 };
7232
7233 /* Setup */
7234
7235 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7236
7237 /* Static Properties */
7238
7239 /**
7240 * @static
7241 * @inheritdoc
7242 */
7243 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7244
7245 /**
7246 * @static
7247 * @inheritdoc
7248 */
7249 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7250
7251 /**
7252 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7253 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7254 * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
7255 * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7256 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7257 * and customized to be opened, closed, and displayed as needed.
7258 *
7259 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7260 * mouse outside the menu.
7261 *
7262 * Menus also have support for keyboard interaction:
7263 *
7264 * - Enter/Return key: choose and select a menu option
7265 * - Up-arrow key: highlight the previous menu option
7266 * - Down-arrow key: highlight the next menu option
7267 * - Esc key: hide the menu
7268 *
7269 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7270 *
7271 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7272 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7273 *
7274 * @class
7275 * @extends OO.ui.SelectWidget
7276 * @mixins OO.ui.mixin.ClippableElement
7277 * @mixins OO.ui.mixin.FloatableElement
7278 *
7279 * @constructor
7280 * @param {Object} [config] Configuration options
7281 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
7282 * the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
7283 * and {@link OO.ui.mixin.LookupElement LookupElement}
7284 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7285 * the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
7286 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
7287 * anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
7288 * that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
7289 * that button, unless the button (or its parent widget) is passed in here.
7290 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7291 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7292 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7293 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7294 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7295 * @cfg {number} [width] Width of the menu
7296 */
7297 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7298 // Configuration initialization
7299 config = config || {};
7300
7301 // Parent constructor
7302 OO.ui.MenuSelectWidget.parent.call( this, config );
7303
7304 // Mixin constructors
7305 OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
7306 OO.ui.mixin.FloatableElement.call( this, config );
7307
7308 // Properties
7309 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7310 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7311 this.filterFromInput = !!config.filterFromInput;
7312 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7313 this.$widget = config.widget ? config.widget.$element : null;
7314 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7315 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7316 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7317 this.highlightOnFilter = !!config.highlightOnFilter;
7318 this.width = config.width;
7319
7320 // Initialization
7321 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7322 if ( config.widget ) {
7323 this.setFocusOwner( config.widget.$tabIndexed );
7324 }
7325
7326 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7327 // that reference properties not initialized at that time of parent class construction
7328 // TODO: Find a better way to handle post-constructor setup
7329 this.visible = false;
7330 this.$element.addClass( 'oo-ui-element-hidden' );
7331 };
7332
7333 /* Setup */
7334
7335 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7336 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7337 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7338
7339 /* Events */
7340
7341 /**
7342 * @event ready
7343 *
7344 * The menu is ready: it is visible and has been positioned and clipped.
7345 */
7346
7347 /* Methods */
7348
7349 /**
7350 * Handles document mouse down events.
7351 *
7352 * @protected
7353 * @param {MouseEvent} e Mouse down event
7354 */
7355 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7356 if (
7357 this.isVisible() &&
7358 !OO.ui.contains(
7359 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7360 e.target,
7361 true
7362 )
7363 ) {
7364 this.toggle( false );
7365 }
7366 };
7367
7368 /**
7369 * @inheritdoc
7370 */
7371 OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
7372 var currentItem = this.findHighlightedItem() || this.getSelectedItem();
7373
7374 if ( !this.isDisabled() && this.isVisible() ) {
7375 switch ( e.keyCode ) {
7376 case OO.ui.Keys.LEFT:
7377 case OO.ui.Keys.RIGHT:
7378 // Do nothing if a text field is associated, arrow keys will be handled natively
7379 if ( !this.$input ) {
7380 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7381 }
7382 break;
7383 case OO.ui.Keys.ESCAPE:
7384 case OO.ui.Keys.TAB:
7385 if ( currentItem ) {
7386 currentItem.setHighlighted( false );
7387 }
7388 this.toggle( false );
7389 // Don't prevent tabbing away, prevent defocusing
7390 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7391 e.preventDefault();
7392 e.stopPropagation();
7393 }
7394 break;
7395 default:
7396 OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
7397 return;
7398 }
7399 }
7400 };
7401
7402 /**
7403 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7404 * or after items were added/removed (always).
7405 *
7406 * @protected
7407 */
7408 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7409 var i, item, visible, section, sectionEmpty, filter, exactFilter,
7410 firstItemFound = false,
7411 anyVisible = false,
7412 len = this.items.length,
7413 showAll = !this.isVisible(),
7414 exactMatch = false;
7415
7416 if ( this.$input && this.filterFromInput ) {
7417 filter = showAll ? null : this.getItemMatcher( this.$input.val() );
7418 exactFilter = this.getItemMatcher( this.$input.val(), true );
7419
7420 // Hide non-matching options, and also hide section headers if all options
7421 // in their section are hidden.
7422 for ( i = 0; i < len; i++ ) {
7423 item = this.items[ i ];
7424 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7425 if ( section ) {
7426 // If the previous section was empty, hide its header
7427 section.toggle( showAll || !sectionEmpty );
7428 }
7429 section = item;
7430 sectionEmpty = true;
7431 } else if ( item instanceof OO.ui.OptionWidget ) {
7432 visible = showAll || filter( item );
7433 exactMatch = exactMatch || exactFilter( item );
7434 anyVisible = anyVisible || visible;
7435 sectionEmpty = sectionEmpty && !visible;
7436 item.toggle( visible );
7437 if ( this.highlightOnFilter && visible && !firstItemFound ) {
7438 // Highlight the first item in the list
7439 this.highlightItem( item );
7440 firstItemFound = true;
7441 }
7442 }
7443 }
7444 // Process the final section
7445 if ( section ) {
7446 section.toggle( showAll || !sectionEmpty );
7447 }
7448
7449 if ( anyVisible && this.items.length && !exactMatch ) {
7450 this.scrollItemIntoView( this.items[ 0 ] );
7451 }
7452
7453 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7454 }
7455
7456 // Reevaluate clipping
7457 this.clip();
7458 };
7459
7460 /**
7461 * @inheritdoc
7462 */
7463 OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
7464 if ( this.$input ) {
7465 this.$input.on( 'keydown', this.onKeyDownHandler );
7466 } else {
7467 OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
7468 }
7469 };
7470
7471 /**
7472 * @inheritdoc
7473 */
7474 OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
7475 if ( this.$input ) {
7476 this.$input.off( 'keydown', this.onKeyDownHandler );
7477 } else {
7478 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
7479 }
7480 };
7481
7482 /**
7483 * @inheritdoc
7484 */
7485 OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
7486 if ( this.$input ) {
7487 if ( this.filterFromInput ) {
7488 this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7489 this.updateItemVisibility();
7490 }
7491 } else {
7492 OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
7493 }
7494 };
7495
7496 /**
7497 * @inheritdoc
7498 */
7499 OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
7500 if ( this.$input ) {
7501 if ( this.filterFromInput ) {
7502 this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
7503 this.updateItemVisibility();
7504 }
7505 } else {
7506 OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
7507 }
7508 };
7509
7510 /**
7511 * Choose an item.
7512 *
7513 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
7514 *
7515 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
7516 * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
7517 *
7518 * @param {OO.ui.OptionWidget} item Item to choose
7519 * @chainable
7520 */
7521 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7522 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7523 if ( this.hideOnChoose ) {
7524 this.toggle( false );
7525 }
7526 return this;
7527 };
7528
7529 /**
7530 * @inheritdoc
7531 */
7532 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7533 // Parent method
7534 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7535
7536 this.updateItemVisibility();
7537
7538 return this;
7539 };
7540
7541 /**
7542 * @inheritdoc
7543 */
7544 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7545 // Parent method
7546 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7547
7548 this.updateItemVisibility();
7549
7550 return this;
7551 };
7552
7553 /**
7554 * @inheritdoc
7555 */
7556 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7557 // Parent method
7558 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7559
7560 this.updateItemVisibility();
7561
7562 return this;
7563 };
7564
7565 /**
7566 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7567 * `.toggle( true )` after its #$element is attached to the DOM.
7568 *
7569 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7570 * it in the right place and with the right dimensions only work correctly while it is attached.
7571 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7572 * strictly enforced, so currently it only generates a warning in the browser console.
7573 *
7574 * @fires ready
7575 * @inheritdoc
7576 */
7577 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7578 var change, belowHeight, aboveHeight;
7579
7580 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7581 change = visible !== this.isVisible();
7582
7583 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7584 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7585 this.warnedUnattached = true;
7586 }
7587
7588 if ( change ) {
7589 if ( visible && ( this.width || this.$floatableContainer ) ) {
7590 this.setIdealSize( this.width || this.$floatableContainer.width() );
7591 }
7592 if ( visible ) {
7593 // Reset position before showing the popup again. It's possible we no longer need to flip
7594 // (e.g. if the user scrolled).
7595 this.setVerticalPosition( 'below' );
7596 }
7597 }
7598
7599 // Parent method
7600 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7601
7602 if ( change ) {
7603 if ( visible ) {
7604 this.bindKeyDownListener();
7605 this.bindKeyPressListener();
7606
7607 this.togglePositioning( !!this.$floatableContainer );
7608 this.toggleClipping( true );
7609
7610 if ( this.isClippedVertically() ) {
7611 // If opening the menu downwards causes it to be clipped, flip it to open upwards instead
7612 belowHeight = this.$element.height();
7613 this.setVerticalPosition( 'above' );
7614 if ( this.isClippedVertically() ) {
7615 // If opening upwards also causes it to be clipped, flip it to open in whichever direction
7616 // we have more space
7617 aboveHeight = this.$element.height();
7618 if ( aboveHeight < belowHeight ) {
7619 this.setVerticalPosition( 'below' );
7620 }
7621 }
7622 }
7623 // Note that we do not flip the menu's opening direction if the clipping changes
7624 // later (e.g. after the user scrolls), that seems like it would be annoying
7625
7626 this.$focusOwner.attr( 'aria-expanded', 'true' );
7627
7628 if ( this.getSelectedItem() ) {
7629 this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
7630 this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
7631 }
7632
7633 // Auto-hide
7634 if ( this.autoHide ) {
7635 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7636 }
7637
7638 this.emit( 'ready' );
7639 } else {
7640 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7641 this.unbindKeyDownListener();
7642 this.unbindKeyPressListener();
7643 this.$focusOwner.attr( 'aria-expanded', 'false' );
7644 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7645 this.togglePositioning( false );
7646 this.toggleClipping( false );
7647 }
7648 }
7649
7650 return this;
7651 };
7652
7653 /**
7654 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
7655 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
7656 * users can interact with it.
7657 *
7658 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7659 * OO.ui.DropdownInputWidget instead.
7660 *
7661 * @example
7662 * // Example: A DropdownWidget with a menu that contains three options
7663 * var dropDown = new OO.ui.DropdownWidget( {
7664 * label: 'Dropdown menu: Select a menu option',
7665 * menu: {
7666 * items: [
7667 * new OO.ui.MenuOptionWidget( {
7668 * data: 'a',
7669 * label: 'First'
7670 * } ),
7671 * new OO.ui.MenuOptionWidget( {
7672 * data: 'b',
7673 * label: 'Second'
7674 * } ),
7675 * new OO.ui.MenuOptionWidget( {
7676 * data: 'c',
7677 * label: 'Third'
7678 * } )
7679 * ]
7680 * }
7681 * } );
7682 *
7683 * $( 'body' ).append( dropDown.$element );
7684 *
7685 * dropDown.getMenu().selectItemByData( 'b' );
7686 *
7687 * dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
7688 *
7689 * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
7690 *
7691 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
7692 *
7693 * @class
7694 * @extends OO.ui.Widget
7695 * @mixins OO.ui.mixin.IconElement
7696 * @mixins OO.ui.mixin.IndicatorElement
7697 * @mixins OO.ui.mixin.LabelElement
7698 * @mixins OO.ui.mixin.TitledElement
7699 * @mixins OO.ui.mixin.TabIndexedElement
7700 *
7701 * @constructor
7702 * @param {Object} [config] Configuration options
7703 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
7704 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
7705 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
7706 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
7707 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
7708 */
7709 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
7710 // Configuration initialization
7711 config = $.extend( { indicator: 'down' }, config );
7712
7713 // Parent constructor
7714 OO.ui.DropdownWidget.parent.call( this, config );
7715
7716 // Properties (must be set before TabIndexedElement constructor call)
7717 this.$handle = $( '<span>' );
7718 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
7719
7720 // Mixin constructors
7721 OO.ui.mixin.IconElement.call( this, config );
7722 OO.ui.mixin.IndicatorElement.call( this, config );
7723 OO.ui.mixin.LabelElement.call( this, config );
7724 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
7725 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
7726
7727 // Properties
7728 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
7729 widget: this,
7730 $floatableContainer: this.$element
7731 }, config.menu ) );
7732
7733 // Events
7734 this.$handle.on( {
7735 click: this.onClick.bind( this ),
7736 keydown: this.onKeyDown.bind( this ),
7737 // Hack? Handle type-to-search when menu is not expanded and not handling its own events
7738 keypress: this.menu.onKeyPressHandler,
7739 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
7740 } );
7741 this.menu.connect( this, {
7742 select: 'onMenuSelect',
7743 toggle: 'onMenuToggle'
7744 } );
7745
7746 // Initialization
7747 this.$handle
7748 .addClass( 'oo-ui-dropdownWidget-handle' )
7749 .attr( {
7750 role: 'combobox',
7751 'aria-owns': this.menu.getElementId(),
7752 'aria-autocomplete': 'list'
7753 } )
7754 .append( this.$icon, this.$label, this.$indicator );
7755 this.$element
7756 .addClass( 'oo-ui-dropdownWidget' )
7757 .append( this.$handle );
7758 this.$overlay.append( this.menu.$element );
7759 };
7760
7761 /* Setup */
7762
7763 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
7764 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
7765 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
7766 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
7767 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
7768 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
7769
7770 /* Methods */
7771
7772 /**
7773 * Get the menu.
7774 *
7775 * @return {OO.ui.MenuSelectWidget} Menu of widget
7776 */
7777 OO.ui.DropdownWidget.prototype.getMenu = function () {
7778 return this.menu;
7779 };
7780
7781 /**
7782 * Handles menu select events.
7783 *
7784 * @private
7785 * @param {OO.ui.MenuOptionWidget} item Selected menu item
7786 */
7787 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
7788 var selectedLabel;
7789
7790 if ( !item ) {
7791 this.setLabel( null );
7792 return;
7793 }
7794
7795 selectedLabel = item.getLabel();
7796
7797 // If the label is a DOM element, clone it, because setLabel will append() it
7798 if ( selectedLabel instanceof jQuery ) {
7799 selectedLabel = selectedLabel.clone();
7800 }
7801
7802 this.setLabel( selectedLabel );
7803 };
7804
7805 /**
7806 * Handle menu toggle events.
7807 *
7808 * @private
7809 * @param {boolean} isVisible Open state of the menu
7810 */
7811 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
7812 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
7813 this.$handle.attr(
7814 'aria-expanded',
7815 this.$element.hasClass( 'oo-ui-dropdownWidget-open' ).toString()
7816 );
7817 };
7818
7819 /**
7820 * Handle mouse click events.
7821 *
7822 * @private
7823 * @param {jQuery.Event} e Mouse click event
7824 */
7825 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
7826 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
7827 this.menu.toggle();
7828 }
7829 return false;
7830 };
7831
7832 /**
7833 * Handle key down events.
7834 *
7835 * @private
7836 * @param {jQuery.Event} e Key down event
7837 */
7838 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
7839 if (
7840 !this.isDisabled() &&
7841 (
7842 e.which === OO.ui.Keys.ENTER ||
7843 (
7844 e.which === OO.ui.Keys.SPACE &&
7845 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
7846 // Space only closes the menu is the user is not typing to search.
7847 this.menu.keyPressBuffer === ''
7848 ) ||
7849 (
7850 !this.menu.isVisible() &&
7851 (
7852 e.which === OO.ui.Keys.UP ||
7853 e.which === OO.ui.Keys.DOWN
7854 )
7855 )
7856 )
7857 ) {
7858 this.menu.toggle();
7859 return false;
7860 }
7861 };
7862
7863 /**
7864 * RadioOptionWidget is an option widget that looks like a radio button.
7865 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
7866 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
7867 *
7868 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
7869 *
7870 * @class
7871 * @extends OO.ui.OptionWidget
7872 *
7873 * @constructor
7874 * @param {Object} [config] Configuration options
7875 */
7876 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
7877 // Configuration initialization
7878 config = config || {};
7879
7880 // Properties (must be done before parent constructor which calls #setDisabled)
7881 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
7882
7883 // Parent constructor
7884 OO.ui.RadioOptionWidget.parent.call( this, config );
7885
7886 // Initialization
7887 // Remove implicit role, we're handling it ourselves
7888 this.radio.$input.attr( 'role', 'presentation' );
7889 this.$element
7890 .addClass( 'oo-ui-radioOptionWidget' )
7891 .attr( 'role', 'radio' )
7892 .attr( 'aria-checked', 'false' )
7893 .removeAttr( 'aria-selected' )
7894 .prepend( this.radio.$element );
7895 };
7896
7897 /* Setup */
7898
7899 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
7900
7901 /* Static Properties */
7902
7903 /**
7904 * @static
7905 * @inheritdoc
7906 */
7907 OO.ui.RadioOptionWidget.static.highlightable = false;
7908
7909 /**
7910 * @static
7911 * @inheritdoc
7912 */
7913 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
7914
7915 /**
7916 * @static
7917 * @inheritdoc
7918 */
7919 OO.ui.RadioOptionWidget.static.pressable = false;
7920
7921 /**
7922 * @static
7923 * @inheritdoc
7924 */
7925 OO.ui.RadioOptionWidget.static.tagName = 'label';
7926
7927 /* Methods */
7928
7929 /**
7930 * @inheritdoc
7931 */
7932 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
7933 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
7934
7935 this.radio.setSelected( state );
7936 this.$element
7937 .attr( 'aria-checked', state.toString() )
7938 .removeAttr( 'aria-selected' );
7939
7940 return this;
7941 };
7942
7943 /**
7944 * @inheritdoc
7945 */
7946 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
7947 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
7948
7949 this.radio.setDisabled( this.isDisabled() );
7950
7951 return this;
7952 };
7953
7954 /**
7955 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
7956 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
7957 * an interface for adding, removing and selecting options.
7958 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
7959 *
7960 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
7961 * OO.ui.RadioSelectInputWidget instead.
7962 *
7963 * @example
7964 * // A RadioSelectWidget with RadioOptions.
7965 * var option1 = new OO.ui.RadioOptionWidget( {
7966 * data: 'a',
7967 * label: 'Selected radio option'
7968 * } );
7969 *
7970 * var option2 = new OO.ui.RadioOptionWidget( {
7971 * data: 'b',
7972 * label: 'Unselected radio option'
7973 * } );
7974 *
7975 * var radioSelect=new OO.ui.RadioSelectWidget( {
7976 * items: [ option1, option2 ]
7977 * } );
7978 *
7979 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
7980 * radioSelect.selectItem( option1 );
7981 *
7982 * $( 'body' ).append( radioSelect.$element );
7983 *
7984 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
7985
7986 *
7987 * @class
7988 * @extends OO.ui.SelectWidget
7989 * @mixins OO.ui.mixin.TabIndexedElement
7990 *
7991 * @constructor
7992 * @param {Object} [config] Configuration options
7993 */
7994 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
7995 // Parent constructor
7996 OO.ui.RadioSelectWidget.parent.call( this, config );
7997
7998 // Mixin constructors
7999 OO.ui.mixin.TabIndexedElement.call( this, config );
8000
8001 // Events
8002 this.$element.on( {
8003 focus: this.bindKeyDownListener.bind( this ),
8004 blur: this.unbindKeyDownListener.bind( this )
8005 } );
8006
8007 // Initialization
8008 this.$element
8009 .addClass( 'oo-ui-radioSelectWidget' )
8010 .attr( 'role', 'radiogroup' );
8011 };
8012
8013 /* Setup */
8014
8015 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8016 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8017
8018 /**
8019 * MultioptionWidgets are special elements that can be selected and configured with data. The
8020 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8021 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8022 * and examples, please see the [OOjs UI documentation on MediaWiki][1].
8023 *
8024 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Multioptions
8025 *
8026 * @class
8027 * @extends OO.ui.Widget
8028 * @mixins OO.ui.mixin.ItemWidget
8029 * @mixins OO.ui.mixin.LabelElement
8030 *
8031 * @constructor
8032 * @param {Object} [config] Configuration options
8033 * @cfg {boolean} [selected=false] Whether the option is initially selected
8034 */
8035 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8036 // Configuration initialization
8037 config = config || {};
8038
8039 // Parent constructor
8040 OO.ui.MultioptionWidget.parent.call( this, config );
8041
8042 // Mixin constructors
8043 OO.ui.mixin.ItemWidget.call( this );
8044 OO.ui.mixin.LabelElement.call( this, config );
8045
8046 // Properties
8047 this.selected = null;
8048
8049 // Initialization
8050 this.$element
8051 .addClass( 'oo-ui-multioptionWidget' )
8052 .append( this.$label );
8053 this.setSelected( config.selected );
8054 };
8055
8056 /* Setup */
8057
8058 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8059 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8060 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8061
8062 /* Events */
8063
8064 /**
8065 * @event change
8066 *
8067 * A change event is emitted when the selected state of the option changes.
8068 *
8069 * @param {boolean} selected Whether the option is now selected
8070 */
8071
8072 /* Methods */
8073
8074 /**
8075 * Check if the option is selected.
8076 *
8077 * @return {boolean} Item is selected
8078 */
8079 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8080 return this.selected;
8081 };
8082
8083 /**
8084 * Set the option’s selected state. In general, all modifications to the selection
8085 * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
8086 * method instead of this method.
8087 *
8088 * @param {boolean} [state=false] Select option
8089 * @chainable
8090 */
8091 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8092 state = !!state;
8093 if ( this.selected !== state ) {
8094 this.selected = state;
8095 this.emit( 'change', state );
8096 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8097 }
8098 return this;
8099 };
8100
8101 /**
8102 * MultiselectWidget allows selecting multiple options from a list.
8103 *
8104 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
8105 *
8106 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
8107 *
8108 * @class
8109 * @abstract
8110 * @extends OO.ui.Widget
8111 * @mixins OO.ui.mixin.GroupWidget
8112 *
8113 * @constructor
8114 * @param {Object} [config] Configuration options
8115 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8116 */
8117 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8118 // Parent constructor
8119 OO.ui.MultiselectWidget.parent.call( this, config );
8120
8121 // Configuration initialization
8122 config = config || {};
8123
8124 // Mixin constructors
8125 OO.ui.mixin.GroupWidget.call( this, config );
8126
8127 // Events
8128 this.aggregate( { change: 'select' } );
8129 // This is mostly for compatibility with CapsuleMultiselectWidget... normally, 'change' is emitted
8130 // by GroupElement only when items are added/removed
8131 this.connect( this, { select: [ 'emit', 'change' ] } );
8132
8133 // Initialization
8134 if ( config.items ) {
8135 this.addItems( config.items );
8136 }
8137 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8138 this.$element.addClass( 'oo-ui-multiselectWidget' )
8139 .append( this.$group );
8140 };
8141
8142 /* Setup */
8143
8144 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8145 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8146
8147 /* Events */
8148
8149 /**
8150 * @event change
8151 *
8152 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8153 */
8154
8155 /**
8156 * @event select
8157 *
8158 * A select event is emitted when an item is selected or deselected.
8159 */
8160
8161 /* Methods */
8162
8163 /**
8164 * Get options that are selected.
8165 *
8166 * @return {OO.ui.MultioptionWidget[]} Selected options
8167 */
8168 OO.ui.MultiselectWidget.prototype.getSelectedItems = function () {
8169 return this.items.filter( function ( item ) {
8170 return item.isSelected();
8171 } );
8172 };
8173
8174 /**
8175 * Get the data of options that are selected.
8176 *
8177 * @return {Object[]|string[]} Values of selected options
8178 */
8179 OO.ui.MultiselectWidget.prototype.getSelectedItemsData = function () {
8180 return this.getSelectedItems().map( function ( item ) {
8181 return item.data;
8182 } );
8183 };
8184
8185 /**
8186 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8187 *
8188 * @param {OO.ui.MultioptionWidget[]} items Items to select
8189 * @chainable
8190 */
8191 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8192 this.items.forEach( function ( item ) {
8193 var selected = items.indexOf( item ) !== -1;
8194 item.setSelected( selected );
8195 } );
8196 return this;
8197 };
8198
8199 /**
8200 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8201 *
8202 * @param {Object[]|string[]} datas Values of items to select
8203 * @chainable
8204 */
8205 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8206 var items,
8207 widget = this;
8208 items = datas.map( function ( data ) {
8209 return widget.getItemFromData( data );
8210 } );
8211 this.selectItems( items );
8212 return this;
8213 };
8214
8215 /**
8216 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8217 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8218 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
8219 *
8220 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
8221 *
8222 * @class
8223 * @extends OO.ui.MultioptionWidget
8224 *
8225 * @constructor
8226 * @param {Object} [config] Configuration options
8227 */
8228 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8229 // Configuration initialization
8230 config = config || {};
8231
8232 // Properties (must be done before parent constructor which calls #setDisabled)
8233 this.checkbox = new OO.ui.CheckboxInputWidget();
8234
8235 // Parent constructor
8236 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8237
8238 // Events
8239 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8240 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8241
8242 // Initialization
8243 this.$element
8244 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8245 .prepend( this.checkbox.$element );
8246 };
8247
8248 /* Setup */
8249
8250 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8251
8252 /* Static Properties */
8253
8254 /**
8255 * @static
8256 * @inheritdoc
8257 */
8258 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8259
8260 /* Methods */
8261
8262 /**
8263 * Handle checkbox selected state change.
8264 *
8265 * @private
8266 */
8267 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8268 this.setSelected( this.checkbox.isSelected() );
8269 };
8270
8271 /**
8272 * @inheritdoc
8273 */
8274 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8275 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8276 this.checkbox.setSelected( state );
8277 return this;
8278 };
8279
8280 /**
8281 * @inheritdoc
8282 */
8283 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8284 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8285 this.checkbox.setDisabled( this.isDisabled() );
8286 return this;
8287 };
8288
8289 /**
8290 * Focus the widget.
8291 */
8292 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8293 this.checkbox.focus();
8294 };
8295
8296 /**
8297 * Handle key down events.
8298 *
8299 * @protected
8300 * @param {jQuery.Event} e
8301 */
8302 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8303 var
8304 element = this.getElementGroup(),
8305 nextItem;
8306
8307 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8308 nextItem = element.getRelativeFocusableItem( this, -1 );
8309 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8310 nextItem = element.getRelativeFocusableItem( this, 1 );
8311 }
8312
8313 if ( nextItem ) {
8314 e.preventDefault();
8315 nextItem.focus();
8316 }
8317 };
8318
8319 /**
8320 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8321 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8322 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8323 * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
8324 *
8325 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8326 * OO.ui.CheckboxMultiselectInputWidget instead.
8327 *
8328 * @example
8329 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8330 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8331 * data: 'a',
8332 * selected: true,
8333 * label: 'Selected checkbox'
8334 * } );
8335 *
8336 * var option2 = new OO.ui.CheckboxMultioptionWidget( {
8337 * data: 'b',
8338 * label: 'Unselected checkbox'
8339 * } );
8340 *
8341 * var multiselect=new OO.ui.CheckboxMultiselectWidget( {
8342 * items: [ option1, option2 ]
8343 * } );
8344 *
8345 * $( 'body' ).append( multiselect.$element );
8346 *
8347 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
8348 *
8349 * @class
8350 * @extends OO.ui.MultiselectWidget
8351 *
8352 * @constructor
8353 * @param {Object} [config] Configuration options
8354 */
8355 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8356 // Parent constructor
8357 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8358
8359 // Properties
8360 this.$lastClicked = null;
8361
8362 // Events
8363 this.$group.on( 'click', this.onClick.bind( this ) );
8364
8365 // Initialization
8366 this.$element
8367 .addClass( 'oo-ui-checkboxMultiselectWidget' );
8368 };
8369
8370 /* Setup */
8371
8372 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8373
8374 /* Methods */
8375
8376 /**
8377 * Get an option by its position relative to the specified item (or to the start of the option array,
8378 * if item is `null`). The direction in which to search through the option array is specified with a
8379 * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
8380 * `null` if there are no options in the array.
8381 *
8382 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
8383 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8384 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items in the select
8385 */
8386 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8387 var currentIndex, nextIndex, i,
8388 increase = direction > 0 ? 1 : -1,
8389 len = this.items.length;
8390
8391 if ( item ) {
8392 currentIndex = this.items.indexOf( item );
8393 nextIndex = ( currentIndex + increase + len ) % len;
8394 } else {
8395 // If no item is selected and moving forward, start at the beginning.
8396 // If moving backward, start at the end.
8397 nextIndex = direction > 0 ? 0 : len - 1;
8398 }
8399
8400 for ( i = 0; i < len; i++ ) {
8401 item = this.items[ nextIndex ];
8402 if ( item && !item.isDisabled() ) {
8403 return item;
8404 }
8405 nextIndex = ( nextIndex + increase + len ) % len;
8406 }
8407 return null;
8408 };
8409
8410 /**
8411 * Handle click events on checkboxes.
8412 *
8413 * @param {jQuery.Event} e
8414 */
8415 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8416 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8417 $lastClicked = this.$lastClicked,
8418 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8419 .not( '.oo-ui-widget-disabled' );
8420
8421 // Allow selecting multiple options at once by Shift-clicking them
8422 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8423 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8424 lastClickedIndex = $options.index( $lastClicked );
8425 nowClickedIndex = $options.index( $nowClicked );
8426 // If it's the same item, either the user is being silly, or it's a fake event generated by the
8427 // browser. In either case we don't need custom handling.
8428 if ( nowClickedIndex !== lastClickedIndex ) {
8429 items = this.items;
8430 wasSelected = items[ nowClickedIndex ].isSelected();
8431 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8432
8433 // This depends on the DOM order of the items and the order of the .items array being the same.
8434 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8435 if ( !items[ i ].isDisabled() ) {
8436 items[ i ].setSelected( !wasSelected );
8437 }
8438 }
8439 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8440 // handling first, then set our value. The order in which events happen is different for
8441 // clicks on the <input> and on the <label> and there are additional fake clicks fired for
8442 // non-click actions that change the checkboxes.
8443 e.preventDefault();
8444 setTimeout( function () {
8445 if ( !items[ nowClickedIndex ].isDisabled() ) {
8446 items[ nowClickedIndex ].setSelected( !wasSelected );
8447 }
8448 } );
8449 }
8450 }
8451
8452 if ( $nowClicked.length ) {
8453 this.$lastClicked = $nowClicked;
8454 }
8455 };
8456
8457 /**
8458 * Focus the widget
8459 *
8460 * @chainable
8461 */
8462 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8463 var item;
8464 if ( !this.isDisabled() ) {
8465 item = this.getRelativeFocusableItem( null, 1 );
8466 if ( item ) {
8467 item.focus();
8468 }
8469 }
8470 return this;
8471 };
8472
8473 /**
8474 * @inheritdoc
8475 */
8476 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8477 this.focus();
8478 };
8479
8480 /**
8481 * Progress bars visually display the status of an operation, such as a download,
8482 * and can be either determinate or indeterminate:
8483 *
8484 * - **determinate** process bars show the percent of an operation that is complete.
8485 *
8486 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8487 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8488 * not use percentages.
8489 *
8490 * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
8491 *
8492 * @example
8493 * // Examples of determinate and indeterminate progress bars.
8494 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8495 * progress: 33
8496 * } );
8497 * var progressBar2 = new OO.ui.ProgressBarWidget();
8498 *
8499 * // Create a FieldsetLayout to layout progress bars
8500 * var fieldset = new OO.ui.FieldsetLayout;
8501 * fieldset.addItems( [
8502 * new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
8503 * new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
8504 * ] );
8505 * $( 'body' ).append( fieldset.$element );
8506 *
8507 * @class
8508 * @extends OO.ui.Widget
8509 *
8510 * @constructor
8511 * @param {Object} [config] Configuration options
8512 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8513 * To create a determinate progress bar, specify a number that reflects the initial percent complete.
8514 * By default, the progress bar is indeterminate.
8515 */
8516 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8517 // Configuration initialization
8518 config = config || {};
8519
8520 // Parent constructor
8521 OO.ui.ProgressBarWidget.parent.call( this, config );
8522
8523 // Properties
8524 this.$bar = $( '<div>' );
8525 this.progress = null;
8526
8527 // Initialization
8528 this.setProgress( config.progress !== undefined ? config.progress : false );
8529 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8530 this.$element
8531 .attr( {
8532 role: 'progressbar',
8533 'aria-valuemin': 0,
8534 'aria-valuemax': 100
8535 } )
8536 .addClass( 'oo-ui-progressBarWidget' )
8537 .append( this.$bar );
8538 };
8539
8540 /* Setup */
8541
8542 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8543
8544 /* Static Properties */
8545
8546 /**
8547 * @static
8548 * @inheritdoc
8549 */
8550 OO.ui.ProgressBarWidget.static.tagName = 'div';
8551
8552 /* Methods */
8553
8554 /**
8555 * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
8556 *
8557 * @return {number|boolean} Progress percent
8558 */
8559 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8560 return this.progress;
8561 };
8562
8563 /**
8564 * Set the percent of the process completed or `false` for an indeterminate process.
8565 *
8566 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8567 */
8568 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8569 this.progress = progress;
8570
8571 if ( progress !== false ) {
8572 this.$bar.css( 'width', this.progress + '%' );
8573 this.$element.attr( 'aria-valuenow', this.progress );
8574 } else {
8575 this.$bar.css( 'width', '' );
8576 this.$element.removeAttr( 'aria-valuenow' );
8577 }
8578 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8579 };
8580
8581 /**
8582 * InputWidget is the base class for all input widgets, which
8583 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs},
8584 * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}.
8585 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
8586 *
8587 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
8588 *
8589 * @abstract
8590 * @class
8591 * @extends OO.ui.Widget
8592 * @mixins OO.ui.mixin.FlaggedElement
8593 * @mixins OO.ui.mixin.TabIndexedElement
8594 * @mixins OO.ui.mixin.TitledElement
8595 * @mixins OO.ui.mixin.AccessKeyedElement
8596 *
8597 * @constructor
8598 * @param {Object} [config] Configuration options
8599 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8600 * @cfg {string} [value=''] The value of the input.
8601 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8602 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8603 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
8604 * before it is accepted.
8605 */
8606 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8607 // Configuration initialization
8608 config = config || {};
8609
8610 // Parent constructor
8611 OO.ui.InputWidget.parent.call( this, config );
8612
8613 // Properties
8614 // See #reusePreInfuseDOM about config.$input
8615 this.$input = config.$input || this.getInputElement( config );
8616 this.value = '';
8617 this.inputFilter = config.inputFilter;
8618
8619 // Mixin constructors
8620 OO.ui.mixin.FlaggedElement.call( this, config );
8621 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
8622 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8623 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
8624
8625 // Events
8626 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
8627
8628 // Initialization
8629 this.$input
8630 .addClass( 'oo-ui-inputWidget-input' )
8631 .attr( 'name', config.name )
8632 .prop( 'disabled', this.isDisabled() );
8633 this.$element
8634 .addClass( 'oo-ui-inputWidget' )
8635 .append( this.$input );
8636 this.setValue( config.value );
8637 if ( config.dir ) {
8638 this.setDir( config.dir );
8639 }
8640 if ( config.inputId !== undefined ) {
8641 this.setInputId( config.inputId );
8642 }
8643 };
8644
8645 /* Setup */
8646
8647 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
8648 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
8649 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
8650 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
8651 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
8652
8653 /* Static Methods */
8654
8655 /**
8656 * @inheritdoc
8657 */
8658 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
8659 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
8660 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
8661 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
8662 return config;
8663 };
8664
8665 /**
8666 * @inheritdoc
8667 */
8668 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
8669 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
8670 if ( config.$input && config.$input.length ) {
8671 state.value = config.$input.val();
8672 // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
8673 state.focus = config.$input.is( ':focus' );
8674 }
8675 return state;
8676 };
8677
8678 /* Events */
8679
8680 /**
8681 * @event change
8682 *
8683 * A change event is emitted when the value of the input changes.
8684 *
8685 * @param {string} value
8686 */
8687
8688 /* Methods */
8689
8690 /**
8691 * Get input element.
8692 *
8693 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
8694 * different circumstances. The element must have a `value` property (like form elements).
8695 *
8696 * @protected
8697 * @param {Object} config Configuration options
8698 * @return {jQuery} Input element
8699 */
8700 OO.ui.InputWidget.prototype.getInputElement = function () {
8701 return $( '<input>' );
8702 };
8703
8704 /**
8705 * Handle potentially value-changing events.
8706 *
8707 * @private
8708 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
8709 */
8710 OO.ui.InputWidget.prototype.onEdit = function () {
8711 var widget = this;
8712 if ( !this.isDisabled() ) {
8713 // Allow the stack to clear so the value will be updated
8714 setTimeout( function () {
8715 widget.setValue( widget.$input.val() );
8716 } );
8717 }
8718 };
8719
8720 /**
8721 * Get the value of the input.
8722 *
8723 * @return {string} Input value
8724 */
8725 OO.ui.InputWidget.prototype.getValue = function () {
8726 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
8727 // it, and we won't know unless they're kind enough to trigger a 'change' event.
8728 var value = this.$input.val();
8729 if ( this.value !== value ) {
8730 this.setValue( value );
8731 }
8732 return this.value;
8733 };
8734
8735 /**
8736 * Set the directionality of the input.
8737 *
8738 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
8739 * @chainable
8740 */
8741 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
8742 this.$input.prop( 'dir', dir );
8743 return this;
8744 };
8745
8746 /**
8747 * Set the value of the input.
8748 *
8749 * @param {string} value New value
8750 * @fires change
8751 * @chainable
8752 */
8753 OO.ui.InputWidget.prototype.setValue = function ( value ) {
8754 value = this.cleanUpValue( value );
8755 // Update the DOM if it has changed. Note that with cleanUpValue, it
8756 // is possible for the DOM value to change without this.value changing.
8757 if ( this.$input.val() !== value ) {
8758 this.$input.val( value );
8759 }
8760 if ( this.value !== value ) {
8761 this.value = value;
8762 this.emit( 'change', this.value );
8763 }
8764 return this;
8765 };
8766
8767 /**
8768 * Clean up incoming value.
8769 *
8770 * Ensures value is a string, and converts undefined and null to empty string.
8771 *
8772 * @private
8773 * @param {string} value Original value
8774 * @return {string} Cleaned up value
8775 */
8776 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
8777 if ( value === undefined || value === null ) {
8778 return '';
8779 } else if ( this.inputFilter ) {
8780 return this.inputFilter( String( value ) );
8781 } else {
8782 return String( value );
8783 }
8784 };
8785
8786 /**
8787 * @inheritdoc
8788 */
8789 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
8790 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
8791 if ( this.$input ) {
8792 this.$input.prop( 'disabled', this.isDisabled() );
8793 }
8794 return this;
8795 };
8796
8797 /**
8798 * Set the 'id' attribute of the `<input>` element.
8799 *
8800 * @param {string} id
8801 * @chainable
8802 */
8803 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
8804 this.$input.attr( 'id', id );
8805 return this;
8806 };
8807
8808 /**
8809 * @inheritdoc
8810 */
8811 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
8812 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
8813 if ( state.value !== undefined && state.value !== this.getValue() ) {
8814 this.setValue( state.value );
8815 }
8816 if ( state.focus ) {
8817 this.focus();
8818 }
8819 };
8820
8821 /**
8822 * Data widget intended for creating 'hidden'-type inputs.
8823 *
8824 * @class
8825 * @extends OO.ui.Widget
8826 *
8827 * @constructor
8828 * @param {Object} [config] Configuration options
8829 * @cfg {string} [value=''] The value of the input.
8830 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8831 */
8832 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
8833 // Configuration initialization
8834 config = $.extend( { value: '', name: '' }, config );
8835
8836 // Parent constructor
8837 OO.ui.HiddenInputWidget.parent.call( this, config );
8838
8839 // Initialization
8840 this.$element.attr( {
8841 type: 'hidden',
8842 value: config.value,
8843 name: config.name
8844 } );
8845 this.$element.removeAttr( 'aria-disabled' );
8846 };
8847
8848 /* Setup */
8849
8850 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
8851
8852 /* Static Properties */
8853
8854 /**
8855 * @static
8856 * @inheritdoc
8857 */
8858 OO.ui.HiddenInputWidget.static.tagName = 'input';
8859
8860 /**
8861 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
8862 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
8863 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
8864 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
8865 * [OOjs UI documentation on MediaWiki] [1] for more information.
8866 *
8867 * @example
8868 * // A ButtonInputWidget rendered as an HTML button, the default.
8869 * var button = new OO.ui.ButtonInputWidget( {
8870 * label: 'Input button',
8871 * icon: 'check',
8872 * value: 'check'
8873 * } );
8874 * $( 'body' ).append( button.$element );
8875 *
8876 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
8877 *
8878 * @class
8879 * @extends OO.ui.InputWidget
8880 * @mixins OO.ui.mixin.ButtonElement
8881 * @mixins OO.ui.mixin.IconElement
8882 * @mixins OO.ui.mixin.IndicatorElement
8883 * @mixins OO.ui.mixin.LabelElement
8884 * @mixins OO.ui.mixin.TitledElement
8885 *
8886 * @constructor
8887 * @param {Object} [config] Configuration options
8888 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
8889 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
8890 * Widgets configured to be an `<input>` do not support {@link #icon icons} and {@link #indicator indicators},
8891 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
8892 * be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
8893 */
8894 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
8895 // Configuration initialization
8896 config = $.extend( { type: 'button', useInputTag: false }, config );
8897
8898 // See InputWidget#reusePreInfuseDOM about config.$input
8899 if ( config.$input ) {
8900 config.$input.empty();
8901 }
8902
8903 // Properties (must be set before parent constructor, which calls #setValue)
8904 this.useInputTag = config.useInputTag;
8905
8906 // Parent constructor
8907 OO.ui.ButtonInputWidget.parent.call( this, config );
8908
8909 // Mixin constructors
8910 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
8911 OO.ui.mixin.IconElement.call( this, config );
8912 OO.ui.mixin.IndicatorElement.call( this, config );
8913 OO.ui.mixin.LabelElement.call( this, config );
8914 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
8915
8916 // Initialization
8917 if ( !config.useInputTag ) {
8918 this.$input.append( this.$icon, this.$label, this.$indicator );
8919 }
8920 this.$element.addClass( 'oo-ui-buttonInputWidget' );
8921 };
8922
8923 /* Setup */
8924
8925 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
8926 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
8927 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
8928 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
8929 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
8930 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
8931
8932 /* Static Properties */
8933
8934 /**
8935 * @static
8936 * @inheritdoc
8937 */
8938 OO.ui.ButtonInputWidget.static.tagName = 'span';
8939
8940 /* Methods */
8941
8942 /**
8943 * @inheritdoc
8944 * @protected
8945 */
8946 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
8947 var type;
8948 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
8949 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
8950 };
8951
8952 /**
8953 * Set label value.
8954 *
8955 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
8956 *
8957 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
8958 * text, or `null` for no label
8959 * @chainable
8960 */
8961 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
8962 if ( typeof label === 'function' ) {
8963 label = OO.ui.resolveMsg( label );
8964 }
8965
8966 if ( this.useInputTag ) {
8967 // Discard non-plaintext labels
8968 if ( typeof label !== 'string' ) {
8969 label = '';
8970 }
8971
8972 this.$input.val( label );
8973 }
8974
8975 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
8976 };
8977
8978 /**
8979 * Set the value of the input.
8980 *
8981 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
8982 * they do not support {@link #value values}.
8983 *
8984 * @param {string} value New value
8985 * @chainable
8986 */
8987 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
8988 if ( !this.useInputTag ) {
8989 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
8990 }
8991 return this;
8992 };
8993
8994 /**
8995 * @inheritdoc
8996 */
8997 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
8998 // Disable generating `<label>` elements for buttons. One would very rarely need additional label
8999 // for a button, and it's already a big clickable target, and it causes unexpected rendering.
9000 return null;
9001 };
9002
9003 /**
9004 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9005 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9006 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9007 * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
9008 *
9009 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9010 *
9011 * @example
9012 * // An example of selected, unselected, and disabled checkbox inputs
9013 * var checkbox1=new OO.ui.CheckboxInputWidget( {
9014 * value: 'a',
9015 * selected: true
9016 * } );
9017 * var checkbox2=new OO.ui.CheckboxInputWidget( {
9018 * value: 'b'
9019 * } );
9020 * var checkbox3=new OO.ui.CheckboxInputWidget( {
9021 * value:'c',
9022 * disabled: true
9023 * } );
9024 * // Create a fieldset layout with fields for each checkbox.
9025 * var fieldset = new OO.ui.FieldsetLayout( {
9026 * label: 'Checkboxes'
9027 * } );
9028 * fieldset.addItems( [
9029 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9030 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9031 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9032 * ] );
9033 * $( 'body' ).append( fieldset.$element );
9034 *
9035 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9036 *
9037 * @class
9038 * @extends OO.ui.InputWidget
9039 *
9040 * @constructor
9041 * @param {Object} [config] Configuration options
9042 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
9043 */
9044 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9045 // Configuration initialization
9046 config = config || {};
9047
9048 // Parent constructor
9049 OO.ui.CheckboxInputWidget.parent.call( this, config );
9050
9051 // Initialization
9052 this.$element
9053 .addClass( 'oo-ui-checkboxInputWidget' )
9054 // Required for pretty styling in WikimediaUI theme
9055 .append( $( '<span>' ) );
9056 this.setSelected( config.selected !== undefined ? config.selected : false );
9057 };
9058
9059 /* Setup */
9060
9061 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9062
9063 /* Static Properties */
9064
9065 /**
9066 * @static
9067 * @inheritdoc
9068 */
9069 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9070
9071 /* Static Methods */
9072
9073 /**
9074 * @inheritdoc
9075 */
9076 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9077 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9078 state.checked = config.$input.prop( 'checked' );
9079 return state;
9080 };
9081
9082 /* Methods */
9083
9084 /**
9085 * @inheritdoc
9086 * @protected
9087 */
9088 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9089 return $( '<input>' ).attr( 'type', 'checkbox' );
9090 };
9091
9092 /**
9093 * @inheritdoc
9094 */
9095 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9096 var widget = this;
9097 if ( !this.isDisabled() ) {
9098 // Allow the stack to clear so the value will be updated
9099 setTimeout( function () {
9100 widget.setSelected( widget.$input.prop( 'checked' ) );
9101 } );
9102 }
9103 };
9104
9105 /**
9106 * Set selection state of this checkbox.
9107 *
9108 * @param {boolean} state `true` for selected
9109 * @chainable
9110 */
9111 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
9112 state = !!state;
9113 if ( this.selected !== state ) {
9114 this.selected = state;
9115 this.$input.prop( 'checked', this.selected );
9116 this.emit( 'change', this.selected );
9117 }
9118 return this;
9119 };
9120
9121 /**
9122 * Check if this checkbox is selected.
9123 *
9124 * @return {boolean} Checkbox is selected
9125 */
9126 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9127 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9128 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9129 var selected = this.$input.prop( 'checked' );
9130 if ( this.selected !== selected ) {
9131 this.setSelected( selected );
9132 }
9133 return this.selected;
9134 };
9135
9136 /**
9137 * @inheritdoc
9138 */
9139 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9140 if ( !this.isDisabled() ) {
9141 this.$input.click();
9142 }
9143 this.focus();
9144 };
9145
9146 /**
9147 * @inheritdoc
9148 */
9149 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9150 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9151 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9152 this.setSelected( state.checked );
9153 }
9154 };
9155
9156 /**
9157 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9158 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9159 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9160 * more information about input widgets.
9161 *
9162 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9163 * are no options. If no `value` configuration option is provided, the first option is selected.
9164 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9165 *
9166 * This and OO.ui.RadioSelectInputWidget support the same configuration options.
9167 *
9168 * @example
9169 * // Example: A DropdownInputWidget with three options
9170 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9171 * options: [
9172 * { data: 'a', label: 'First' },
9173 * { data: 'b', label: 'Second'},
9174 * { data: 'c', label: 'Third' }
9175 * ]
9176 * } );
9177 * $( 'body' ).append( dropdownInput.$element );
9178 *
9179 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9180 *
9181 * @class
9182 * @extends OO.ui.InputWidget
9183 *
9184 * @constructor
9185 * @param {Object} [config] Configuration options
9186 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9187 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9188 */
9189 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9190 // Configuration initialization
9191 config = config || {};
9192
9193 // Properties (must be done before parent constructor which calls #setDisabled)
9194 this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
9195
9196 // Parent constructor
9197 OO.ui.DropdownInputWidget.parent.call( this, config );
9198
9199 // Events
9200 this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
9201
9202 // Initialization
9203 this.setOptions( config.options || [] );
9204 // Set the value again, after we did setOptions(). The call from parent doesn't work because the
9205 // widget has no valid options when it happens.
9206 this.setValue( config.value );
9207 this.$element
9208 .addClass( 'oo-ui-dropdownInputWidget' )
9209 .append( this.dropdownWidget.$element );
9210 this.setTabIndexedElement( null );
9211 };
9212
9213 /* Setup */
9214
9215 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9216
9217 /* Methods */
9218
9219 /**
9220 * @inheritdoc
9221 * @protected
9222 */
9223 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9224 return $( '<select>' );
9225 };
9226
9227 /**
9228 * Handles menu select events.
9229 *
9230 * @private
9231 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9232 */
9233 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9234 this.setValue( item ? item.getData() : '' );
9235 };
9236
9237 /**
9238 * @inheritdoc
9239 */
9240 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9241 var selected;
9242 value = this.cleanUpValue( value );
9243 // Only allow setting values that are actually present in the dropdown
9244 selected = this.dropdownWidget.getMenu().getItemFromData( value ) ||
9245 this.dropdownWidget.getMenu().findFirstSelectableItem();
9246 this.dropdownWidget.getMenu().selectItem( selected );
9247 value = selected ? selected.getData() : '';
9248 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9249 return this;
9250 };
9251
9252 /**
9253 * @inheritdoc
9254 */
9255 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9256 this.dropdownWidget.setDisabled( state );
9257 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9258 return this;
9259 };
9260
9261 /**
9262 * Set the options available for this input.
9263 *
9264 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9265 * @chainable
9266 */
9267 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9268 var
9269 optionWidgets = [],
9270 value = this.getValue(),
9271 $optionsContainer = this.$input,
9272 widget = this;
9273
9274 this.dropdownWidget.getMenu().clearItems();
9275 this.$input.empty();
9276
9277 // Rebuild the dropdown menu: our visible one and the hidden `<select>`
9278 options.forEach( function ( opt ) {
9279 var optValue, $optionNode, optionWidget;
9280
9281 if ( opt.optgroup === undefined ) {
9282 optValue = widget.cleanUpValue( opt.data );
9283
9284 $optionNode = $( '<option>' )
9285 .attr( 'value', optValue )
9286 .text( opt.label !== undefined ? opt.label : optValue );
9287 optionWidget = new OO.ui.MenuOptionWidget( {
9288 data: optValue,
9289 label: opt.label !== undefined ? opt.label : optValue
9290 } );
9291
9292 $optionsContainer.append( $optionNode );
9293 optionWidgets.push( optionWidget );
9294 } else {
9295 $optionNode = $( '<optgroup>' )
9296 .attr( 'label', opt.optgroup );
9297 optionWidget = new OO.ui.MenuSectionOptionWidget( {
9298 label: opt.optgroup
9299 } );
9300
9301 widget.$input.append( $optionNode );
9302 $optionsContainer = $optionNode;
9303 optionWidgets.push( optionWidget );
9304 }
9305 } );
9306 this.dropdownWidget.getMenu().addItems( optionWidgets );
9307
9308 // Restore the previous value, or reset to something sensible
9309 if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
9310 // Previous value is still available, ensure consistency with the dropdown
9311 this.setValue( value );
9312 } else {
9313 // No longer valid, reset
9314 if ( options.length ) {
9315 this.setValue( options[ 0 ].data );
9316 }
9317 }
9318
9319 return this;
9320 };
9321
9322 /**
9323 * @inheritdoc
9324 */
9325 OO.ui.DropdownInputWidget.prototype.focus = function () {
9326 this.dropdownWidget.focus();
9327 return this;
9328 };
9329
9330 /**
9331 * @inheritdoc
9332 */
9333 OO.ui.DropdownInputWidget.prototype.blur = function () {
9334 this.dropdownWidget.blur();
9335 return this;
9336 };
9337
9338 /**
9339 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9340 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9341 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9342 * please see the [OOjs UI documentation on MediaWiki][1].
9343 *
9344 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9345 *
9346 * @example
9347 * // An example of selected, unselected, and disabled radio inputs
9348 * var radio1 = new OO.ui.RadioInputWidget( {
9349 * value: 'a',
9350 * selected: true
9351 * } );
9352 * var radio2 = new OO.ui.RadioInputWidget( {
9353 * value: 'b'
9354 * } );
9355 * var radio3 = new OO.ui.RadioInputWidget( {
9356 * value: 'c',
9357 * disabled: true
9358 * } );
9359 * // Create a fieldset layout with fields for each radio button.
9360 * var fieldset = new OO.ui.FieldsetLayout( {
9361 * label: 'Radio inputs'
9362 * } );
9363 * fieldset.addItems( [
9364 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9365 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9366 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9367 * ] );
9368 * $( 'body' ).append( fieldset.$element );
9369 *
9370 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9371 *
9372 * @class
9373 * @extends OO.ui.InputWidget
9374 *
9375 * @constructor
9376 * @param {Object} [config] Configuration options
9377 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
9378 */
9379 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9380 // Configuration initialization
9381 config = config || {};
9382
9383 // Parent constructor
9384 OO.ui.RadioInputWidget.parent.call( this, config );
9385
9386 // Initialization
9387 this.$element
9388 .addClass( 'oo-ui-radioInputWidget' )
9389 // Required for pretty styling in WikimediaUI theme
9390 .append( $( '<span>' ) );
9391 this.setSelected( config.selected !== undefined ? config.selected : false );
9392 };
9393
9394 /* Setup */
9395
9396 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9397
9398 /* Static Properties */
9399
9400 /**
9401 * @static
9402 * @inheritdoc
9403 */
9404 OO.ui.RadioInputWidget.static.tagName = 'span';
9405
9406 /* Static Methods */
9407
9408 /**
9409 * @inheritdoc
9410 */
9411 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9412 var state = OO.ui.RadioInputWidget.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.RadioInputWidget.prototype.getInputElement = function () {
9424 return $( '<input>' ).attr( 'type', 'radio' );
9425 };
9426
9427 /**
9428 * @inheritdoc
9429 */
9430 OO.ui.RadioInputWidget.prototype.onEdit = function () {
9431 // RadioInputWidget doesn't track its state.
9432 };
9433
9434 /**
9435 * Set selection state of this radio button.
9436 *
9437 * @param {boolean} state `true` for selected
9438 * @chainable
9439 */
9440 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
9441 // RadioInputWidget doesn't track its state.
9442 this.$input.prop( 'checked', state );
9443 return this;
9444 };
9445
9446 /**
9447 * Check if this radio button is selected.
9448 *
9449 * @return {boolean} Radio is selected
9450 */
9451 OO.ui.RadioInputWidget.prototype.isSelected = function () {
9452 return this.$input.prop( 'checked' );
9453 };
9454
9455 /**
9456 * @inheritdoc
9457 */
9458 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
9459 if ( !this.isDisabled() ) {
9460 this.$input.click();
9461 }
9462 this.focus();
9463 };
9464
9465 /**
9466 * @inheritdoc
9467 */
9468 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
9469 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9470 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9471 this.setSelected( state.checked );
9472 }
9473 };
9474
9475 /**
9476 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
9477 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
9478 * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
9479 * more information about input widgets.
9480 *
9481 * This and OO.ui.DropdownInputWidget support the same configuration options.
9482 *
9483 * @example
9484 * // Example: A RadioSelectInputWidget with three options
9485 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
9486 * options: [
9487 * { data: 'a', label: 'First' },
9488 * { data: 'b', label: 'Second'},
9489 * { data: 'c', label: 'Third' }
9490 * ]
9491 * } );
9492 * $( 'body' ).append( radioSelectInput.$element );
9493 *
9494 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9495 *
9496 * @class
9497 * @extends OO.ui.InputWidget
9498 *
9499 * @constructor
9500 * @param {Object} [config] Configuration options
9501 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
9502 */
9503 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
9504 // Configuration initialization
9505 config = config || {};
9506
9507 // Properties (must be done before parent constructor which calls #setDisabled)
9508 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
9509
9510 // Parent constructor
9511 OO.ui.RadioSelectInputWidget.parent.call( this, config );
9512
9513 // Events
9514 this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
9515
9516 // Initialization
9517 this.setOptions( config.options || [] );
9518 this.$element
9519 .addClass( 'oo-ui-radioSelectInputWidget' )
9520 .append( this.radioSelectWidget.$element );
9521 this.setTabIndexedElement( null );
9522 };
9523
9524 /* Setup */
9525
9526 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
9527
9528 /* Static Methods */
9529
9530 /**
9531 * @inheritdoc
9532 */
9533 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9534 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
9535 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
9536 return state;
9537 };
9538
9539 /**
9540 * @inheritdoc
9541 */
9542 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9543 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9544 // Cannot reuse the `<input type=radio>` set
9545 delete config.$input;
9546 return config;
9547 };
9548
9549 /* Methods */
9550
9551 /**
9552 * @inheritdoc
9553 * @protected
9554 */
9555 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
9556 return $( '<input>' ).attr( 'type', 'hidden' );
9557 };
9558
9559 /**
9560 * Handles menu select events.
9561 *
9562 * @private
9563 * @param {OO.ui.RadioOptionWidget} item Selected menu item
9564 */
9565 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
9566 this.setValue( item.getData() );
9567 };
9568
9569 /**
9570 * @inheritdoc
9571 */
9572 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
9573 value = this.cleanUpValue( value );
9574 this.radioSelectWidget.selectItemByData( value );
9575 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
9576 return this;
9577 };
9578
9579 /**
9580 * @inheritdoc
9581 */
9582 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
9583 this.radioSelectWidget.setDisabled( state );
9584 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
9585 return this;
9586 };
9587
9588 /**
9589 * Set the options available for this input.
9590 *
9591 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9592 * @chainable
9593 */
9594 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
9595 var
9596 value = this.getValue(),
9597 widget = this;
9598
9599 // Rebuild the radioSelect menu
9600 this.radioSelectWidget
9601 .clearItems()
9602 .addItems( options.map( function ( opt ) {
9603 var optValue = widget.cleanUpValue( opt.data );
9604 return new OO.ui.RadioOptionWidget( {
9605 data: optValue,
9606 label: opt.label !== undefined ? opt.label : optValue
9607 } );
9608 } ) );
9609
9610 // Restore the previous value, or reset to something sensible
9611 if ( this.radioSelectWidget.getItemFromData( value ) ) {
9612 // Previous value is still available, ensure consistency with the radioSelect
9613 this.setValue( value );
9614 } else {
9615 // No longer valid, reset
9616 if ( options.length ) {
9617 this.setValue( options[ 0 ].data );
9618 }
9619 }
9620
9621 return this;
9622 };
9623
9624 /**
9625 * @inheritdoc
9626 */
9627 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
9628 this.radioSelectWidget.focus();
9629 return this;
9630 };
9631
9632 /**
9633 * @inheritdoc
9634 */
9635 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
9636 this.radioSelectWidget.blur();
9637 return this;
9638 };
9639
9640 /**
9641 * CheckboxMultiselectInputWidget is a
9642 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
9643 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
9644 * HTML `<input type=checkbox>` tags. Please see the [OOjs UI documentation on MediaWiki][1] for
9645 * more information about input widgets.
9646 *
9647 * @example
9648 * // Example: A CheckboxMultiselectInputWidget with three options
9649 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
9650 * options: [
9651 * { data: 'a', label: 'First' },
9652 * { data: 'b', label: 'Second'},
9653 * { data: 'c', label: 'Third' }
9654 * ]
9655 * } );
9656 * $( 'body' ).append( multiselectInput.$element );
9657 *
9658 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9659 *
9660 * @class
9661 * @extends OO.ui.InputWidget
9662 *
9663 * @constructor
9664 * @param {Object} [config] Configuration options
9665 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: …, disabled: … }`
9666 */
9667 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
9668 // Configuration initialization
9669 config = config || {};
9670
9671 // Properties (must be done before parent constructor which calls #setDisabled)
9672 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
9673
9674 // Parent constructor
9675 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
9676
9677 // Properties
9678 this.inputName = config.name;
9679
9680 // Initialization
9681 this.$element
9682 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
9683 .append( this.checkboxMultiselectWidget.$element );
9684 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
9685 this.$input.detach();
9686 this.setOptions( config.options || [] );
9687 // Have to repeat this from parent, as we need options to be set up for this to make sense
9688 this.setValue( config.value );
9689
9690 // setValue when checkboxMultiselectWidget changes
9691 this.checkboxMultiselectWidget.on( 'change', function () {
9692 this.setValue( this.checkboxMultiselectWidget.getSelectedItemsData() );
9693 }.bind( this ) );
9694 };
9695
9696 /* Setup */
9697
9698 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
9699
9700 /* Static Methods */
9701
9702 /**
9703 * @inheritdoc
9704 */
9705 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9706 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState( node, config );
9707 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9708 .toArray().map( function ( el ) { return el.value; } );
9709 return state;
9710 };
9711
9712 /**
9713 * @inheritdoc
9714 */
9715 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9716 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
9717 // Cannot reuse the `<input type=checkbox>` set
9718 delete config.$input;
9719 return config;
9720 };
9721
9722 /* Methods */
9723
9724 /**
9725 * @inheritdoc
9726 * @protected
9727 */
9728 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
9729 // Actually unused
9730 return $( '<unused>' );
9731 };
9732
9733 /**
9734 * @inheritdoc
9735 */
9736 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
9737 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
9738 .toArray().map( function ( el ) { return el.value; } );
9739 if ( this.value !== value ) {
9740 this.setValue( value );
9741 }
9742 return this.value;
9743 };
9744
9745 /**
9746 * @inheritdoc
9747 */
9748 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
9749 value = this.cleanUpValue( value );
9750 this.checkboxMultiselectWidget.selectItemsByData( value );
9751 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
9752 return this;
9753 };
9754
9755 /**
9756 * Clean up incoming value.
9757 *
9758 * @param {string[]} value Original value
9759 * @return {string[]} Cleaned up value
9760 */
9761 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
9762 var i, singleValue,
9763 cleanValue = [];
9764 if ( !Array.isArray( value ) ) {
9765 return cleanValue;
9766 }
9767 for ( i = 0; i < value.length; i++ ) {
9768 singleValue =
9769 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( this, value[ i ] );
9770 // Remove options that we don't have here
9771 if ( !this.checkboxMultiselectWidget.getItemFromData( singleValue ) ) {
9772 continue;
9773 }
9774 cleanValue.push( singleValue );
9775 }
9776 return cleanValue;
9777 };
9778
9779 /**
9780 * @inheritdoc
9781 */
9782 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
9783 this.checkboxMultiselectWidget.setDisabled( state );
9784 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
9785 return this;
9786 };
9787
9788 /**
9789 * Set the options available for this input.
9790 *
9791 * @param {Object[]} options Array of menu options in the format `{ data: …, label: …, disabled: … }`
9792 * @chainable
9793 */
9794 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
9795 var widget = this;
9796
9797 // Rebuild the checkboxMultiselectWidget menu
9798 this.checkboxMultiselectWidget
9799 .clearItems()
9800 .addItems( options.map( function ( opt ) {
9801 var optValue, item, optDisabled;
9802 optValue =
9803 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue.call( widget, opt.data );
9804 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
9805 item = new OO.ui.CheckboxMultioptionWidget( {
9806 data: optValue,
9807 label: opt.label !== undefined ? opt.label : optValue,
9808 disabled: optDisabled
9809 } );
9810 // Set the 'name' and 'value' for form submission
9811 item.checkbox.$input.attr( 'name', widget.inputName );
9812 item.checkbox.setValue( optValue );
9813 return item;
9814 } ) );
9815
9816 // Re-set the value, checking the checkboxes as needed.
9817 // This will also get rid of any stale options that we just removed.
9818 this.setValue( this.getValue() );
9819
9820 return this;
9821 };
9822
9823 /**
9824 * @inheritdoc
9825 */
9826 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
9827 this.checkboxMultiselectWidget.focus();
9828 return this;
9829 };
9830
9831 /**
9832 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
9833 * size of the field as well as its presentation. In addition, these widgets can be configured
9834 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
9835 * validation-pattern (used to determine if an input value is valid or not) and an input filter,
9836 * which modifies incoming values rather than validating them.
9837 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
9838 *
9839 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9840 *
9841 * @example
9842 * // Example of a text input widget
9843 * var textInput = new OO.ui.TextInputWidget( {
9844 * value: 'Text input'
9845 * } )
9846 * $( 'body' ).append( textInput.$element );
9847 *
9848 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
9849 *
9850 * @class
9851 * @extends OO.ui.InputWidget
9852 * @mixins OO.ui.mixin.IconElement
9853 * @mixins OO.ui.mixin.IndicatorElement
9854 * @mixins OO.ui.mixin.PendingElement
9855 * @mixins OO.ui.mixin.LabelElement
9856 *
9857 * @constructor
9858 * @param {Object} [config] Configuration options
9859 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
9860 * 'email', 'url' or 'number'.
9861 * @cfg {string} [placeholder] Placeholder text
9862 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
9863 * instruct the browser to focus this widget.
9864 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
9865 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
9866 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
9867 * the value or placeholder text: `'before'` or `'after'`
9868 * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
9869 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
9870 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined` means
9871 * leaving it up to the browser).
9872 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
9873 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
9874 * (the value must contain only numbers); when RegExp, a regular expression that must match the
9875 * value for it to be considered valid; when Function, a function receiving the value as parameter
9876 * that must return true, or promise resolving to true, for it to be considered valid.
9877 */
9878 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
9879 // Configuration initialization
9880 config = $.extend( {
9881 type: 'text',
9882 labelPosition: 'after'
9883 }, config );
9884
9885 if ( config.multiline ) {
9886 OO.ui.warnDeprecation( 'TextInputWidget: config.multiline is deprecated. Use the MultilineTextInputWidget instead. See T130434.' );
9887 return new OO.ui.MultilineTextInputWidget( config );
9888 }
9889
9890 // Parent constructor
9891 OO.ui.TextInputWidget.parent.call( this, config );
9892
9893 // Mixin constructors
9894 OO.ui.mixin.IconElement.call( this, config );
9895 OO.ui.mixin.IndicatorElement.call( this, config );
9896 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
9897 OO.ui.mixin.LabelElement.call( this, config );
9898
9899 // Properties
9900 this.type = this.getSaneType( config );
9901 this.readOnly = false;
9902 this.required = false;
9903 this.validate = null;
9904 this.styleHeight = null;
9905 this.scrollWidth = null;
9906
9907 this.setValidation( config.validate );
9908 this.setLabelPosition( config.labelPosition );
9909
9910 // Events
9911 this.$input.on( {
9912 keypress: this.onKeyPress.bind( this ),
9913 blur: this.onBlur.bind( this ),
9914 focus: this.onFocus.bind( this )
9915 } );
9916 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
9917 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
9918 this.on( 'labelChange', this.updatePosition.bind( this ) );
9919 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
9920
9921 // Initialization
9922 this.$element
9923 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
9924 .append( this.$icon, this.$indicator );
9925 this.setReadOnly( !!config.readOnly );
9926 this.setRequired( !!config.required );
9927 if ( config.placeholder !== undefined ) {
9928 this.$input.attr( 'placeholder', config.placeholder );
9929 }
9930 if ( config.maxLength !== undefined ) {
9931 this.$input.attr( 'maxlength', config.maxLength );
9932 }
9933 if ( config.autofocus ) {
9934 this.$input.attr( 'autofocus', 'autofocus' );
9935 }
9936 if ( config.autocomplete === false ) {
9937 this.$input.attr( 'autocomplete', 'off' );
9938 // Turning off autocompletion also disables "form caching" when the user navigates to a
9939 // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
9940 $( window ).on( {
9941 beforeunload: function () {
9942 this.$input.removeAttr( 'autocomplete' );
9943 }.bind( this ),
9944 pageshow: function () {
9945 // Browsers don't seem to actually fire this event on "Back", they instead just reload the
9946 // whole page... it shouldn't hurt, though.
9947 this.$input.attr( 'autocomplete', 'off' );
9948 }.bind( this )
9949 } );
9950 }
9951 if ( config.spellcheck !== undefined ) {
9952 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
9953 }
9954 if ( this.label ) {
9955 this.isWaitingToBeAttached = true;
9956 this.installParentChangeDetector();
9957 }
9958 };
9959
9960 /* Setup */
9961
9962 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
9963 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
9964 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
9965 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
9966 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
9967
9968 /* Static Properties */
9969
9970 OO.ui.TextInputWidget.static.validationPatterns = {
9971 'non-empty': /.+/,
9972 integer: /^\d+$/
9973 };
9974
9975 /* Static Methods */
9976
9977 /**
9978 * @inheritdoc
9979 */
9980 OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9981 var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
9982 return state;
9983 };
9984
9985 /* Events */
9986
9987 /**
9988 * An `enter` event is emitted when the user presses 'enter' inside the text box.
9989 *
9990 * @event enter
9991 */
9992
9993 /* Methods */
9994
9995 /**
9996 * Handle icon mouse down events.
9997 *
9998 * @private
9999 * @param {jQuery.Event} e Mouse down event
10000 */
10001 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10002 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10003 this.focus();
10004 return false;
10005 }
10006 };
10007
10008 /**
10009 * Handle indicator mouse down events.
10010 *
10011 * @private
10012 * @param {jQuery.Event} e Mouse down event
10013 */
10014 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10015 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10016 this.focus();
10017 return false;
10018 }
10019 };
10020
10021 /**
10022 * Handle key press events.
10023 *
10024 * @private
10025 * @param {jQuery.Event} e Key press event
10026 * @fires enter If enter key is pressed
10027 */
10028 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10029 if ( e.which === OO.ui.Keys.ENTER ) {
10030 this.emit( 'enter', e );
10031 }
10032 };
10033
10034 /**
10035 * Handle blur events.
10036 *
10037 * @private
10038 * @param {jQuery.Event} e Blur event
10039 */
10040 OO.ui.TextInputWidget.prototype.onBlur = function () {
10041 this.setValidityFlag();
10042 };
10043
10044 /**
10045 * Handle focus events.
10046 *
10047 * @private
10048 * @param {jQuery.Event} e Focus event
10049 */
10050 OO.ui.TextInputWidget.prototype.onFocus = function () {
10051 if ( this.isWaitingToBeAttached ) {
10052 // If we've received focus, then we must be attached to the document, and if
10053 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10054 this.onElementAttach();
10055 }
10056 this.setValidityFlag( true );
10057 };
10058
10059 /**
10060 * Handle element attach events.
10061 *
10062 * @private
10063 * @param {jQuery.Event} e Element attach event
10064 */
10065 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10066 this.isWaitingToBeAttached = false;
10067 // Any previously calculated size is now probably invalid if we reattached elsewhere
10068 this.valCache = null;
10069 this.positionLabel();
10070 };
10071
10072 /**
10073 * Handle debounced change events.
10074 *
10075 * @param {string} value
10076 * @private
10077 */
10078 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10079 this.setValidityFlag();
10080 };
10081
10082 /**
10083 * Check if the input is {@link #readOnly read-only}.
10084 *
10085 * @return {boolean}
10086 */
10087 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10088 return this.readOnly;
10089 };
10090
10091 /**
10092 * Set the {@link #readOnly read-only} state of the input.
10093 *
10094 * @param {boolean} state Make input read-only
10095 * @chainable
10096 */
10097 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10098 this.readOnly = !!state;
10099 this.$input.prop( 'readOnly', this.readOnly );
10100 return this;
10101 };
10102
10103 /**
10104 * Check if the input is {@link #required required}.
10105 *
10106 * @return {boolean}
10107 */
10108 OO.ui.TextInputWidget.prototype.isRequired = function () {
10109 return this.required;
10110 };
10111
10112 /**
10113 * Set the {@link #required required} state of the input.
10114 *
10115 * @param {boolean} state Make input required
10116 * @chainable
10117 */
10118 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10119 this.required = !!state;
10120 if ( this.required ) {
10121 this.$input
10122 .prop( 'required', true )
10123 .attr( 'aria-required', 'true' );
10124 if ( this.getIndicator() === null ) {
10125 this.setIndicator( 'required' );
10126 }
10127 } else {
10128 this.$input
10129 .prop( 'required', false )
10130 .removeAttr( 'aria-required' );
10131 if ( this.getIndicator() === 'required' ) {
10132 this.setIndicator( null );
10133 }
10134 }
10135 return this;
10136 };
10137
10138 /**
10139 * Support function for making #onElementAttach work across browsers.
10140 *
10141 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10142 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10143 *
10144 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10145 * first time that the element gets attached to the documented.
10146 */
10147 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10148 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10149 MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
10150 widget = this;
10151
10152 if ( MutationObserver ) {
10153 // The new way. If only it wasn't so ugly.
10154
10155 if ( this.isElementAttached() ) {
10156 // Widget is attached already, do nothing. This breaks the functionality of this function when
10157 // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
10158 // would require observation of the whole document, which would hurt performance of other,
10159 // more important code.
10160 return;
10161 }
10162
10163 // Find topmost node in the tree
10164 topmostNode = this.$element[ 0 ];
10165 while ( topmostNode.parentNode ) {
10166 topmostNode = topmostNode.parentNode;
10167 }
10168
10169 // We have no way to detect the $element being attached somewhere without observing the entire
10170 // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
10171 // parent node of $element, and instead detect when $element is removed from it (and thus
10172 // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
10173 // doesn't get attached, we end up back here and create the parent.
10174
10175 mutationObserver = new MutationObserver( function ( mutations ) {
10176 var i, j, removedNodes;
10177 for ( i = 0; i < mutations.length; i++ ) {
10178 removedNodes = mutations[ i ].removedNodes;
10179 for ( j = 0; j < removedNodes.length; j++ ) {
10180 if ( removedNodes[ j ] === topmostNode ) {
10181 setTimeout( onRemove, 0 );
10182 return;
10183 }
10184 }
10185 }
10186 } );
10187
10188 onRemove = function () {
10189 // If the node was attached somewhere else, report it
10190 if ( widget.isElementAttached() ) {
10191 widget.onElementAttach();
10192 }
10193 mutationObserver.disconnect();
10194 widget.installParentChangeDetector();
10195 };
10196
10197 // Create a fake parent and observe it
10198 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10199 mutationObserver.observe( fakeParentNode, { childList: true } );
10200 } else {
10201 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10202 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10203 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10204 }
10205 };
10206
10207 /**
10208 * @inheritdoc
10209 * @protected
10210 */
10211 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10212 if ( this.getSaneType( config ) === 'number' ) {
10213 return $( '<input>' )
10214 .attr( 'step', 'any' )
10215 .attr( 'type', 'number' );
10216 } else {
10217 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10218 }
10219 };
10220
10221 /**
10222 * Get sanitized value for 'type' for given config.
10223 *
10224 * @param {Object} config Configuration options
10225 * @return {string|null}
10226 * @protected
10227 */
10228 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10229 var allowedTypes = [
10230 'text',
10231 'password',
10232 'email',
10233 'url',
10234 'number'
10235 ];
10236 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10237 };
10238
10239 /**
10240 * Focus the input and select a specified range within the text.
10241 *
10242 * @param {number} from Select from offset
10243 * @param {number} [to] Select to offset, defaults to from
10244 * @chainable
10245 */
10246 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10247 var isBackwards, start, end,
10248 input = this.$input[ 0 ];
10249
10250 to = to || from;
10251
10252 isBackwards = to < from;
10253 start = isBackwards ? to : from;
10254 end = isBackwards ? from : to;
10255
10256 this.focus();
10257
10258 try {
10259 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10260 } catch ( e ) {
10261 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10262 // Rather than expensively check if the input is attached every time, just check
10263 // if it was the cause of an error being thrown. If not, rethrow the error.
10264 if ( this.getElementDocument().body.contains( input ) ) {
10265 throw e;
10266 }
10267 }
10268 return this;
10269 };
10270
10271 /**
10272 * Get an object describing the current selection range in a directional manner
10273 *
10274 * @return {Object} Object containing 'from' and 'to' offsets
10275 */
10276 OO.ui.TextInputWidget.prototype.getRange = function () {
10277 var input = this.$input[ 0 ],
10278 start = input.selectionStart,
10279 end = input.selectionEnd,
10280 isBackwards = input.selectionDirection === 'backward';
10281
10282 return {
10283 from: isBackwards ? end : start,
10284 to: isBackwards ? start : end
10285 };
10286 };
10287
10288 /**
10289 * Get the length of the text input value.
10290 *
10291 * This could differ from the length of #getValue if the
10292 * value gets filtered
10293 *
10294 * @return {number} Input length
10295 */
10296 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10297 return this.$input[ 0 ].value.length;
10298 };
10299
10300 /**
10301 * Focus the input and select the entire text.
10302 *
10303 * @chainable
10304 */
10305 OO.ui.TextInputWidget.prototype.select = function () {
10306 return this.selectRange( 0, this.getInputLength() );
10307 };
10308
10309 /**
10310 * Focus the input and move the cursor to the start.
10311 *
10312 * @chainable
10313 */
10314 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10315 return this.selectRange( 0 );
10316 };
10317
10318 /**
10319 * Focus the input and move the cursor to the end.
10320 *
10321 * @chainable
10322 */
10323 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10324 return this.selectRange( this.getInputLength() );
10325 };
10326
10327 /**
10328 * Insert new content into the input.
10329 *
10330 * @param {string} content Content to be inserted
10331 * @chainable
10332 */
10333 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10334 var start, end,
10335 range = this.getRange(),
10336 value = this.getValue();
10337
10338 start = Math.min( range.from, range.to );
10339 end = Math.max( range.from, range.to );
10340
10341 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
10342 this.selectRange( start + content.length );
10343 return this;
10344 };
10345
10346 /**
10347 * Insert new content either side of a selection.
10348 *
10349 * @param {string} pre Content to be inserted before the selection
10350 * @param {string} post Content to be inserted after the selection
10351 * @chainable
10352 */
10353 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
10354 var start, end,
10355 range = this.getRange(),
10356 offset = pre.length;
10357
10358 start = Math.min( range.from, range.to );
10359 end = Math.max( range.from, range.to );
10360
10361 this.selectRange( start ).insertContent( pre );
10362 this.selectRange( offset + end ).insertContent( post );
10363
10364 this.selectRange( offset + start, offset + end );
10365 return this;
10366 };
10367
10368 /**
10369 * Set the validation pattern.
10370 *
10371 * The validation pattern is either a regular expression, a function, or the symbolic name of a
10372 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
10373 * value must contain only numbers).
10374 *
10375 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
10376 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
10377 */
10378 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
10379 if ( validate instanceof RegExp || validate instanceof Function ) {
10380 this.validate = validate;
10381 } else {
10382 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
10383 }
10384 };
10385
10386 /**
10387 * Sets the 'invalid' flag appropriately.
10388 *
10389 * @param {boolean} [isValid] Optionally override validation result
10390 */
10391 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
10392 var widget = this,
10393 setFlag = function ( valid ) {
10394 if ( !valid ) {
10395 widget.$input.attr( 'aria-invalid', 'true' );
10396 } else {
10397 widget.$input.removeAttr( 'aria-invalid' );
10398 }
10399 widget.setFlags( { invalid: !valid } );
10400 };
10401
10402 if ( isValid !== undefined ) {
10403 setFlag( isValid );
10404 } else {
10405 this.getValidity().then( function () {
10406 setFlag( true );
10407 }, function () {
10408 setFlag( false );
10409 } );
10410 }
10411 };
10412
10413 /**
10414 * Get the validity of current value.
10415 *
10416 * This method returns a promise that resolves if the value is valid and rejects if
10417 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
10418 *
10419 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
10420 */
10421 OO.ui.TextInputWidget.prototype.getValidity = function () {
10422 var result;
10423
10424 function rejectOrResolve( valid ) {
10425 if ( valid ) {
10426 return $.Deferred().resolve().promise();
10427 } else {
10428 return $.Deferred().reject().promise();
10429 }
10430 }
10431
10432 // Check browser validity and reject if it is invalid
10433 if (
10434 this.$input[ 0 ].checkValidity !== undefined &&
10435 this.$input[ 0 ].checkValidity() === false
10436 ) {
10437 return rejectOrResolve( false );
10438 }
10439
10440 // Run our checks if the browser thinks the field is valid
10441 if ( this.validate instanceof Function ) {
10442 result = this.validate( this.getValue() );
10443 if ( result && $.isFunction( result.promise ) ) {
10444 return result.promise().then( function ( valid ) {
10445 return rejectOrResolve( valid );
10446 } );
10447 } else {
10448 return rejectOrResolve( result );
10449 }
10450 } else {
10451 return rejectOrResolve( this.getValue().match( this.validate ) );
10452 }
10453 };
10454
10455 /**
10456 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
10457 *
10458 * @param {string} labelPosition Label position, 'before' or 'after'
10459 * @chainable
10460 */
10461 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
10462 this.labelPosition = labelPosition;
10463 if ( this.label ) {
10464 // If there is no label and we only change the position, #updatePosition is a no-op,
10465 // but it takes really a lot of work to do nothing.
10466 this.updatePosition();
10467 }
10468 return this;
10469 };
10470
10471 /**
10472 * Update the position of the inline label.
10473 *
10474 * This method is called by #setLabelPosition, and can also be called on its own if
10475 * something causes the label to be mispositioned.
10476 *
10477 * @chainable
10478 */
10479 OO.ui.TextInputWidget.prototype.updatePosition = function () {
10480 var after = this.labelPosition === 'after';
10481
10482 this.$element
10483 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
10484 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
10485
10486 this.valCache = null;
10487 this.scrollWidth = null;
10488 this.positionLabel();
10489
10490 return this;
10491 };
10492
10493 /**
10494 * Position the label by setting the correct padding on the input.
10495 *
10496 * @private
10497 * @chainable
10498 */
10499 OO.ui.TextInputWidget.prototype.positionLabel = function () {
10500 var after, rtl, property, newCss;
10501
10502 if ( this.isWaitingToBeAttached ) {
10503 // #onElementAttach will be called soon, which calls this method
10504 return this;
10505 }
10506
10507 newCss = {
10508 'padding-right': '',
10509 'padding-left': ''
10510 };
10511
10512 if ( this.label ) {
10513 this.$element.append( this.$label );
10514 } else {
10515 this.$label.detach();
10516 // Clear old values if present
10517 this.$input.css( newCss );
10518 return;
10519 }
10520
10521 after = this.labelPosition === 'after';
10522 rtl = this.$element.css( 'direction' ) === 'rtl';
10523 property = after === rtl ? 'padding-left' : 'padding-right';
10524
10525 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
10526 // We have to clear the padding on the other side, in case the element direction changed
10527 this.$input.css( newCss );
10528
10529 return this;
10530 };
10531
10532 /**
10533 * @inheritdoc
10534 */
10535 OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
10536 OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10537 if ( state.scrollTop !== undefined ) {
10538 this.$input.scrollTop( state.scrollTop );
10539 }
10540 };
10541
10542 /**
10543 * @class
10544 * @extends OO.ui.TextInputWidget
10545 *
10546 * @constructor
10547 * @param {Object} [config] Configuration options
10548 */
10549 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
10550 config = $.extend( {
10551 icon: 'search'
10552 }, config );
10553
10554 // Parent constructor
10555 OO.ui.SearchInputWidget.parent.call( this, config );
10556
10557 // Events
10558 this.connect( this, {
10559 change: 'onChange'
10560 } );
10561
10562 // Initialization
10563 this.updateSearchIndicator();
10564 this.connect( this, {
10565 disable: 'onDisable'
10566 } );
10567 };
10568
10569 /* Setup */
10570
10571 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
10572
10573 /* Methods */
10574
10575 /**
10576 * @inheritdoc
10577 * @protected
10578 */
10579 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
10580 return 'search';
10581 };
10582
10583 /**
10584 * @inheritdoc
10585 */
10586 OO.ui.SearchInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10587 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10588 // Clear the text field
10589 this.setValue( '' );
10590 this.focus();
10591 return false;
10592 }
10593 };
10594
10595 /**
10596 * Update the 'clear' indicator displayed on type: 'search' text
10597 * fields, hiding it when the field is already empty or when it's not
10598 * editable.
10599 */
10600 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
10601 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
10602 this.setIndicator( null );
10603 } else {
10604 this.setIndicator( 'clear' );
10605 }
10606 };
10607
10608 /**
10609 * Handle change events.
10610 *
10611 * @private
10612 */
10613 OO.ui.SearchInputWidget.prototype.onChange = function () {
10614 this.updateSearchIndicator();
10615 };
10616
10617 /**
10618 * Handle disable events.
10619 *
10620 * @param {boolean} disabled Element is disabled
10621 * @private
10622 */
10623 OO.ui.SearchInputWidget.prototype.onDisable = function () {
10624 this.updateSearchIndicator();
10625 };
10626
10627 /**
10628 * @inheritdoc
10629 */
10630 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
10631 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
10632 this.updateSearchIndicator();
10633 return this;
10634 };
10635
10636 /**
10637 * @class
10638 * @extends OO.ui.TextInputWidget
10639 *
10640 * @constructor
10641 * @param {Object} [config] Configuration options
10642 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
10643 * specifies minimum number of rows to display.
10644 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10645 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
10646 * Use the #maxRows config to specify a maximum number of displayed rows.
10647 * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
10648 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
10649 */
10650 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
10651 config = $.extend( {
10652 type: 'text'
10653 }, config );
10654 config.multiline = false;
10655 // Parent constructor
10656 OO.ui.MultilineTextInputWidget.parent.call( this, config );
10657
10658 // Properties
10659 this.multiline = true;
10660 this.autosize = !!config.autosize;
10661 this.minRows = config.rows !== undefined ? config.rows : '';
10662 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
10663
10664 // Clone for resizing
10665 if ( this.autosize ) {
10666 this.$clone = this.$input
10667 .clone()
10668 .insertAfter( this.$input )
10669 .attr( 'aria-hidden', 'true' )
10670 .addClass( 'oo-ui-element-hidden' );
10671 }
10672
10673 // Events
10674 this.connect( this, {
10675 change: 'onChange'
10676 } );
10677
10678 // Initialization
10679 if ( this.multiline && config.rows ) {
10680 this.$input.attr( 'rows', config.rows );
10681 }
10682 if ( this.autosize ) {
10683 this.isWaitingToBeAttached = true;
10684 this.installParentChangeDetector();
10685 }
10686 };
10687
10688 /* Setup */
10689
10690 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
10691
10692 /* Static Methods */
10693
10694 /**
10695 * @inheritdoc
10696 */
10697 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10698 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
10699 state.scrollTop = config.$input.scrollTop();
10700 return state;
10701 };
10702
10703 /* Methods */
10704
10705 /**
10706 * @inheritdoc
10707 */
10708 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
10709 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
10710 this.adjustSize();
10711 };
10712
10713 /**
10714 * Handle change events.
10715 *
10716 * @private
10717 */
10718 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
10719 this.adjustSize();
10720 };
10721
10722 /**
10723 * @inheritdoc
10724 */
10725 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
10726 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
10727 this.adjustSize();
10728 };
10729
10730 /**
10731 * Override TextInputWidget so it doesn't emit the 'enter' event.
10732 *
10733 * @private
10734 * @param {jQuery.Event} e Key press event
10735 */
10736 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function () {
10737 return;
10738 };
10739
10740 /**
10741 * Automatically adjust the size of the text input.
10742 *
10743 * This only affects multiline inputs that are {@link #autosize autosized}.
10744 *
10745 * @chainable
10746 * @fires resize
10747 */
10748 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
10749 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
10750 idealHeight, newHeight, scrollWidth, property;
10751
10752 if ( this.$input.val() !== this.valCache ) {
10753 if ( this.autosize ) {
10754 this.$clone
10755 .val( this.$input.val() )
10756 .attr( 'rows', this.minRows )
10757 // Set inline height property to 0 to measure scroll height
10758 .css( 'height', 0 );
10759
10760 this.$clone.removeClass( 'oo-ui-element-hidden' );
10761
10762 this.valCache = this.$input.val();
10763
10764 scrollHeight = this.$clone[ 0 ].scrollHeight;
10765
10766 // Remove inline height property to measure natural heights
10767 this.$clone.css( 'height', '' );
10768 innerHeight = this.$clone.innerHeight();
10769 outerHeight = this.$clone.outerHeight();
10770
10771 // Measure max rows height
10772 this.$clone
10773 .attr( 'rows', this.maxRows )
10774 .css( 'height', 'auto' )
10775 .val( '' );
10776 maxInnerHeight = this.$clone.innerHeight();
10777
10778 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
10779 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
10780 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
10781 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
10782
10783 this.$clone.addClass( 'oo-ui-element-hidden' );
10784
10785 // Only apply inline height when expansion beyond natural height is needed
10786 // Use the difference between the inner and outer height as a buffer
10787 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
10788 if ( newHeight !== this.styleHeight ) {
10789 this.$input.css( 'height', newHeight );
10790 this.styleHeight = newHeight;
10791 this.emit( 'resize' );
10792 }
10793 }
10794 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
10795 if ( scrollWidth !== this.scrollWidth ) {
10796 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
10797 // Reset
10798 this.$label.css( { right: '', left: '' } );
10799 this.$indicator.css( { right: '', left: '' } );
10800
10801 if ( scrollWidth ) {
10802 this.$indicator.css( property, scrollWidth );
10803 if ( this.labelPosition === 'after' ) {
10804 this.$label.css( property, scrollWidth );
10805 }
10806 }
10807
10808 this.scrollWidth = scrollWidth;
10809 this.positionLabel();
10810 }
10811 }
10812 return this;
10813 };
10814
10815 /**
10816 * @inheritdoc
10817 * @protected
10818 */
10819 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
10820 return $( '<textarea>' );
10821 };
10822
10823 /**
10824 * Check if the input supports multiple lines.
10825 *
10826 * @return {boolean}
10827 */
10828 OO.ui.MultilineTextInputWidget.prototype.isMultiline = function () {
10829 return !!this.multiline;
10830 };
10831
10832 /**
10833 * Check if the input automatically adjusts its size.
10834 *
10835 * @return {boolean}
10836 */
10837 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
10838 return !!this.autosize;
10839 };
10840
10841 /**
10842 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
10843 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
10844 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
10845 *
10846 * - by typing a value in the text input field. If the value exactly matches the value of a menu
10847 * option, that option will appear to be selected.
10848 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
10849 * input field.
10850 *
10851 * After the user chooses an option, its `data` will be used as a new value for the widget.
10852 * A `label` also can be specified for each option: if given, it will be shown instead of the
10853 * `data` in the dropdown menu.
10854 *
10855 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10856 *
10857 * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
10858 *
10859 * @example
10860 * // Example: A ComboBoxInputWidget.
10861 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10862 * value: 'Option 1',
10863 * options: [
10864 * { data: 'Option 1' },
10865 * { data: 'Option 2' },
10866 * { data: 'Option 3' }
10867 * ]
10868 * } );
10869 * $( 'body' ).append( comboBox.$element );
10870 *
10871 * @example
10872 * // Example: A ComboBoxInputWidget with additional option labels.
10873 * var comboBox = new OO.ui.ComboBoxInputWidget( {
10874 * value: 'Option 1',
10875 * options: [
10876 * {
10877 * data: 'Option 1',
10878 * label: 'Option One'
10879 * },
10880 * {
10881 * data: 'Option 2',
10882 * label: 'Option Two'
10883 * },
10884 * {
10885 * data: 'Option 3',
10886 * label: 'Option Three'
10887 * }
10888 * ]
10889 * } );
10890 * $( 'body' ).append( comboBox.$element );
10891 *
10892 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
10893 *
10894 * @class
10895 * @extends OO.ui.TextInputWidget
10896 *
10897 * @constructor
10898 * @param {Object} [config] Configuration options
10899 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10900 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
10901 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
10902 * the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
10903 * containing `<div>` and has a larger area. By default, the menu uses relative positioning.
10904 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
10905 */
10906 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
10907 // Configuration initialization
10908 config = $.extend( {
10909 autocomplete: false
10910 }, config );
10911
10912 // ComboBoxInputWidget shouldn't support `multiline`
10913 config.multiline = false;
10914
10915 // See InputWidget#reusePreInfuseDOM about `config.$input`
10916 if ( config.$input ) {
10917 config.$input.removeAttr( 'list' );
10918 }
10919
10920 // Parent constructor
10921 OO.ui.ComboBoxInputWidget.parent.call( this, config );
10922
10923 // Properties
10924 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
10925 this.dropdownButton = new OO.ui.ButtonWidget( {
10926 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
10927 indicator: 'down',
10928 disabled: this.disabled
10929 } );
10930 this.menu = new OO.ui.MenuSelectWidget( $.extend(
10931 {
10932 widget: this,
10933 input: this,
10934 $floatableContainer: this.$element,
10935 disabled: this.isDisabled()
10936 },
10937 config.menu
10938 ) );
10939
10940 // Events
10941 this.connect( this, {
10942 change: 'onInputChange',
10943 enter: 'onInputEnter'
10944 } );
10945 this.dropdownButton.connect( this, {
10946 click: 'onDropdownButtonClick'
10947 } );
10948 this.menu.connect( this, {
10949 choose: 'onMenuChoose',
10950 add: 'onMenuItemsChange',
10951 remove: 'onMenuItemsChange',
10952 toggle: 'onMenuToggle'
10953 } );
10954
10955 // Initialization
10956 this.$input.attr( {
10957 role: 'combobox',
10958 'aria-owns': this.menu.getElementId(),
10959 'aria-autocomplete': 'list'
10960 } );
10961 // Do not override options set via config.menu.items
10962 if ( config.options !== undefined ) {
10963 this.setOptions( config.options );
10964 }
10965 this.$field = $( '<div>' )
10966 .addClass( 'oo-ui-comboBoxInputWidget-field' )
10967 .append( this.$input, this.dropdownButton.$element );
10968 this.$element
10969 .addClass( 'oo-ui-comboBoxInputWidget' )
10970 .append( this.$field );
10971 this.$overlay.append( this.menu.$element );
10972 this.onMenuItemsChange();
10973 };
10974
10975 /* Setup */
10976
10977 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
10978
10979 /* Methods */
10980
10981 /**
10982 * Get the combobox's menu.
10983 *
10984 * @return {OO.ui.MenuSelectWidget} Menu widget
10985 */
10986 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
10987 return this.menu;
10988 };
10989
10990 /**
10991 * Get the combobox's text input widget.
10992 *
10993 * @return {OO.ui.TextInputWidget} Text input widget
10994 */
10995 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
10996 return this;
10997 };
10998
10999 /**
11000 * Handle input change events.
11001 *
11002 * @private
11003 * @param {string} value New value
11004 */
11005 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11006 var match = this.menu.getItemFromData( value );
11007
11008 this.menu.selectItem( match );
11009 if ( this.menu.findHighlightedItem() ) {
11010 this.menu.highlightItem( match );
11011 }
11012
11013 if ( !this.isDisabled() ) {
11014 this.menu.toggle( true );
11015 }
11016 };
11017
11018 /**
11019 * Handle input enter events.
11020 *
11021 * @private
11022 */
11023 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11024 if ( !this.isDisabled() ) {
11025 this.menu.toggle( false );
11026 }
11027 };
11028
11029 /**
11030 * Handle button click events.
11031 *
11032 * @private
11033 */
11034 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11035 this.menu.toggle();
11036 this.focus();
11037 };
11038
11039 /**
11040 * Handle menu choose events.
11041 *
11042 * @private
11043 * @param {OO.ui.OptionWidget} item Chosen item
11044 */
11045 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11046 this.setValue( item.getData() );
11047 };
11048
11049 /**
11050 * Handle menu item change events.
11051 *
11052 * @private
11053 */
11054 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11055 var match = this.menu.getItemFromData( this.getValue() );
11056 this.menu.selectItem( match );
11057 if ( this.menu.findHighlightedItem() ) {
11058 this.menu.highlightItem( match );
11059 }
11060 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11061 };
11062
11063 /**
11064 * Handle menu toggle events.
11065 *
11066 * @private
11067 * @param {boolean} isVisible Open state of the menu
11068 */
11069 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11070 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11071 };
11072
11073 /**
11074 * @inheritdoc
11075 */
11076 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
11077 // Parent method
11078 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
11079
11080 if ( this.dropdownButton ) {
11081 this.dropdownButton.setDisabled( this.isDisabled() );
11082 }
11083 if ( this.menu ) {
11084 this.menu.setDisabled( this.isDisabled() );
11085 }
11086
11087 return this;
11088 };
11089
11090 /**
11091 * Set the options available for this input.
11092 *
11093 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11094 * @chainable
11095 */
11096 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11097 this.getMenu()
11098 .clearItems()
11099 .addItems( options.map( function ( opt ) {
11100 return new OO.ui.MenuOptionWidget( {
11101 data: opt.data,
11102 label: opt.label !== undefined ? opt.label : opt.data
11103 } );
11104 } ) );
11105
11106 return this;
11107 };
11108
11109 /**
11110 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11111 * which is a widget that is specified by reference before any optional configuration settings.
11112 *
11113 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways:
11114 *
11115 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11116 * A left-alignment is used for forms with many fields.
11117 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11118 * A right-alignment is used for long but familiar forms which users tab through,
11119 * verifying the current field with a quick glance at the label.
11120 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11121 * that users fill out from top to bottom.
11122 * - **inline**: The label is placed after the field-widget and aligned to the left.
11123 * An inline-alignment is best used with checkboxes or radio buttons.
11124 *
11125 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout.
11126 * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information.
11127 *
11128 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11129 *
11130 * @class
11131 * @extends OO.ui.Layout
11132 * @mixins OO.ui.mixin.LabelElement
11133 * @mixins OO.ui.mixin.TitledElement
11134 *
11135 * @constructor
11136 * @param {OO.ui.Widget} fieldWidget Field widget
11137 * @param {Object} [config] Configuration options
11138 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
11139 * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
11140 * The array may contain strings or OO.ui.HtmlSnippet instances.
11141 * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
11142 * The array may contain strings or OO.ui.HtmlSnippet instances.
11143 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11144 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11145 * For important messages, you are advised to use `notices`, as they are always shown.
11146 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11147 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11148 *
11149 * @throws {Error} An error is thrown if no widget is specified
11150 */
11151 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11152 // Allow passing positional parameters inside the config object
11153 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11154 config = fieldWidget;
11155 fieldWidget = config.fieldWidget;
11156 }
11157
11158 // Make sure we have required constructor arguments
11159 if ( fieldWidget === undefined ) {
11160 throw new Error( 'Widget not found' );
11161 }
11162
11163 // Configuration initialization
11164 config = $.extend( { align: 'left' }, config );
11165
11166 // Parent constructor
11167 OO.ui.FieldLayout.parent.call( this, config );
11168
11169 // Mixin constructors
11170 OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
11171 $label: $( '<label>' )
11172 } ) );
11173 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
11174
11175 // Properties
11176 this.fieldWidget = fieldWidget;
11177 this.errors = [];
11178 this.notices = [];
11179 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11180 this.$messages = $( '<ul>' );
11181 this.$header = $( '<span>' );
11182 this.$body = $( '<div>' );
11183 this.align = null;
11184 if ( config.help ) {
11185 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11186 $overlay: config.$overlay,
11187 popup: {
11188 padded: true
11189 },
11190 classes: [ 'oo-ui-fieldLayout-help' ],
11191 framed: false,
11192 icon: 'info'
11193 } );
11194 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11195 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11196 } else {
11197 this.popupButtonWidget.getPopup().$body.text( config.help );
11198 }
11199 this.$help = this.popupButtonWidget.$element;
11200 } else {
11201 this.$help = $( [] );
11202 }
11203
11204 // Events
11205 this.fieldWidget.connect( this, { disable: 'onFieldDisable' } );
11206
11207 // Initialization
11208 if ( config.help ) {
11209 // Set the 'aria-describedby' attribute on the fieldWidget
11210 // Preference given to an input or a button
11211 (
11212 this.fieldWidget.$input ||
11213 this.fieldWidget.$button ||
11214 this.fieldWidget.$element
11215 ).attr(
11216 'aria-describedby',
11217 this.popupButtonWidget.getPopup().getBodyId()
11218 );
11219 }
11220 if ( this.fieldWidget.getInputId() ) {
11221 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11222 } else {
11223 this.$label.on( 'click', function () {
11224 this.fieldWidget.simulateLabelClick();
11225 return false;
11226 }.bind( this ) );
11227 }
11228 this.$element
11229 .addClass( 'oo-ui-fieldLayout' )
11230 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11231 .append( this.$body );
11232 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11233 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11234 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11235 this.$field
11236 .addClass( 'oo-ui-fieldLayout-field' )
11237 .append( this.fieldWidget.$element );
11238
11239 this.setErrors( config.errors || [] );
11240 this.setNotices( config.notices || [] );
11241 this.setAlignment( config.align );
11242 // Call this again to take into account the widget's accessKey
11243 this.updateTitle();
11244 };
11245
11246 /* Setup */
11247
11248 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11249 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11250 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11251
11252 /* Methods */
11253
11254 /**
11255 * Handle field disable events.
11256 *
11257 * @private
11258 * @param {boolean} value Field is disabled
11259 */
11260 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11261 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11262 };
11263
11264 /**
11265 * Get the widget contained by the field.
11266 *
11267 * @return {OO.ui.Widget} Field widget
11268 */
11269 OO.ui.FieldLayout.prototype.getField = function () {
11270 return this.fieldWidget;
11271 };
11272
11273 /**
11274 * Return `true` if the given field widget can be used with `'inline'` alignment (see
11275 * #setAlignment). Return `false` if it can't or if this can't be determined.
11276 *
11277 * @return {boolean}
11278 */
11279 OO.ui.FieldLayout.prototype.isFieldInline = function () {
11280 // This is very simplistic, but should be good enough.
11281 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
11282 };
11283
11284 /**
11285 * @protected
11286 * @param {string} kind 'error' or 'notice'
11287 * @param {string|OO.ui.HtmlSnippet} text
11288 * @return {jQuery}
11289 */
11290 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
11291 var $listItem, $icon, message;
11292 $listItem = $( '<li>' );
11293 if ( kind === 'error' ) {
11294 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
11295 $listItem.attr( 'role', 'alert' );
11296 } else if ( kind === 'notice' ) {
11297 $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
11298 } else {
11299 $icon = '';
11300 }
11301 message = new OO.ui.LabelWidget( { label: text } );
11302 $listItem
11303 .append( $icon, message.$element )
11304 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
11305 return $listItem;
11306 };
11307
11308 /**
11309 * Set the field alignment mode.
11310 *
11311 * @private
11312 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
11313 * @chainable
11314 */
11315 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
11316 if ( value !== this.align ) {
11317 // Default to 'left'
11318 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
11319 value = 'left';
11320 }
11321 // Validate
11322 if ( value === 'inline' && !this.isFieldInline() ) {
11323 value = 'top';
11324 }
11325 // Reorder elements
11326 if ( value === 'top' ) {
11327 this.$header.append( this.$label, this.$help );
11328 this.$body.append( this.$header, this.$field );
11329 } else if ( value === 'inline' ) {
11330 this.$header.append( this.$label, this.$help );
11331 this.$body.append( this.$field, this.$header );
11332 } else {
11333 this.$header.append( this.$label );
11334 this.$body.append( this.$header, this.$help, this.$field );
11335 }
11336 // Set classes. The following classes can be used here:
11337 // * oo-ui-fieldLayout-align-left
11338 // * oo-ui-fieldLayout-align-right
11339 // * oo-ui-fieldLayout-align-top
11340 // * oo-ui-fieldLayout-align-inline
11341 if ( this.align ) {
11342 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
11343 }
11344 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
11345 this.align = value;
11346 }
11347
11348 return this;
11349 };
11350
11351 /**
11352 * Set the list of error messages.
11353 *
11354 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
11355 * The array may contain strings or OO.ui.HtmlSnippet instances.
11356 * @chainable
11357 */
11358 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
11359 this.errors = errors.slice();
11360 this.updateMessages();
11361 return this;
11362 };
11363
11364 /**
11365 * Set the list of notice messages.
11366 *
11367 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
11368 * The array may contain strings or OO.ui.HtmlSnippet instances.
11369 * @chainable
11370 */
11371 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
11372 this.notices = notices.slice();
11373 this.updateMessages();
11374 return this;
11375 };
11376
11377 /**
11378 * Update the rendering of error and notice messages.
11379 *
11380 * @private
11381 */
11382 OO.ui.FieldLayout.prototype.updateMessages = function () {
11383 var i;
11384 this.$messages.empty();
11385
11386 if ( this.errors.length || this.notices.length ) {
11387 this.$body.after( this.$messages );
11388 } else {
11389 this.$messages.remove();
11390 return;
11391 }
11392
11393 for ( i = 0; i < this.notices.length; i++ ) {
11394 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
11395 }
11396 for ( i = 0; i < this.errors.length; i++ ) {
11397 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
11398 }
11399 };
11400
11401 /**
11402 * Include information about the widget's accessKey in our title. TitledElement calls this method.
11403 * (This is a bit of a hack.)
11404 *
11405 * @protected
11406 * @param {string} title Tooltip label for 'title' attribute
11407 * @return {string}
11408 */
11409 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
11410 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
11411 return this.fieldWidget.formatTitleWithAccessKey( title );
11412 }
11413 return title;
11414 };
11415
11416 /**
11417 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
11418 * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
11419 * is required and is specified before any optional configuration settings.
11420 *
11421 * Labels can be aligned in one of four ways:
11422 *
11423 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11424 * A left-alignment is used for forms with many fields.
11425 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11426 * A right-alignment is used for long but familiar forms which users tab through,
11427 * verifying the current field with a quick glance at the label.
11428 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11429 * that users fill out from top to bottom.
11430 * - **inline**: The label is placed after the field-widget and aligned to the left.
11431 * An inline-alignment is best used with checkboxes or radio buttons.
11432 *
11433 * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout when help
11434 * text is specified.
11435 *
11436 * @example
11437 * // Example of an ActionFieldLayout
11438 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
11439 * new OO.ui.TextInputWidget( {
11440 * placeholder: 'Field widget'
11441 * } ),
11442 * new OO.ui.ButtonWidget( {
11443 * label: 'Button'
11444 * } ),
11445 * {
11446 * label: 'An ActionFieldLayout. This label is aligned top',
11447 * align: 'top',
11448 * help: 'This is help text'
11449 * }
11450 * );
11451 *
11452 * $( 'body' ).append( actionFieldLayout.$element );
11453 *
11454 * @class
11455 * @extends OO.ui.FieldLayout
11456 *
11457 * @constructor
11458 * @param {OO.ui.Widget} fieldWidget Field widget
11459 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
11460 * @param {Object} config
11461 */
11462 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
11463 // Allow passing positional parameters inside the config object
11464 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11465 config = fieldWidget;
11466 fieldWidget = config.fieldWidget;
11467 buttonWidget = config.buttonWidget;
11468 }
11469
11470 // Parent constructor
11471 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
11472
11473 // Properties
11474 this.buttonWidget = buttonWidget;
11475 this.$button = $( '<span>' );
11476 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11477
11478 // Initialization
11479 this.$element
11480 .addClass( 'oo-ui-actionFieldLayout' );
11481 this.$button
11482 .addClass( 'oo-ui-actionFieldLayout-button' )
11483 .append( this.buttonWidget.$element );
11484 this.$input
11485 .addClass( 'oo-ui-actionFieldLayout-input' )
11486 .append( this.fieldWidget.$element );
11487 this.$field
11488 .append( this.$input, this.$button );
11489 };
11490
11491 /* Setup */
11492
11493 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
11494
11495 /**
11496 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
11497 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
11498 * configured with a label as well. For more information and examples,
11499 * please see the [OOjs UI documentation on MediaWiki][1].
11500 *
11501 * @example
11502 * // Example of a fieldset layout
11503 * var input1 = new OO.ui.TextInputWidget( {
11504 * placeholder: 'A text input field'
11505 * } );
11506 *
11507 * var input2 = new OO.ui.TextInputWidget( {
11508 * placeholder: 'A text input field'
11509 * } );
11510 *
11511 * var fieldset = new OO.ui.FieldsetLayout( {
11512 * label: 'Example of a fieldset layout'
11513 * } );
11514 *
11515 * fieldset.addItems( [
11516 * new OO.ui.FieldLayout( input1, {
11517 * label: 'Field One'
11518 * } ),
11519 * new OO.ui.FieldLayout( input2, {
11520 * label: 'Field Two'
11521 * } )
11522 * ] );
11523 * $( 'body' ).append( fieldset.$element );
11524 *
11525 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
11526 *
11527 * @class
11528 * @extends OO.ui.Layout
11529 * @mixins OO.ui.mixin.IconElement
11530 * @mixins OO.ui.mixin.LabelElement
11531 * @mixins OO.ui.mixin.GroupElement
11532 *
11533 * @constructor
11534 * @param {Object} [config] Configuration options
11535 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
11536 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
11537 * in the upper-right corner of the rendered field; clicking it will display the text in a popup.
11538 * For important messages, you are advised to use `notices`, as they are always shown.
11539 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
11540 * See <https://www.mediawiki.org/wiki/OOjs_UI/Concepts#Overlays>.
11541 */
11542 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
11543 // Configuration initialization
11544 config = config || {};
11545
11546 // Parent constructor
11547 OO.ui.FieldsetLayout.parent.call( this, config );
11548
11549 // Mixin constructors
11550 OO.ui.mixin.IconElement.call( this, config );
11551 OO.ui.mixin.LabelElement.call( this, config );
11552 OO.ui.mixin.GroupElement.call( this, config );
11553
11554 // Properties
11555 this.$header = $( '<legend>' );
11556 if ( config.help ) {
11557 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
11558 $overlay: config.$overlay,
11559 popup: {
11560 padded: true
11561 },
11562 classes: [ 'oo-ui-fieldsetLayout-help' ],
11563 framed: false,
11564 icon: 'info'
11565 } );
11566 if ( config.help instanceof OO.ui.HtmlSnippet ) {
11567 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
11568 } else {
11569 this.popupButtonWidget.getPopup().$body.text( config.help );
11570 }
11571 this.$help = this.popupButtonWidget.$element;
11572 } else {
11573 this.$help = $( [] );
11574 }
11575
11576 // Initialization
11577 this.$header
11578 .addClass( 'oo-ui-fieldsetLayout-header' )
11579 .append( this.$icon, this.$label, this.$help );
11580 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
11581 this.$element
11582 .addClass( 'oo-ui-fieldsetLayout' )
11583 .prepend( this.$header, this.$group );
11584 if ( Array.isArray( config.items ) ) {
11585 this.addItems( config.items );
11586 }
11587 };
11588
11589 /* Setup */
11590
11591 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
11592 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
11593 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
11594 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
11595
11596 /* Static Properties */
11597
11598 /**
11599 * @static
11600 * @inheritdoc
11601 */
11602 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
11603
11604 /**
11605 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
11606 * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
11607 * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
11608 * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
11609 *
11610 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
11611 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
11612 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
11613 * some fancier controls. Some controls have both regular and InputWidget variants, for example
11614 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
11615 * often have simplified APIs to match the capabilities of HTML forms.
11616 * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
11617 *
11618 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
11619 * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
11620 *
11621 * @example
11622 * // Example of a form layout that wraps a fieldset layout
11623 * var input1 = new OO.ui.TextInputWidget( {
11624 * placeholder: 'Username'
11625 * } );
11626 * var input2 = new OO.ui.TextInputWidget( {
11627 * placeholder: 'Password',
11628 * type: 'password'
11629 * } );
11630 * var submit = new OO.ui.ButtonInputWidget( {
11631 * label: 'Submit'
11632 * } );
11633 *
11634 * var fieldset = new OO.ui.FieldsetLayout( {
11635 * label: 'A form layout'
11636 * } );
11637 * fieldset.addItems( [
11638 * new OO.ui.FieldLayout( input1, {
11639 * label: 'Username',
11640 * align: 'top'
11641 * } ),
11642 * new OO.ui.FieldLayout( input2, {
11643 * label: 'Password',
11644 * align: 'top'
11645 * } ),
11646 * new OO.ui.FieldLayout( submit )
11647 * ] );
11648 * var form = new OO.ui.FormLayout( {
11649 * items: [ fieldset ],
11650 * action: '/api/formhandler',
11651 * method: 'get'
11652 * } )
11653 * $( 'body' ).append( form.$element );
11654 *
11655 * @class
11656 * @extends OO.ui.Layout
11657 * @mixins OO.ui.mixin.GroupElement
11658 *
11659 * @constructor
11660 * @param {Object} [config] Configuration options
11661 * @cfg {string} [method] HTML form `method` attribute
11662 * @cfg {string} [action] HTML form `action` attribute
11663 * @cfg {string} [enctype] HTML form `enctype` attribute
11664 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
11665 */
11666 OO.ui.FormLayout = function OoUiFormLayout( config ) {
11667 var action;
11668
11669 // Configuration initialization
11670 config = config || {};
11671
11672 // Parent constructor
11673 OO.ui.FormLayout.parent.call( this, config );
11674
11675 // Mixin constructors
11676 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11677
11678 // Events
11679 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
11680
11681 // Make sure the action is safe
11682 action = config.action;
11683 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
11684 action = './' + action;
11685 }
11686
11687 // Initialization
11688 this.$element
11689 .addClass( 'oo-ui-formLayout' )
11690 .attr( {
11691 method: config.method,
11692 action: action,
11693 enctype: config.enctype
11694 } );
11695 if ( Array.isArray( config.items ) ) {
11696 this.addItems( config.items );
11697 }
11698 };
11699
11700 /* Setup */
11701
11702 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
11703 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
11704
11705 /* Events */
11706
11707 /**
11708 * A 'submit' event is emitted when the form is submitted.
11709 *
11710 * @event submit
11711 */
11712
11713 /* Static Properties */
11714
11715 /**
11716 * @static
11717 * @inheritdoc
11718 */
11719 OO.ui.FormLayout.static.tagName = 'form';
11720
11721 /* Methods */
11722
11723 /**
11724 * Handle form submit events.
11725 *
11726 * @private
11727 * @param {jQuery.Event} e Submit event
11728 * @fires submit
11729 */
11730 OO.ui.FormLayout.prototype.onFormSubmit = function () {
11731 if ( this.emit( 'submit' ) ) {
11732 return false;
11733 }
11734 };
11735
11736 /**
11737 * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
11738 * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
11739 *
11740 * @example
11741 * // Example of a panel layout
11742 * var panel = new OO.ui.PanelLayout( {
11743 * expanded: false,
11744 * framed: true,
11745 * padded: true,
11746 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
11747 * } );
11748 * $( 'body' ).append( panel.$element );
11749 *
11750 * @class
11751 * @extends OO.ui.Layout
11752 *
11753 * @constructor
11754 * @param {Object} [config] Configuration options
11755 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
11756 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
11757 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
11758 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
11759 */
11760 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
11761 // Configuration initialization
11762 config = $.extend( {
11763 scrollable: false,
11764 padded: false,
11765 expanded: true,
11766 framed: false
11767 }, config );
11768
11769 // Parent constructor
11770 OO.ui.PanelLayout.parent.call( this, config );
11771
11772 // Initialization
11773 this.$element.addClass( 'oo-ui-panelLayout' );
11774 if ( config.scrollable ) {
11775 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
11776 }
11777 if ( config.padded ) {
11778 this.$element.addClass( 'oo-ui-panelLayout-padded' );
11779 }
11780 if ( config.expanded ) {
11781 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
11782 }
11783 if ( config.framed ) {
11784 this.$element.addClass( 'oo-ui-panelLayout-framed' );
11785 }
11786 };
11787
11788 /* Setup */
11789
11790 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
11791
11792 /* Methods */
11793
11794 /**
11795 * Focus the panel layout
11796 *
11797 * The default implementation just focuses the first focusable element in the panel
11798 */
11799 OO.ui.PanelLayout.prototype.focus = function () {
11800 OO.ui.findFocusable( this.$element ).focus();
11801 };
11802
11803 /**
11804 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
11805 * items), with small margins between them. Convenient when you need to put a number of block-level
11806 * widgets on a single line next to each other.
11807 *
11808 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
11809 *
11810 * @example
11811 * // HorizontalLayout with a text input and a label
11812 * var layout = new OO.ui.HorizontalLayout( {
11813 * items: [
11814 * new OO.ui.LabelWidget( { label: 'Label' } ),
11815 * new OO.ui.TextInputWidget( { value: 'Text' } )
11816 * ]
11817 * } );
11818 * $( 'body' ).append( layout.$element );
11819 *
11820 * @class
11821 * @extends OO.ui.Layout
11822 * @mixins OO.ui.mixin.GroupElement
11823 *
11824 * @constructor
11825 * @param {Object} [config] Configuration options
11826 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
11827 */
11828 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
11829 // Configuration initialization
11830 config = config || {};
11831
11832 // Parent constructor
11833 OO.ui.HorizontalLayout.parent.call( this, config );
11834
11835 // Mixin constructors
11836 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
11837
11838 // Initialization
11839 this.$element.addClass( 'oo-ui-horizontalLayout' );
11840 if ( Array.isArray( config.items ) ) {
11841 this.addItems( config.items );
11842 }
11843 };
11844
11845 /* Setup */
11846
11847 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
11848 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
11849
11850 }( OO ) );
11851
11852 //# sourceMappingURL=oojs-ui-core.js.map