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