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