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