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